From 5d94c0a7d44a2255b81815a52a7056a94a39842d Mon Sep 17 00:00:00 2001 From: nippy Date: Sat, 18 Apr 2026 13:49:56 +0200 Subject: update Raveos themes --- .../theme-data/DankMaterialShell/core/Makefile | 163 + .../theme-data/DankMaterialShell/core/README.md | 217 + .../DankMaterialShell/core/assets/dank.svg | 46 + .../DankMaterialShell/core/assets/danklogo.svg | 113 + .../DankMaterialShell/core/build_dankinstall.sh | 38 + .../DankMaterialShell/core/cmd/dankinstall/main.go | 219 + .../core/cmd/dms/assets/cli-policy.default.json | 10 + .../core/cmd/dms/commands_auth.go | 76 + .../core/cmd/dms/commands_blur.go | 40 + .../core/cmd/dms/commands_brightness.go | 311 + .../core/cmd/dms/commands_chroma.go | 300 + .../core/cmd/dms/commands_clipboard.go | 1004 +++ .../core/cmd/dms/commands_colorpicker.go | 139 + .../core/cmd/dms/commands_common.go | 530 ++ .../core/cmd/dms/commands_config.go | 318 + .../core/cmd/dms/commands_dank16.go | 126 + .../core/cmd/dms/commands_doctor.go | 1107 +++ .../core/cmd/dms/commands_download.go | 99 + .../core/cmd/dms/commands_dpms.go | 105 + .../core/cmd/dms/commands_features.go | 491 ++ .../core/cmd/dms/commands_greeter.go | 1850 +++++ .../core/cmd/dms/commands_greeter_test.go | 87 + .../core/cmd/dms/commands_keybinds.go | 265 + .../core/cmd/dms/commands_matugen.go | 202 + .../core/cmd/dms/commands_notify.go | 68 + .../core/cmd/dms/commands_open.go | 205 + .../core/cmd/dms/commands_randr.go | 58 + .../core/cmd/dms/commands_root.go | 74 + .../core/cmd/dms/commands_screenshot.go | 422 ++ .../core/cmd/dms/commands_setup.go | 436 ++ .../core/cmd/dms/commands_windowrules.go | 338 + .../DankMaterialShell/core/cmd/dms/dpms_client.go | 339 + .../core/cmd/dms/immutable_policy.go | 271 + .../DankMaterialShell/core/cmd/dms/main.go | 43 + .../DankMaterialShell/core/cmd/dms/main_distro.go | 40 + .../DankMaterialShell/core/cmd/dms/randr_client.go | 172 + .../core/cmd/dms/server_client.go | 114 + .../DankMaterialShell/core/cmd/dms/shell.go | 739 ++ .../DankMaterialShell/core/cmd/dms/ui.go | 53 + .../DankMaterialShell/core/cmd/dms/utils.go | 89 + .../theme-data/DankMaterialShell/core/go.mod | 87 + .../theme-data/DankMaterialShell/core/go.sum | 191 + .../theme-data/DankMaterialShell/core/install.sh | 86 + .../DankMaterialShell/core/internal/blur/probe.go | 35 + .../core/internal/clipboard/clipboard.go | 630 ++ .../core/internal/clipboard/store.go | 248 + .../core/internal/clipboard/watch.go | 303 + .../core/internal/colorpicker/color.go | 306 + .../core/internal/colorpicker/picker.go | 744 ++ .../core/internal/colorpicker/shm.go | 55 + .../core/internal/colorpicker/state.go | 1195 ++++ .../core/internal/colorpicker/state_test.go | 314 + .../core/internal/config/deployer.go | 718 ++ .../core/internal/config/deployer_test.go | 792 ++ .../DankMaterialShell/core/internal/config/dms.go | 57 + .../internal/config/embedded/alacritty-theme.toml | 31 + .../core/internal/config/embedded/alacritty.toml | 37 + .../internal/config/embedded/ghostty-colors.conf | 21 + .../core/internal/config/embedded/ghostty.conf | 51 + .../core/internal/config/embedded/hypr-binds.conf | 162 + .../core/internal/config/embedded/hypr-colors.conf | 25 + .../core/internal/config/embedded/hypr-layout.conf | 11 + .../core/internal/config/embedded/hyprland.conf | 121 + .../core/internal/config/embedded/kitty-tabs.conf | 24 + .../core/internal/config/embedded/kitty-theme.conf | 24 + .../core/internal/config/embedded/kitty.conf | 37 + .../core/internal/config/embedded/niri-alttab.kdl | 10 + .../core/internal/config/embedded/niri-binds.kdl | 221 + .../core/internal/config/embedded/niri-colors.kdl | 39 + .../core/internal/config/embedded/niri-greeter.kdl | 17 + .../core/internal/config/embedded/niri-layout.kdl | 22 + .../core/internal/config/embedded/niri.kdl | 279 + .../core/internal/config/embedded/testpage.pdf | Bin 0 -> 7396 bytes .../core/internal/config/hyprland.go | 15 + .../DankMaterialShell/core/internal/config/niri.go | 21 + .../core/internal/config/terminals.go | 24 + .../core/internal/config/testpage.go | 6 + .../core/internal/dank16/dank16.go | 651 ++ .../core/internal/dank16/dank16_test.go | 681 ++ .../core/internal/dank16/terminals.go | 135 + .../core/internal/deps/detector.go | 51 + .../core/internal/distros/arch.go | 807 +++ .../core/internal/distros/base.go | 721 ++ .../core/internal/distros/base_test.go | 216 + .../core/internal/distros/debian.go | 706 ++ .../core/internal/distros/factory.go | 19 + .../core/internal/distros/fedora.go | 550 ++ .../core/internal/distros/gentoo.go | 700 ++ .../core/internal/distros/interface.go | 158 + .../core/internal/distros/manual_packages.go | 663 ++ .../core/internal/distros/manual_packages_test.go | 122 + .../core/internal/distros/minimal_install.go | 44 + .../core/internal/distros/opensuse.go | 856 +++ .../core/internal/distros/osinfo.go | 122 + .../core/internal/distros/ubuntu.go | 702 ++ .../core/internal/errdefs/errdefs.go | 65 + .../core/internal/geolocation/client.go | 42 + .../core/internal/geolocation/client_geoclue.go | 243 + .../core/internal/geolocation/client_ip.go | 91 + .../core/internal/geolocation/types.go | 15 + .../greeter/assets/apparmor/usr.bin.dms-greeter | 91 + .../core/internal/greeter/installer.go | 2076 ++++++ .../core/internal/greeter/installer_test.go | 98 + .../core/internal/headless/runner.go | 418 ++ .../core/internal/headless/runner_test.go | 459 ++ .../core/internal/keybinds/discovery.go | 107 + .../core/internal/keybinds/discovery_test.go | 287 + .../core/internal/keybinds/providers/hyprland.go | 497 ++ .../internal/keybinds/providers/hyprland_parser.go | 627 ++ .../keybinds/providers/hyprland_parser_test.go | 519 ++ .../internal/keybinds/providers/hyprland_test.go | 220 + .../core/internal/keybinds/providers/jsonfile.go | 119 + .../internal/keybinds/providers/jsonfile_test.go | 281 + .../core/internal/keybinds/providers/mangowc.go | 442 ++ .../internal/keybinds/providers/mangowc_parser.go | 613 ++ .../keybinds/providers/mangowc_parser_test.go | 499 ++ .../internal/keybinds/providers/mangowc_test.go | 320 + .../core/internal/keybinds/providers/miracle.go | 95 + .../internal/keybinds/providers/miracle_parser.go | 320 + .../core/internal/keybinds/providers/niri.go | 650 ++ .../internal/keybinds/providers/niri_parser.go | 400 ++ .../keybinds/providers/niri_parser_test.go | 630 ++ .../core/internal/keybinds/providers/niri_test.go | 661 ++ .../core/internal/keybinds/providers/sway.go | 150 + .../internal/keybinds/providers/sway_parser.go | 364 + .../keybinds/providers/sway_parser_test.go | 471 ++ .../core/internal/keybinds/providers/sway_test.go | 293 + .../core/internal/keybinds/registry.go | 79 + .../core/internal/keybinds/registry_test.go | 183 + .../core/internal/keybinds/types.go | 47 + .../core/internal/log/filelogger.go | 114 + .../DankMaterialShell/core/internal/log/log.go | 116 + .../core/internal/matugen/matugen.go | 978 +++ .../core/internal/matugen/matugen_test.go | 443 ++ .../core/internal/matugen/queue.go | 143 + .../internal/mocks/brightness/mock_DBusConn.go | 130 + .../mocks/cups/mock_CUPSClientInterface.go | 1246 ++++ .../internal/mocks/cups_pkhelper/mock_PkHelper.go | 708 ++ .../core/internal/mocks/evdev/mock_EvdevDevice.go | 295 + .../core/internal/mocks/geolocation/mock_Client.go | 203 + .../Wifx/gonetworkmanager/v2/mock_AccessPoint.go | 689 ++ .../gonetworkmanager/v2/mock_ActiveConnection.go | 1025 +++ .../Wifx/gonetworkmanager/v2/mock_Connection.go | 646 ++ .../Wifx/gonetworkmanager/v2/mock_Device.go | 1638 +++++ .../gonetworkmanager/v2/mock_DeviceWireless.go | 2241 ++++++ .../Wifx/gonetworkmanager/v2/mock_IP4Config.go | 772 ++ .../gonetworkmanager/v2/mock_NetworkManager.go | 2349 ++++++ .../Wifx/gonetworkmanager/v2/mock_Settings.go | 467 ++ .../github.com/godbus/dbus/v5/mock_BusObject.go | 649 ++ .../mocks/internal/plugins/mock_GitClient.go | 181 + .../core/internal/mocks/net/mock_Conn.go | 427 ++ .../core/internal/mocks/network/mock_Backend.go | 1975 +++++ .../core/internal/mocks/utils/mock_AppChecker.go | 242 + .../internal/mocks/version/mock_VersionFetcher.go | 144 + .../internal/mocks/wlclient/mock_WaylandDisplay.go | 229 + .../mocks/wlcontext/mock_WaylandContext.go | 226 + .../core/internal/notify/notify.go | 185 + .../DankMaterialShell/core/internal/pam/pam.go | 892 +++ .../core/internal/pam/pam_test.go | 671 ++ .../core/internal/plugins/manager.go | 588 ++ .../core/internal/plugins/manager_test.go | 247 + .../core/internal/plugins/registry.go | 258 + .../core/internal/plugins/registry_test.go | 326 + .../core/internal/plugins/search.go | 91 + .../core/internal/proto/dwl_ipc/dwl_ipc.go | 791 ++ .../proto/ext_data_control/data_control.go | 695 ++ .../core/internal/proto/ext_workspace/workspace.go | 1040 +++ .../keyboard_shortcuts_inhibit.go | 284 + .../proto/wlr_gamma_control/gamma_control.go | 268 + .../internal/proto/wlr_layer_shell/layer_shell.go | 792 ++ .../wlr_output_management/output_management.go | 1515 ++++ .../proto/wlr_output_power/output_power.go | 283 + .../internal/proto/wlr_screencopy/screencopy.go | 532 ++ .../internal/proto/wp_viewporter/viewporter.go | 399 ++ .../internal/proto/xml/dwl-ipc-unstable-v2.xml | 252 + .../internal/proto/xml/ext-data-control-v1.xml | 276 + .../core/internal/proto/xml/ext-workspace-v1.xml | 422 ++ .../xml/keyboard-shortcuts-inhibit-unstable-v1.xml | 143 + .../proto/xml/wlr-gamma-control-unstable-v1.xml | 126 + .../proto/xml/wlr-layer-shell-unstable-v1.xml | 407 ++ .../xml/wlr-output-management-unstable-v1.xml | 611 ++ .../wlr-output-power-management-unstable-v1.xml | 128 + .../proto/xml/wlr-screencopy-unstable-v1.xml | 234 + .../core/internal/screenshot/compositor.go | 694 ++ .../core/internal/screenshot/encode.go | 172 + .../core/internal/screenshot/notify.go | 180 + .../core/internal/screenshot/region.go | 859 +++ .../core/internal/screenshot/region_input.go | 274 + .../core/internal/screenshot/region_render.go | 326 + .../core/internal/screenshot/screenshot.go | 1089 +++ .../core/internal/screenshot/shm.go | 35 + .../core/internal/screenshot/state.go | 65 + .../core/internal/screenshot/theme.go | 125 + .../core/internal/screenshot/types.go | 81 + .../core/internal/server/apppicker/handlers.go | 51 + .../core/internal/server/apppicker/manager.go | 48 + .../core/internal/server/apppicker/models.go | 8 + .../core/internal/server/bluez/agent.go | 341 + .../core/internal/server/bluez/broker.go | 21 + .../core/internal/server/bluez/broker_test.go | 220 + .../core/internal/server/bluez/handlers.go | 237 + .../core/internal/server/bluez/handlers_test.go | 41 + .../core/internal/server/bluez/manager.go | 588 ++ .../internal/server/bluez/subscription_broker.go | 86 + .../core/internal/server/bluez/types.go | 78 + .../core/internal/server/bluez/types_test.go | 210 + .../core/internal/server/brightness/ddc.go | 510 ++ .../core/internal/server/brightness/ddc_filter.go | 135 + .../internal/server/brightness/ddc_filter_test.go | 122 + .../core/internal/server/brightness/ddc_test.go | 135 + .../core/internal/server/brightness/handlers.go | 124 + .../core/internal/server/brightness/logind.go | 66 + .../core/internal/server/brightness/logind_test.go | 95 + .../core/internal/server/brightness/manager.go | 389 + .../internal/server/brightness/manager_test.go | 11 + .../core/internal/server/brightness/sysfs.go | 267 + .../server/brightness/sysfs_logind_test.go | 278 + .../core/internal/server/brightness/sysfs_test.go | 180 + .../core/internal/server/brightness/types.go | 184 + .../core/internal/server/brightness/udev.go | 237 + .../core/internal/server/brightness/udev_test.go | 260 + .../core/internal/server/browser/handlers.go | 22 + .../core/internal/server/browser/manager.go | 49 + .../core/internal/server/browser/models.go | 5 + .../core/internal/server/clipboard/handlers.go | 325 + .../core/internal/server/clipboard/manager.go | 1911 +++++ .../core/internal/server/clipboard/manager_test.go | 531 ++ .../core/internal/server/clipboard/types.go | 196 + .../core/internal/server/cups/actions.go | 479 ++ .../internal/server/cups/actions_pkhelper_test.go | 235 + .../core/internal/server/cups/actions_test.go | 450 ++ .../core/internal/server/cups/handlers.go | 487 ++ .../core/internal/server/cups/handlers_test.go | 711 ++ .../core/internal/server/cups/manager.go | 390 + .../core/internal/server/cups/manager_test.go | 363 + .../core/internal/server/cups/pkhelper.go | 184 + .../core/internal/server/cups/pkhelper_test.go | 95 + .../core/internal/server/cups/subscription.go | 245 + .../core/internal/server/cups/subscription_dbus.go | 295 + .../core/internal/server/cups/test_connection.go | 176 + .../internal/server/cups/test_connection_test.go | 397 ++ .../core/internal/server/cups/testing.go | 13 + .../core/internal/server/cups/types.go | 132 + .../core/internal/server/dbus/handlers.go | 237 + .../core/internal/server/dbus/manager.go | 392 + .../core/internal/server/dbus/types.go | 52 + .../core/internal/server/dwl/handlers.go | 138 + .../core/internal/server/dwl/manager.go | 522 ++ .../core/internal/server/dwl/manager_test.go | 366 + .../core/internal/server/dwl/types.go | 176 + .../core/internal/server/evdev/handlers.go | 20 + .../core/internal/server/evdev/handlers_test.go | 130 + .../core/internal/server/evdev/manager.go | 413 ++ .../core/internal/server/evdev/manager_test.go | 344 + .../core/internal/server/evdev/models.go | 6 + .../core/internal/server/extworkspace/handlers.go | 134 + .../core/internal/server/extworkspace/manager.go | 598 ++ .../internal/server/extworkspace/manager_test.go | 392 + .../core/internal/server/extworkspace/types.go | 169 + .../core/internal/server/freedesktop/actions.go | 112 + .../internal/server/freedesktop/actions_test.go | 145 + .../core/internal/server/freedesktop/constants.go | 23 + .../core/internal/server/freedesktop/handlers.go | 154 + .../internal/server/freedesktop/handlers_test.go | 581 ++ .../core/internal/server/freedesktop/manager.go | 206 + .../internal/server/freedesktop/manager_test.go | 143 + .../internal/server/freedesktop/screensaver.go | 349 + .../server/freedesktop/screensaver_test.go | 102 + .../core/internal/server/freedesktop/types.go | 66 + .../core/internal/server/freedesktop/types_test.go | 70 + .../core/internal/server/location/handlers.go | 61 + .../core/internal/server/location/manager.go | 175 + .../core/internal/server/location/types.go | 28 + .../core/internal/server/loginctl/actions.go | 88 + .../core/internal/server/loginctl/constants.go | 10 + .../core/internal/server/loginctl/handlers.go | 156 + .../core/internal/server/loginctl/handlers_test.go | 500 ++ .../core/internal/server/loginctl/manager.go | 723 ++ .../core/internal/server/loginctl/manager_test.go | 296 + .../core/internal/server/loginctl/monitor.go | 155 + .../core/internal/server/loginctl/monitor_test.go | 298 + .../core/internal/server/loginctl/types.go | 76 + .../core/internal/server/loginctl/types_test.go | 50 + .../core/internal/server/matugen_handler.go | 79 + .../core/internal/server/models/types.go | 47 + .../core/internal/server/models/types_test.go | 52 + .../core/internal/server/network/API.md | 552 ++ .../core/internal/server/network/agent_iwd.go | 306 + .../server/network/agent_networkmanager.go | 897 +++ .../server/network/agent_networkmanager_test.go | 355 + .../core/internal/server/network/backend.go | 80 + .../server/network/backend_hybrid_iwd_networkd.go | 245 + .../internal/server/network/backend_hybrid_test.go | 135 + .../core/internal/server/network/backend_iwd.go | 238 + .../internal/server/network/backend_iwd_signals.go | 355 + .../internal/server/network/backend_iwd_test.go | 212 + .../server/network/backend_iwd_unimplemented.go | 133 + .../internal/server/network/backend_iwd_wifi.go | 662 ++ .../internal/server/network/backend_networkd.go | 301 + .../server/network/backend_networkd_ethernet.go | 120 + .../server/network/backend_networkd_signals.go | 68 + .../server/network/backend_networkd_test.go | 147 + .../network/backend_networkd_unimplemented.go | 99 + .../server/network/backend_networkmanager.go | 405 ++ .../network/backend_networkmanager_ethernet.go | 402 ++ .../backend_networkmanager_ethernet_test.go | 134 + .../network/backend_networkmanager_gp_saml.go | 203 + .../network/backend_networkmanager_gp_saml_test.go | 169 + .../network/backend_networkmanager_signals.go | 530 ++ .../network/backend_networkmanager_signals_test.go | 255 + .../server/network/backend_networkmanager_state.go | 277 + .../network/backend_networkmanager_state_test.go | 82 + .../server/network/backend_networkmanager_test.go | 154 + .../server/network/backend_networkmanager_vpn.go | 1394 ++++ .../network/backend_networkmanager_vpn_test.go | 138 + .../server/network/backend_networkmanager_wifi.go | 1173 +++ .../network/backend_networkmanager_wifi_test.go | 202 + .../core/internal/server/network/broker.go | 22 + .../internal/server/network/connection_test.go | 109 + .../core/internal/server/network/detect.go | 89 + .../core/internal/server/network/detect_test.go | 41 + .../core/internal/server/network/handlers.go | 630 ++ .../core/internal/server/network/handlers_test.go | 269 + .../core/internal/server/network/helpers.go | 53 + .../core/internal/server/network/manager.go | 626 ++ .../core/internal/server/network/manager_test.go | 191 + .../core/internal/server/network/monitor.go | 1 + .../core/internal/server/network/priority.go | 187 + .../core/internal/server/network/priority_test.go | 50 + .../internal/server/network/subscription_broker.go | 125 + .../core/internal/server/network/testing.go | 14 + .../core/internal/server/network/types.go | 267 + .../core/internal/server/network/types_test.go | 178 + .../core/internal/server/network/wifi_qrcode.go | 59 + .../core/internal/server/network/wifi_test.go | 148 + .../core/internal/server/network/wired_test.go | 51 + .../core/internal/server/params/params.go | 113 + .../core/internal/server/params/params_test.go | 154 + .../core/internal/server/plugins/handlers.go | 27 + .../core/internal/server/plugins/handlers_test.go | 196 + .../core/internal/server/plugins/install.go | 69 + .../core/internal/server/plugins/list.go | 53 + .../core/internal/server/plugins/list_installed.go | 77 + .../core/internal/server/plugins/search.go | 74 + .../core/internal/server/plugins/types.go | 25 + .../core/internal/server/plugins/uninstall.go | 66 + .../core/internal/server/plugins/update.go | 64 + .../core/internal/server/plugins/utils.go | 17 + .../core/internal/server/router.go | 250 + .../core/internal/server/server.go | 1758 +++++ .../core/internal/server/server_test.go | 180 + .../core/internal/server/thememode/handlers.go | 154 + .../core/internal/server/thememode/manager.go | 462 ++ .../core/internal/server/thememode/types.go | 23 + .../core/internal/server/themes/handlers.go | 27 + .../core/internal/server/themes/install.go | 52 + .../core/internal/server/themes/list.go | 54 + .../core/internal/server/themes/list_installed.go | 149 + .../core/internal/server/themes/search.go | 53 + .../core/internal/server/themes/types.go | 47 + .../core/internal/server/themes/uninstall.go | 63 + .../core/internal/server/themes/update.go | 57 + .../core/internal/server/trayrecovery/manager.go | 93 + .../core/internal/server/trayrecovery/recovery.go | 262 + .../core/internal/server/wayland/gamma.go | 167 + .../core/internal/server/wayland/gamma_test.go | 120 + .../core/internal/server/wayland/geolocation.go | 50 + .../core/internal/server/wayland/handlers.go | 182 + .../core/internal/server/wayland/manager.go | 1256 ++++ .../core/internal/server/wayland/manager_test.go | 414 ++ .../core/internal/server/wayland/suncalc.go | 122 + .../core/internal/server/wayland/suncalc_test.go | 387 + .../core/internal/server/wayland/types.go | 216 + .../core/internal/server/wayland/types_test.go | 330 + .../core/internal/server/wlcontext/context.go | 200 + .../core/internal/server/wlcontext/context_test.go | 128 + .../core/internal/server/wlroutput/handlers.go | 282 + .../core/internal/server/wlroutput/manager.go | 492 ++ .../core/internal/server/wlroutput/manager_test.go | 414 ++ .../core/internal/server/wlroutput/types.go | 192 + .../core/internal/themes/manager.go | 258 + .../core/internal/themes/registry.go | 309 + .../core/internal/themes/search.go | 40 + .../DankMaterialShell/core/internal/tui/app.go | 240 + .../DankMaterialShell/core/internal/tui/banner.go | 21 + .../core/internal/tui/messages.go | 39 + .../DankMaterialShell/core/internal/tui/states.go | 22 + .../DankMaterialShell/core/internal/tui/styles.go | 124 + .../core/internal/tui/views_config.go | 382 + .../core/internal/tui/views_dependencies.go | 304 + .../core/internal/tui/views_gentoo_gcc.go | 103 + .../core/internal/tui/views_gentoo_use_flags.go | 92 + .../core/internal/tui/views_install.go | 405 ++ .../core/internal/tui/views_password.go | 338 + .../core/internal/tui/views_selection.go | 224 + .../core/internal/tui/views_welcome.go | 216 + .../DankMaterialShell/core/internal/utils/dbus.go | 37 + .../DankMaterialShell/core/internal/utils/exec.go | 44 + .../core/internal/utils/flatpak.go | 83 + .../core/internal/utils/flatpak_test.go | 271 + .../DankMaterialShell/core/internal/utils/group.go | 37 + .../core/internal/utils/group_test.go | 142 + .../core/internal/utils/gsettings.go | 31 + .../DankMaterialShell/core/internal/utils/math.go | 13 + .../DankMaterialShell/core/internal/utils/paths.go | 70 + .../core/internal/utils/paths_test.go | 43 + .../core/internal/utils/slices.go | 56 + .../core/internal/utils/slices_test.go | 72 + .../core/internal/version/version.go | 268 + .../core/internal/version/version_test.go | 362 + .../core/internal/wayland/client/helpers.go | 26 + .../core/internal/wayland/shm/buffer.go | 298 + .../windowrules/providers/hyprland_parser.go | 658 ++ .../windowrules/providers/hyprland_parser_test.go | 280 + .../internal/windowrules/providers/niri_parser.go | 875 +++ .../windowrules/providers/niri_parser_test.go | 335 + .../windowrules/providers/providers_test.go | 22 + .../core/internal/windowrules/types.go | 103 + .../DankMaterialShell/core/pkg/dbusutil/variant.go | 95 + .../core/pkg/dbusutil/variant_test.go | 155 + .../DankMaterialShell/core/pkg/go-wayland/AUTHORS | 3 + .../DankMaterialShell/core/pkg/go-wayland/LICENSE | 24 + .../core/pkg/go-wayland/README.md | 25 + .../DankMaterialShell/core/pkg/go-wayland/generate | 4 + .../core/pkg/go-wayland/generatep | 9 + .../core/pkg/go-wayland/wayland/client/client.go | 7542 ++++++++++++++++++++ .../core/pkg/go-wayland/wayland/client/common.go | 55 + .../core/pkg/go-wayland/wayland/client/context.go | 143 + .../pkg/go-wayland/wayland/client/context_test.go | 111 + .../core/pkg/go-wayland/wayland/client/display.go | 37 + .../core/pkg/go-wayland/wayland/client/doc.go | 6 + .../core/pkg/go-wayland/wayland/client/event.go | 120 + .../core/pkg/go-wayland/wayland/client/request.go | 44 + .../core/pkg/go-wayland/wayland/client/util.go | 24 + .../wayland/stable/xdg-shell/xdg_shell.go | 13 + .../DankMaterialShell/core/pkg/ipp/CREDITS.MD | 5 + .../DankMaterialShell/core/pkg/ipp/LICENSE | 201 + .../DankMaterialShell/core/pkg/ipp/adapter-http.go | 132 + .../DankMaterialShell/core/pkg/ipp/adapter.go | 9 + .../DankMaterialShell/core/pkg/ipp/attribute.go | 528 ++ .../DankMaterialShell/core/pkg/ipp/constants.go | 450 ++ .../DankMaterialShell/core/pkg/ipp/cups-client.go | 313 + .../DankMaterialShell/core/pkg/ipp/error.go | 31 + .../DankMaterialShell/core/pkg/ipp/ipp-client.go | 329 + .../DankMaterialShell/core/pkg/ipp/request.go | 299 + .../DankMaterialShell/core/pkg/ipp/response.go | 383 + .../DankMaterialShell/core/pkg/ipp/utils.go | 28 + .../DankMaterialShell/core/pkg/syncmap/LICENSE | 28 + .../DankMaterialShell/core/pkg/syncmap/syncmap.go | 537 ++ 449 files changed, 136761 insertions(+) create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/Makefile create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/README.md create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/assets/dank.svg create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/assets/danklogo.svg create mode 100755 raveos-theme/hyprland/theme-data/DankMaterialShell/core/build_dankinstall.sh create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dankinstall/main.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/assets/cli-policy.default.json create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_auth.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_blur.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_brightness.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_chroma.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_clipboard.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_colorpicker.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_common.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_config.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_dank16.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_doctor.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_download.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_dpms.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_features.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_keybinds.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_matugen.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_notify.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_open.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_randr.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_root.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_screenshot.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_setup.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_windowrules.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/dpms_client.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/immutable_policy.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/main.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/main_distro.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/randr_client.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/server_client.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/shell.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/ui.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/utils.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/go.mod create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/go.sum create mode 100755 raveos-theme/hyprland/theme-data/DankMaterialShell/core/install.sh create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/blur/probe.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/clipboard.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/store.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/watch.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/color.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/picker.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/shm.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/state.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/state_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/deployer.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/deployer_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/dms.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/alacritty-theme.toml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/alacritty.toml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/ghostty-colors.conf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/ghostty.conf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-binds.conf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-colors.conf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-layout.conf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hyprland.conf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty-tabs.conf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty-theme.conf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty.conf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-alttab.kdl create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-binds.kdl create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-colors.kdl create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-greeter.kdl create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-layout.kdl create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri.kdl create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/testpage.pdf create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/hyprland.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/niri.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/terminals.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/testpage.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/dank16.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/dank16_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/terminals.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/deps/detector.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/arch.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/base.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/base_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/debian.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/factory.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/fedora.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/gentoo.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/interface.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/manual_packages.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/manual_packages_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/minimal_install.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/opensuse.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/osinfo.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/ubuntu.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/errdefs/errdefs.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client_geoclue.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client_ip.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/assets/apparmor/usr.bin.dms-greeter create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/installer.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/installer_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/headless/runner.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/headless/runner_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle_parser.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/log/filelogger.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/log/log.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/matugen.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/matugen_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/queue.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/brightness/mock_DBusConn.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/cups/mock_CUPSClientInterface.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/cups_pkhelper/mock_PkHelper.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/evdev/mock_EvdevDevice.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/geolocation/mock_Client.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_AccessPoint.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_ActiveConnection.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Connection.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Device.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_DeviceWireless.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_IP4Config.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_NetworkManager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Settings.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5/mock_BusObject.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/internal/plugins/mock_GitClient.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/net/mock_Conn.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/network/mock_Backend.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/utils/mock_AppChecker.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/version/mock_VersionFetcher.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/wlclient/mock_WaylandDisplay.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/wlcontext/mock_WaylandContext.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/notify/notify.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/pam/pam.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/pam/pam_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/registry.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/registry_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/search.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/dwl_ipc/dwl_ipc.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/ext_data_control/data_control.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/ext_workspace/workspace.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/keyboard_shortcuts_inhibit/keyboard_shortcuts_inhibit.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_gamma_control/gamma_control.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_layer_shell/layer_shell.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_output_management/output_management.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_output_power/output_power.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_screencopy/screencopy.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wp_viewporter/viewporter.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/dwl-ipc-unstable-v2.xml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/ext-data-control-v1.xml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/ext-workspace-v1.xml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/keyboard-shortcuts-inhibit-unstable-v1.xml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-gamma-control-unstable-v1.xml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-layer-shell-unstable-v1.xml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-output-management-unstable-v1.xml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-output-power-management-unstable-v1.xml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-screencopy-unstable-v1.xml create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/compositor.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/encode.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/notify.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region_input.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region_render.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/screenshot.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/shm.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/state.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/theme.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/apppicker/models.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/agent.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/broker_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/handlers_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/subscription_broker.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/bluez/types_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_filter_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/ddc_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/logind_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_logind_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/sysfs_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/brightness/udev_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/browser/models.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_pkhelper_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/actions_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/handlers_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/pkhelper_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/subscription_dbus.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/test_connection_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/testing.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/cups/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dbus/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/dwl/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/handlers_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/evdev/models.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/extworkspace/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/actions_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/constants.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/handlers_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/screensaver_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/freedesktop/types_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/location/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/actions.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/constants.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/handlers_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/monitor_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/loginctl/types_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/matugen_handler.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/models/types_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/API.md create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_iwd.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/agent_networkmanager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_iwd_networkd.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_hybrid_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_signals.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_unimplemented.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_iwd_wifi.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_ethernet.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_signals.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkd_unimplemented.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_ethernet_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_gp_saml_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_signals_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_state_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_vpn_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/backend_networkmanager_wifi_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/broker.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/connection_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/detect_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/handlers_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/helpers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/monitor.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/priority_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/subscription_broker.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/testing.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/types_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_qrcode.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wifi_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/network/wired_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/params/params_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/handlers_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/install.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/list_installed.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/search.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/uninstall.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/update.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/plugins/utils.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/router.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/server_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/thememode/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/install.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/list_installed.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/search.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/uninstall.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/themes/update.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/trayrecovery/recovery.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlcontext/context_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/manager.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/registry.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/search.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/app.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/banner.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/messages.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/states.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/styles.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_config.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_dependencies.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_gentoo_gcc.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_gentoo_use_flags.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_install.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_password.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_selection.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_welcome.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/dbus.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/exec.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/flatpak.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/flatpak_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/group.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/group_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/gsettings.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/math.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/paths.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/paths_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/slices.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/slices_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/version/version.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/version/version_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/wayland/client/helpers.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/wayland/shm/buffer.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/providers_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/types.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/dbusutil/variant.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/dbusutil/variant_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/AUTHORS create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/LICENSE create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/README.md create mode 100755 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/generate create mode 100755 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/generatep create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/client.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/common.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/context.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/context_test.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/display.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/doc.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/event.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/request.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/util.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/stable/xdg-shell/xdg_shell.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/CREDITS.MD create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/LICENSE create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/adapter-http.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/adapter.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/attribute.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/constants.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/cups-client.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/error.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/ipp-client.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/request.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/response.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/utils.go create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/syncmap/LICENSE create mode 100644 raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/syncmap/syncmap.go (limited to 'raveos-theme/hyprland/theme-data/DankMaterialShell/core') diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/Makefile b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/Makefile new file mode 100644 index 0000000..cad9b95 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/Makefile @@ -0,0 +1,163 @@ +BINARY_NAME=dms +BINARY_NAME_INSTALL=dankinstall +SOURCE_DIR=cmd/dms +SOURCE_DIR_INSTALL=cmd/dankinstall +BUILD_DIR=bin +PREFIX ?= /usr/local +INSTALL_DIR=$(PREFIX)/bin + +GO=go +GOFLAGS=-ldflags="-s -w" + +# Version and build info +BASE_VERSION=$(shell git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "0.0.0") +COMMIT_COUNT=$(shell git rev-list --count HEAD 2>/dev/null || echo "0") +COMMIT_HASH=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "unknown") +VERSION?=$(BASE_VERSION)+git$(COMMIT_COUNT).$(COMMIT_HASH) +BUILD_TIME?=$(shell date -u '+%Y-%m-%d_%H:%M:%S') +COMMIT?=$(COMMIT_HASH) + +BUILD_LDFLAGS=-ldflags='-s -w -X main.Version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.commit=$(COMMIT)' + +# Architecture to build for dist target (amd64, arm64, or all) +ARCH ?= all + +.PHONY: all build dankinstall dist clean install install-all install-dankinstall uninstall uninstall-all uninstall-dankinstall install-config uninstall-config test fmt vet deps print-version help + +# Default target +all: build + +# Build the main binary (dms) +build: + @echo "Building $(BINARY_NAME)..." + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" + +dankinstall: + @echo "Building $(BINARY_NAME_INSTALL)..." + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME_INSTALL) ./$(SOURCE_DIR_INSTALL) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME_INSTALL)" + +# Build distro binaries for amd64 and arm64 (Linux only, no update/greeter support) +dist: +ifeq ($(ARCH),all) + @echo "Building $(BINARY_NAME) for distribution (amd64 and arm64)..." + @mkdir -p $(BUILD_DIR) + @echo "Building for linux/amd64..." + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(SOURCE_DIR) + @echo "Building for linux/arm64..." + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(SOURCE_DIR) + @echo "Distribution builds complete:" + @echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64" + @echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64" +else + @echo "Building $(BINARY_NAME) for distribution ($(ARCH))..." + @mkdir -p $(BUILD_DIR) + @echo "Building for linux/$(ARCH)..." + CGO_ENABLED=0 GOOS=linux GOARCH=$(ARCH) $(GO) build -tags distro_binary $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH) ./$(SOURCE_DIR) + @echo "Distribution build complete:" + @echo " $(BUILD_DIR)/$(BINARY_NAME)-linux-$(ARCH)" +endif + +build-all: build dankinstall + +install: + @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." + @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) + @echo "Installation complete" + +install-all: + @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." + @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) + @echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..." + @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) + @echo "Installation complete" + +install-dankinstall: + @echo "Installing $(BINARY_NAME_INSTALL) to $(INSTALL_DIR)..." + @install -D -m 755 $(BUILD_DIR)/$(BINARY_NAME_INSTALL) $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) + @echo "Installation complete" + +uninstall: + @echo "Uninstalling $(BINARY_NAME) from $(INSTALL_DIR)..." + @rm -f $(INSTALL_DIR)/$(BINARY_NAME) + @echo "Uninstall complete" + +uninstall-all: + @echo "Uninstalling $(BINARY_NAME) from $(INSTALL_DIR)..." + @rm -f $(INSTALL_DIR)/$(BINARY_NAME) + @echo "Uninstalling $(BINARY_NAME_INSTALL) from $(INSTALL_DIR)..." + @rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) + @echo "Uninstall complete" + +uninstall-dankinstall: + @echo "Uninstalling $(BINARY_NAME_INSTALL) from $(INSTALL_DIR)..." + @rm -f $(INSTALL_DIR)/$(BINARY_NAME_INSTALL) + @echo "Uninstall complete" + +clean: + @echo "Cleaning build artifacts..." + @rm -rf $(BUILD_DIR) + @echo "Clean complete" + +test: + @echo "Running tests..." + $(GO) test -v ./... + +fmt: + @echo "Formatting Go code..." + $(GO) fmt ./... + +vet: + @echo "Running go vet..." + $(GO) vet ./... + +deps: + @echo "Updating dependencies..." + $(GO) mod tidy + $(GO) mod download + +dev: + @echo "Building $(BINARY_NAME) for development..." + @mkdir -p $(BUILD_DIR) + $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME) ./$(SOURCE_DIR) + @echo "Development build complete: $(BUILD_DIR)/$(BINARY_NAME)" + +check-go: + @echo "Checking Go version..." + @go version | grep -E "go1\.(2[2-9]|[3-9][0-9])" > /dev/null || (echo "ERROR: Go 1.22 or higher required" && exit 1) + @echo "Go version OK" + +version: check-go + @echo "Version: $(VERSION)" + @echo "Build Time: $(BUILD_TIME)" + @echo "Commit: $(COMMIT)" + +print-version: + @echo "$(VERSION)" + +help: + @echo "Available targets:" + @echo " all - Build the main binary (dms) (default)" + @echo " build - Build the main binary (dms)" + @echo " dankinstall - Build dankinstall binary" + @echo " dist - Build dms for linux amd64/arm64 (no update/greeter)" + @echo " Use ARCH=amd64 or ARCH=arm64 to build only one" + @echo " build-all - Build both binaries" + @echo " install - Install dms to $(INSTALL_DIR)" + @echo " install-all - Install both dms and dankinstall to $(INSTALL_DIR)" + @echo " install-dankinstall - Install only dankinstall to $(INSTALL_DIR)" + @echo " uninstall - Remove dms from $(INSTALL_DIR)" + @echo " uninstall-all - Remove both binaries from $(INSTALL_DIR)" + @echo " uninstall-dankinstall - Remove only dankinstall from $(INSTALL_DIR)" + @echo " clean - Clean build artifacts" + @echo " test - Run tests" + @echo " fmt - Format Go code" + @echo " vet - Run go vet" + @echo " deps - Update dependencies" + @echo " dev - Build with debug info" + @echo " check-go - Check Go version compatibility" + @echo " version - Show version information" + @echo " help - Show this help message" diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/README.md b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/README.md new file mode 100644 index 0000000..fed5bfa --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/README.md @@ -0,0 +1,217 @@ +# DMS Backend & CLI + +Go-based backend for DankMaterialShell providing system integration, IPC, and installation tools. + +**See [root README](../README.md) for project overview and installation.** + +## Components + +**dms CLI** +Command-line interface and daemon for shell management and system control. + +**dankinstall** +Distribution-aware installer for deploying DMS and compositor configurations on Arch, Fedora, Debian, Ubuntu, openSUSE, and Gentoo. Supports both an interactive TUI and a headless (unattended) mode via CLI flags. + +## System Integration + +### Wayland Protocols (Client) + +All Wayland protocols are consumed as a client - connecting to the compositor. + +| Protocol | Purpose | +| ----------------------------------------- | ----------------------------------------------------------- | +| `wlr-gamma-control-unstable-v1` | Night mode color temperature control | +| `wlr-screencopy-unstable-v1` | Screen capture for color picker/screenshot | +| `wlr-layer-shell-unstable-v1` | Overlay surfaces for color picker UI/screenshot | +| `wlr-output-management-unstable-v1` | Display configuration | +| `wlr-output-power-management-unstable-v1` | DPMS on/off CLI | +| `wp-viewporter` | Fractional scaling support (color picker/screenshot UIs) | +| `keyboard-shortcuts-inhibit-unstable-v1` | Inhibit compositor shortcuts during color picker/screenshot | +| `ext-data-control-v1` | Clipboard history and persistence | +| `ext-workspace-v1` | Workspace integration | +| `dwl-ipc-unstable-v2` | dwl/MangoWC IPC for tags, outputs, etc. | + +### DBus Interfaces + +**Client (consuming external services):** + +| Interface | Purpose | +| -------------------------------- | --------------------------------------------- | +| `org.bluez` | Bluetooth management with pairing agent | +| `org.freedesktop.NetworkManager` | Network management | +| `net.connman.iwd` | iwd Wi-Fi backend | +| `org.freedesktop.network1` | systemd-networkd integration | +| `org.freedesktop.login1` | Session control, sleep inhibitors, brightness | +| `org.freedesktop.Accounts` | User account information | +| `org.freedesktop.portal.Desktop` | Desktop appearance settings (color scheme) | +| CUPS via IPP + D-Bus | Printer management with job notifications | + +**Server (implementing interfaces):** + +| Interface | Purpose | +| ----------------------------- | -------------------------------------- | +| `org.freedesktop.ScreenSaver` | Screensaver inhibit for video playback | + +Custom IPC via unix socket (JSON API) for shell communication. + +### Hardware Control + +| Subsystem | Method | Purpose | +| --------- | ------------------- | ---------------------------------- | +| DDC/CI | I2C direct | External monitor brightness | +| Backlight | logind or sysfs | Internal display brightness | +| evdev | `/dev/input/event*` | Keyboard state (caps lock LED) | +| udev | netlink monitor | Backlight device updates (for OSD) | + +### Plugin System + +- Plugin registry integration +- Plugin lifecycle management +- Settings persistence + +## CLI Commands + +- `dms run [-d]` - Start shell (optionally as daemon) +- `dms restart` / `dms kill` - Manage running processes +- `dms ipc ` - Send IPC commands (toggle launcher, notifications, etc.) +- `dms plugins [install|browse|search]` - Plugin management +- `dms brightness [list|set]` - Control display/monitor brightness +- `dms color pick` - Native color picker (see below) +- `dms update` - Update DMS and dependencies (disabled in distro packages) +- `dms greeter install` - Install greetd greeter (disabled in distro packages) + +### Color Picker + +Native Wayland color picker with magnifier, no external dependencies. Supports HiDPI and fractional scaling. + +```bash +dms color pick # Pick color, output hex +dms color pick --rgb # Output as RGB (255 128 64) +dms color pick --hsv # Output as HSV (24 75% 100%) +dms color pick --json # Output all formats as JSON +dms color pick -a # Auto-copy to clipboard +``` + +The on-screen preview displays the selected format. JSON output includes hex, RGB, HSL, HSV, and CMYK values. + +## Building + +Requires Go 1.25+ + +**Development build:** + +```bash +make # Build dms CLI +make dankinstall # Build installer +make test # Run tests +``` + +**Distribution build:** + +```bash +make dist # Build without update/greeter features +``` + +Produces `bin/dms-linux-amd64` and `bin/dms-linux-arm64` + +**Installation:** + +```bash +sudo make install # Install to /usr/local/bin/dms +``` + +## Development + +**Setup pre-commit hooks:** + +```bash +git config core.hooksPath .githooks +``` + +This runs gofmt, golangci-lint, tests, and builds before each commit when `core/` files are staged. + +**Regenerating Wayland Protocol Bindings:** + +```bash +go install github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner@latest +go-wayland-scanner -i internal/proto/xml/wlr-gamma-control-unstable-v1.xml \ + -pkg wlr_gamma_control -o internal/proto/wlr_gamma_control/gamma_control.go +``` + +**Module Structure:** + +- `cmd/` - Binary entrypoints (dms, dankinstall) +- `internal/distros/` - Distribution-specific installation logic +- `internal/proto/` - Wayland protocol bindings +- `pkg/` - Shared packages + +## Installation via dankinstall + +**Interactive (TUI):** + +```bash +curl -fsSL https://install.danklinux.com | sh +``` + +**Headless (unattended):** + +Headless mode requires cached sudo credentials. Run `sudo -v` first: + +```bash +sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c niri -t ghostty -y +sudo -v && curl -fsSL https://install.danklinux.com | sh -s -- -c hyprland -t kitty --include-deps dms-greeter -y +``` + +| Flag | Short | Description | +|------|-------|-------------| +| `--compositor ` | `-c` | Compositor/WM to install (required for headless) | +| `--term ` | `-t` | Terminal emulator (required for headless) | +| `--include-deps ` | | Enable optional dependencies (e.g. `dms-greeter`) | +| `--exclude-deps ` | | Skip specific dependencies | +| `--replace-configs ` | | Replace specific configuration files (mutually exclusive with `--replace-configs-all`) | +| `--replace-configs-all` | | Replace all configuration files (mutually exclusive with `--replace-configs`) | +| `--yes` | `-y` | Required for headless mode — confirms installation without interactive prompts | + +Headless mode requires `--yes` to proceed; without it, the installer exits with an error. +Configuration files are not replaced by default unless `--replace-configs` or `--replace-configs-all` is specified. +`dms-greeter` is disabled by default; use `--include-deps dms-greeter` to enable it. + +When no flags are provided, `dankinstall` launches the interactive TUI. + +### Headless mode validation rules + +Headless mode activates when `--compositor` or `--term` is provided. + +- Both `--compositor` and `--term` are required; providing only one results in an error. +- Headless-only flags (`--include-deps`, `--exclude-deps`, `--replace-configs`, `--replace-configs-all`, `--yes`) are rejected in TUI mode. +- Positional arguments are not accepted. + +### Log file location + +`dankinstall` writes logs to `/tmp` by default. +Set the `DANKINSTALL_LOG_DIR` environment variable to override the log directory. + +## Supported Distributions + +Arch, Fedora, Debian, Ubuntu, openSUSE, Gentoo (and derivatives) + +**Arch Linux** +Uses `pacman` for system packages, builds AUR packages via `makepkg`, no AUR helper dependency. + +**Fedora** + +Uses COPR repositories (`avengemedia/danklinux`, `avengemedia/dms`). + +**Ubuntu** +Requires PPA support. Most packages built from source (slow first install). + +**Debian** +Debian 13+ (Trixie). niri only, no Hyprland support. Builds from source. + +**openSUSE** +Most packages available in standard repos. Minimal building required. + +**Gentoo** +Uses Portage with GURU overlay. Automatically configures USE flags. Variable success depending on system configuration. + +See installer output for distribution-specific details during installation. diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/assets/dank.svg b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/assets/dank.svg new file mode 100644 index 0000000..3b4b334 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/assets/dank.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/assets/danklogo.svg b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/assets/danklogo.svg new file mode 100644 index 0000000..1ee6d51 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/assets/danklogo.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/build_dankinstall.sh b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/build_dankinstall.sh new file mode 100755 index 0000000..c430e73 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/build_dankinstall.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +set -euo pipefail + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Get latest version tag +VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + +echo -e "${GREEN}Building dankinstall ${VERSION}${NC}" + +# Create bin directory if it doesn't exist +mkdir -p bin + +# Build for each architecture +for ARCH in amd64 arm64; do + echo -e "${BLUE}Building for ${ARCH}...${NC}" + + cd cmd/dankinstall + GOOS=linux CGO_ENABLED=0 GOARCH=${ARCH} \ + go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" \ + -o ../../bin/dankinstall-${ARCH} + cd ../.. + + # Compress + gzip -9 -k -f bin/dankinstall-${ARCH} + + # Generate checksum + (cd bin && sha256sum dankinstall-${ARCH}.gz > dankinstall-${ARCH}.gz.sha256) + + echo -e "${GREEN}✓ Built bin/dankinstall-${ARCH}.gz${NC}" +done + +echo -e "${GREEN}Done! Files ready in bin/:${NC}" +ls -lh bin/dankinstall-* diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dankinstall/main.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dankinstall/main.go new file mode 100644 index 0000000..042506e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dankinstall/main.go @@ -0,0 +1,219 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/headless" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/tui" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" +) + +var Version = "dev" + +// Flag variables bound via pflag +var ( + compositor string + term string + includeDeps []string + excludeDeps []string + replaceConfigs []string + replaceConfigsAll bool + yes bool +) + +var rootCmd = &cobra.Command{ + Use: "dankinstall", + Short: "Install DankMaterialShell and its dependencies", + Long: `dankinstall sets up DankMaterialShell with your chosen compositor and terminal. + +Without flags, it launches an interactive TUI. Providing either --compositor +or --term activates headless (unattended) mode, which requires both flags. + +Headless mode requires cached sudo credentials. Run 'sudo -v' beforehand, or +configure passwordless sudo for your user.`, + Args: cobra.NoArgs, + RunE: runDankinstall, + SilenceErrors: true, + SilenceUsage: true, +} + +func init() { + rootCmd.Flags().StringVarP(&compositor, "compositor", "c", "", "Compositor/WM to install: niri or hyprland (enables headless mode)") + rootCmd.Flags().StringVarP(&term, "term", "t", "", "Terminal emulator to install: ghostty, kitty, or alacritty (enables headless mode)") + rootCmd.Flags().StringSliceVar(&includeDeps, "include-deps", []string{}, "Optional deps to enable (e.g. dms-greeter)") + rootCmd.Flags().StringSliceVar(&excludeDeps, "exclude-deps", []string{}, "Deps to skip during installation") + rootCmd.Flags().StringSliceVar(&replaceConfigs, "replace-configs", []string{}, "Deploy only named configs (e.g. niri,ghostty)") + rootCmd.Flags().BoolVar(&replaceConfigsAll, "replace-configs-all", false, "Deploy and replace all configurations") + rootCmd.Flags().BoolVarP(&yes, "yes", "y", false, "Auto-confirm all prompts") +} + +func main() { + if os.Getuid() == 0 { + fmt.Fprintln(os.Stderr, "Error: dankinstall must not be run as root") + os.Exit(1) + } + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func runDankinstall(cmd *cobra.Command, args []string) error { + headlessMode := compositor != "" || term != "" + + if !headlessMode { + // Reject headless-only flags when running in TUI mode. + headlessOnly := []string{ + "include-deps", + "exclude-deps", + "replace-configs", + "replace-configs-all", + "yes", + } + var set []string + for _, name := range headlessOnly { + if cmd.Flags().Changed(name) { + set = append(set, "--"+name) + } + } + if len(set) > 0 { + return fmt.Errorf("flags %s are only valid in headless mode (requires both --compositor and --term)", strings.Join(set, ", ")) + } + } + + if headlessMode { + return runHeadless() + } + return runTUI() +} + +func runHeadless() error { + // Validate required flags + if compositor == "" { + return fmt.Errorf("--compositor is required for headless mode (niri or hyprland)") + } + if term == "" { + return fmt.Errorf("--term is required for headless mode (ghostty, kitty, or alacritty)") + } + + cfg := headless.Config{ + Compositor: compositor, + Terminal: term, + IncludeDeps: includeDeps, + ExcludeDeps: excludeDeps, + ReplaceConfigs: replaceConfigs, + ReplaceConfigsAll: replaceConfigsAll, + Yes: yes, + } + + runner := headless.NewRunner(cfg) + + // Set up file logging + fileLogger, err := log.NewFileLogger() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to create log file: %v\n", err) + } + + if fileLogger != nil { + fmt.Printf("Logging to: %s\n", fileLogger.GetLogPath()) + fileLogger.StartListening(runner.GetLogChan()) + defer func() { + if err := fileLogger.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to close log file: %v\n", err) + } + }() + } else { + // Drain the log channel to prevent blocking sends from deadlocking + // downstream components (distros, config deployer) that write to it. + // Use an explicit stop signal because this code does not own the + // runner log channel and cannot assume it will be closed. + defer drainLogChan(runner.GetLogChan())() + } + + if err := runner.Run(); err != nil { + if fileLogger != nil { + fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", fileLogger.GetLogPath()) + } + return err + } + + if fileLogger != nil { + fmt.Printf("\nFull logs are available at: %s\n", fileLogger.GetLogPath()) + } + return nil +} + +func runTUI() error { + fileLogger, err := log.NewFileLogger() + if err != nil { + fmt.Printf("Warning: Failed to create log file: %v\n", err) + fmt.Println("Continuing without file logging...") + } + + logFilePath := "" + if fileLogger != nil { + logFilePath = fileLogger.GetLogPath() + fmt.Printf("Logging to: %s\n", logFilePath) + defer func() { + if err := fileLogger.Close(); err != nil { + fmt.Printf("Warning: Failed to close log file: %v\n", err) + } + }() + } + + model := tui.NewModel(Version, logFilePath) + + if fileLogger != nil { + fileLogger.StartListening(model.GetLogChan()) + } else { + // Drain the log channel to prevent blocking sends from deadlocking + // downstream components (distros, config deployer) that write to it. + // Use an explicit stop signal because this code does not own the + // model log channel and cannot assume it will be closed. + defer drainLogChan(model.GetLogChan())() + } + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + if logFilePath != "" { + fmt.Fprintf(os.Stderr, "\nFull logs are available at: %s\n", logFilePath) + } + return fmt.Errorf("error running program: %w", err) + } + + if logFilePath != "" { + fmt.Printf("\nFull logs are available at: %s\n", logFilePath) + } + return nil +} + +// drainLogChan starts a goroutine that discards all messages from logCh, +// preventing blocking sends from deadlocking downstream components. It returns +// a cleanup function that signals the goroutine to stop and waits for it to +// exit. Callers should defer the returned function. +func drainLogChan(logCh <-chan string) func() { + drainStop := make(chan struct{}) + drainDone := make(chan struct{}) + go func() { + defer close(drainDone) + for { + select { + case <-drainStop: + return + case _, ok := <-logCh: + if !ok { + return + } + } + } + }() + return func() { + close(drainStop) + <-drainDone + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/assets/cli-policy.default.json b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/assets/cli-policy.default.json new file mode 100644 index 0000000..de9b8ad --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/assets/cli-policy.default.json @@ -0,0 +1,10 @@ +{ + "policy_version": 1, + "blocked_commands": [ + "greeter install", + "greeter enable", + "greeter uninstall", + "setup" + ], + "message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes." +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_auth.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_auth.go new file mode 100644 index 0000000..727700e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_auth.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" + "github.com/spf13/cobra" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage DMS authentication sync", + Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen", +} + +var authSyncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync DMS authentication configuration", + Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings", + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + if err := syncAuthInTerminal(yes); err != nil { + log.Fatalf("Error launching auth sync in terminal: %v", err) + } + return + } + if err := syncAuth(yes); err != nil { + log.Fatalf("Error syncing authentication: %v", err) + } + }, +} + +func init() { + authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts") + authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)") +} + +func syncAuth(nonInteractive bool) error { + if !nonInteractive { + fmt.Println("=== DMS Authentication Sync ===") + fmt.Println() + } + + logFunc := func(msg string) { + fmt.Println(msg) + } + + if err := sharedpam.SyncAuthConfig(logFunc, "", sharedpam.SyncAuthOptions{}); err != nil { + return err + } + + if !nonInteractive { + fmt.Println("\n=== Authentication Sync Complete ===") + fmt.Println("\nAuthentication changes have been applied.") + } + + return nil +} + +func syncAuthInTerminal(nonInteractive bool) error { + syncFlags := make([]string, 0, 1) + if nonInteractive { + syncFlags = append(syncFlags, "--yes") + } + + shellSyncCmd := "dms auth sync" + if len(syncFlags) > 0 { + shellSyncCmd += " " + strings.Join(syncFlags, " ") + } + shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3` + return runCommandInTerminal(shellCmd) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_blur.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_blur.go new file mode 100644 index 0000000..d01eb93 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_blur.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/blur" + "github.com/spf13/cobra" +) + +var blurCmd = &cobra.Command{ + Use: "blur", + Short: "Background blur utilities", +} + +var blurCheckCmd = &cobra.Command{ + Use: "check", + Short: "Check if the compositor supports background blur (ext-background-effect-v1)", + Args: cobra.NoArgs, + Run: runBlurCheck, +} + +func init() { + blurCmd.AddCommand(blurCheckCmd) +} + +func runBlurCheck(cmd *cobra.Command, args []string) { + supported, err := blur.ProbeSupport() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + switch supported { + case true: + fmt.Println("supported") + default: + fmt.Println("unsupported") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_brightness.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_brightness.go new file mode 100644 index 0000000..0f73493 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_brightness.go @@ -0,0 +1,311 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" + "github.com/spf13/cobra" +) + +var brightnessCmd = &cobra.Command{ + Use: "brightness", + Short: "Control device brightness", + Long: "Control brightness for backlight and LED devices (use --ddc to include DDC/I2C monitors)", +} + +var brightnessListCmd = &cobra.Command{ + Use: "list", + Short: "List all brightness devices", + Long: "List all available brightness devices with their current values", + Run: runBrightnessList, +} + +var brightnessSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set brightness for a device", + Long: "Set brightness percentage (0-100) for a specific device", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + includeDDC, _ := cmd.Flags().GetBool("ddc") + return getBrightnessDevices(includeDDC), cobra.ShellCompDirectiveNoFileComp + }, + Run: runBrightnessSet, +} + +var brightnessGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get brightness for a device", + Long: "Get current brightness percentage for a specific device", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + includeDDC, _ := cmd.Flags().GetBool("ddc") + return getBrightnessDevices(includeDDC), cobra.ShellCompDirectiveNoFileComp + }, + Run: runBrightnessGet, +} + +func init() { + brightnessListCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)") + brightnessSetCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)") + brightnessSetCmd.Flags().Bool("exponential", false, "Use exponential brightness scaling") + brightnessSetCmd.Flags().Float64("exponent", 1.2, "Exponent for exponential scaling (default 1.2)") + brightnessGetCmd.Flags().Bool("ddc", false, "Include DDC/I2C monitors (slower)") + + brightnessCmd.SetHelpTemplate(`{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableSubCommands}} + +Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +`) + + brightnessListCmd.SetHelpTemplate(`{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} +`) + + brightnessSetCmd.SetHelpTemplate(`{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} +`) + + brightnessGetCmd.SetHelpTemplate(`{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}} +`) + + brightnessCmd.AddCommand(brightnessListCmd, brightnessSetCmd, brightnessGetCmd) +} + +func getAllBrightnessDevices(includeDDC bool) []brightness.Device { + allDevices := []brightness.Device{} + + sysfs, err := brightness.NewSysfsBackend() + if err != nil { + log.Debugf("Failed to initialize sysfs backend: %v", err) + } else { + devices, err := sysfs.GetDevices() + if err != nil { + log.Debugf("Failed to get sysfs devices: %v", err) + } else { + allDevices = append(allDevices, devices...) + } + } + + if includeDDC { + ddc, err := brightness.NewDDCBackend() + if err != nil { + fmt.Printf("Warning: Failed to initialize DDC backend: %v\n", err) + } else { + time.Sleep(100 * time.Millisecond) + devices, err := ddc.GetDevices() + if err != nil { + fmt.Printf("Warning: Failed to get DDC devices: %v\n", err) + } else { + allDevices = append(allDevices, devices...) + } + ddc.Close() + } + } + + return allDevices +} + +func runBrightnessList(cmd *cobra.Command, args []string) { + includeDDC, _ := cmd.Flags().GetBool("ddc") + allDevices := getAllBrightnessDevices(includeDDC) + + if len(allDevices) == 0 { + fmt.Println("No brightness devices found") + return + } + + maxIDLen := len("Device") + maxNameLen := len("Name") + for _, dev := range allDevices { + if len(dev.ID) > maxIDLen { + maxIDLen = len(dev.ID) + } + if len(dev.Name) > maxNameLen { + maxNameLen = len(dev.Name) + } + } + + idPad := maxIDLen + 2 + namePad := maxNameLen + 2 + + fmt.Printf("%-*s %-12s %-*s %s\n", idPad, "Device", "Class", namePad, "Name", "Brightness") + + sepLen := idPad + 2 + 12 + 2 + namePad + 2 + 15 + for range sepLen { + fmt.Print("─") + } + fmt.Println() + + for _, device := range allDevices { + fmt.Printf("%-*s %-12s %-*s %3d%%\n", + idPad, + device.ID, + device.Class, + namePad, + device.Name, + device.CurrentPercent, + ) + } +} + +func runBrightnessSet(cmd *cobra.Command, args []string) { + deviceID := args[0] + var percent int + if _, err := fmt.Sscanf(args[1], "%d", &percent); err != nil { + log.Fatalf("Invalid percent value: %s", args[1]) + } + + if percent < 0 || percent > 100 { + log.Fatalf("Percent must be between 0 and 100") + } + + includeDDC, _ := cmd.Flags().GetBool("ddc") + exponential, _ := cmd.Flags().GetBool("exponential") + exponent, _ := cmd.Flags().GetFloat64("exponent") + + parts := strings.SplitN(deviceID, ":", 2) + if len(parts) == 2 && (parts[0] == "backlight" || parts[0] == "leds") { + if ok := tryLogindBrightness(parts[0], parts[1], deviceID, percent, exponential, exponent); ok { + return + } + } + + sysfs, err := brightness.NewSysfsBackend() + if err == nil { + if err := sysfs.SetBrightnessWithExponent(deviceID, percent, exponential, exponent); err == nil { + fmt.Printf("Set %s to %d%%\n", deviceID, percent) + return + } + log.Debugf("sysfs.SetBrightness failed: %v", err) + } else { + log.Debugf("NewSysfsBackend failed: %v", err) + } + + // Try DDC if requested + if includeDDC { + ddc, err := brightness.NewDDCBackend() + if err == nil { + defer ddc.Close() + time.Sleep(100 * time.Millisecond) + if err := ddc.SetBrightnessWithExponent(deviceID, percent, exponential, exponent, nil); err == nil { + ddc.WaitPending() + fmt.Printf("Set %s to %d%%\n", deviceID, percent) + return + } + log.Debugf("ddc.SetBrightness failed: %v", err) + } else { + log.Debugf("NewDDCBackend failed: %v", err) + } + } + + log.Fatalf("Failed to set brightness for device: %s", deviceID) +} + +func tryLogindBrightness(subsystem, name, deviceID string, percent int, exponential bool, exponent float64) bool { + sysfs, err := brightness.NewSysfsBackend() + if err != nil { + log.Debugf("NewSysfsBackend failed: %v", err) + return false + } + + logind, err := brightness.NewLogindBackend() + if err != nil { + log.Debugf("NewLogindBackend failed: %v", err) + return false + } + defer logind.Close() + + dev, err := sysfs.GetDevice(deviceID) + if err != nil { + log.Debugf("sysfs.GetDeviceByID failed: %v", err) + return false + } + + value := sysfs.PercentToValueWithExponent(percent, dev, exponential, exponent) + if err := logind.SetBrightness(subsystem, name, uint32(value)); err != nil { + log.Debugf("logind.SetBrightness failed: %v", err) + return false + } + + log.Debugf("set %s to %d%% (%d) via logind", deviceID, percent, value) + fmt.Printf("Set %s to %d%%\n", deviceID, percent) + return true +} + +func getBrightnessDevices(includeDDC bool) []string { + allDevices := getAllBrightnessDevices(includeDDC) + + var deviceIDs []string + for _, device := range allDevices { + deviceIDs = append(deviceIDs, device.ID) + } + return deviceIDs +} + +func runBrightnessGet(cmd *cobra.Command, args []string) { + deviceID := args[0] + includeDDC, _ := cmd.Flags().GetBool("ddc") + allDevices := getAllBrightnessDevices(includeDDC) + + for _, device := range allDevices { + if device.ID == deviceID { + fmt.Printf("%s: %d%% (%d/%d)\n", + device.ID, + device.CurrentPercent, + device.Current, + device.Max, + ) + return + } + } + + log.Fatalf("Device not found: %s", deviceID) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_chroma.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_chroma.go new file mode 100644 index 0000000..4945f33 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_chroma.go @@ -0,0 +1,300 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/spf13/cobra" + "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + ghtml "github.com/yuin/goldmark/renderer/html" +) + +var ( + chromaLanguage string + chromaStyle string + chromaInline bool + chromaMarkdown bool + chromaLineNumbers bool + + // Caching layer for performance + lexerCache = make(map[string]chroma.Lexer) + styleCache = make(map[string]*chroma.Style) + formatterCache = make(map[string]*html.Formatter) + cacheMutex sync.RWMutex + maxFileSize = int64(5 * 1024 * 1024) // 5MB default +) + +var chromaCmd = &cobra.Command{ + Use: "chroma [file]", + Short: "Syntax highlight source code", + Long: `Generate syntax-highlighted HTML from source code. + +Reads from file or stdin, outputs HTML with syntax highlighting. +Language is auto-detected from filename or can be specified with --language. + +Examples: + dms chroma main.go + dms chroma --language python script.py + echo "def foo(): pass" | dms chroma -l python + cat code.rs | dms chroma -l rust --style dracula + dms chroma --markdown README.md + dms chroma --markdown --style github-dark notes.md + dms chroma list-languages + dms chroma list-styles`, + Args: cobra.MaximumNArgs(1), + Run: runChroma, +} + +var chromaListLanguagesCmd = &cobra.Command{ + Use: "list-languages", + Short: "List all supported languages", + Run: func(cmd *cobra.Command, args []string) { + for _, name := range lexers.Names(true) { + fmt.Println(name) + } + }, +} + +var chromaListStylesCmd = &cobra.Command{ + Use: "list-styles", + Short: "List all available color styles", + Run: func(cmd *cobra.Command, args []string) { + for _, name := range styles.Names() { + fmt.Println(name) + } + }, +} + +func init() { + chromaCmd.Flags().StringVarP(&chromaLanguage, "language", "l", "", "Language for highlighting (auto-detect if not specified)") + chromaCmd.Flags().StringVarP(&chromaStyle, "style", "s", "monokai", "Color style (monokai, dracula, github, etc.)") + chromaCmd.Flags().BoolVar(&chromaInline, "inline", false, "Output inline styles instead of CSS classes") + chromaCmd.Flags().BoolVar(&chromaLineNumbers, "line-numbers", false, "Show line numbers in output") + chromaCmd.Flags().BoolVarP(&chromaMarkdown, "markdown", "m", false, "Render markdown with syntax-highlighted code blocks") + chromaCmd.Flags().Int64Var(&maxFileSize, "max-size", 5*1024*1024, "Maximum file size to process without warning (bytes)") + + chromaCmd.AddCommand(chromaListLanguagesCmd) + chromaCmd.AddCommand(chromaListStylesCmd) +} + +func getCachedLexer(key string, fallbackFunc func() chroma.Lexer) chroma.Lexer { + cacheMutex.RLock() + if lexer, ok := lexerCache[key]; ok { + cacheMutex.RUnlock() + return lexer + } + cacheMutex.RUnlock() + + lexer := fallbackFunc() + if lexer != nil { + cacheMutex.Lock() + lexerCache[key] = lexer + cacheMutex.Unlock() + } + return lexer +} + +func getCachedStyle(name string) *chroma.Style { + cacheMutex.RLock() + if style, ok := styleCache[name]; ok { + cacheMutex.RUnlock() + return style + } + cacheMutex.RUnlock() + + style := styles.Get(name) + if style == nil { + fmt.Fprintf(os.Stderr, "Warning: Style '%s' not found, using fallback\n", name) + style = styles.Fallback + } + + cacheMutex.Lock() + styleCache[name] = style + cacheMutex.Unlock() + return style +} + +func getCachedFormatter(inline bool, lineNumbers bool) *html.Formatter { + key := fmt.Sprintf("inline=%t,lineNumbers=%t", inline, lineNumbers) + + cacheMutex.RLock() + if formatter, ok := formatterCache[key]; ok { + cacheMutex.RUnlock() + return formatter + } + cacheMutex.RUnlock() + + var opts []html.Option + if inline { + opts = append(opts, html.WithClasses(false)) + } else { + opts = append(opts, html.WithClasses(true)) + } + opts = append(opts, html.TabWidth(4)) + + if lineNumbers { + opts = append(opts, html.WithLineNumbers(true)) + opts = append(opts, html.LineNumbersInTable(false)) + opts = append(opts, html.WithLinkableLineNumbers(false, "")) + } + + formatter := html.New(opts...) + + cacheMutex.Lock() + formatterCache[key] = formatter + cacheMutex.Unlock() + return formatter +} + +func runChroma(cmd *cobra.Command, args []string) { + var source string + var filename string + + // Read from file or stdin + if len(args) > 0 { + filename = args[0] + + // Check file size before reading + fileInfo, err := os.Stat(filename) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file info: %v\n", err) + os.Exit(1) + } + + if fileInfo.Size() > maxFileSize { + fmt.Fprintf(os.Stderr, "Warning: File size (%d bytes) exceeds recommended limit (%d bytes)\n", + fileInfo.Size(), maxFileSize) + fmt.Fprintf(os.Stderr, "Processing may be slow. Consider using smaller files.\n") + } + + content, err := os.ReadFile(filename) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) + os.Exit(1) + } + source = string(content) + } else { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + _ = cmd.Help() + os.Exit(0) + } + + content, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err) + os.Exit(1) + } + source = string(content) + } + + // Handle empty input + if strings.TrimSpace(source) == "" { + return + } + + // Handle Markdown rendering + if chromaMarkdown { + md := goldmark.New( + goldmark.WithExtensions( + extension.GFM, + highlighting.NewHighlighting( + highlighting.WithStyle(chromaStyle), + highlighting.WithFormatOptions( + html.WithClasses(!chromaInline), + ), + ), + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + ghtml.WithHardWraps(), + ghtml.WithXHTML(), + ), + ) + + var buf bytes.Buffer + if err := md.Convert([]byte(source), &buf); err != nil { + fmt.Fprintf(os.Stderr, "Markdown rendering error: %v\n", err) + os.Exit(1) + } + fmt.Print(buf.String()) + return + } + + // Detect or use specified lexer + var lexer chroma.Lexer + if chromaLanguage != "" { + lexer = getCachedLexer(chromaLanguage, func() chroma.Lexer { + l := lexers.Get(chromaLanguage) + if l == nil { + fmt.Fprintf(os.Stderr, "Unknown language: %s\n", chromaLanguage) + os.Exit(1) + } + return l + }) + } else if filename != "" { + lexer = getCachedLexer("file:"+filename, func() chroma.Lexer { + return lexers.Match(filename) + }) + } + + // Try content analysis if no lexer found (limit to first 1KB for performance) + if lexer == nil { + analyzeContent := source + if len(source) > 1024 { + analyzeContent = source[:1024] + } + lexer = lexers.Analyse(analyzeContent) + } + + // Fallback to plaintext + if lexer == nil { + lexer = lexers.Fallback + } + + lexer = chroma.Coalesce(lexer) + + // Get cached style + style := getCachedStyle(chromaStyle) + + // Get cached formatter + formatter := getCachedFormatter(chromaInline, chromaLineNumbers) + + // Tokenize + iterator, err := lexer.Tokenise(nil, source) + if err != nil { + fmt.Fprintf(os.Stderr, "Tokenization error: %v\n", err) + os.Exit(1) + } + + // Format and output + if chromaLineNumbers { + var buf bytes.Buffer + if err := formatter.Format(&buf, style, iterator); err != nil { + fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err) + os.Exit(1) + } + // Add spacing between line numbers + output := buf.String() + output = strings.ReplaceAll(output, "", "\u00A0\u00A0") + fmt.Print(output) + } else { + if err := formatter.Format(os.Stdout, style, iterator); err != nil { + fmt.Fprintf(os.Stderr, "Formatting error: %v\n", err) + os.Exit(1) + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_clipboard.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_clipboard.go new file mode 100644 index 0000000..8a9b858 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_clipboard.go @@ -0,0 +1,1004 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + bolt "go.etcd.io/bbolt" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/spf13/cobra" +) + +var clipboardCmd = &cobra.Command{ + Use: "clipboard", + Aliases: []string{"cl"}, + Short: "Manage clipboard", + Long: "Interact with the clipboard manager", +} + +var clipCopyCmd = &cobra.Command{ + Use: "copy [text]", + Short: "Copy text to clipboard", + Long: "Copy text to clipboard. If no text provided, reads from stdin. Works without server.", + Run: runClipCopy, +} + +var ( + clipCopyForeground bool + clipCopyPasteOnce bool + clipCopyType string + clipCopyDownload bool + clipJSONOutput bool +) + +var clipPasteCmd = &cobra.Command{ + Use: "paste", + Short: "Paste text from clipboard", + Long: "Paste text from clipboard to stdout. Works without server.", + Run: runClipPaste, +} + +var clipWatchCmd = &cobra.Command{ + Use: "watch [command]", + Short: "Watch clipboard for changes", + Long: `Watch clipboard for changes and optionally execute a command. +Works like wl-paste --watch. Does not require server. + +If a command is provided, it will be executed each time the clipboard changes, +with the clipboard content piped to its stdin. + +Examples: + dms cl watch # Print clipboard changes to stdout + dms cl watch cat # Same as above + dms cl watch notify-send # Send notification on clipboard change`, + Run: runClipWatch, +} + +var clipHistoryCmd = &cobra.Command{ + Use: "history", + Short: "Show clipboard history", + Long: "Show clipboard history with previews (requires server)", + Run: runClipHistory, +} + +var clipGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get clipboard entry by ID", + Long: "Get full clipboard entry data by ID (requires server). Use --copy to copy it to clipboard.", + Args: cobra.ExactArgs(1), + Run: runClipGet, +} + +var clipGetCopy bool + +var clipDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete clipboard entry", + Long: "Delete a clipboard history entry by ID (requires server)", + Args: cobra.ExactArgs(1), + Run: runClipDelete, +} + +var clipClearCmd = &cobra.Command{ + Use: "clear", + Short: "Clear clipboard history", + Long: "Clear all clipboard history (requires server)", + Run: runClipClear, +} + +var clipWatchStore bool +var clipWatchMimes bool + +var clipSearchCmd = &cobra.Command{ + Use: "search [query]", + Short: "Search clipboard history", + Long: "Search clipboard history with filters (requires server)", + Run: runClipSearch, +} + +var ( + clipSearchLimit int + clipSearchOffset int + clipSearchMimeType string + clipSearchImages bool + clipSearchText bool +) + +var clipConfigCmd = &cobra.Command{ + Use: "config", + Short: "Manage clipboard config", + Long: "Get or set clipboard configuration (requires server)", +} + +var clipConfigGetCmd = &cobra.Command{ + Use: "get", + Short: "Get clipboard config", + Run: runClipConfigGet, +} + +var clipConfigSetCmd = &cobra.Command{ + Use: "set", + Short: "Set clipboard config", + Long: `Set clipboard configuration options. + +Examples: + dms cl config set --max-history 200 + dms cl config set --auto-clear-days 7 + dms cl config set --clear-at-startup`, + Run: runClipConfigSet, +} + +var ( + clipConfigMaxHistory int + clipConfigAutoClearDays int + clipConfigClearAtStartup bool + clipConfigNoClearStartup bool + clipConfigDisabled bool + clipConfigEnabled bool +) + +var clipExportCmd = &cobra.Command{ + Use: "export [file]", + Short: "Export clipboard history to JSON", + Long: "Export clipboard history to JSON file. If no file specified, writes to stdout.", + Run: runClipExport, +} + +var clipImportCmd = &cobra.Command{ + Use: "import ", + Short: "Import clipboard history from JSON", + Long: "Import clipboard history from JSON file exported by 'dms cl export'.", + Args: cobra.ExactArgs(1), + Run: runClipImport, +} + +var clipMigrateCmd = &cobra.Command{ + Use: "cliphist-migrate [db-path]", + Short: "Migrate from cliphist", + Long: "Migrate clipboard history from cliphist. Uses default cliphist path if not specified.", + Run: runClipMigrate, +} + +var clipMigrateDelete bool + +func init() { + clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking") + clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste") + clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type") + clipCopyCmd.Flags().BoolVarP(&clipCopyDownload, "download", "d", false, "Download URL as image and copy as file") + + clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + clipGetCmd.Flags().BoolVarP(&clipGetCopy, "copy", "C", false, "Copy entry to clipboard") + + clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results") + clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset") + clipSearchCmd.Flags().StringVarP(&clipSearchMimeType, "mime", "m", "", "Filter by MIME type") + clipSearchCmd.Flags().BoolVar(&clipSearchImages, "images", false, "Only images") + clipSearchCmd.Flags().BoolVar(&clipSearchText, "text", false, "Only text") + clipSearchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + + clipConfigSetCmd.Flags().IntVar(&clipConfigMaxHistory, "max-history", 0, "Max history entries") + clipConfigSetCmd.Flags().IntVar(&clipConfigAutoClearDays, "auto-clear-days", -1, "Auto-clear entries older than N days (0 to disable)") + clipConfigSetCmd.Flags().BoolVar(&clipConfigClearAtStartup, "clear-at-startup", false, "Clear history on startup") + clipConfigSetCmd.Flags().BoolVar(&clipConfigNoClearStartup, "no-clear-at-startup", false, "Don't clear history on startup") + clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard tracking") + clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard tracking") + + clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)") + clipWatchCmd.Flags().BoolVarP(&clipWatchMimes, "mimes", "m", false, "Show all offered MIME types") + + clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration") + + clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd) + clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd) +} + +func runClipCopy(cmd *cobra.Command, args []string) { + var data []byte + copyFromStdin := false + + switch { + case len(args) > 0: + data = []byte(args[0]) + case clipCopyDownload || clipCopyType == "__multi__": + var err error + data, err = io.ReadAll(os.Stdin) + if err != nil { + log.Fatalf("read stdin: %v", err) + } + default: + copyFromStdin = true + } + + if clipCopyDownload { + filePath, err := downloadToTempFile(strings.TrimSpace(string(data))) + if err != nil { + log.Fatalf("download: %v", err) + } + if err := copyFileToClipboard(filePath); err != nil { + log.Fatalf("copy file: %v", err) + } + fmt.Printf("Downloaded and copied: %s\n", filePath) + return + } + + if clipCopyType == "__multi__" { + offers, err := parseMultiOffers(data) + if err != nil { + log.Fatalf("parse multi offers: %v", err) + } + if err := clipboard.CopyMulti(offers, true, clipCopyPasteOnce); err != nil { + log.Fatalf("copy multi: %v", err) + } + return + } + + if copyFromStdin { + if err := clipboard.CopyReader(os.Stdin, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil { + log.Fatalf("copy: %v", err) + } + return + } + + if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil { + log.Fatalf("copy: %v", err) + } +} + +func parseMultiOffers(data []byte) ([]clipboard.Offer, error) { + var offers []clipboard.Offer + pos := 0 + + for pos < len(data) { + mimeEnd := bytes.IndexByte(data[pos:], 0) + if mimeEnd == -1 { + break + } + mimeType := string(data[pos : pos+mimeEnd]) + pos += mimeEnd + 1 + + lenEnd := bytes.IndexByte(data[pos:], 0) + if lenEnd == -1 { + break + } + dataLen, err := strconv.Atoi(string(data[pos : pos+lenEnd])) + if err != nil { + return nil, fmt.Errorf("parse length: %w", err) + } + pos += lenEnd + 1 + + if pos+dataLen > len(data) { + return nil, fmt.Errorf("data truncated") + } + offerData := data[pos : pos+dataLen] + pos += dataLen + + offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData}) + } + + return offers, nil +} + +func runClipPaste(cmd *cobra.Command, args []string) { + data, _, err := clipboard.Paste() + if err != nil { + log.Fatalf("paste: %v", err) + } + os.Stdout.Write(data) +} + +func runClipWatch(cmd *cobra.Command, args []string) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + switch { + case len(args) > 0: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + runCommand(args, data) + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + case clipWatchStore: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + if err := clipboard.Store(data, mimeType); err != nil { + log.Errorf("store: %v", err) + } + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + case clipWatchMimes: + if err := clipboard.WatchAll(ctx, func(data []byte, mimeType string, allMimes []string) { + if clipJSONOutput { + out := map[string]any{ + "data": string(data), + "mimeType": mimeType, + "mimeTypes": allMimes, + "timestamp": time.Now().Format(time.RFC3339), + "size": len(data), + } + j, _ := json.Marshal(out) + fmt.Println(string(j)) + return + } + fmt.Printf("=== Clipboard Change ===\n") + fmt.Printf("Selected: %s\n", mimeType) + fmt.Printf("All MIME types:\n") + for _, m := range allMimes { + fmt.Printf(" - %s\n", m) + } + fmt.Printf("Size: %d bytes\n\n", len(data)) + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + case clipJSONOutput: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + out := map[string]any{ + "data": string(data), + "mimeType": mimeType, + "timestamp": time.Now().Format(time.RFC3339), + "size": len(data), + } + j, _ := json.Marshal(out) + fmt.Println(string(j)) + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + default: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + os.Stdout.Write(data) + os.Stdout.WriteString("\n") + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + } +} + +func runCommand(args []string, stdin []byte) { + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if len(stdin) == 0 { + cmd.Run() + return + } + + r, w, err := os.Pipe() + if err != nil { + cmd.Run() + return + } + + cmd.Stdin = r + go func() { + w.Write(stdin) + w.Close() + }() + cmd.Run() +} + +func runClipHistory(cmd *cobra.Command, args []string) { + req := models.Request{ + ID: 1, + Method: "clipboard.getHistory", + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get clipboard history: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + if clipJSONOutput { + fmt.Println("[]") + } else { + fmt.Println("No clipboard history") + } + return + } + + historyList, ok := (*resp.Result).([]any) + if !ok { + log.Fatal("Invalid response format") + } + + if clipJSONOutput { + out, _ := json.MarshalIndent(historyList, "", " ") + fmt.Println(string(out)) + return + } + + if len(historyList) == 0 { + fmt.Println("No clipboard history") + return + } + + fmt.Println("Clipboard History:") + fmt.Println() + + for _, item := range historyList { + entry, ok := item.(map[string]any) + if !ok { + continue + } + + id := uint64(entry["id"].(float64)) + preview := entry["preview"].(string) + timestamp := entry["timestamp"].(string) + isImage := entry["isImage"].(bool) + + typeStr := "text" + if isImage { + typeStr = "image" + } + + fmt.Printf("ID: %d | %s | %s\n", id, typeStr, timestamp) + fmt.Printf(" %s\n", preview) + fmt.Println() + } +} + +func runClipGet(cmd *cobra.Command, args []string) { + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + log.Fatalf("Invalid ID: %v", err) + } + + if clipGetCopy { + req := models.Request{ + ID: 1, + Method: "clipboard.copyEntry", + Params: map[string]any{"id": id}, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to copy clipboard entry: %v", err) + } + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Printf("Copied entry %d to clipboard\n", id) + return + } + + req := models.Request{ + ID: 1, + Method: "clipboard.getEntry", + Params: map[string]any{ + "id": id, + }, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get clipboard entry: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + log.Fatal("Entry not found") + } + + entry, ok := (*resp.Result).(map[string]any) + if !ok { + log.Fatal("Invalid response format") + } + + switch { + case clipJSONOutput: + output, _ := json.MarshalIndent(entry, "", " ") + fmt.Println(string(output)) + default: + if data, ok := entry["data"].(string); ok { + fmt.Print(data) + } else { + output, _ := json.MarshalIndent(entry, "", " ") + fmt.Println(string(output)) + } + } +} + +func runClipDelete(cmd *cobra.Command, args []string) { + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + log.Fatalf("Invalid ID: %v", err) + } + + req := models.Request{ + ID: 1, + Method: "clipboard.deleteEntry", + Params: map[string]any{ + "id": id, + }, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to delete clipboard entry: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Printf("Deleted entry %d\n", id) +} + +func runClipClear(cmd *cobra.Command, args []string) { + req := models.Request{ + ID: 1, + Method: "clipboard.clearHistory", + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to clear clipboard history: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Println("Clipboard history cleared") +} + +func runClipSearch(cmd *cobra.Command, args []string) { + params := map[string]any{ + "limit": clipSearchLimit, + "offset": clipSearchOffset, + } + + if len(args) > 0 { + params["query"] = args[0] + } + if clipSearchMimeType != "" { + params["mimeType"] = clipSearchMimeType + } + if clipSearchImages { + params["isImage"] = true + } else if clipSearchText { + params["isImage"] = false + } + + req := models.Request{ + ID: 1, + Method: "clipboard.search", + Params: params, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to search clipboard: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + log.Fatal("No results") + } + + result, ok := (*resp.Result).(map[string]any) + if !ok { + log.Fatal("Invalid response format") + } + + if clipJSONOutput { + out, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(out)) + return + } + + entries, _ := result["entries"].([]any) + total := int(result["total"].(float64)) + hasMore := result["hasMore"].(bool) + + if len(entries) == 0 { + fmt.Println("No results found") + return + } + + fmt.Printf("Results: %d of %d\n\n", len(entries), total) + + for _, item := range entries { + entry, ok := item.(map[string]any) + if !ok { + continue + } + + id := uint64(entry["id"].(float64)) + preview := entry["preview"].(string) + timestamp := entry["timestamp"].(string) + isImage := entry["isImage"].(bool) + + typeStr := "text" + if isImage { + typeStr = "image" + } + + fmt.Printf("ID: %d | %s | %s\n", id, typeStr, timestamp) + fmt.Printf(" %s\n\n", preview) + } + + if hasMore { + fmt.Printf("Use --offset %d to see more results\n", clipSearchOffset+clipSearchLimit) + } +} + +func runClipConfigGet(cmd *cobra.Command, args []string) { + req := models.Request{ + ID: 1, + Method: "clipboard.getConfig", + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get config: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + log.Fatal("No config returned") + } + + cfg, ok := (*resp.Result).(map[string]any) + if !ok { + log.Fatal("Invalid response format") + } + + output, _ := json.MarshalIndent(cfg, "", " ") + fmt.Println(string(output)) +} + +func runClipConfigSet(cmd *cobra.Command, args []string) { + params := map[string]any{} + + if cmd.Flags().Changed("max-history") { + params["maxHistory"] = clipConfigMaxHistory + } + if cmd.Flags().Changed("auto-clear-days") { + params["autoClearDays"] = clipConfigAutoClearDays + } + if clipConfigClearAtStartup { + params["clearAtStartup"] = true + } + if clipConfigNoClearStartup { + params["clearAtStartup"] = false + } + if clipConfigDisabled { + params["disabled"] = true + } + if clipConfigEnabled { + params["disabled"] = false + } + + if len(params) == 0 { + fmt.Println("No config options specified") + return + } + + req := models.Request{ + ID: 1, + Method: "clipboard.setConfig", + Params: params, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to set config: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Println("Config updated") +} + +func runClipExport(cmd *cobra.Command, args []string) { + req := models.Request{ + ID: 1, + Method: "clipboard.getHistory", + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get clipboard history: %v", err) + } + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + if resp.Result == nil { + log.Fatal("No clipboard history") + } + + out, err := json.MarshalIndent(resp.Result, "", " ") + if err != nil { + log.Fatalf("Failed to marshal: %v", err) + } + + if len(args) == 0 { + fmt.Println(string(out)) + return + } + + if err := os.WriteFile(args[0], out, 0o644); err != nil { + log.Fatalf("Failed to write file: %v", err) + } + fmt.Printf("Exported to %s\n", args[0]) +} + +func runClipImport(cmd *cobra.Command, args []string) { + data, err := os.ReadFile(args[0]) + if err != nil { + log.Fatalf("Failed to read file: %v", err) + } + + var entries []map[string]any + if err := json.Unmarshal(data, &entries); err != nil { + log.Fatalf("Failed to parse JSON: %v", err) + } + + var imported int + for _, entry := range entries { + dataStr, ok := entry["data"].(string) + if !ok { + continue + } + mimeType, _ := entry["mimeType"].(string) + if mimeType == "" { + mimeType = "text/plain" + } + + var entryData []byte + if decoded, err := base64.StdEncoding.DecodeString(dataStr); err == nil { + entryData = decoded + } else { + entryData = []byte(dataStr) + } + + if err := clipboard.Store(entryData, mimeType); err != nil { + log.Errorf("Failed to store entry: %v", err) + continue + } + imported++ + } + + fmt.Printf("Imported %d entries\n", imported) +} + +func runClipMigrate(cmd *cobra.Command, args []string) { + dbPath := getCliphistPath() + if len(args) > 0 { + dbPath = args[0] + } + + if _, err := os.Stat(dbPath); err != nil { + log.Fatalf("Cliphist db not found: %s", dbPath) + } + + db, err := bolt.Open(dbPath, 0o644, &bolt.Options{ + ReadOnly: true, + Timeout: 1 * time.Second, + }) + if err != nil { + log.Fatalf("Failed to open cliphist db: %v", err) + } + defer db.Close() + + var migrated int + err = db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("b")) + if b == nil { + return fmt.Errorf("cliphist bucket not found") + } + + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + if len(v) == 0 { + continue + } + + mimeType := detectMimeType(v) + if err := clipboard.Store(v, mimeType); err != nil { + log.Errorf("Failed to store entry %d: %v", btoi(k), err) + continue + } + migrated++ + } + return nil + }) + if err != nil { + log.Fatalf("Migration failed: %v", err) + } + + fmt.Printf("Migrated %d entries from cliphist\n", migrated) + + if !clipMigrateDelete { + return + } + + db.Close() + if err := os.Remove(dbPath); err != nil { + log.Errorf("Failed to delete cliphist db: %v", err) + return + } + os.Remove(filepath.Dir(dbPath)) + fmt.Println("Deleted cliphist db") +} + +func getCliphistPath() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + return filepath.Join(os.Getenv("HOME"), ".cache", "cliphist", "db") + } + return filepath.Join(cacheDir, "cliphist", "db") +} + +func detectMimeType(data []byte) string { + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + return "image/png" + } + return "text/plain" +} + +func btoi(v []byte) uint64 { + return binary.BigEndian.Uint64(v) +} + +func downloadToTempFile(rawURL string) (string, error) { + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + return "", fmt.Errorf("invalid URL: %s", rawURL) + } + + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse URL: %w", err) + } + + ext := filepath.Ext(parsedURL.Path) + if ext == "" { + ext = ".png" + } + + client := &http.Client{Timeout: 30 * time.Second} + + var data []byte + var contentType string + var lastErr error + + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 500 * time.Millisecond) + } + + req, err := http.NewRequest("GET", rawURL, nil) + if err != nil { + lastErr = fmt.Errorf("create request: %w", err) + continue + } + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "image/*,video/*,*/*") + + resp, err := client.Do(req) + if err != nil { + lastErr = fmt.Errorf("download (attempt %d): %w", attempt+1, err) + continue + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + lastErr = fmt.Errorf("download failed (attempt %d): status %d", attempt+1, resp.StatusCode) + continue + } + + data, err = io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + lastErr = fmt.Errorf("read response (attempt %d): %w", attempt+1, err) + continue + } + + contentType = resp.Header.Get("Content-Type") + if idx := strings.Index(contentType, ";"); idx != -1 { + contentType = strings.TrimSpace(contentType[:idx]) + } + + lastErr = nil + break + } + + if lastErr != nil { + return "", lastErr + } + + if len(data) == 0 { + return "", fmt.Errorf("downloaded empty file") + } + + if !strings.HasPrefix(contentType, "image/") && !strings.HasPrefix(contentType, "video/") { + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil { + return "", fmt.Errorf("not a valid media file (content-type: %s)", contentType) + } + } + + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = "/tmp" + } + clipDir := filepath.Join(cacheDir, "dms", "clipboard") + if err := os.MkdirAll(clipDir, 0o755); err != nil { + return "", fmt.Errorf("create cache dir: %w", err) + } + + filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)) + if err := os.WriteFile(filePath, data, 0o644); err != nil { + return "", fmt.Errorf("write file: %w", err) + } + + return filePath, nil +} + +func copyFileToClipboard(filePath string) error { + req := models.Request{ + ID: 1, + Method: "clipboard.copyFile", + Params: map[string]any{"filePath": filePath}, + } + + resp, err := sendServerRequest(req) + if err != nil { + return fmt.Errorf("server request: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("server error: %s", resp.Error) + } + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_colorpicker.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_colorpicker.go new file mode 100644 index 0000000..7c366e8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_colorpicker.go @@ -0,0 +1,139 @@ +package main + +import ( + "fmt" + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/colorpicker" + "github.com/spf13/cobra" +) + +var ( + colorOutputFmt string + colorAutocopy bool + colorNotify bool + colorLowercase bool +) + +var colorCmd = &cobra.Command{ + Use: "color", + Short: "Color utilities", + Long: "Color utilities including picking colors from the screen", +} + +var colorPickCmd = &cobra.Command{ + Use: "pick", + Short: "Pick a color from the screen", + Long: `Pick a color from anywhere on your screen using an interactive color picker. + +Click on any pixel to capture its color, or press Escape to cancel. + +Output format flags (mutually exclusive, default: --hex): + --hex - Hexadecimal (#RRGGBB) + --rgb - RGB values (R G B) + --hsl - HSL values (H S% L%) + --hsv - HSV values (H S% V%) + --cmyk - CMYK values (C% M% Y% K%) + --json - JSON with all formats + +Optional: + --raw - Removes ANSI escape codes and background colors. Use this when piping to other commands + +Examples: + dms color pick # Pick color, output as hex + dms color pick --rgb # Output as RGB + dms color pick --json # Output all formats as JSON + dms color pick --hex -l # Output hex in lowercase + dms color pick -a # Auto-copy result to clipboard`, + Run: runColorPick, +} + +func init() { + colorPickCmd.Flags().Bool("hex", false, "Output as hexadecimal (#RRGGBB)") + colorPickCmd.Flags().Bool("rgb", false, "Output as RGB (R G B)") + colorPickCmd.Flags().Bool("hsl", false, "Output as HSL (H S% L%)") + colorPickCmd.Flags().Bool("hsv", false, "Output as HSV (H S% V%)") + colorPickCmd.Flags().Bool("cmyk", false, "Output as CMYK (C% M% Y% K%)") + colorPickCmd.Flags().Bool("json", false, "Output all formats as JSON") + colorPickCmd.Flags().Bool("raw", false, "Removes ANSI escape codes and background colors. Use this when piping to other commands") + colorPickCmd.Flags().StringVarP(&colorOutputFmt, "output-format", "o", "", "Custom output format template") + colorPickCmd.Flags().BoolVarP(&colorAutocopy, "autocopy", "a", false, "Copy result to clipboard") + colorPickCmd.Flags().BoolVarP(&colorLowercase, "lowercase", "l", false, "Output hex in lowercase") + + colorPickCmd.MarkFlagsMutuallyExclusive("hex", "rgb", "hsl", "hsv", "cmyk", "json") + + colorCmd.AddCommand(colorPickCmd) +} + +func runColorPick(cmd *cobra.Command, args []string) { + format := colorpicker.FormatHex // default + jsonOutput, _ := cmd.Flags().GetBool("json") + + if rgb, _ := cmd.Flags().GetBool("rgb"); rgb { + format = colorpicker.FormatRGB + } else if hsl, _ := cmd.Flags().GetBool("hsl"); hsl { + format = colorpicker.FormatHSL + } else if hsv, _ := cmd.Flags().GetBool("hsv"); hsv { + format = colorpicker.FormatHSV + } else if cmyk, _ := cmd.Flags().GetBool("cmyk"); cmyk { + format = colorpicker.FormatCMYK + } + + config := colorpicker.Config{ + Format: format, + CustomFormat: colorOutputFmt, + Lowercase: colorLowercase, + Autocopy: colorAutocopy, + Notify: colorNotify, + } + + picker := colorpicker.New(config) + color, err := picker.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if color == nil { + os.Exit(0) + } + + var output string + if jsonOutput { + jsonStr, err := color.ToJSON() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + output = jsonStr + } else { + output = color.Format(config.Format, config.Lowercase, config.CustomFormat) + } + + if colorAutocopy { + copyToClipboard(output) + } + + if jsonOutput { + fmt.Println(output) + return + } + + if raw, _ := cmd.Flags().GetBool("raw"); raw { + fmt.Printf("%s\n", output) + return + } + + if color.IsDark() { + fmt.Printf("\033[48;2;%d;%d;%dm\033[97m %s \033[0m\n", color.R, color.G, color.B, output) + } else { + fmt.Printf("\033[48;2;%d;%d;%dm\033[30m %s \033[0m\n", color.R, color.G, color.B, output) + } +} + +func copyToClipboard(text string) { + if err := clipboard.CopyText(text); err != nil { + fmt.Fprintln(os.Stderr, "clipboard copy failed:", err) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_common.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_common.go new file mode 100644 index 0000000..6557860 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_common.go @@ -0,0 +1,530 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Show version information", + Run: runVersion, +} + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Launch quickshell with DMS configuration", + Long: "Launch quickshell with DMS configuration (qs -c dms)", + PreRunE: findConfig, + Run: func(cmd *cobra.Command, args []string) { + daemon, _ := cmd.Flags().GetBool("daemon") + session, _ := cmd.Flags().GetBool("session") + if daemon { + runShellDaemon(session) + } else { + runShellInteractive(session) + } + }, +} + +var restartCmd = &cobra.Command{ + Use: "restart", + Short: "Restart quickshell with DMS configuration", + Long: "Kill existing DMS shell processes and restart quickshell with DMS configuration", + PreRunE: findConfig, + Run: func(cmd *cobra.Command, args []string) { + restartShell() + }, +} + +var restartDetachedCmd = &cobra.Command{ + Use: "restart-detached ", + Hidden: true, + Args: cobra.ExactArgs(1), + PreRunE: findConfig, + Run: func(cmd *cobra.Command, args []string) { + runDetachedRestart(args[0]) + }, +} + +var killCmd = &cobra.Command{ + Use: "kill", + Short: "Kill running DMS shell processes", + Long: "Kill all running quickshell processes with DMS configuration", + Run: func(cmd *cobra.Command, args []string) { + killShell() + }, +} + +var ipcCmd = &cobra.Command{ + Use: "ipc [target] [function] [args...]", + Short: "Send IPC commands to running DMS shell", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + _ = findConfig(cmd, args) + return getShellIPCCompletions(args, toComplete), cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + runShellIPCCommand(args) + }, +} + +func init() { + ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + _ = findConfig(cmd, args) + printIPCHelp() + }) +} + +var debugSrvCmd = &cobra.Command{ + Use: "debug-srv", + Short: "Start the debug server", + Long: "Start the Unix socket debug server for DMS", + Run: func(cmd *cobra.Command, args []string) { + if err := startDebugServer(); err != nil { + log.Fatalf("Error starting debug server: %v", err) + } + }, +} + +var pluginsCmd = &cobra.Command{ + Use: "plugins", + Short: "Manage DMS plugins", + Long: "Browse and manage DMS plugins from the registry", +} + +var pluginsBrowseCmd = &cobra.Command{ + Use: "browse", + Short: "Browse available plugins", + Long: "Browse available plugins from the DMS plugin registry", + Run: func(cmd *cobra.Command, args []string) { + if err := browsePlugins(); err != nil { + log.Fatalf("Error browsing plugins: %v", err) + } + }, +} + +var pluginsListCmd = &cobra.Command{ + Use: "list", + Short: "List installed plugins", + Long: "List all installed DMS plugins", + Run: func(cmd *cobra.Command, args []string) { + if err := listInstalledPlugins(); err != nil { + log.Fatalf("Error listing plugins: %v", err) + } + }, +} + +var pluginsInstallCmd = &cobra.Command{ + Use: "install ", + Short: "Install a plugin by ID", + Long: "Install a DMS plugin from the registry using its ID (e.g., 'myPlugin'). Plugin names with spaces are also supported for backward compatibility.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getAvailablePluginIDs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + if err := installPluginCLI(args[0]); err != nil { + log.Fatalf("Error installing plugin: %v", err) + } + }, +} + +var pluginsUninstallCmd = &cobra.Command{ + Use: "uninstall ", + Short: "Uninstall a plugin by ID", + Long: "Uninstall a DMS plugin using its ID (e.g., 'myPlugin'). Plugin names with spaces are also supported for backward compatibility.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + if err := uninstallPluginCLI(args[0]); err != nil { + log.Fatalf("Error uninstalling plugin: %v", err) + } + }, +} + +var pluginsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a plugin by ID", + Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + if err := updatePluginCLI(args[0]); err != nil { + log.Fatalf("Error updating plugin: %v", err) + } + }, +} + +func runVersion(cmd *cobra.Command, args []string) { + fmt.Printf("%s\n", formatVersion(Version)) +} + +// Git builds: dms (git) v0.6.2-XXXX +// Stable releases: dms v0.6.2 +func formatVersion(version string) string { + // Arch/Debian/Ubuntu/OpenSUSE git format: 0.6.2+git2264.c5c5ce84 + re := regexp.MustCompile(`^([\d.]+)\+git(\d+)\.`) + if matches := re.FindStringSubmatch(version); matches != nil { + return fmt.Sprintf("dms (git) v%s-%s", matches[1], matches[2]) + } + + // Fedora COPR git format: 0.0.git.2267.d430cae9 + re = regexp.MustCompile(`^[\d.]+\.git\.(\d+)\.`) + if matches := re.FindStringSubmatch(version); matches != nil { + baseVersion := getBaseVersion() + return fmt.Sprintf("dms (git) v%s-%s", baseVersion, matches[1]) + } + + // Stable release format: 0.6.2 + re = regexp.MustCompile(`^([\d.]+)$`) + if matches := re.FindStringSubmatch(version); matches != nil { + return fmt.Sprintf("dms v%s", matches[1]) + } + + return fmt.Sprintf("dms %s", version) +} + +func getBaseVersion() string { + paths := []string{ + "/usr/share/quickshell/dms/VERSION", + "/usr/local/share/quickshell/dms/VERSION", + "/etc/xdg/quickshell/dms/VERSION", + } + + for _, path := range paths { + if content, err := os.ReadFile(path); err == nil { + ver := strings.TrimSpace(string(content)) + ver = strings.TrimPrefix(ver, "v") + if re := regexp.MustCompile(`^([\d.]+)`); re.MatchString(ver) { + if matches := re.FindStringSubmatch(ver); matches != nil { + return matches[1] + } + } + } + } + + // Fallback + return "1.0.2" +} + +func startDebugServer() error { + server.CLIVersion = Version + return server.Start(true) +} + +func browsePlugins() error { + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + fmt.Println("Fetching plugin registry...") + pluginList, err := registry.List() + if err != nil { + return fmt.Errorf("failed to list plugins: %w", err) + } + + if len(pluginList) == 0 { + fmt.Println("No plugins found in registry.") + return nil + } + + fmt.Printf("\nAvailable Plugins (%d):\n\n", len(pluginList)) + for _, plugin := range pluginList { + installed, _ := manager.IsInstalled(plugin) + installedMarker := "" + if installed { + installedMarker = " [Installed]" + } + + fmt.Printf(" %s%s\n", plugin.Name, installedMarker) + fmt.Printf(" ID: %s\n", plugin.ID) + fmt.Printf(" Category: %s\n", plugin.Category) + fmt.Printf(" Author: %s\n", plugin.Author) + fmt.Printf(" Description: %s\n", plugin.Description) + fmt.Printf(" Repository: %s\n", plugin.Repo) + if len(plugin.Capabilities) > 0 { + fmt.Printf(" Capabilities: %s\n", strings.Join(plugin.Capabilities, ", ")) + } + if len(plugin.Compositors) > 0 { + fmt.Printf(" Compositors: %s\n", strings.Join(plugin.Compositors, ", ")) + } + if len(plugin.Dependencies) > 0 { + fmt.Printf(" Dependencies: %s\n", strings.Join(plugin.Dependencies, ", ")) + } + fmt.Println() + } + + return nil +} + +func listInstalledPlugins() error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + installedNames, err := manager.ListInstalled() + if err != nil { + return fmt.Errorf("failed to list installed plugins: %w", err) + } + + if len(installedNames) == 0 { + fmt.Println("No plugins installed.") + return nil + } + + allPlugins, err := registry.List() + if err != nil { + return fmt.Errorf("failed to list plugins: %w", err) + } + + pluginMap := make(map[string]plugins.Plugin) + for _, p := range allPlugins { + pluginMap[p.ID] = p + } + + fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames)) + for _, id := range installedNames { + if plugin, ok := pluginMap[id]; ok { + fmt.Printf(" %s\n", plugin.Name) + fmt.Printf(" ID: %s\n", plugin.ID) + fmt.Printf(" Category: %s\n", plugin.Category) + fmt.Printf(" Author: %s\n", plugin.Author) + fmt.Println() + } else { + fmt.Printf(" %s (not in registry)\n\n", id) + } + } + + return nil +} + +func installPluginCLI(idOrName string) error { + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + pluginList, err := registry.List() + if err != nil { + return fmt.Errorf("failed to list plugins: %w", err) + } + + // 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 { + return fmt.Errorf("plugin not found: %s", idOrName) + } + + installed, err := manager.IsInstalled(*plugin) + if err != nil { + return fmt.Errorf("failed to check install status: %w", err) + } + + if installed { + return fmt.Errorf("plugin already installed: %s", plugin.Name) + } + + fmt.Printf("Installing plugin: %s (ID: %s)\n", plugin.Name, plugin.ID) + if err := manager.Install(*plugin); err != nil { + return fmt.Errorf("failed to install plugin: %w", err) + } + + fmt.Printf("Plugin installed successfully: %s\n", plugin.Name) + return nil +} + +func getAvailablePluginIDs() []string { + registry, err := plugins.NewRegistry() + if err != nil { + return nil + } + + pluginList, err := registry.List() + if err != nil { + return nil + } + + var ids []string + for _, p := range pluginList { + ids = append(ids, p.ID) + } + return ids +} + +func getInstalledPluginIDs() []string { + manager, err := plugins.NewManager() + if err != nil { + return nil + } + + installed, err := manager.ListInstalled() + if err != nil { + return nil + } + + return installed +} + +func uninstallPluginCLI(idOrName string) error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + pluginList, _ := registry.List() + plugin := plugins.FindByIDOrName(idOrName, pluginList) + + if plugin != nil { + installed, err := manager.IsInstalled(*plugin) + if err != nil { + return fmt.Errorf("failed to check install status: %w", err) + } + if !installed { + return fmt.Errorf("plugin not installed: %s", plugin.Name) + } + + fmt.Printf("Uninstalling plugin: %s (ID: %s)\n", plugin.Name, plugin.ID) + if err := manager.Uninstall(*plugin); err != nil { + return fmt.Errorf("failed to uninstall plugin: %w", err) + } + fmt.Printf("Plugin uninstalled successfully: %s\n", plugin.Name) + return nil + } + + fmt.Printf("Uninstalling plugin: %s\n", idOrName) + if err := manager.UninstallByIDOrName(idOrName); err != nil { + return err + } + fmt.Printf("Plugin uninstalled successfully: %s\n", idOrName) + return nil +} + +func updatePluginCLI(idOrName string) error { + manager, err := plugins.NewManager() + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + + registry, err := plugins.NewRegistry() + if err != nil { + return fmt.Errorf("failed to create registry: %w", err) + } + + pluginList, _ := registry.List() + plugin := plugins.FindByIDOrName(idOrName, pluginList) + + if plugin != nil { + installed, err := manager.IsInstalled(*plugin) + if err != nil { + return fmt.Errorf("failed to check install status: %w", err) + } + if !installed { + return fmt.Errorf("plugin not installed: %s", plugin.Name) + } + + fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID) + if err := manager.Update(*plugin); err != nil { + return fmt.Errorf("failed to update plugin: %w", err) + } + fmt.Printf("Plugin updated successfully: %s\n", plugin.Name) + return nil + } + + fmt.Printf("Updating plugin: %s\n", idOrName) + if err := manager.UpdateByIDOrName(idOrName); err != nil { + return err + } + fmt.Printf("Plugin updated successfully: %s\n", idOrName) + return nil +} + +func getCommonCommands() []*cobra.Command { + return []*cobra.Command{ + versionCmd, + runCmd, + restartCmd, + restartDetachedCmd, + killCmd, + ipcCmd, + debugSrvCmd, + pluginsCmd, + dank16Cmd, + brightnessCmd, + dpmsCmd, + keybindsCmd, + greeterCmd, + setupCmd, + colorCmd, + screenshotCmd, + notifyActionCmd, + notifyCmd, + genericNotifyActionCmd, + matugenCmd, + clipboardCmd, + chromaCmd, + doctorCmd, + configCmd, + dlCmd, + randrCmd, + blurCmd, + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_config.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_config.go new file mode 100644 index 0000000..d604256 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_config.go @@ -0,0 +1,318 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Configuration utilities", +} + +var resolveIncludeCmd = &cobra.Command{ + Use: "resolve-include ", + Short: "Check if a file is included in compositor config", + Long: "Recursively check if a file is included/sourced in compositor configuration. Returns JSON with exists and included status.", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + return []string{"hyprland", "niri", "mangowc"}, cobra.ShellCompDirectiveNoFileComp + case 1: + return []string{"cursor.kdl", "cursor.conf", "outputs.kdl", "outputs.conf", "binds.kdl", "binds.conf"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runResolveInclude, +} + +func init() { + configCmd.AddCommand(resolveIncludeCmd) +} + +type IncludeResult struct { + Exists bool `json:"exists"` + Included bool `json:"included"` +} + +func runResolveInclude(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + filename := args[1] + + var result IncludeResult + var err error + + switch compositor { + case "hyprland": + result, err = checkHyprlandInclude(filename) + case "niri": + result, err = checkNiriInclude(filename) + case "mangowc", "dwl", "mango": + result, err = checkMangoWCInclude(filename) + default: + log.Fatalf("Unknown compositor: %s", compositor) + } + + if err != nil { + log.Fatalf("Error checking include: %v", err) + } + + output, _ := json.Marshal(result) + fmt.Fprintln(os.Stdout, string(output)) +} + +func checkHyprlandInclude(filename string) (IncludeResult, error) { + configDir, err := utils.ExpandPath("$HOME/.config/hypr") + if err != nil { + return IncludeResult{}, err + } + + targetPath := filepath.Join(configDir, "dms", filename) + result := IncludeResult{} + + if _, err := os.Stat(targetPath); err == nil { + result.Exists = true + } + + mainConfig := filepath.Join(configDir, "hyprland.conf") + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + return result, nil + } + + processed := make(map[string]bool) + result.Included = hyprlandFindInclude(mainConfig, "dms/"+filename, processed) + return result, nil +} + +func hyprlandFindInclude(filePath, target string, processed map[string]bool) bool { + absPath, err := filepath.Abs(filePath) + if err != nil { + return false + } + + if processed[absPath] { + return false + } + processed[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return false + } + + baseDir := filepath.Dir(absPath) + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || trimmed == "" { + continue + } + + if !strings.HasPrefix(trimmed, "source") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) < 2 { + continue + } + + sourcePath := strings.TrimSpace(parts[1]) + if matchesTarget(sourcePath, target) { + return true + } + + fullPath := sourcePath + if !filepath.IsAbs(sourcePath) { + fullPath = filepath.Join(baseDir, sourcePath) + } + + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + continue + } + + if hyprlandFindInclude(expanded, target, processed) { + return true + } + } + + return false +} + +func checkNiriInclude(filename string) (IncludeResult, error) { + configDir, err := utils.ExpandPath("$HOME/.config/niri") + if err != nil { + return IncludeResult{}, err + } + + targetPath := filepath.Join(configDir, "dms", filename) + result := IncludeResult{} + + if _, err := os.Stat(targetPath); err == nil { + result.Exists = true + } + + mainConfig := filepath.Join(configDir, "config.kdl") + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + return result, nil + } + + processed := make(map[string]bool) + result.Included = niriFindInclude(mainConfig, "dms/"+filename, processed) + return result, nil +} + +func niriFindInclude(filePath, target string, processed map[string]bool) bool { + absPath, err := filepath.Abs(filePath) + if err != nil { + return false + } + + if processed[absPath] { + return false + } + processed[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return false + } + + baseDir := filepath.Dir(absPath) + content := string(data) + + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") || trimmed == "" { + continue + } + + if !strings.HasPrefix(trimmed, "include") { + continue + } + + startQuote := strings.Index(trimmed, "\"") + if startQuote == -1 { + continue + } + endQuote := strings.LastIndex(trimmed, "\"") + if endQuote <= startQuote { + continue + } + + includePath := trimmed[startQuote+1 : endQuote] + if matchesTarget(includePath, target) { + return true + } + + fullPath := includePath + if !filepath.IsAbs(includePath) { + fullPath = filepath.Join(baseDir, includePath) + } + + if niriFindInclude(fullPath, target, processed) { + return true + } + } + + return false +} + +func checkMangoWCInclude(filename string) (IncludeResult, error) { + configDir, err := utils.ExpandPath("$HOME/.config/mango") + if err != nil { + return IncludeResult{}, err + } + + targetPath := filepath.Join(configDir, "dms", filename) + result := IncludeResult{} + + if _, err := os.Stat(targetPath); err == nil { + result.Exists = true + } + + mainConfig := filepath.Join(configDir, "config.conf") + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + mainConfig = filepath.Join(configDir, "mango.conf") + } + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + return result, nil + } + + processed := make(map[string]bool) + result.Included = mangowcFindInclude(mainConfig, "dms/"+filename, processed) + return result, nil +} + +func mangowcFindInclude(filePath, target string, processed map[string]bool) bool { + absPath, err := filepath.Abs(filePath) + if err != nil { + return false + } + + if processed[absPath] { + return false + } + processed[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return false + } + + baseDir := filepath.Dir(absPath) + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || trimmed == "" { + continue + } + + if !strings.HasPrefix(trimmed, "source") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) < 2 { + continue + } + + sourcePath := strings.TrimSpace(parts[1]) + if matchesTarget(sourcePath, target) { + return true + } + + fullPath := sourcePath + if !filepath.IsAbs(sourcePath) { + fullPath = filepath.Join(baseDir, sourcePath) + } + + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + continue + } + + if mangowcFindInclude(expanded, target, processed) { + return true + } + } + + return false +} + +func matchesTarget(path, target string) bool { + path = strings.TrimPrefix(path, "./") + target = strings.TrimPrefix(target, "./") + return path == target || strings.HasSuffix(path, "/"+target) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_dank16.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_dank16.go new file mode 100644 index 0000000..0bbbab1 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_dank16.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/dank16" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var dank16Cmd = &cobra.Command{ + Use: "dank16 [hex_color]", + Short: "Generate Base16 color palettes", + Long: "Generate Base16 color palettes from a color with support for various output formats", + Args: cobra.MaximumNArgs(1), + Run: runDank16, +} + +func init() { + dank16Cmd.Flags().Bool("light", false, "Generate light theme variant (sets default to light)") + dank16Cmd.Flags().Bool("json", false, "Output in JSON format") + dank16Cmd.Flags().Bool("kitty", false, "Output in Kitty terminal format") + dank16Cmd.Flags().Bool("foot", false, "Output in Foot terminal format") + dank16Cmd.Flags().Bool("neovim", false, "Output in Neovim plugin format") + dank16Cmd.Flags().Bool("alacritty", false, "Output in Alacritty terminal format") + dank16Cmd.Flags().Bool("ghostty", false, "Output in Ghostty terminal format") + dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format") + dank16Cmd.Flags().String("background", "", "Custom background color") + dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag") + dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON") + dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)") + dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)") + _ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp + }) +} + +func runDank16(cmd *cobra.Command, args []string) { + isLight, _ := cmd.Flags().GetBool("light") + isJson, _ := cmd.Flags().GetBool("json") + isKitty, _ := cmd.Flags().GetBool("kitty") + isFoot, _ := cmd.Flags().GetBool("foot") + isNeovim, _ := cmd.Flags().GetBool("neovim") + isAlacritty, _ := cmd.Flags().GetBool("alacritty") + isGhostty, _ := cmd.Flags().GetBool("ghostty") + isWezterm, _ := cmd.Flags().GetBool("wezterm") + background, _ := cmd.Flags().GetString("background") + contrastAlgo, _ := cmd.Flags().GetString("contrast") + useVariants, _ := cmd.Flags().GetBool("variants") + primaryDark, _ := cmd.Flags().GetString("primary-dark") + primaryLight, _ := cmd.Flags().GetString("primary-light") + + if background != "" && !strings.HasPrefix(background, "#") { + background = "#" + background + } + if primaryDark != "" && !strings.HasPrefix(primaryDark, "#") { + primaryDark = "#" + primaryDark + } + if primaryLight != "" && !strings.HasPrefix(primaryLight, "#") { + primaryLight = "#" + primaryLight + } + + contrastAlgo = strings.ToLower(contrastAlgo) + if contrastAlgo != "dps" && contrastAlgo != "wcag" { + log.Fatalf("Invalid contrast algorithm: %s (must be 'dps' or 'wcag')", contrastAlgo) + } + + if useVariants { + if primaryDark == "" || primaryLight == "" { + if len(args) == 0 { + log.Fatalf("--variants requires either a positional color argument or both --primary-dark and --primary-light") + } + primaryColor := args[0] + if !strings.HasPrefix(primaryColor, "#") { + primaryColor = "#" + primaryColor + } + primaryDark = primaryColor + primaryLight = primaryColor + } + variantOpts := dank16.VariantOptions{ + PrimaryDark: primaryDark, + PrimaryLight: primaryLight, + Background: background, + UseDPS: contrastAlgo == "dps", + IsLightMode: isLight, + } + variantColors := dank16.GenerateVariantPalette(variantOpts) + fmt.Print(dank16.GenerateVariantJSON(variantColors)) + return + } + + if len(args) == 0 { + log.Fatalf("A color argument is required (or use --variants with --primary-dark and --primary-light)") + } + primaryColor := args[0] + if !strings.HasPrefix(primaryColor, "#") { + primaryColor = "#" + primaryColor + } + + opts := dank16.PaletteOptions{ + IsLight: isLight, + Background: background, + UseDPS: contrastAlgo == "dps", + } + + colors := dank16.GeneratePalette(primaryColor, opts) + + if isJson { + fmt.Print(dank16.GenerateJSON(colors)) + } else if isKitty { + fmt.Print(dank16.GenerateKittyTheme(colors)) + } else if isFoot { + fmt.Print(dank16.GenerateFootTheme(colors)) + } else if isAlacritty { + fmt.Print(dank16.GenerateAlacrittyTheme(colors)) + } else if isGhostty { + fmt.Print(dank16.GenerateGhosttyTheme(colors)) + } else if isWezterm { + fmt.Print(dank16.GenerateWeztermTheme(colors)) + } else if isNeovim { + fmt.Print(dank16.GenerateNeovimTheme(colors)) + } else { + fmt.Print(dank16.GenerateGhosttyTheme(colors)) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_doctor.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_doctor.go new file mode 100644 index 0000000..b182d06 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_doctor.go @@ -0,0 +1,1107 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" + "github.com/AvengeMedia/DankMaterialShell/core/internal/tui" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/AvengeMedia/DankMaterialShell/core/internal/version" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +type status string + +const ( + statusOK status = "ok" + statusWarn status = "warn" + statusError status = "error" + statusInfo status = "info" +) + +func (s status) IconStyle(styles tui.Styles) (string, lipgloss.Style) { + switch s { + case statusOK: + return "●", styles.Success + case statusWarn: + return "●", styles.Warning + case statusError: + return "●", styles.Error + default: + return "○", styles.Subtle + } +} + +type DoctorStatus struct { + Errors []checkResult + Warnings []checkResult + OK []checkResult + Info []checkResult +} + +func (ds *DoctorStatus) Add(r checkResult) { + switch r.status { + case statusError: + ds.Errors = append(ds.Errors, r) + case statusWarn: + ds.Warnings = append(ds.Warnings, r) + case statusOK: + ds.OK = append(ds.OK, r) + case statusInfo: + ds.Info = append(ds.Info, r) + } +} + +func (ds *DoctorStatus) HasIssues() bool { + return len(ds.Errors) > 0 || len(ds.Warnings) > 0 +} + +func (ds *DoctorStatus) ErrorCount() int { + return len(ds.Errors) +} + +func (ds *DoctorStatus) WarningCount() int { + return len(ds.Warnings) +} + +func (ds *DoctorStatus) OKCount() int { + return len(ds.OK) +} + +var ( + quickshellVersionRegex = regexp.MustCompile(`(?i)quickshell (\d+\.\d+\.\d+)`) + hyprlandVersionRegex = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`) + niriVersionRegex = regexp.MustCompile(`niri (\d+\.\d+)`) + swayVersionRegex = regexp.MustCompile(`sway version (\d+\.\d+)`) + riverVersionRegex = regexp.MustCompile(`river (\d+\.\d+)`) + wayfireVersionRegex = regexp.MustCompile(`wayfire (\d+\.\d+)`) + labwcVersionRegex = regexp.MustCompile(`labwc (\d+\.\d+\.\d+)`) + mangowcVersionRegex = regexp.MustCompile(`mango (\d+\.\d+\.\d+)`) +) + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Diagnose DMS installation and dependencies", + Long: "Check system health, verify dependencies, and diagnose configuration issues for DMS", + Run: runDoctor, +} + +var ( + doctorVerbose bool + doctorJSON bool + doctorCopy bool +) + +func init() { + doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output including paths and versions") + doctorCmd.Flags().BoolVarP(&doctorJSON, "json", "j", false, "Output results in JSON format") + doctorCmd.Flags().BoolVarP(&doctorCopy, "copy", "C", false, "Copy results to clipboard in GitHub-friendly format") +} + +type category int + +const ( + catSystem category = iota + catVersions + catInstallation + catCompositor + catQuickshellFeatures + catOptionalFeatures + catConfigFiles + catServices + catEnvironment +) + +func (c category) String() string { + switch c { + case catSystem: + return "System" + case catVersions: + return "Versions" + case catInstallation: + return "Installation" + case catCompositor: + return "Compositor" + case catQuickshellFeatures: + return "Quickshell Features" + case catOptionalFeatures: + return "Optional Features" + case catConfigFiles: + return "Config Files" + case catServices: + return "Services" + case catEnvironment: + return "Environment" + default: + return "Unknown" + } +} + +const ( + checkNameMaxLength = 21 + doctorDocsURL = "https://danklinux.com/docs/dankmaterialshell/cli-doctor" +) + +type checkResult struct { + category category + name string + status status + message string + details string + url string +} + +type checkResultJSON struct { + Category string `json:"category"` + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message"` + Details string `json:"details,omitempty"` + URL string `json:"url,omitempty"` +} + +type doctorOutputJSON struct { + Summary struct { + Errors int `json:"errors"` + Warnings int `json:"warnings"` + OK int `json:"ok"` + Info int `json:"info"` + } `json:"summary"` + Results []checkResultJSON `json:"results"` +} + +func (r checkResult) toJSON() checkResultJSON { + return checkResultJSON{ + Category: r.category.String(), + Name: r.name, + Status: string(r.status), + Message: r.message, + Details: r.details, + URL: r.url, + } +} + +func runDoctor(cmd *cobra.Command, args []string) { + if !doctorJSON && !doctorCopy { + printDoctorHeader() + } + + qsFeatures, qsMissingFeatures := checkQuickshellFeatures() + + results := slices.Concat( + checkSystemInfo(), + checkVersions(qsMissingFeatures), + checkDMSInstallation(), + checkWindowManagers(), + qsFeatures, + checkOptionalDependencies(), + checkConfigurationFiles(), + checkSystemdServices(), + checkEnvironmentVars(), + ) + + switch { + case doctorCopy: + text := formatResultsPlain(results) + if err := clipboard.CopyOpts([]byte(text), "text/plain;charset=utf-8", false, false); err != nil { + fmt.Fprintf(os.Stderr, "Failed to copy to clipboard: %v\n", err) + os.Exit(1) + } + fmt.Println("Doctor report copied to clipboard") + case doctorJSON: + printResultsJSON(results) + default: + printResults(results) + printSummary(results, qsMissingFeatures) + } +} + +func printDoctorHeader() { + theme := tui.TerminalTheme() + styles := tui.NewStyles(theme) + + fmt.Println(getThemedASCII()) + fmt.Println(styles.Title.Render("System Health Check")) + fmt.Println(styles.Subtle.Render("──────────────────────────────────────")) + fmt.Println() +} + +func checkSystemInfo() []checkResult { + var results []checkResult + + osInfo, err := distros.GetOSInfo() + if err != nil { + status, message, details := statusWarn, fmt.Sprintf("Unknown (%v)", err), "" + + if strings.Contains(err.Error(), "Unsupported distribution") { + osRelease := readOSRelease() + switch { + case osRelease["ID"] == "nixos": + status = statusOK + message = osRelease["PRETTY_NAME"] + if message == "" { + message = fmt.Sprintf("NixOS %s", osRelease["VERSION_ID"]) + } + details = "Supported for runtime (install via NixOS module or Flake)" + case osRelease["PRETTY_NAME"] != "": + message = fmt.Sprintf("%s (not supported by dms setup)", osRelease["PRETTY_NAME"]) + details = "DMS may work but automatic installation is not available" + } + } + + results = append(results, checkResult{catSystem, "Operating System", status, message, details, doctorDocsURL + "#operating-system"}) + } else { + status := statusOK + message := osInfo.PrettyName + if message == "" { + message = fmt.Sprintf("%s %s", osInfo.Distribution.ID, osInfo.VersionID) + } + if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) { + status = statusWarn + message += " (version may not be fully supported)" + } + results = append(results, checkResult{ + catSystem, "Operating System", status, message, + fmt.Sprintf("ID: %s, Version: %s, Arch: %s", osInfo.Distribution.ID, osInfo.VersionID, osInfo.Architecture), + doctorDocsURL + "#operating-system", + }) + } + + arch := runtime.GOARCH + archStatus := statusOK + if arch != "amd64" && arch != "arm64" { + archStatus = statusError + } + results = append(results, checkResult{catSystem, "Architecture", archStatus, arch, "", doctorDocsURL + "#architecture"}) + + waylandDisplay := os.Getenv("WAYLAND_DISPLAY") + xdgSessionType := os.Getenv("XDG_SESSION_TYPE") + + switch { + case waylandDisplay != "" || xdgSessionType == "wayland": + results = append(results, checkResult{ + catSystem, "Display Server", statusOK, "Wayland", + fmt.Sprintf("WAYLAND_DISPLAY=%s", waylandDisplay), + doctorDocsURL + "#display-server", + }) + case xdgSessionType == "x11": + results = append(results, checkResult{catSystem, "Display Server", statusError, "X11 (DMS requires Wayland)", "", doctorDocsURL + "#display-server"}) + default: + results = append(results, checkResult{ + catSystem, "Display Server", statusWarn, "Unknown (ensure you're running Wayland)", + fmt.Sprintf("XDG_SESSION_TYPE=%s", xdgSessionType), + doctorDocsURL + "#display-server", + }) + } + + return results +} + +func checkEnvironmentVars() []checkResult { + var results []checkResult + results = append(results, checkEnvVar("QT_QPA_PLATFORMTHEME")...) + results = append(results, checkEnvVar("QS_ICON_THEME")...) + return results +} + +func checkEnvVar(name string) []checkResult { + value := os.Getenv(name) + if value != "" { + return []checkResult{{catEnvironment, name, statusInfo, value, "", doctorDocsURL + "#environment-variables"}} + } + if doctorVerbose { + return []checkResult{{catEnvironment, name, statusInfo, "Not set", "", doctorDocsURL + "#environment-variables"}} + } + return nil +} + +func readOSRelease() map[string]string { + result := make(map[string]string) + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return result + } + for line := range strings.SplitSeq(string(data), "\n") { + if parts := strings.SplitN(line, "=", 2); len(parts) == 2 { + result[parts[0]] = strings.Trim(parts[1], "\"") + } + } + return result +} + +func checkVersions(qsMissingFeatures bool) []checkResult { + dmsCliPath, _ := os.Executable() + dmsCliDetails := "" + if doctorVerbose { + dmsCliDetails = dmsCliPath + } + + results := []checkResult{ + {catVersions, "DMS CLI", statusOK, formatVersion(Version), dmsCliDetails, doctorDocsURL + "#dms-cli"}, + } + + qsVersion, qsStatus, qsPath := getQuickshellVersionInfo(qsMissingFeatures) + qsDetails := "" + if doctorVerbose && qsPath != "" { + qsDetails = qsPath + } + results = append(results, checkResult{catVersions, "Quickshell", qsStatus, qsVersion, qsDetails, doctorDocsURL + "#quickshell"}) + + dmsVersion, dmsPath := getDMSShellVersion() + if dmsVersion != "" { + results = append(results, checkResult{catVersions, "DMS Shell", statusOK, dmsVersion, dmsPath, doctorDocsURL + "#dms-shell"}) + } else { + results = append(results, checkResult{catVersions, "DMS Shell", statusError, "Not installed or not detected", "Run 'dms setup' to install", doctorDocsURL + "#dms-shell"}) + } + + return results +} + +func getDMSShellVersion() (version, path string) { + if err := findConfig(nil, nil); err == nil && configPath != "" { + versionFile := filepath.Join(configPath, "VERSION") + if data, err := os.ReadFile(versionFile); err == nil { + return strings.TrimSpace(string(data)), configPath + } + return "installed", configPath + } + + if dmsPath, err := config.LocateDMSConfig(); err == nil { + versionFile := filepath.Join(dmsPath, "VERSION") + if data, err := os.ReadFile(versionFile); err == nil { + return strings.TrimSpace(string(data)), dmsPath + } + return "installed", dmsPath + } + + return "", "" +} + +func getQuickshellVersionInfo(missingFeatures bool) (string, status, string) { + if !utils.CommandExists("qs") { + return "Not installed", statusError, "" + } + + qsPath, _ := exec.LookPath("qs") + + output, err := exec.Command("qs", "--version").Output() + if err != nil { + return "Installed (version check failed)", statusWarn, qsPath + } + + fullVersion := strings.TrimSpace(string(output)) + if matches := quickshellVersionRegex.FindStringSubmatch(fullVersion); len(matches) >= 2 { + if version.CompareVersions(matches[1], "0.2.0") < 0 { + return fmt.Sprintf("%s (needs >= 0.2.0)", fullVersion), statusError, qsPath + } + if missingFeatures { + return fullVersion, statusWarn, qsPath + } + return fullVersion, statusOK, qsPath + } + + return fullVersion, statusWarn, qsPath +} + +func checkDMSInstallation() []checkResult { + var results []checkResult + + dmsPath := "" + if err := findConfig(nil, nil); err == nil && configPath != "" { + dmsPath = configPath + } else if path, err := config.LocateDMSConfig(); err == nil { + dmsPath = path + } + + if dmsPath == "" { + return []checkResult{{catInstallation, "DMS Configuration", statusError, "Not found", "shell.qml not found in any config path", doctorDocsURL + "#dms-configuration"}} + } + + results = append(results, checkResult{catInstallation, "DMS Configuration", statusOK, "Found", dmsPath, doctorDocsURL + "#dms-configuration"}) + + shellQml := filepath.Join(dmsPath, "shell.qml") + if _, err := os.Stat(shellQml); err != nil { + results = append(results, checkResult{catInstallation, "shell.qml", statusError, "Missing", shellQml, doctorDocsURL + "#dms-configuration"}) + } else { + results = append(results, checkResult{catInstallation, "shell.qml", statusOK, "Present", shellQml, doctorDocsURL + "#dms-configuration"}) + } + + if doctorVerbose { + installType := "Unknown" + switch { + case strings.Contains(dmsPath, "/nix/store"): + installType = "Nix store" + case strings.Contains(dmsPath, ".local/share") || strings.Contains(dmsPath, "/usr/share"): + installType = "System package" + case strings.Contains(dmsPath, ".config"): + installType = "User config" + } + results = append(results, checkResult{catInstallation, "Install Type", statusInfo, installType, dmsPath, doctorDocsURL + "#dms-configuration"}) + } + + return results +} + +func checkWindowManagers() []checkResult { + compositors := []struct { + name, versionCmd, versionArg string + versionRegex *regexp.Regexp + commands []string + }{ + {"Hyprland", "Hyprland", "--version", hyprlandVersionRegex, []string{"hyprland", "Hyprland"}}, + {"niri", "niri", "--version", niriVersionRegex, []string{"niri"}}, + {"Sway", "sway", "--version", swayVersionRegex, []string{"sway"}}, + {"River", "river", "-version", riverVersionRegex, []string{"river"}}, + {"Wayfire", "wayfire", "--version", wayfireVersionRegex, []string{"wayfire"}}, + {"labwc", "labwc", "--version", labwcVersionRegex, []string{"labwc"}}, + {"mangowc", "mango", "-v", mangowcVersionRegex, []string{"mango"}}, + } + + var results []checkResult + foundAny := false + + for _, c := range compositors { + if !slices.ContainsFunc(c.commands, utils.CommandExists) { + continue + } + foundAny = true + var compositorPath string + for _, cmd := range c.commands { + if path, err := exec.LookPath(cmd); err == nil { + compositorPath = path + break + } + } + details := "" + if doctorVerbose && compositorPath != "" { + details = compositorPath + } + results = append(results, checkResult{ + catCompositor, c.name, statusOK, + getVersionFromCommand(c.versionCmd, c.versionArg, c.versionRegex), details, + doctorDocsURL + "#compositor-checks", + }) + } + + if !foundAny { + results = append(results, checkResult{ + catCompositor, "Compositor", statusError, + "No supported Wayland compositor found", + "Install Hyprland, niri, Sway, River, or Wayfire", + doctorDocsURL + "#compositor-checks", + }) + } + + if wm := detectRunningWM(); wm != "" { + results = append(results, checkResult{catCompositor, "Active", statusInfo, wm, "", doctorDocsURL + "#compositor"}) + } + + return results +} + +func getVersionFromCommand(cmd, arg string, regex *regexp.Regexp) string { + output, err := exec.Command(cmd, arg).CombinedOutput() + if err != nil && len(output) == 0 { + return "installed" + } + + outStr := string(output) + if matches := regex.FindStringSubmatch(outStr); len(matches) > 1 { + ver := matches[1] + if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") { + return ver + " (git)" + } + return ver + } + return strings.TrimSpace(outStr) +} + +func detectRunningWM() string { + switch { + case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "": + return "Hyprland" + case os.Getenv("NIRI_SOCKET") != "": + return "niri" + case os.Getenv("XDG_CURRENT_DESKTOP") != "": + return os.Getenv("XDG_CURRENT_DESKTOP") + } + return "" +} + +func checkQuickshellFeatures() ([]checkResult, bool) { + if !utils.CommandExists("qs") { + return nil, false + } + + tmpDir := os.TempDir() + testScript := filepath.Join(tmpDir, "qs-feature-test.qml") + defer os.Remove(testScript) + + qmlContent := ` +import QtQuick +import Quickshell + +ShellRoot { + id: root + + property bool polkitAvailable: false + property bool idleMonitorAvailable: false + property bool idleInhibitorAvailable: false + property bool shortcutInhibitorAvailable: false + + Timer { + interval: 50 + running: true + repeat: false + onTriggered: { + try { + var polkitTest = Qt.createQmlObject( + 'import Quickshell.Services.Polkit; import QtQuick; Item {}', + root + ) + root.polkitAvailable = true + polkitTest.destroy() + } catch (e) {} + + try { + var testItem = Qt.createQmlObject( + 'import Quickshell.Wayland; import QtQuick; QtObject { ' + + 'readonly property bool hasIdleMonitor: typeof IdleMonitor !== "undefined"; ' + + 'readonly property bool hasIdleInhibitor: typeof IdleInhibitor !== "undefined"; ' + + 'readonly property bool hasShortcutInhibitor: typeof ShortcutInhibitor !== "undefined" ' + + '}', + root + ) + root.idleMonitorAvailable = testItem.hasIdleMonitor + root.idleInhibitorAvailable = testItem.hasIdleInhibitor + root.shortcutInhibitorAvailable = testItem.hasShortcutInhibitor + testItem.destroy() + } catch (e) {} + + console.warn(root.polkitAvailable ? "FEATURE:Polkit:OK" : "FEATURE:Polkit:UNAVAILABLE") + console.warn(root.idleMonitorAvailable ? "FEATURE:IdleMonitor:OK" : "FEATURE:IdleMonitor:UNAVAILABLE") + console.warn(root.idleInhibitorAvailable ? "FEATURE:IdleInhibitor:OK" : "FEATURE:IdleInhibitor:UNAVAILABLE") + console.warn(root.shortcutInhibitorAvailable ? "FEATURE:ShortcutInhibitor:OK" : "FEATURE:ShortcutInhibitor:UNAVAILABLE") + + Quickshell.execDetached(["kill", "-TERM", String(Quickshell.processId)]) + } + } +} +` + + if err := os.WriteFile(testScript, []byte(qmlContent), 0o644); err != nil { + return nil, false + } + + cmd := exec.Command("qs", "-p", testScript) + cmd.Env = append(os.Environ(), "NO_COLOR=1") + output, _ := cmd.CombinedOutput() + outputStr := string(output) + + features := []struct{ name, desc string }{ + {"Polkit", "Authentication prompts"}, + {"IdleMonitor", "Idle detection"}, + {"IdleInhibitor", "Prevent idle/sleep"}, + {"ShortcutInhibitor", "Allow shortcut management (niri)"}, + } + + var results []checkResult + missingFeatures := false + + for _, f := range features { + available := strings.Contains(outputStr, fmt.Sprintf("FEATURE:%s:OK", f.name)) + status, message := statusOK, "Available" + if !available { + status, message = statusInfo, "Not available" + missingFeatures = true + } + results = append(results, checkResult{catQuickshellFeatures, f.name, status, message, f.desc, doctorDocsURL + "#quickshell-features"}) + } + + return results, missingFeatures +} + +func checkI2CAvailability() checkResult { + ddc, err := brightness.NewDDCBackend() + if err != nil { + return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "Not available", "External monitor brightness control", doctorDocsURL + "#optional-features"} + } + defer ddc.Close() + + devices, err := ddc.GetDevices() + if err != nil || len(devices) == 0 { + return checkResult{catOptionalFeatures, "I2C/DDC", statusInfo, "No monitors detected", "External monitor brightness control", doctorDocsURL + "#optional-features"} + } + + return checkResult{catOptionalFeatures, "I2C/DDC", statusOK, fmt.Sprintf("%d monitor(s) detected", len(devices)), "External monitor brightness control", doctorDocsURL + "#optional-features"} +} + +func checkImageFormatPlugins() []checkResult { + url := doctorDocsURL + "#optional-features" + + pluginDirs := findQtPluginDirs() + if len(pluginDirs) == 0 { + return []checkResult{ + {catOptionalFeatures, "qt6-imageformats", statusInfo, "Cannot detect (plugin dir not found)", "WebP, TIFF, JP2 support", url}, + {catOptionalFeatures, "kimageformats", statusInfo, "Cannot detect (plugin dir not found)", "AVIF, HEIF, JXL support", url}, + } + } + + type pluginCheck struct { + name string + desc string + plugins []struct{ file, format string } + } + + checks := []pluginCheck{ + { + name: "qt6-imageformats", + desc: "WebP, TIFF, GIF, JP2 support", + plugins: []struct{ file, format string }{ + {"libqwebp.so", "WebP"}, + {"libqtiff.so", "TIFF"}, + {"libqgif.so", "GIF"}, + {"libqjp2.so", "JP2"}, + {"libqicns.so", "ICNS"}, + }, + }, + { + name: "kimageformats", + desc: "AVIF, HEIF, JXL support", + plugins: []struct{ file, format string }{ + {"kimg_avif.so", "AVIF"}, + {"kimg_heif.so", "HEIF"}, + {"kimg_jxl.so", "JXL"}, + {"kimg_exr.so", "EXR"}, + }, + }, + } + + var results []checkResult + for _, c := range checks { + var found []string + var foundDirs []string + for _, pluginDir := range pluginDirs { + imageFormatsDir := filepath.Join(pluginDir, "imageformats") + for _, p := range c.plugins { + if _, err := os.Stat(filepath.Join(imageFormatsDir, p.file)); err == nil { + if !slices.Contains(found, p.format) { + found = append(found, p.format) + } + if !slices.Contains(foundDirs, imageFormatsDir) { + foundDirs = append(foundDirs, imageFormatsDir) + } + } + } + } + + var result checkResult + switch { + case len(found) == 0: + result = checkResult{catOptionalFeatures, c.name, statusWarn, "Not installed", c.desc, url} + default: + details := "" + if doctorVerbose { + details = fmt.Sprintf("Formats: %s (%s)", strings.Join(found, ", "), strings.Join(foundDirs, ":")) + } + result = checkResult{catOptionalFeatures, c.name, statusOK, fmt.Sprintf("Installed (%d formats)", len(found)), details, url} + } + results = append(results, result) + } + + return results +} + +func findQtPluginDirs() []string { + var dirs []string + + addDir := func(dir string) { + if dir != "" { + if _, err := os.Stat(filepath.Join(dir, "imageformats")); err == nil { + dirs = append(dirs, dir) + } + } + } + + // Check all paths in QT_PLUGIN_PATH env var (used by NixOS and custom setups) + if envPath := os.Getenv("QT_PLUGIN_PATH"); envPath != "" { + for dir := range strings.SplitSeq(envPath, ":") { + addDir(dir) + } + } + + // Try qtpaths + for _, cmd := range []string{"qtpaths6", "qtpaths"} { + if output, err := exec.Command(cmd, "-query", "QT_INSTALL_PLUGINS").Output(); err == nil { + addDir(strings.TrimSpace(string(output))) + } + } + + // Fallback: common distro paths + for _, dir := range []string{ + "/usr/lib/qt6/plugins", + "/usr/lib64/qt6/plugins", + "/usr/lib/x86_64-linux-gnu/qt6/plugins", + "/usr/lib/aarch64-linux-gnu/qt6/plugins", + } { + addDir(dir) + } + + return dirs +} + +func detectNetworkBackend(stackResult *network.DetectResult) string { + switch stackResult.Backend { + case network.BackendNetworkManager: + return "NetworkManager" + case network.BackendIwd: + return "iwd" + case network.BackendNetworkd: + if stackResult.HasIwd { + return "iwd + systemd-networkd" + } + return "systemd-networkd" + case network.BackendConnMan: + return "ConnMan" + default: + return "" + } +} + +func getOptionalDBusStatus(busName string) (status, string) { + if utils.IsDBusServiceAvailable(busName) { + return statusOK, "Available" + } else { + return statusWarn, "Not available" + } +} + +func checkOptionalDependencies() []checkResult { + var results []checkResult + + optionalFeaturesURL := doctorDocsURL + "#optional-features" + + accountsStatus, accountsMsg := getOptionalDBusStatus("org.freedesktop.Accounts") + results = append(results, checkResult{catOptionalFeatures, "accountsservice", accountsStatus, accountsMsg, "User accounts", optionalFeaturesURL}) + + ppdStatus, ppdMsg := getOptionalDBusStatus("org.freedesktop.UPower.PowerProfiles") + results = append(results, checkResult{catOptionalFeatures, "power-profiles-daemon", ppdStatus, ppdMsg, "Power profile management", optionalFeaturesURL}) + + logindStatus, logindMsg := getOptionalDBusStatus("org.freedesktop.login1") + results = append(results, checkResult{catOptionalFeatures, "logind", logindStatus, logindMsg, "Session management", optionalFeaturesURL}) + + cupsPkHelperBus := "org.opensuse.CupsPkHelper.Mechanism" + var cupsPkStatus status + var cupsPkMsg string + switch { + case utils.IsDBusServiceAvailable(cupsPkHelperBus): + cupsPkStatus, cupsPkMsg = statusOK, "Running" + case utils.IsDBusServiceActivatable(cupsPkHelperBus): + cupsPkStatus, cupsPkMsg = statusOK, "Available" + default: + cupsPkStatus, cupsPkMsg = statusWarn, "Not available (install cups-pk-helper)" + } + results = append(results, checkResult{catOptionalFeatures, "cups-pk-helper", cupsPkStatus, cupsPkMsg, "Printer management", optionalFeaturesURL}) + + results = append(results, checkI2CAvailability()) + results = append(results, checkImageFormatPlugins()...) + + terminals := []string{"ghostty", "kitty", "alacritty", "foot", "wezterm"} + terminals = slices.DeleteFunc(terminals, func(t string) bool { + return !utils.CommandExists(t) + }) + + if len(terminals) > 0 { + results = append(results, checkResult{catOptionalFeatures, "Terminal", statusOK, strings.Join(terminals, ", "), "", optionalFeaturesURL}) + } else { + results = append(results, checkResult{catOptionalFeatures, "Terminal", statusWarn, "None found", "Install ghostty, kitty, foot or alacritty", optionalFeaturesURL}) + } + + networkResult, err := network.DetectNetworkStack() + networkStatus, networkMessage, networkDetails := statusOK, "Not available", "Network management" + + if err == nil && networkResult.Backend != network.BackendNone { + networkMessage = detectNetworkBackend(networkResult) + if doctorVerbose { + networkDetails = networkResult.ChosenReason + } + } else { + networkStatus = statusInfo + } + + results = append(results, checkResult{catOptionalFeatures, "Network", networkStatus, networkMessage, networkDetails, optionalFeaturesURL}) + + deps := []struct { + name, cmd, desc string + important bool + }{ + {"matugen", "matugen", "Dynamic theming", true}, + {"dgop", "dgop", "System monitoring", true}, + {"cava", "cava", "Audio visualizer", true}, + {"khal", "khal", "Calendar events", false}, + {"danksearch", "dsearch", "File search", false}, + {"fprintd", "fprintd-list", "Fingerprint auth", false}, + } + + for _, d := range deps { + found := utils.CommandExists(d.cmd) + + switch { + case found: + results = append(results, checkResult{catOptionalFeatures, d.name, statusOK, "Installed", d.desc, optionalFeaturesURL}) + case d.important: + results = append(results, checkResult{catOptionalFeatures, d.name, statusWarn, "Missing", d.desc, optionalFeaturesURL}) + default: + results = append(results, checkResult{catOptionalFeatures, d.name, statusInfo, "Not installed", d.desc, optionalFeaturesURL}) + } + } + + return results +} + +func checkConfigurationFiles() []checkResult { + configDir, _ := os.UserConfigDir() + cacheDir, _ := os.UserCacheDir() + dmsDir := "DankMaterialShell" + + configFiles := []struct{ name, path string }{ + {"settings.json", filepath.Join(configDir, dmsDir, "settings.json")}, + {"clsettings.json", filepath.Join(configDir, dmsDir, "clsettings.json")}, + {"plugin_settings.json", filepath.Join(configDir, dmsDir, "plugin_settings.json")}, + {"session.json", filepath.Join(utils.XDGStateHome(), dmsDir, "session.json")}, + {"dms-colors.json", filepath.Join(cacheDir, dmsDir, "dms-colors.json")}, + } + + var results []checkResult + for _, cf := range configFiles { + info, err := os.Stat(cf.path) + if err != nil { + results = append(results, checkResult{catConfigFiles, cf.name, statusInfo, "Not yet created", cf.path, doctorDocsURL + "#config-files"}) + continue + } + + status := statusOK + message := "Present" + if info.Mode().Perm()&0o200 == 0 { + status = statusWarn + message += " (read-only)" + } + results = append(results, checkResult{catConfigFiles, cf.name, status, message, cf.path, doctorDocsURL + "#config-files"}) + } + return results +} + +func checkSystemdServices() []checkResult { + if !utils.CommandExists("systemctl") { + return nil + } + + var results []checkResult + + dmsState := getServiceState("dms", true) + if !dmsState.exists { + results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"}) + } else { + status, message := statusOK, dmsState.enabled + if dmsState.active != "" { + message = fmt.Sprintf("%s, %s", dmsState.enabled, dmsState.active) + } + switch { + case dmsState.enabled == "disabled": + status, message = statusWarn, "Disabled" + case dmsState.active == "failed" || dmsState.active == "inactive": + status = statusError + } + results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"}) + } + + greetdState := getServiceState("greetd", false) + switch { + case greetdState.exists: + status := statusOK + if greetdState.enabled == "disabled" { + status = statusInfo + } + results = append(results, checkResult{catServices, "greetd", status, greetdState.enabled, "", doctorDocsURL + "#services"}) + case doctorVerbose: + results = append(results, checkResult{catServices, "greetd", statusInfo, "Not installed", "Optional greeter service", doctorDocsURL + "#services"}) + } + + return results +} + +type serviceState struct { + exists bool + enabled string + active string +} + +func getServiceState(name string, userService bool) serviceState { + args := []string{"is-enabled", name} + if userService { + args = []string{"--user", "is-enabled", name} + } + + output, _ := exec.Command("systemctl", args...).Output() + enabled := strings.TrimSpace(string(output)) + + if enabled == "" || enabled == "not-found" { + return serviceState{} + } + + state := serviceState{exists: true, enabled: enabled} + + if userService { + output, _ = exec.Command("systemctl", "--user", "is-active", name).Output() + if active := strings.TrimSpace(string(output)); active != "" && active != "unknown" { + state.active = active + } + } + + return state +} + +func printResults(results []checkResult) { + theme := tui.TerminalTheme() + styles := tui.NewStyles(theme) + + currentCategory := category(-1) + for _, r := range results { + if r.category != currentCategory { + if currentCategory != -1 { + fmt.Println() + } + fmt.Printf(" %s\n", styles.Bold.Render(r.category.String())) + currentCategory = r.category + } + printResultLine(r, styles) + } +} + +func printResultsJSON(results []checkResult) { + var ds DoctorStatus + for _, r := range results { + ds.Add(r) + } + + output := doctorOutputJSON{} + output.Summary.Errors = ds.ErrorCount() + output.Summary.Warnings = ds.WarningCount() + output.Summary.OK = ds.OKCount() + output.Summary.Info = len(ds.Info) + + output.Results = make([]checkResultJSON, 0, len(results)) + for _, r := range results { + output.Results = append(output.Results, r.toJSON()) + } + + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(output); err != nil { + fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err) + os.Exit(1) + } +} + +func printResultLine(r checkResult, styles tui.Styles) { + icon, style := r.status.IconStyle(styles) + + name := r.name + nameLen := len(name) + + if nameLen > checkNameMaxLength { + name = name[:checkNameMaxLength-1] + "…" + nameLen = checkNameMaxLength + } + dots := strings.Repeat("·", checkNameMaxLength-nameLen) + + fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message) + + if doctorVerbose && r.details != "" { + fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details)) + } + + if (r.status == statusError || r.status == statusWarn) && r.url != "" { + fmt.Printf(" %s\n", styles.Subtle.Render("→ "+r.url)) + } +} + +func printSummary(results []checkResult, qsMissingFeatures bool) { + theme := tui.TerminalTheme() + styles := tui.NewStyles(theme) + + var ds DoctorStatus + for _, r := range results { + ds.Add(r) + } + + fmt.Println() + fmt.Printf(" %s\n", styles.Subtle.Render("──────────────────────────────────────")) + + if !ds.HasIssues() { + fmt.Printf(" %s\n", styles.Success.Render("✓ All checks passed!")) + } else { + var parts []string + + if ds.ErrorCount() > 0 { + parts = append(parts, styles.Error.Render(fmt.Sprintf("%d error(s)", ds.ErrorCount()))) + } + if ds.WarningCount() > 0 { + parts = append(parts, styles.Warning.Render(fmt.Sprintf("%d warning(s)", ds.WarningCount()))) + } + parts = append(parts, styles.Success.Render(fmt.Sprintf("%d ok", ds.OKCount()))) + fmt.Printf(" %s\n", strings.Join(parts, ", ")) + + if qsMissingFeatures { + fmt.Println() + fmt.Printf(" %s\n", styles.Subtle.Render("→ Consider using quickshell-git for full feature support")) + } + } + fmt.Println() +} + +func formatResultsPlain(results []checkResult) string { + var sb strings.Builder + sb.WriteString("## DMS Doctor Report\n\n") + + currentCategory := category(-1) + for _, r := range results { + if r.category != currentCategory { + if currentCategory != -1 { + sb.WriteString("\n") + } + fmt.Fprintf(&sb, "**%s**\n", r.category.String()) + currentCategory = r.category + } + + fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message) + + if doctorVerbose && r.details != "" { + fmt.Fprintf(&sb, " - %s\n", r.details) + } + } + + var ds DoctorStatus + for _, r := range results { + ds.Add(r) + } + + sb.WriteString("\n---\n") + fmt.Fprintf(&sb, "**Summary:** %d error(s), %d warning(s), %d ok\n", + ds.ErrorCount(), ds.WarningCount(), ds.OKCount()) + + return sb.String() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_download.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_download.go new file mode 100644 index 0000000..e9caeb0 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_download.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" +) + +var dlOutput string +var dlUserAgent string +var dlTimeout int +var dlIPv4Only bool + +var dlCmd = &cobra.Command{ + Use: "dl ", + Short: "Download a URL to stdout or file", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runDownload(args[0]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }, +} + +func init() { + dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)") + dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header") + dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds") + dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only") +} + +func runDownload(url string) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("invalid request: %w", err) + } + + switch { + case dlUserAgent != "": + req.Header.Set("User-Agent", dlUserAgent) + default: + req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)") + } + + dialer := &net.Dialer{Timeout: 5 * time.Second} + transport := &http.Transport{DialContext: dialer.DialContext} + if dlIPv4Only { + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, "tcp4", addr) + } + } + client := &http.Client{Transport: transport} + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + if dlOutput == "" { + _, err = io.Copy(os.Stdout, resp.Body) + return err + } + + if dir := filepath.Dir(dlOutput); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir failed: %w", err) + } + } + + f, err := os.Create(dlOutput) + if err != nil { + return fmt.Errorf("create failed: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + os.Remove(dlOutput) + return fmt.Errorf("write failed: %w", err) + } + + fmt.Println(dlOutput) + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_dpms.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_dpms.go new file mode 100644 index 0000000..77eff1a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_dpms.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var dpmsCmd = &cobra.Command{ + Use: "dpms", + Short: "Control display power management", +} + +var dpmsOnCmd = &cobra.Command{ + Use: "on [output]", + Short: "Turn display(s) on", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getDPMSOutputs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: runDPMSOn, +} + +var dpmsOffCmd = &cobra.Command{ + Use: "off [output]", + Short: "Turn display(s) off", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return getDPMSOutputs(), cobra.ShellCompDirectiveNoFileComp + }, + Run: runDPMSOff, +} + +var dpmsListCmd = &cobra.Command{ + Use: "list", + Short: "List outputs", + Args: cobra.NoArgs, + Run: runDPMSList, +} + +func init() { + dpmsCmd.AddCommand(dpmsOnCmd, dpmsOffCmd, dpmsListCmd) +} + +func runDPMSOn(cmd *cobra.Command, args []string) { + outputName := "" + if len(args) > 0 { + outputName = args[0] + } + + client, err := newDPMSClient() + if err != nil { + log.Fatalf("%v", err) + } + defer client.Close() + + if err := client.SetDPMS(outputName, true); err != nil { + log.Fatalf("%v", err) + } +} + +func runDPMSOff(cmd *cobra.Command, args []string) { + outputName := "" + if len(args) > 0 { + outputName = args[0] + } + + client, err := newDPMSClient() + if err != nil { + log.Fatalf("%v", err) + } + defer client.Close() + + if err := client.SetDPMS(outputName, false); err != nil { + log.Fatalf("%v", err) + } +} + +func getDPMSOutputs() []string { + client, err := newDPMSClient() + if err != nil { + return nil + } + defer client.Close() + return client.ListOutputs() +} + +func runDPMSList(cmd *cobra.Command, args []string) { + client, err := newDPMSClient() + if err != nil { + log.Fatalf("%v", err) + } + defer client.Close() + + for _, output := range client.ListOutputs() { + fmt.Println(output) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_features.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_features.go new file mode 100644 index 0000000..7ec5437 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_features.go @@ -0,0 +1,491 @@ +//go:build !distro_binary + +package main + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/AvengeMedia/DankMaterialShell/core/internal/version" + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update DankMaterialShell to the latest version", + Long: "Update DankMaterialShell to the latest version using the appropriate package manager for your distribution", + PreRunE: findConfig, + Run: func(cmd *cobra.Command, args []string) { + runUpdate() + }, +} + +var updateCheckCmd = &cobra.Command{ + Use: "check", + Short: "Check if updates are available for DankMaterialShell", + Long: "Check for available updates without performing the actual update", + Run: func(cmd *cobra.Command, args []string) { + runUpdateCheck() + }, +} + +func runUpdateCheck() { + fmt.Println("Checking for DankMaterialShell updates...") + fmt.Println() + + versionInfo, err := version.GetDMSVersionInfo() + if err != nil { + log.Fatalf("Error checking for updates: %v", err) + } + + fmt.Printf("Current version: %s\n", versionInfo.Current) + fmt.Printf("Latest version: %s\n", versionInfo.Latest) + fmt.Println() + + if versionInfo.HasUpdate { + fmt.Println("✓ Update available!") + fmt.Println() + fmt.Println("Run 'dms update' to install the latest version.") + os.Exit(0) + } else { + fmt.Println("✓ You are running the latest version.") + os.Exit(0) + } +} + +func runUpdate() { + osInfo, err := distros.GetOSInfo() + if err != nil { + log.Fatalf("Error detecting OS: %v", err) + } + + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + log.Fatalf("Unsupported distribution: %s", osInfo.Distribution.ID) + } + + var updateErr error + switch config.Family { + case distros.FamilyArch: + updateErr = updateArchLinux() + case distros.FamilySUSE: + updateErr = updateOtherDistros() + default: + updateErr = updateOtherDistros() + } + + if updateErr != nil { + if errors.Is(updateErr, errdefs.ErrUpdateCancelled) { + log.Info("Update cancelled.") + return + } + if errors.Is(updateErr, errdefs.ErrNoUpdateNeeded) { + return + } + log.Fatalf("Error updating DMS: %v", updateErr) + } + + log.Info("Update complete! Restarting DMS...") + restartShell() +} + +func updateArchLinux() error { + homeDir, err := os.UserHomeDir() + if err == nil { + dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") + if _, err := os.Stat(dmsPath); err == nil { + return updateOtherDistros() + } + } + + var packageName string + var isAUR bool + if isArchPackageInstalled("dms-shell") { + packageName = "dms-shell" + } else if isArchPackageInstalled("dms-shell-git") { + packageName = "dms-shell-git" + isAUR = true + } else if isArchPackageInstalled("dms-shell-bin") { + packageName = "dms-shell-bin" + isAUR = true + } else { + fmt.Println("Info: No dms-shell package found.") + fmt.Println("Info: Falling back to git-based update method...") + return updateOtherDistros() + } + + if !isAUR { + fmt.Printf("This will update %s using pacman.\n", packageName) + if !confirmUpdate() { + return errdefs.ErrUpdateCancelled + } + + fmt.Printf("\nRunning: sudo pacman -S %s\n", packageName) + cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", packageName) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf("Error: Failed to update using pacman: %v\n", err) + return err + } + + fmt.Println("dms successfully updated") + return nil + } + + var helper string + var updateCmd *exec.Cmd + + if utils.CommandExists("yay") { + helper = "yay" + updateCmd = exec.Command("yay", "-S", packageName) + } else if utils.CommandExists("paru") { + helper = "paru" + updateCmd = exec.Command("paru", "-S", packageName) + } else { + fmt.Println("Error: Neither yay nor paru found - please install an AUR helper") + fmt.Println("Info: Falling back to git-based update method...") + return updateOtherDistros() + } + + fmt.Printf("This will update DankMaterialShell using %s.\n", helper) + if !confirmUpdate() { + return errdefs.ErrUpdateCancelled + } + + fmt.Printf("\nRunning: %s -S %s\n", helper, packageName) + updateCmd.Stdout = os.Stdout + updateCmd.Stderr = os.Stderr + err = updateCmd.Run() + if err != nil { + fmt.Printf("Error: Failed to update using %s: %v\n", helper, err) + } + + fmt.Println("dms successfully updated") + return nil +} + +func updateOtherDistros() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") + + if _, err := os.Stat(dmsPath); os.IsNotExist(err) { + return fmt.Errorf("DMS configuration directory not found at %s", dmsPath) + } + + fmt.Printf("Found DMS configuration at %s\n", dmsPath) + + versionInfo, err := version.GetDMSVersionInfo() + if err == nil && !versionInfo.HasUpdate { + fmt.Println() + fmt.Printf("Current version: %s\n", versionInfo.Current) + fmt.Printf("Latest version: %s\n", versionInfo.Latest) + fmt.Println() + fmt.Println("✓ You are already running the latest version.") + return errdefs.ErrNoUpdateNeeded + } + + fmt.Println("\nThis will update:") + fmt.Println(" 1. The dms binary from GitHub releases") + fmt.Println(" 2. DankMaterialShell configuration using git") + if !confirmUpdate() { + return errdefs.ErrUpdateCancelled + } + + fmt.Println("\n=== Updating dms binary ===") + if err := updateDMSBinary(); err != nil { + fmt.Printf("Warning: Failed to update dms binary: %v\n", err) + fmt.Println("Continuing with shell configuration update...") + } else { + fmt.Println("dms binary successfully updated") + } + + fmt.Println("\n=== Updating DMS shell configuration ===") + + if err := os.Chdir(dmsPath); err != nil { + return fmt.Errorf("failed to change to DMS directory: %w", err) + } + + statusCmd := exec.Command("git", "status", "--porcelain") + statusOutput, _ := statusCmd.Output() + hasLocalChanges := len(strings.TrimSpace(string(statusOutput))) > 0 + + currentRefCmd := exec.Command("git", "symbolic-ref", "-q", "HEAD") + currentRefOutput, _ := currentRefCmd.Output() + onBranch := len(currentRefOutput) > 0 + + var currentTag string + var currentBranch string + + if !onBranch { + tagCmd := exec.Command("git", "describe", "--exact-match", "--tags", "HEAD") + if tagOutput, err := tagCmd.Output(); err == nil { + currentTag = strings.TrimSpace(string(tagOutput)) + } + } else { + branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + if branchOutput, err := branchCmd.Output(); err == nil { + currentBranch = strings.TrimSpace(string(branchOutput)) + } + } + + fmt.Println("Fetching latest changes...") + fetchCmd := exec.Command("git", "fetch", "origin", "--tags", "--force") + fetchCmd.Stdout = os.Stdout + fetchCmd.Stderr = os.Stderr + if err := fetchCmd.Run(); err != nil { + return fmt.Errorf("failed to fetch changes: %w", err) + } + + if currentTag != "" { + latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname") + latestTagOutput, err := latestTagCmd.Output() + if err != nil { + return fmt.Errorf("failed to get latest tag: %w", err) + } + + tags := strings.Split(strings.TrimSpace(string(latestTagOutput)), "\n") + if len(tags) == 0 || tags[0] == "" { + return fmt.Errorf("no version tags found") + } + latestTag := tags[0] + + if latestTag == currentTag { + fmt.Printf("Already on latest tag: %s\n", currentTag) + return nil + } + + fmt.Printf("Current tag: %s\n", currentTag) + fmt.Printf("Latest tag: %s\n", latestTag) + + if hasLocalChanges { + fmt.Println("\nWarning: You have local changes in your DMS configuration.") + if offerReclone(dmsPath) { + return nil + } + return errdefs.ErrUpdateCancelled + } + + fmt.Printf("Updating to %s...\n", latestTag) + checkoutCmd := exec.Command("git", "checkout", latestTag) + checkoutCmd.Stdout = os.Stdout + checkoutCmd.Stderr = os.Stderr + if err := checkoutCmd.Run(); err != nil { + fmt.Printf("Error: Failed to checkout %s: %v\n", latestTag, err) + if offerReclone(dmsPath) { + return nil + } + return fmt.Errorf("update cancelled") + } + + fmt.Printf("\nUpdate complete! Updated from %s to %s\n", currentTag, latestTag) + return nil + } + + if currentBranch == "" { + currentBranch = "master" + } + + fmt.Printf("Current branch: %s\n", currentBranch) + + if hasLocalChanges { + fmt.Println("\nWarning: You have local changes in your DMS configuration.") + if offerReclone(dmsPath) { + return nil + } + return errdefs.ErrUpdateCancelled + } + + pullCmd := exec.Command("git", "pull", "origin", currentBranch) + pullCmd.Stdout = os.Stdout + pullCmd.Stderr = os.Stderr + if err := pullCmd.Run(); err != nil { + fmt.Printf("Error: Failed to pull latest changes: %v\n", err) + if offerReclone(dmsPath) { + return nil + } + return fmt.Errorf("update cancelled") + } + + fmt.Println("\nUpdate complete!") + return nil +} + +func offerReclone(dmsPath string) bool { + fmt.Println("\nWould you like to backup and re-clone the repository? (y/N): ") + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil || !strings.HasPrefix(strings.ToLower(strings.TrimSpace(response)), "y") { + return false + } + + timestamp := time.Now().Unix() + backupPath := fmt.Sprintf("%s.backup-%d", dmsPath, timestamp) + + fmt.Printf("Backing up current directory to %s...\n", backupPath) + if err := os.Rename(dmsPath, backupPath); err != nil { + fmt.Printf("Error: Failed to backup directory: %v\n", err) + return false + } + + fmt.Println("Cloning fresh copy...") + cloneCmd := exec.Command("git", "clone", "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath) + cloneCmd.Stdout = os.Stdout + cloneCmd.Stderr = os.Stderr + if err := cloneCmd.Run(); err != nil { + fmt.Printf("Error: Failed to clone repository: %v\n", err) + fmt.Printf("Restoring backup...\n") + os.Rename(backupPath, dmsPath) + return false + } + + fmt.Printf("Successfully re-cloned repository (backup at %s)\n", backupPath) + return true +} + +func confirmUpdate() bool { + fmt.Print("Do you want to proceed with the update? (y/N): ") + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + fmt.Printf("Error reading input: %v\n", err) + return false + } + response = strings.TrimSpace(strings.ToLower(response)) + return response == "y" || response == "yes" +} + +func updateDMSBinary() error { + arch := "" + switch strings.ToLower(os.Getenv("HOSTTYPE")) { + case "x86_64", "amd64": + arch = "amd64" + case "aarch64", "arm64": + arch = "arm64" + default: + cmd := exec.Command("uname", "-m") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to detect architecture: %w", err) + } + archStr := strings.TrimSpace(string(output)) + switch archStr { + case "x86_64": + arch = "amd64" + case "aarch64": + arch = "arm64" + default: + return fmt.Errorf("unsupported architecture: %s", archStr) + } + } + + fmt.Println("Fetching latest release version...") + cmd := exec.Command("curl", "-s", "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to fetch latest release: %w", err) + } + + version := "" + for line := range strings.SplitSeq(string(output), "\n") { + if strings.Contains(line, "\"tag_name\"") { + parts := strings.Split(line, "\"") + if len(parts) >= 4 { + version = parts[3] + break + } + } + } + + if version == "" { + return fmt.Errorf("could not determine latest version") + } + + fmt.Printf("Latest version: %s\n", version) + + tempDir, err := os.MkdirTemp("", "dms-update-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + binaryURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch) + checksumURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz.sha256", version, arch) + + binaryPath := filepath.Join(tempDir, "dms.gz") + checksumPath := filepath.Join(tempDir, "dms.gz.sha256") + + fmt.Println("Downloading dms binary...") + downloadCmd := exec.Command("curl", "-L", binaryURL, "-o", binaryPath) + if err := downloadCmd.Run(); err != nil { + return fmt.Errorf("failed to download binary: %w", err) + } + + fmt.Println("Downloading checksum...") + downloadCmd = exec.Command("curl", "-L", checksumURL, "-o", checksumPath) + if err := downloadCmd.Run(); err != nil { + return fmt.Errorf("failed to download checksum: %w", err) + } + + fmt.Println("Verifying checksum...") + checksumData, err := os.ReadFile(checksumPath) + if err != nil { + return fmt.Errorf("failed to read checksum file: %w", err) + } + expectedChecksum := strings.Fields(string(checksumData))[0] + + actualCmd := exec.Command("sha256sum", binaryPath) + actualOutput, err := actualCmd.Output() + if err != nil { + return fmt.Errorf("failed to calculate checksum: %w", err) + } + actualChecksum := strings.Fields(string(actualOutput))[0] + + if expectedChecksum != actualChecksum { + return fmt.Errorf("checksum verification failed\nExpected: %s\nGot: %s", expectedChecksum, actualChecksum) + } + + fmt.Println("Decompressing binary...") + decompressCmd := exec.Command("gunzip", binaryPath) + if err := decompressCmd.Run(); err != nil { + return fmt.Errorf("failed to decompress binary: %w", err) + } + + decompressedPath := filepath.Join(tempDir, "dms") + + if err := os.Chmod(decompressedPath, 0o755); err != nil { + return fmt.Errorf("failed to make binary executable: %w", err) + } + + currentPath, err := exec.LookPath("dms") + if err != nil { + return fmt.Errorf("could not find current dms binary: %w", err) + } + + fmt.Printf("Installing to %s...\n", currentPath) + + replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath) + replaceCmd.Stdin = os.Stdin + replaceCmd.Stdout = os.Stdout + replaceCmd.Stderr = os.Stderr + if err := replaceCmd.Run(); err != nil { + return fmt.Errorf("failed to replace binary: %w", err) + } + + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter.go new file mode 100644 index 0000000..1efa545 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter.go @@ -0,0 +1,1850 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/spf13/cobra" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var greeterCmd = &cobra.Command{ + Use: "greeter", + Short: "Manage DMS greeter", + Long: "Manage DMS greeter (greetd)", +} + +var ( + greeterConfigSyncFn = greeter.SyncDMSConfigs + sharedAuthSyncFn = sharedpam.SyncAuthConfig +) + +var greeterInstallCmd = &cobra.Command{ + Use: "install", + Short: "Install and configure DMS greeter", + Long: "Install greetd and configure it to use DMS as the greeter interface", + PreRunE: requireMutableSystemCommand, + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + installCmd := "dms greeter install" + if yes { + installCmd += " --yes" + } + installCmd += "; echo; echo \"Install finished. Closing in 3 seconds...\"; sleep 3" + if err := runCommandInTerminal(installCmd); err != nil { + log.Fatalf("Error launching install in terminal: %v", err) + } + return + } + if err := installGreeter(yes); err != nil { + log.Fatalf("Error installing greeter: %v", err) + } + }, +} + +var greeterSyncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync DMS theme and settings with greeter", + Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen", + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + auth, _ := cmd.Flags().GetBool("auth") + local, _ := cmd.Flags().GetBool("local") + term, _ := cmd.Flags().GetBool("terminal") + if term { + if err := syncInTerminal(yes, auth, local); err != nil { + log.Fatalf("Error launching sync in terminal: %v", err) + } + return + } + if err := syncGreeter(yes, auth, local); err != nil { + log.Fatalf("Error syncing greeter: %v", err) + } + }, +} + +func init() { + greeterSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts, use defaults (for UI)") + greeterSyncCmd.Flags().BoolP("terminal", "t", false, "Run sync in a new terminal (for entering sudo password); terminal auto-closes when done") + greeterSyncCmd.Flags().BoolP("auth", "a", false, "Configure PAM for fingerprint and U2F (adds both if modules exist); overrides UI toggles") + greeterSyncCmd.Flags().BoolP("local", "l", false, "Developer mode: force greetd config to use a local DMS checkout path") +} + +var greeterEnableCmd = &cobra.Command{ + Use: "enable", + Short: "Enable DMS greeter in greetd config", + Long: "Configure greetd to use DMS as the greeter", + PreRunE: requireMutableSystemCommand, + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + enableCmd := "dms greeter enable" + if yes { + enableCmd += " --yes" + } + enableCmd += "; echo; echo \"Enable finished. Closing in 3 seconds...\"; sleep 3" + if err := runCommandInTerminal(enableCmd); err != nil { + log.Fatalf("Error launching enable in terminal: %v", err) + } + return + } + if err := enableGreeter(yes); err != nil { + log.Fatalf("Error enabling greeter: %v", err) + } + }, +} + +var greeterStatusCmd = &cobra.Command{ + Use: "status", + Short: "Check greeter sync status", + Long: "Check the status of greeter installation and configuration sync", + Run: func(cmd *cobra.Command, args []string) { + if err := checkGreeterStatus(); err != nil { + log.Fatalf("Error checking greeter status: %v", err) + } + }, +} + +var greeterUninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "Remove DMS greeter configuration and restore previous display manager", + Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state", + PreRunE: requireMutableSystemCommand, + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + uninstallCmd := "dms greeter uninstall" + if yes { + uninstallCmd += " --yes" + } + uninstallCmd += "; echo; echo \"Uninstall finished. Closing in 3 seconds...\"; sleep 3" + if err := runCommandInTerminal(uninstallCmd); err != nil { + log.Fatalf("Error launching uninstall in terminal: %v", err) + } + return + } + if err := uninstallGreeter(yes); err != nil { + log.Fatalf("Error uninstalling greeter: %v", err) + } + }, +} + +func init() { + greeterInstallCmd.Flags().BoolP("yes", "y", false, "Non-interactive: skip confirmation prompt") + greeterInstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") + greeterEnableCmd.Flags().BoolP("yes", "y", false, "Non-interactive: skip confirmation prompt") + greeterEnableCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") + greeterUninstallCmd.Flags().BoolP("yes", "y", false, "Non-interactive: skip confirmation prompt") + greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") +} + +func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error { + if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil { + return err + } + if beforeAuth != nil { + beforeAuth() + } + return sharedAuthSyncFn(logFunc, "", options) +} + +func installGreeter(nonInteractive bool) error { + fmt.Println("=== DMS Greeter Installation ===") + + logFunc := func(msg string) { + fmt.Println(msg) + } + + if !nonInteractive { + fmt.Print("\nThis will install greetd (if needed), configure the DMS greeter, and enable it. Continue? [Y/n]: ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) == "n" || strings.ToLower(strings.TrimSpace(response)) == "no" { + fmt.Println("Aborted.") + return nil + } + fmt.Println() + } + + if err := greeter.EnsureGreetdInstalled(logFunc, ""); err != nil { + return err + } + + greeter.TryInstallGreeterPackage(logFunc, "") + if isPackageOnlyGreeterDistro() && !greeter.IsGreeterPackaged() { + return fmt.Errorf("dms-greeter must be installed from distro packages on this distribution. %s", packageInstallHint()) + } + if greeter.IsGreeterPackaged() && greeter.HasLegacyLocalGreeterWrapper() { + return fmt.Errorf("legacy manual wrapper detected at /usr/local/bin/dms-greeter; remove it before using packaged dms-greeter: sudo rm -f /usr/local/bin/dms-greeter") + } + + if isGreeterEnabled() { + fmt.Print("\nGreeter is already installed and configured. Re-run to re-sync settings and permissions? [Y/n]: ") + var response string + fmt.Scanln(&response) + response = strings.TrimSpace(strings.ToLower(response)) + if response == "n" || response == "no" { + fmt.Println("Run 'dms greeter sync' to re-sync theme and settings at any time.") + return nil + } + fmt.Println() + } + + fmt.Println("\nDetecting DMS installation...") + dmsPath, err := greeter.DetectDMSPath() + if err != nil { + return err + } + fmt.Printf("✓ Found DMS at: %s\n", dmsPath) + + fmt.Println("\nDetecting installed compositors...") + compositors := greeter.DetectCompositors() + if len(compositors) == 0 { + return fmt.Errorf("no supported compositors found (niri or Hyprland required)") + } + + var selectedCompositor string + if len(compositors) == 1 { + selectedCompositor = compositors[0] + fmt.Printf("✓ Found compositor: %s\n", selectedCompositor) + } else { + var err error + selectedCompositor, err = greeter.PromptCompositorChoice(compositors) + if err != nil { + return err + } + fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor) + } + + fmt.Println("\nSetting up dms-greeter group and permissions...") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } + + fmt.Println("\nCopying greeter files...") + if err := greeter.CopyGreeterFiles(dmsPath, selectedCompositor, logFunc, ""); err != nil { + return err + } + + if greeter.IsAppArmorEnabled() { + fmt.Println("\nConfiguring AppArmor profile...") + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } + } + + fmt.Println("\nConfiguring greetd...") + greeterPathForConfig := "" + if !greeter.IsGreeterPackaged() { + greeterPathForConfig = dmsPath + } + if err := greeter.ConfigureGreetd(greeterPathForConfig, selectedCompositor, logFunc, ""); err != nil { + return err + } + + fmt.Println("\nSynchronizing DMS configurations...") + if err := syncGreeterConfigsAndAuth(dmsPath, selectedCompositor, logFunc, sharedpam.SyncAuthOptions{}, func() { + fmt.Println("\nConfiguring authentication...") + }); err != nil { + return err + } + + if err := ensureGraphicalTarget(); err != nil { + return err + } + + if err := handleConflictingDisplayManagers(); err != nil { + return err + } + + if err := ensureGreetdEnabled(); err != nil { + return err + } + + fmt.Println("\n=== Installation Complete ===") + fmt.Println("\nTo start the greeter now, run:") + fmt.Println(" sudo systemctl start greetd") + fmt.Println("\nOr reboot to see the greeter at next boot.") + + return nil +} + +func uninstallGreeter(nonInteractive bool) error { + fmt.Println("=== DMS Greeter Uninstall ===") + + logFunc := func(msg string) { fmt.Println(msg) } + + if !isGreeterEnabled() { + fmt.Println("ℹ DMS greeter is not currently configured in /etc/greetd/config.toml.") + fmt.Println(" Nothing to undo for greetd configuration.") + } + + if !nonInteractive { + fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS-managed greeter auth block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) != "y" { + fmt.Println("Aborted.") + return nil + } + } + + fmt.Println("\nDisabling greetd...") + disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd") + disableCmd.Stdout = os.Stdout + disableCmd.Stderr = os.Stderr + if err := disableCmd.Run(); err != nil { + fmt.Printf(" ⚠ Could not disable greetd: %v\n", err) + } else { + fmt.Println(" ✓ greetd disabled") + } + + fmt.Println("\nRemoving DMS authentication configuration...") + if err := sharedpam.RemoveManagedGreeterPamBlock(logFunc, ""); err != nil { + fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err) + } + + fmt.Println("\nRemoving DMS AppArmor profile...") + if err := greeter.UninstallAppArmorProfile(logFunc, ""); err != nil { + fmt.Printf(" ⚠ AppArmor cleanup failed: %v\n", err) + } + + fmt.Println("\nRestoring greetd configuration...") + if err := restorePreDMSGreetdConfig(""); err != nil { + fmt.Printf(" ⚠ Could not restore previous greetd config: %v\n", err) + fmt.Println(" You may need to manually edit /etc/greetd/config.toml.") + } + + fmt.Println("\nChecking for other display managers to re-enable...") + suggestDisplayManagerRestore(nonInteractive) + + fmt.Println("\n=== Uninstall Complete ===") + fmt.Println("\nReboot to complete the uninstallation and switch to your previous display manager.") + fmt.Println("To re-enable DMS greeter at any time, run: dms greeter enable") + + return nil +} + +func restorePreDMSGreetdConfig(sudoPassword string) error { + const configPath = "/etc/greetd/config.toml" + const backupGlob = "/etc/greetd/config.toml.backup-*" + + matches, _ := filepath.Glob(backupGlob) + + for i := 0; i < len(matches)-1; i++ { + for j := i + 1; j < len(matches); j++ { + if matches[j] > matches[i] { + matches[i], matches[j] = matches[j], matches[i] + } + } + } + + for _, candidate := range matches { + data, err := os.ReadFile(candidate) + if err != nil { + continue + } + if strings.Contains(string(data), "dms-greeter") { + continue + } + tmp, err := os.CreateTemp("", "greetd-restore-*") + if err != nil { + return fmt.Errorf("could not create temp file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + tmp.Close() + + if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { + return fmt.Errorf("failed to restore %s: %w", candidate, err) + } + if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil { + return err + } + fmt.Printf(" ✓ Restored greetd config from %s\n", candidate) + return nil + } + + minimal := `[terminal] +vt = 1 + +# DMS greeter has been uninstalled. +# Configure a greeter command here or re-enable a display manager. +[default_session] +user = "greeter" +command = "agreety --cmd /bin/bash" +` + tmp, err := os.CreateTemp("", "greetd-minimal-*") + if err != nil { + return fmt.Errorf("could not create temp file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.WriteString(minimal); err != nil { + tmp.Close() + return err + } + tmp.Close() + + if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { + return fmt.Errorf("failed to write fallback greetd config: %w", err) + } + _ = runSudoCommand(sudoPassword, "chmod", "644", configPath) + fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)") + return nil +} + +func runSudoCommand(_ string, command string, args ...string) error { + cmd := exec.Command("sudo", append([]string{command}, args...)...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// suggestDisplayManagerRestore scans for installed DMs and re-enables one +func suggestDisplayManagerRestore(nonInteractive bool) { + knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} + var found []string + for _, dm := range knownDMs { + if utils.CommandExists(dm) || isSystemdUnitInstalled(dm) { + found = append(found, dm) + } + } + if len(found) == 0 { + fmt.Println(" ℹ No other display managers detected.") + fmt.Println(" You can install one (e.g. gdm, lightdm, sddm) and then run:") + fmt.Println(" sudo systemctl enable --now ") + return + } + + enableDM := func(dm string) { + fmt.Printf(" Enabling %s...\n", dm) + cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err) + } else { + fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm) + } + } + + if len(found) == 1 || nonInteractive { + chosen := found[0] + if len(found) > 1 { + fmt.Printf(" ℹ Multiple display managers found (%s); enabling %s automatically.\n", + strings.Join(found, ", "), chosen) + } else { + fmt.Printf(" ℹ Found display manager: %s\n", chosen) + } + enableDM(chosen) + return + } + + fmt.Println(" ℹ Found the following display managers:") + for i, dm := range found { + fmt.Printf(" %d) %s\n", i+1, dm) + } + fmt.Print(" Choose a number to re-enable (or press Enter to skip): ") + + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + fmt.Println(" Skipped. You can re-enable a display manager later with:") + fmt.Println(" sudo systemctl enable --now ") + return + } + + n, err := strconv.Atoi(input) + if err != nil || n < 1 || n > len(found) { + fmt.Printf(" Invalid selection %q — skipping.\n", input) + return + } + + enableDM(found[n-1]) +} + +func isSystemdUnitInstalled(unit string) bool { + cmd := exec.Command("systemctl", "list-unit-files", unit+".service", "--no-legend", "--no-pager") + out, err := cmd.Output() + return err == nil && strings.Contains(string(out), unit) +} + +func runCommandInTerminal(shellCmd string) error { + terminals := []struct { + name string + args []string + }{ + {"gnome-terminal", []string{"--", "bash", "-c", shellCmd}}, + {"konsole", []string{"-e", "bash", "-c", shellCmd}}, + {"xfce4-terminal", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}}, + {"ghostty", []string{"-e", "bash", "-c", shellCmd}}, + {"wezterm", []string{"start", "--", "bash", "-c", shellCmd}}, + {"alacritty", []string{"-e", "bash", "-c", shellCmd}}, + {"kitty", []string{"bash", "-c", shellCmd}}, + {"xterm", []string{"-e", "bash -c \"" + strings.ReplaceAll(shellCmd, `"`, `\"`) + "\""}}, + } + for _, t := range terminals { + if _, err := exec.LookPath(t.name); err != nil { + continue + } + cmd := exec.Command(t.name, t.args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + return nil + } + return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)") +} + +func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error { + syncFlags := make([]string, 0, 3) + if nonInteractive { + syncFlags = append(syncFlags, "--yes") + } + if forceAuth { + syncFlags = append(syncFlags, "--auth") + } + if local { + syncFlags = append(syncFlags, "--local") + } + shellSyncCmd := "dms greeter sync" + if len(syncFlags) > 0 { + shellSyncCmd += " " + strings.Join(syncFlags, " ") + } + shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3` + return runCommandInTerminal(shellCmd) +} + +func resolveLocalWrapperShell() (string, error) { + for _, shellName := range []string{"bash", "sh"} { + shellPath, err := exec.LookPath(shellName) + if err == nil { + return shellPath, nil + } + } + return "", fmt.Errorf("could not find bash or sh in PATH for local greeter wrapper") +} + +func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { + if !nonInteractive { + fmt.Println("=== DMS Greeter Sync ===") + fmt.Println() + } + + logFunc := func(msg string) { + fmt.Println(msg) + } + + if !nonInteractive { + fmt.Println("Detecting DMS installation...") + } + var dmsPath string + var err error + if local { + dmsPath, err = resolveLocalDMSPath() + if err != nil { + return err + } + if !nonInteractive { + fmt.Printf("✓ Using local DMS path: %s\n", dmsPath) + } + } else { + dmsPath, err = greeter.DetectDMSPath() + if err != nil { + return err + } + if !nonInteractive { + fmt.Printf("✓ Found DMS at: %s\n", dmsPath) + } + } + + if !isGreeterEnabled() { + if nonInteractive { + return fmt.Errorf("greeter is not enabled; run 'dms greeter install' or 'dms greeter enable' first") + } + fmt.Println("\n⚠ DMS greeter is not enabled in greetd config.") + fmt.Print("Would you like to enable it now? (Y/n): ") + + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response != "n" && response != "no" { + if err := enableGreeter(false); err != nil { + return err + } + } else { + return fmt.Errorf("greeter must be enabled before syncing") + } + } + + if greeter.IsGreeterPackaged() && greeter.HasLegacyLocalGreeterWrapper() { + return fmt.Errorf("legacy manual wrapper detected at /usr/local/bin/dms-greeter; remove it before using packaged dms-greeter: sudo rm -f /usr/local/bin/dms-greeter") + } + + cacheDir := greeter.GreeterCacheDir + if _, err := os.Stat(cacheDir); os.IsNotExist(err) { + logFunc("Cache directory not found — attempting to create it...") + } + + greeterGroup := greeter.DetectGreeterGroup() + greeterGroupExists := utils.HasGroup(greeterGroup) + if greeterGroupExists { + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + + groupsCmd := exec.Command("groups", currentUser.Username) + groupsOutput, err := groupsCmd.Output() + if err != nil { + return fmt.Errorf("failed to check groups: %w", err) + } + + inGreeterGroup := strings.Contains(string(groupsOutput), greeterGroup) + if !inGreeterGroup { + if nonInteractive { + logFunc(fmt.Sprintf("⚠ Not yet in %s group — will be added during sync (logout/login required to take effect).", greeterGroup)) + } else { + fmt.Printf("\n⚠ Warning: You are not in the %s group.\n", greeterGroup) + fmt.Printf("Would you like to add your user to the %s group? (Y/n): ", greeterGroup) + + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response != "n" && response != "no" { + fmt.Printf("\nAdding user to %s group...\n", greeterGroup) + addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username) + addUserCmd.Stdout = os.Stdout + addUserCmd.Stderr = os.Stderr + if err := addUserCmd.Run(); err != nil { + return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err) + } + fmt.Printf("✓ User added to %s group\n", greeterGroup) + fmt.Println("⚠ You will need to log out and back in for the group change to take effect") + } else { + return fmt.Errorf("aborted: user must be in the greeter group before syncing") + } + } + } + } + + compositor := detectConfiguredCompositor() + if compositor == "" { + compositors := greeter.DetectCompositors() + switch len(compositors) { + case 0: + return fmt.Errorf("no supported compositors found") + case 1: + compositor = compositors[0] + if !nonInteractive { + fmt.Printf("✓ Using compositor: %s\n", compositor) + } + default: + if nonInteractive { + compositor = compositors[0] + break + } + var err error + compositor, err = promptCompositorChoice(compositors) + if err != nil { + return err + } + fmt.Printf("✓ Selected compositor: %s\n", compositor) + } + } else if !nonInteractive { + fmt.Printf("✓ Detected compositor from config: %s\n", compositor) + } + + if local { + localWrapperScript := filepath.Join(dmsPath, "Modules", "Greetd", "assets", "dms-greeter") + restoreWrapperOverride := func() {} + if info, statErr := os.Stat(localWrapperScript); statErr == nil && !info.IsDir() { + wrapperShell, shellErr := resolveLocalWrapperShell() + if shellErr != nil { + return shellErr + } + previousWrapperOverride, hadWrapperOverride := os.LookupEnv("DMS_GREETER_WRAPPER_CMD") + wrapperCmdOverride := wrapperShell + " " + localWrapperScript + _ = os.Setenv("DMS_GREETER_WRAPPER_CMD", wrapperCmdOverride) + restoreWrapperOverride = func() { + if hadWrapperOverride { + _ = os.Setenv("DMS_GREETER_WRAPPER_CMD", previousWrapperOverride) + } else { + _ = os.Unsetenv("DMS_GREETER_WRAPPER_CMD") + } + } + if !nonInteractive { + fmt.Printf("✓ Using local greeter wrapper script: %s\n", localWrapperScript) + } + } else if !nonInteractive { + fmt.Printf("ℹ Local wrapper script not found at %s; using system wrapper.\n", localWrapperScript) + } + + fmt.Println("\nUpdating greetd command to use local DMS path...") + err := greeter.ConfigureGreetd(dmsPath, compositor, logFunc, "") + restoreWrapperOverride() + if err != nil { + return fmt.Errorf("failed to apply local greeter path: %w", err) + } + if !nonInteractive { + fmt.Println("ℹ Local mode applies both DMS path override (-p) and local wrapper behavior when available.") + } + } else { + greeterPathForConfig := "" + if !greeter.IsGreeterPackaged() { + greeterPathForConfig = dmsPath + } + fmt.Println("\nUpdating greetd command...") + if err := greeter.ConfigureGreetd(greeterPathForConfig, compositor, logFunc, ""); err != nil { + return fmt.Errorf("failed to update greetd command: %w", err) + } + } + + fmt.Println("\nSetting up permissions and ACLs...") + greeter.RemediateStaleACLs(logFunc, "") + greeter.RemediateStaleAppArmor(logFunc, "") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } + if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { + return fmt.Errorf("failed to ensure greeter cache directory at %s: %w\nRun: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s", cacheDir, err, cacheDir, greeterGroup, cacheDir, cacheDir) + } + + fmt.Println("\nSynchronizing DMS configurations...") + if err := syncGreeterConfigsAndAuth(dmsPath, compositor, logFunc, sharedpam.SyncAuthOptions{ + ForceGreeterAuth: forceAuth, + }, func() { + fmt.Println("\nConfiguring authentication...") + }); err != nil { + return err + } + + if greeter.IsAppArmorEnabled() { + fmt.Println("\nConfiguring AppArmor profile...") + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } + } + + fmt.Println("\n=== Sync Complete ===") + fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.") + fmt.Println("Shared authentication settings were also checked and reconciled where needed.") + if forceAuth { + fmt.Println("Authentication has been configured for fingerprint and U2F (where modules exist).") + } + fmt.Println("The changes will be visible on the next login screen.") + + return nil +} + +func hasDmsShellQml(dir string) bool { + info, err := os.Stat(filepath.Join(dir, "shell.qml")) + return err == nil && !info.IsDir() +} + +func resolveDMSLocalCandidate(path string) (string, bool) { + if path == "" { + return "", false + } + if hasDmsShellQml(path) { + abs, err := filepath.Abs(path) + if err != nil { + return path, true + } + return abs, true + } + + quickshellPath := filepath.Join(path, "quickshell") + if hasDmsShellQml(quickshellPath) { + abs, err := filepath.Abs(quickshellPath) + if err != nil { + return quickshellPath, true + } + return abs, true + } + + return "", false +} + +func resolveLocalDMSPath() (string, error) { + if override := strings.TrimSpace(os.Getenv("DMS_LOCAL_PATH")); override != "" { + if resolved, ok := resolveDMSLocalCandidate(override); ok { + return resolved, nil + } + return "", fmt.Errorf("DMS_LOCAL_PATH is set but does not point to a valid DMS quickshell path: %s", override) + } + + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current directory: %w", err) + } + + dir := wd + for { + if resolved, ok := resolveDMSLocalCandidate(dir); ok { + return resolved, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + homeDir, err := os.UserHomeDir() + if err == nil && homeDir != "" { + for _, candidate := range []string{ + filepath.Join(homeDir, "dms"), + filepath.Join(homeDir, "DankMaterialShell"), + filepath.Join(homeDir, "dankmaterialshell"), + filepath.Join(homeDir, "projects", "dms"), + filepath.Join(homeDir, "src", "dms"), + } { + if resolved, ok := resolveDMSLocalCandidate(candidate); ok { + return resolved, nil + } + } + + if entries, readErr := os.ReadDir(homeDir); readErr == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := strings.ToLower(entry.Name()) + if !strings.Contains(name, "dms") && !strings.Contains(name, "dank") { + continue + } + if resolved, ok := resolveDMSLocalCandidate(filepath.Join(homeDir, entry.Name())); ok { + return resolved, nil + } + } + } + } + + return "", fmt.Errorf("could not locate a local DMS checkout from %s; run from repo root or set DMS_LOCAL_PATH=/absolute/path/to/repo", wd) +} + +func disableDisplayManager(dmName string) (bool, error) { + state, err := getSystemdServiceState(dmName) + if err != nil { + return false, fmt.Errorf("failed to check %s state: %w", dmName, err) + } + + if !state.Exists { + return false, nil + } + + fmt.Printf("\nChecking %s...\n", dmName) + fmt.Printf(" Current state: enabled=%s\n", state.EnabledState) + + actionTaken := false + + if state.NeedsDisable { + var disableCmd *exec.Cmd + var actionVerb string + + if state.EnabledState == "static" { + fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName) + disableCmd = exec.Command("sudo", "systemctl", "mask", dmName) + actionVerb = "masked" + } else { + fmt.Printf(" Disabling %s...\n", dmName) + disableCmd = exec.Command("sudo", "systemctl", "disable", dmName) + actionVerb = "disabled" + } + + disableCmd.Stdout = os.Stdout + disableCmd.Stderr = os.Stderr + if err := disableCmd.Run(); err != nil { + return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err) + } + + enabledState, shouldDisable, verifyErr := checkSystemdServiceEnabled(dmName) + if verifyErr != nil { + fmt.Printf(" ⚠ Warning: Could not verify %s was %s: %v\n", dmName, actionVerb, verifyErr) + } else if shouldDisable { + return actionTaken, fmt.Errorf("%s is still in state '%s' after %s operation", dmName, enabledState, actionVerb) + } else { + fmt.Printf(" ✓ %s %s (now: %s)\n", cases.Title(language.English).String(actionVerb), dmName, enabledState) + } + + actionTaken = true + } else { + if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" { + fmt.Printf(" ✓ %s is already masked\n", dmName) + } else { + fmt.Printf(" ✓ %s is already disabled\n", dmName) + } + } + + return actionTaken, nil +} + +func ensureGreetdEnabled() error { + fmt.Println("\nChecking greetd service status...") + + state, err := getSystemdServiceState("greetd") + if err != nil { + return fmt.Errorf("failed to check greetd state: %w", err) + } + + if !state.Exists { + return fmt.Errorf("greetd service not found. Please install greetd first") + } + + fmt.Printf(" Current state: %s\n", state.EnabledState) + + if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" { + fmt.Println(" Unmasking greetd...") + unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd") + unmaskCmd.Stdout = os.Stdout + unmaskCmd.Stderr = os.Stderr + if err := unmaskCmd.Run(); err != nil { + return fmt.Errorf("failed to unmask greetd: %w", err) + } + fmt.Println(" ✓ Unmasked greetd") + } + + if state.EnabledState == "enabled" || state.EnabledState == "enabled-runtime" { + fmt.Println(" Reasserting greetd as active display manager...") + } else { + fmt.Println(" Enabling greetd service...") + } + + enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd") + enableCmd.Stdout = os.Stdout + enableCmd.Stderr = os.Stderr + if err := enableCmd.Run(); err != nil { + return fmt.Errorf("failed to enable greetd: %w", err) + } + + enabledState, _, verifyErr := checkSystemdServiceEnabled("greetd") + if verifyErr != nil { + fmt.Printf(" ⚠ Warning: Could not verify greetd enabled state: %v\n", verifyErr) + } else { + switch enabledState { + case "enabled", "enabled-runtime", "static", "indirect", "alias": + fmt.Printf(" ✓ greetd enabled (state: %s)\n", enabledState) + default: + return fmt.Errorf("greetd is still in state '%s' after enable operation", enabledState) + } + } + + return nil +} + +func ensureGraphicalTarget() error { + getDefaultCmd := exec.Command("systemctl", "get-default") + currentTarget, err := getDefaultCmd.Output() + if err != nil { + fmt.Println("⚠ Warning: Could not detect current default systemd target") + return nil + } + + currentTargetStr := strings.TrimSpace(string(currentTarget)) + if currentTargetStr != "graphical.target" { + fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr) + setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target") + setDefaultCmd.Stdout = os.Stdout + setDefaultCmd.Stderr = os.Stderr + if err := setDefaultCmd.Run(); err != nil { + fmt.Println("⚠ Warning: Failed to set graphical.target as default") + fmt.Println(" Greeter may not start on boot. Run manually:") + fmt.Println(" sudo systemctl set-default graphical.target") + return nil + } + fmt.Println("✓ Set graphical.target as default") + } else { + fmt.Println("✓ Default target already set to graphical.target") + } + + return nil +} + +func handleConflictingDisplayManagers() error { + fmt.Println("\n=== Checking for Conflicting Display Managers ===") + + conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} + + disabledAny := false + var errors []string + + for _, dm := range conflictingDMs { + actionTaken, err := disableDisplayManager(dm) + if err != nil { + errMsg := fmt.Sprintf("Failed to handle %s: %v", dm, err) + errors = append(errors, errMsg) + fmt.Printf(" ⚠⚠⚠ ERROR: %s\n", errMsg) + continue + } + if actionTaken { + disabledAny = true + } + } + + if len(errors) > 0 { + fmt.Println("\n╔════════════════════════════════════════════════════════════╗") + fmt.Println("║ ⚠⚠⚠ ERRORS OCCURRED ⚠⚠⚠ ║") + fmt.Println("╚════════════════════════════════════════════════════════════╝") + fmt.Println("\nSome display managers could not be disabled:") + for _, err := range errors { + fmt.Printf(" ✗ %s\n", err) + } + fmt.Println("\nThis may prevent greetd from starting properly.") + fmt.Println("You may need to manually disable them before greetd will work.") + fmt.Println("\nManual commands to try:") + for _, dm := range conflictingDMs { + fmt.Printf(" sudo systemctl disable %s\n", dm) + fmt.Printf(" sudo systemctl mask %s\n", dm) + } + fmt.Print("\nContinue with greeter enablement anyway? (Y/n): ") + + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response == "n" || response == "no" { + return fmt.Errorf("aborted due to display manager conflicts") + } + fmt.Println("\nContinuing despite errors...") + } + + if !disabledAny && len(errors) == 0 { + fmt.Println("\n✓ No conflicting display managers found") + } else if disabledAny && len(errors) == 0 { + fmt.Println("\n✓ Successfully handled all conflicting display managers") + } + + return nil +} + +func enableGreeter(nonInteractive bool) error { + fmt.Println("=== DMS Greeter Enable ===") + fmt.Println() + + configPath := "/etc/greetd/config.toml" + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("greetd config not found at %s\nPlease install greetd first", configPath) + } else if err != nil { + return fmt.Errorf("failed to access greetd config at %s: %w", configPath, err) + } + + if greeter.IsGreeterPackaged() && greeter.HasLegacyLocalGreeterWrapper() { + return fmt.Errorf("legacy manual wrapper detected at /usr/local/bin/dms-greeter; remove it before using packaged dms-greeter: sudo rm -f /usr/local/bin/dms-greeter") + } + + configAlreadyCorrect := isGreeterEnabled() + configuredCompositor := detectConfiguredCompositor() + + logFunc := func(msg string) { + fmt.Println(msg) + } + greeterGroup := greeter.DetectGreeterGroup() + + if configAlreadyCorrect { + fmt.Println("✓ Greeter is already configured with dms-greeter") + if configuredCompositor != "" { + fmt.Printf("✓ Configured compositor: %s\n", configuredCompositor) + } + + fmt.Println("\nSetting up dms-greeter group and permissions...") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } + if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { + fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir) + } + + if err := ensureGraphicalTarget(); err != nil { + return err + } + + if err := handleConflictingDisplayManagers(); err != nil { + return err + } + + if err := ensureGreetdEnabled(); err != nil { + return err + } + + fmt.Println("\n=== Enable Complete ===") + fmt.Println("\nGreeter configuration verified and system state corrected.") + fmt.Println("To start the greeter now, run:") + fmt.Println(" sudo systemctl start greetd") + fmt.Println("\nOr reboot to see the greeter at boot time.") + + return nil + } + + if !nonInteractive { + fmt.Print("\nThis will configure greetd to use the DMS greeter and may disable other display managers. Continue? [Y/n]: ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) == "n" || strings.ToLower(strings.TrimSpace(response)) == "no" { + fmt.Println("Aborted.") + return nil + } + fmt.Println() + } + + fmt.Println("Detecting installed compositors...") + compositors := greeter.DetectCompositors() + + if utils.CommandExists("sway") { + compositors = append(compositors, "sway") + } + + if len(compositors) == 0 { + return fmt.Errorf("no supported compositors found (niri, Hyprland, or sway required)") + } + + var selectedCompositor string + if len(compositors) == 1 { + selectedCompositor = compositors[0] + fmt.Printf("✓ Found compositor: %s\n", selectedCompositor) + } else { + var err error + selectedCompositor, err = promptCompositorChoice(compositors) + if err != nil { + return err + } + fmt.Printf("✓ Selected compositor: %s\n", selectedCompositor) + } + + greeterPathForConfig := "" + if !greeter.IsGreeterPackaged() { + dmsPath, err := greeter.DetectDMSPath() + if err != nil { + return fmt.Errorf("failed to detect DMS path for manual greeter configuration: %w", err) + } + greeterPathForConfig = dmsPath + } + if err := greeter.ConfigureGreetd(greeterPathForConfig, selectedCompositor, logFunc, ""); err != nil { + return fmt.Errorf("failed to configure greetd: %w", err) + } + + fmt.Println("\nSetting up dms-greeter group and permissions...") + if err := greeter.SetupDMSGroup(logFunc, ""); err != nil { + return err + } + if err := greeter.EnsureGreeterCacheDir(logFunc, ""); err != nil { + fmt.Printf("⚠ Could not ensure cache directory: %v\n Run: sudo mkdir -p %s && sudo chown root:%s %s && sudo chmod 2770 %s\n", err, greeter.GreeterCacheDir, greeterGroup, greeter.GreeterCacheDir, greeter.GreeterCacheDir) + } + + if greeter.IsAppArmorEnabled() { + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } + } + + if err := ensureGraphicalTarget(); err != nil { + return err + } + + if err := handleConflictingDisplayManagers(); err != nil { + return err + } + + if err := ensureGreetdEnabled(); err != nil { + return err + } + + fmt.Println("\n=== Enable Complete ===") + fmt.Println("\nTo start the greeter now, run:") + fmt.Println(" sudo systemctl start greetd") + fmt.Println("\nOr reboot to see the greeter at boot time.") + + return nil +} + +func isGreeterEnabled() bool { + command := readDefaultSessionCommand("/etc/greetd/config.toml") + return command != "" && strings.Contains(command, "dms-greeter") +} + +func detectConfiguredCompositor() string { + command := strings.ToLower(readDefaultSessionCommand("/etc/greetd/config.toml")) + switch { + case strings.Contains(command, "--command niri"): + return "niri" + case strings.Contains(command, "--command hyprland"): + return "hyprland" + case strings.Contains(command, "--command sway"): + return "sway" + } + return "" +} + +func stripTomlComment(line string) string { + trimmed := strings.TrimSpace(line) + if idx := strings.Index(trimmed, "#"); idx >= 0 { + return strings.TrimSpace(trimmed[:idx]) + } + return trimmed +} + +func parseTomlSection(line string) (string, bool) { + trimmed := stripTomlComment(line) + if len(trimmed) < 3 || !strings.HasPrefix(trimmed, "[") || !strings.HasSuffix(trimmed, "]") { + return "", false + } + return strings.TrimSpace(trimmed[1 : len(trimmed)-1]), true +} + +func readDefaultSessionCommand(configPath string) string { + data, err := os.ReadFile(configPath) + if err != nil { + return "" + } + + inDefaultSession := false + for line := range strings.SplitSeq(string(data), "\n") { + if section, ok := parseTomlSection(line); ok { + inDefaultSession = section == "default_session" + continue + } + + if !inDefaultSession { + continue + } + + trimmed := stripTomlComment(line) + if !strings.HasPrefix(trimmed, "command =") && !strings.HasPrefix(trimmed, "command=") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + continue + } + + command := strings.Trim(strings.TrimSpace(parts[1]), `"`) + if command != "" { + return command + } + } + + return "" +} + +func extractGreeterCacheDirFromCommand(command string) string { + if command == "" { + return greeter.GreeterCacheDir + } + tokens := strings.Fields(command) + for i := 0; i < len(tokens); i++ { + token := strings.Trim(tokens[i], "\"") + if token == "--cache-dir" && i+1 < len(tokens) { + return strings.Trim(tokens[i+1], "\"") + } + if strings.HasPrefix(token, "--cache-dir=") { + value := strings.TrimPrefix(token, "--cache-dir=") + value = strings.Trim(value, "\"") + if value != "" { + return value + } + } + } + return greeter.GreeterCacheDir +} + +func extractGreeterWrapperFromCommand(command string) string { + if command == "" { + return "" + } + tokens := strings.Fields(command) + if len(tokens) == 0 { + return "" + } + wrapper := strings.Trim(tokens[0], "\"") + if wrapper == "" { + return "" + } + if len(tokens) > 1 { + next := strings.Trim(tokens[1], "\"") + if next != "" && (filepath.Base(wrapper) == "bash" || filepath.Base(wrapper) == "sh") && strings.Contains(filepath.Base(next), "dms-greeter") { + return fmt.Sprintf("%s (script: %s)", wrapper, next) + } + } + return wrapper +} + +func extractGreeterPathOverrideFromCommand(command string) string { + if command == "" { + return "" + } + tokens := strings.Fields(command) + for i := 0; i < len(tokens); i++ { + token := strings.Trim(tokens[i], "\"") + if (token == "-p" || token == "--path") && i+1 < len(tokens) { + return strings.Trim(tokens[i+1], "\"") + } + if strings.HasPrefix(token, "--path=") { + value := strings.TrimPrefix(token, "--path=") + value = strings.Trim(value, "\"") + if value != "" { + return value + } + } + } + return "" +} + +func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) { + return sharedpam.ParseManagedGreeterPamAuth(pamText) +} + +func packageInstallHint() string { + osInfo, err := distros.GetOSInfo() + if err != nil { + return "Install package: dms-greeter" + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return "Install package: dms-greeter" + } + + switch config.Family { + case distros.FamilyDebian: + return "Install with 'sudo apt install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#debian)" + case distros.FamilySUSE: + return "Install with 'sudo zypper install dms-greeter' (requires DankLinux OBS repo — see https://danklinux.com/docs/dankgreeter/installation#opensuse)" + case distros.FamilyUbuntu: + return "Install with 'sudo apt install dms-greeter' (requires ppa:avengemedia/danklinux: sudo add-apt-repository ppa:avengemedia/danklinux)" + case distros.FamilyFedora: + return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)" + case distros.FamilyArch: + return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'" + default: + return "Run 'dms greeter install' to install greeter" + } +} + +func systemPamManagerRemediationHint() string { + osInfo, err := distros.GetOSInfo() + if err != nil { + return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } + + switch config.Family { + case distros.FamilyFedora: + return "Disable it in authselect to force password-only greeter login." + case distros.FamilyDebian, distros.FamilyUbuntu: + return "Disable it in pam-auth-update to force password-only greeter login." + default: + return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } +} + +func isPackageOnlyGreeterDistro() bool { + osInfo, err := distros.GetOSInfo() + if err != nil { + return false + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return false + } + return config.Family == distros.FamilyDebian || + config.Family == distros.FamilySUSE || + config.Family == distros.FamilyUbuntu || + config.Family == distros.FamilyFedora || + config.Family == distros.FamilyArch +} + +func promptCompositorChoice(compositors []string) (string, error) { + fmt.Println("\nMultiple compositors detected:") + for i, comp := range compositors { + fmt.Printf("%d) %s\n", i+1, comp) + } + + var response string + fmt.Print("Choose compositor for greeter: ") + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + choice := 0 + fmt.Sscanf(response, "%d", &choice) + + if choice < 1 || choice > len(compositors) { + return "", fmt.Errorf("invalid choice") + } + + return compositors[choice-1], nil +} + +func checkGreeterStatus() error { + fmt.Println("=== DMS Greeter Status ===") + fmt.Println() + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + + configPath := "/etc/greetd/config.toml" + configuredCommand := "" + allGood := true + fmt.Println("Greeter Configuration:") + if _, err := os.ReadFile(configPath); err == nil { + configuredCommand = readDefaultSessionCommand(configPath) + if configuredCommand != "" && strings.Contains(configuredCommand, "dms-greeter") { + fmt.Println(" ✓ Greeter is enabled") + if wrapper := extractGreeterWrapperFromCommand(configuredCommand); wrapper != "" { + fmt.Printf(" Wrapper: %s\n", wrapper) + } + if pathOverride := extractGreeterPathOverrideFromCommand(configuredCommand); pathOverride != "" { + fmt.Printf(" DMS path override: %s\n", pathOverride) + } + + compositor := detectConfiguredCompositor() + switch compositor { + case "niri": + fmt.Println(" Compositor: niri") + case "hyprland": + fmt.Println(" Compositor: Hyprland") + case "sway": + fmt.Println(" Compositor: sway") + default: + fmt.Println(" Compositor: unknown") + } + } else { + fmt.Println(" ✗ Greeter is NOT enabled") + fmt.Println(" Run 'dms greeter enable' to enable it, or use the Activate button in Settings → Greeter, then Sync.") + allGood = false + } + } else { + fmt.Println(" ✗ Greeter config not found") + fmt.Printf(" %s\n", packageInstallHint()) + allGood = false + } + + fmt.Println("\nGroup Membership:") + groupsCmd := exec.Command("groups", currentUser.Username) + groupsOutput, err := groupsCmd.Output() + if err != nil { + return fmt.Errorf("failed to check groups: %w", err) + } + + greeterGroup := greeter.DetectGreeterGroup() + inGreeterGroup := strings.Contains(string(groupsOutput), greeterGroup) + if inGreeterGroup { + fmt.Printf(" ✓ User is in %s group\n", greeterGroup) + } else { + fmt.Printf(" ✗ User is NOT in %s group\n", greeterGroup) + fmt.Println(" Run 'dms greeter sync' to set up group membership and permissions") + } + + cacheDir := extractGreeterCacheDirFromCommand(configuredCommand) + fmt.Println("\nGreeter Cache Directory:") + fmt.Printf(" Effective cache dir: %s\n", cacheDir) + if cacheDir != greeter.GreeterCacheDir { + fmt.Printf(" ⚠ Non-default cache dir detected (default: %s)\n", greeter.GreeterCacheDir) + } + if stat, err := os.Stat(cacheDir); err == nil && stat.IsDir() { + fmt.Printf(" ✓ %s exists\n", cacheDir) + requiredSubdirs := []string{".local/state", ".local/share", ".cache"} + missingSubdirs := false + for _, sub := range requiredSubdirs { + subPath := filepath.Join(cacheDir, sub) + if _, err := os.Stat(subPath); os.IsNotExist(err) { + fmt.Printf(" ⚠ Missing required subdir: %s\n", subPath) + missingSubdirs = true + } + } + if missingSubdirs { + fmt.Println(" Run 'dms greeter sync' to initialize the cache directory structure.") + allGood = false + } + } else { + fmt.Printf(" ✗ %s not found\n", cacheDir) + fmt.Printf(" %s\n", packageInstallHint()) + return nil + } + + fmt.Println("\nConfiguration Symlinks:") + colorSyncInfo, colorSyncErr := greeter.ResolveGreeterColorSyncInfo(homeDir) + if colorSyncErr != nil { + fmt.Printf(" ✗ Failed to resolve expected greeter color source: %v\n", colorSyncErr) + allGood = false + colorSyncInfo = greeter.GreeterColorSyncInfo{ + SourcePath: filepath.Join(homeDir, ".cache", "DankMaterialShell", "dms-colors.json"), + } + } + + colorThemeDesc := "Color theme" + if colorSyncInfo.UsesDynamicWallpaperOverride { + colorThemeDesc = "Color theme (greeter wallpaper override)" + } + + symlinks := []struct { + source string + target string + desc string + }{ + { + source: filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), + target: filepath.Join(cacheDir, "settings.json"), + desc: "Settings", + }, + { + source: filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), + target: filepath.Join(cacheDir, "session.json"), + desc: "Session state", + }, + { + source: colorSyncInfo.SourcePath, + target: filepath.Join(cacheDir, "colors.json"), + desc: colorThemeDesc, + }, + } + + for _, link := range symlinks { + targetInfo, err := os.Lstat(link.target) + if err != nil { + fmt.Printf(" ✗ %s: symlink not found at %s\n", link.desc, link.target) + allGood = false + continue + } + + if targetInfo.Mode()&os.ModeSymlink == 0 { + fmt.Printf(" ✗ %s: %s is not a symlink\n", link.desc, link.target) + allGood = false + continue + } + + linkDest, err := os.Readlink(link.target) + if err != nil { + fmt.Printf(" ✗ %s: failed to read symlink\n", link.desc) + allGood = false + continue + } + + if linkDest != link.source { + fmt.Printf(" ✗ %s: symlink points to wrong location\n", link.desc) + fmt.Printf(" Expected: %s\n", link.source) + fmt.Printf(" Got: %s\n", linkDest) + allGood = false + continue + } + + if _, err := os.Stat(link.source); os.IsNotExist(err) { + fmt.Printf(" ⚠ %s: symlink OK, but source file doesn't exist yet\n", link.desc) + fmt.Printf(" Will be created when you run DMS\n") + continue + } + + fmt.Printf(" ✓ %s: synced correctly\n", link.desc) + } + + if colorSyncInfo.UsesDynamicWallpaperOverride { + fmt.Printf(" ℹ Dynamic theme uses greeter override colors from %s\n", colorSyncInfo.SourcePath) + } + + fmt.Println("\nGreeter Wallpaper Override:") + overridePath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg") + if stat, err := os.Stat(overridePath); err == nil && !stat.IsDir() { + fmt.Printf(" ✓ Override file present: %s\n", overridePath) + } else if os.IsNotExist(err) { + fmt.Println(" ℹ Override file not present (desktop/session wallpaper fallback in effect)") + } else if err != nil { + fmt.Printf(" ✗ Could not inspect override file: %v\n", err) + allGood = false + } else { + fmt.Printf(" ✗ Override path is not a regular file: %s\n", overridePath) + allGood = false + } + + fmt.Println("\nGreeter PAM Authentication (DMS-managed block):") + if greeter.IsNixOS() { + fmt.Println(" ℹ NixOS detected: PAM is managed by NixOS modules.") + fmt.Println(" Configure fingerprint/U2F via your greetd NixOS module (security.pam.services.greetd).") + fmt.Println() + if allGood && inGreeterGroup { + fmt.Println("✓ All checks passed! Greeter is properly configured.") + } else if !allGood { + fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to repair configuration.") + } else if !inGreeterGroup { + fmt.Printf("⚠ User is not in %s group. Run 'dms greeter sync' after adding group membership.\n", greeterGroup) + } + return nil + } + greetdPamPath := "/etc/pam.d/greetd" + pamData, err := os.ReadFile(greetdPamPath) + if err != nil { + fmt.Printf(" ✗ Failed to read %s: %v\n", greetdPamPath, err) + allGood = false + } else { + managed, managedFprint, managedU2f, legacyManaged := parseManagedGreeterPamAuth(string(pamData)) + if managed { + fmt.Println(" ✓ Managed auth block present") + if managedFprint { + fmt.Println(" - fingerprint: enabled") + } else { + fmt.Println(" - fingerprint: disabled") + } + if managedU2f { + fmt.Println(" - security key (U2F): enabled") + } else { + fmt.Println(" - security key (U2F): disabled") + } + } else { + fmt.Println(" ℹ No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)") + } + if legacyManaged { + fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms auth sync' to normalize.") + allGood = false + } + enableFprintToggle, enableU2fToggle := false, false + if enableFprint, enableU2f, settingsErr := sharedpam.ReadGreeterAuthToggles(homeDir); settingsErr == nil { + enableFprintToggle = enableFprint + enableU2fToggle = enableU2f + } else { + fmt.Printf(" ℹ Could not read greeter auth toggles from settings: %v\n", settingsErr) + } + + includedFprintFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_fprintd.so") + includedU2fFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_u2f.so") + fprintAvailableForCurrentUser := sharedpam.FingerprintAuthAvailableForCurrentUser() + + if managedFprint && includedFprintFile != "" { + fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", includedFprintFile) + fmt.Println(" Double fingerprint auth detected — run 'dms auth sync' to resolve.") + allGood = false + } + if managedU2f && includedU2fFile != "" { + fmt.Printf(" ⚠ pam_u2f found in both DMS managed block and %s.\n", includedU2fFile) + fmt.Println(" Double security-key auth detected — run 'dms auth sync' to resolve.") + allGood = false + } + + if includedFprintFile != "" && !managedFprint { + if enableFprintToggle { + fmt.Printf(" ℹ Fingerprint auth is enabled via included %s.\n", includedFprintFile) + if fprintAvailableForCurrentUser { + fmt.Println(" DMS toggle is enabled, and effective auth is coming from the included PAM stack.") + } else { + fmt.Println(" No enrolled fingerprints detected for the current user; password auth remains the effective path.") + } + } else { + if fprintAvailableForCurrentUser { + fmt.Printf(" ℹ Fingerprint auth is active via included %s while DMS fingerprint toggle is off.\n", includedFprintFile) + fmt.Println(" Password login will work but may be delayed while the fingerprint module runs first.") + fmt.Printf(" To eliminate the delay, %s\n", systemPamManagerRemediationHint()) + } else { + fmt.Printf(" ℹ pam_fprintd is present via included %s, but no enrolled fingerprints were detected for user %s.\n", includedFprintFile, currentUser.Username) + fmt.Println(" Password auth remains the effective login path.") + } + } + } + if includedU2fFile != "" && !managedU2f { + if enableU2fToggle { + fmt.Printf(" ℹ Security-key auth is enabled via included %s.\n", includedU2fFile) + fmt.Println(" DMS toggle is enabled, but effective auth is coming from the included PAM stack.") + } else { + fmt.Printf(" ⚠ Security-key auth is active via included %s while DMS security-key toggle is off.\n", includedU2fFile) + fmt.Printf(" %s\n", systemPamManagerRemediationHint()) + } + } + } + + fmt.Println("\nSecurity (AppArmor):") + if !greeter.IsAppArmorEnabled() { + fmt.Println(" ℹ AppArmor not enabled") + } else { + fmt.Println(" ℹ AppArmor is enabled") + + const appArmorProfilePath = "/etc/apparmor.d/usr.bin.dms-greeter" + if _, err := os.Stat(appArmorProfilePath); os.IsNotExist(err) { + fmt.Println(" ⚠ DMS AppArmor profile not installed") + fmt.Println(" Run 'dms greeter sync' to install it and prevent potential TTY fallback") + allGood = false + } else { + mode := appArmorProfileMode("dms-greeter") + if mode != "" { + fmt.Printf(" ✓ DMS AppArmor profile installed (%s mode)\n", mode) + } else { + fmt.Println(" ✓ DMS AppArmor profile installed") + } + } + + denialCount, denialSamples, denialErr := recentAppArmorGreeterDenials(3) + if denialErr != nil { + fmt.Printf(" ℹ Could not inspect AppArmor denials automatically: %v\n", denialErr) + fmt.Println(" If greetd falls back to TTY, run: sudo journalctl -b -k | grep 'apparmor.*DENIED'") + } else if denialCount > 0 { + fmt.Printf(" ⚠ Found %d recent AppArmor denial(s) related to greeter runtime.\n", denialCount) + fmt.Println(" This can cause greetd fallback to TTY (for example: 'Failed to create stream fd: Permission denied').") + fmt.Println(" Review denials with: sudo journalctl -b -k | grep 'apparmor.*DENIED'") + fmt.Println(" Then refine the profile with: sudo aa-logprof") + for i, sample := range denialSamples { + fmt.Printf(" %d) %s\n", i+1, sample) + } + allGood = false + } else { + fmt.Println(" ✓ No recent AppArmor denials detected for common greeter components") + } + } + + fmt.Println() + if allGood && inGreeterGroup { + fmt.Println("✓ All checks passed! Greeter is properly configured.") + } else if !allGood { + fmt.Println("⚠ Some issues detected. Run 'dms greeter sync' to repair configuration.") + } else if !inGreeterGroup { + fmt.Printf("⚠ User is not in %s group. Run 'dms greeter sync' after adding group membership.\n", greeterGroup) + } + + return nil +} + +func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) { + if sampleLimit <= 0 { + sampleLimit = 3 + } + if !utils.CommandExists("journalctl") { + return 0, nil, fmt.Errorf("journalctl not found") + } + + queries := [][]string{ + {"-b", "-k", "--no-pager", "-n", "2000", "-o", "cat"}, + {"-b", "--no-pager", "-n", "2000", "-o", "cat"}, + } + + seen := make(map[string]bool) + samples := make([]string, 0, sampleLimit) + total := 0 + var lastErr error + successfulQuery := false + + for _, query := range queries { + cmd := exec.Command("journalctl", query...) + output, err := cmd.CombinedOutput() + if err != nil { + lastErr = err + continue + } + successfulQuery = true + total += collectGreeterAppArmorDenials(string(output), seen, &samples, sampleLimit) + } + + if !successfulQuery && lastErr != nil { + return 0, nil, lastErr + } + + return total, samples, nil +} + +func collectGreeterAppArmorDenials(text string, seen map[string]bool, samples *[]string, sampleLimit int) int { + count := 0 + for _, rawLine := range strings.Split(text, "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || !isGreeterRelatedAppArmorDenial(line) { + continue + } + if seen[line] { + continue + } + seen[line] = true + count++ + if len(*samples) < sampleLimit { + *samples = append(*samples, line) + } + } + return count +} + +func isGreeterRelatedAppArmorDenial(line string) bool { + lower := strings.ToLower(line) + if !strings.Contains(lower, "apparmor") || !strings.Contains(lower, "denied") { + return false + } + + greeterTokens := []string{ + "dms-greeter", + "/usr/bin/dms-greeter", + "greetd", + "quickshell", + "/usr/bin/qs", + "/usr/bin/quickshell", + "niri", + "hyprland", + "sway", + "mango", + "miracle", + "labwc", + "pipewire", + "wireplumber", + "stream fd", + } + + for _, token := range greeterTokens { + if strings.Contains(lower, token) { + return true + } + } + return false +} + +// appArmorProfileMode returns "complain", "enforce", or "" for a named AppArmor profile. +func appArmorProfileMode(profileName string) string { + data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles") + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if !strings.Contains(line, profileName) { + continue + } + lower := strings.ToLower(line) + if strings.Contains(lower, "(complain)") { + return "complain" + } + if strings.Contains(lower, "(enforce)") { + return "enforce" + } + if strings.Contains(lower, "(kill)") { + return "kill" + } + } + return "" +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter_test.go new file mode 100644 index 0000000..775149f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_greeter_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "errors" + "reflect" + "testing" + + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" +) + +func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) { + origGreeterConfigSyncFn := greeterConfigSyncFn + origSharedAuthSyncFn := sharedAuthSyncFn + t.Cleanup(func() { + greeterConfigSyncFn = origGreeterConfigSyncFn + sharedAuthSyncFn = origSharedAuthSyncFn + }) + + var calls []string + greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { + if dmsPath != "/tmp/dms" { + t.Fatalf("unexpected dmsPath %q", dmsPath) + } + if compositor != "niri" { + t.Fatalf("unexpected compositor %q", compositor) + } + if sudoPassword != "" { + t.Fatalf("expected empty sudoPassword, got %q", sudoPassword) + } + calls = append(calls, "configs") + return nil + } + + var gotOptions sharedpam.SyncAuthOptions + sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error { + if sudoPassword != "" { + t.Fatalf("expected empty sudoPassword, got %q", sudoPassword) + } + gotOptions = options + calls = append(calls, "auth") + return nil + } + + err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{ + ForceGreeterAuth: true, + }, func() { + calls = append(calls, "before-auth") + }) + if err != nil { + t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err) + } + + wantCalls := []string{"configs", "before-auth", "auth"} + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("call order = %v, want %v", calls, wantCalls) + } + if !gotOptions.ForceGreeterAuth { + t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions) + } +} + +func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) { + origGreeterConfigSyncFn := greeterConfigSyncFn + origSharedAuthSyncFn := sharedAuthSyncFn + t.Cleanup(func() { + greeterConfigSyncFn = origGreeterConfigSyncFn + sharedAuthSyncFn = origSharedAuthSyncFn + }) + + greeterConfigSyncFn = func(string, string, func(string), string) error { + return errors.New("config sync failed") + } + + authCalled := false + sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error { + authCalled = true + return nil + } + + err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil) + if err == nil || err.Error() != "config sync failed" { + t.Fatalf("expected config sync error, got %v", err) + } + if authCalled { + t.Fatal("expected auth sync not to run after config sync failure") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_keybinds.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_keybinds.go new file mode 100644 index 0000000..cdb15ac --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_keybinds.go @@ -0,0 +1,265 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var keybindsCmd = &cobra.Command{ + Use: "keybinds", + Aliases: []string{"cheatsheet", "chsht"}, + Short: "Manage keybinds and cheatsheets", + Long: "Display and manage keybinds and cheatsheets for various applications", +} + +var keybindsListCmd = &cobra.Command{ + Use: "list", + Short: "List available providers", + Long: "List all available keybind/cheatsheet providers", + Run: runKeybindsList, +} + +var keybindsShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show keybinds for a provider", + Long: "Display keybinds/cheatsheet for the specified provider", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + registry := keybinds.GetDefaultRegistry() + return registry.List(), cobra.ShellCompDirectiveNoFileComp + }, + Run: runKeybindsShow, +} + +var keybindsSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set a keybind override", + Long: "Create or update a keybind override for the specified provider", + Args: cobra.ExactArgs(3), + Run: runKeybindsSet, +} + +var keybindsRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a keybind override", + Long: "Remove a keybind override from the specified provider", + Args: cobra.ExactArgs(2), + Run: runKeybindsRemove, +} + +func init() { + keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON") + keybindsShowCmd.Flags().String("path", "", "Override config path for the provider") + keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay") + keybindsSetCmd.Flags().Bool("allow-when-locked", false, "Allow when screen is locked") + keybindsSetCmd.Flags().Int("cooldown-ms", 0, "Cooldown in milliseconds") + keybindsSetCmd.Flags().Bool("no-repeat", false, "Disable key repeat") + keybindsSetCmd.Flags().Bool("no-inhibiting", false, "Keep bind active when shortcuts are inhibited (allow-inhibiting=false)") + keybindsSetCmd.Flags().String("replace-key", "", "Original key to replace (removes old key)") + keybindsSetCmd.Flags().String("flags", "", "Hyprland bind flags (e.g., 'e' for repeat, 'l' for locked, 'r' for release)") + + keybindsCmd.AddCommand(keybindsListCmd) + keybindsCmd.AddCommand(keybindsShowCmd) + keybindsCmd.AddCommand(keybindsSetCmd) + keybindsCmd.AddCommand(keybindsRemoveCmd) + + keybinds.SetJSONProviderFactory(func(filePath string) (keybinds.Provider, error) { + return providers.NewJSONFileProvider(filePath) + }) + + initializeProviders() +} + +func initializeProviders() { + registry := keybinds.GetDefaultRegistry() + + hyprlandProvider := providers.NewHyprlandProvider("") + if err := registry.Register(hyprlandProvider); err != nil { + log.Warnf("Failed to register Hyprland provider: %v", err) + } + + mangowcProvider := providers.NewMangoWCProvider("") + if err := registry.Register(mangowcProvider); err != nil { + log.Warnf("Failed to register MangoWC provider: %v", err) + } + + configDir, _ := os.UserConfigDir() + + if configDir != "" { + scrollProvider := providers.NewSwayProvider(filepath.Join(configDir, "scroll")) + if err := registry.Register(scrollProvider); err != nil { + log.Warnf("Failed to register Scroll provider: %v", err) + } + } + + miracleProvider := providers.NewMiracleProvider("") + if err := registry.Register(miracleProvider); err != nil { + log.Warnf("Failed to register Miracle WM provider: %v", err) + } + + if configDir != "" { + swayProvider := providers.NewSwayProvider(filepath.Join(configDir, "sway")) + if err := registry.Register(swayProvider); err != nil { + log.Warnf("Failed to register Sway provider: %v", err) + } + } + + niriProvider := providers.NewNiriProvider("") + if err := registry.Register(niriProvider); err != nil { + log.Warnf("Failed to register Niri provider: %v", err) + } + + config := keybinds.DefaultDiscoveryConfig() + if err := keybinds.AutoDiscoverProviders(registry, config); err != nil { + log.Warnf("Failed to auto-discover providers: %v", err) + } +} + +func runKeybindsList(cmd *cobra.Command, _ []string) { + providerList := keybinds.GetDefaultRegistry().List() + asJSON, _ := cmd.Flags().GetBool("json") + + if asJSON { + output, _ := json.Marshal(providerList) + fmt.Fprintln(os.Stdout, string(output)) + return + } + + if len(providerList) == 0 { + fmt.Fprintln(os.Stdout, "No providers available") + return + } + + fmt.Fprintln(os.Stdout, "Available providers:") + for _, name := range providerList { + fmt.Fprintf(os.Stdout, " - %s\n", name) + } +} + +func makeProviderWithPath(name, path string) keybinds.Provider { + switch name { + case "hyprland": + return providers.NewHyprlandProvider(path) + case "mangowc": + return providers.NewMangoWCProvider(path) + case "sway": + return providers.NewSwayProvider(path) + case "scroll": + return providers.NewSwayProvider(path) + case "miracle": + return providers.NewMiracleProvider(path) + case "niri": + return providers.NewNiriProvider(path) + default: + return nil + } +} + +func printCheatSheet(provider keybinds.Provider) { + sheet, err := provider.GetCheatSheet() + if err != nil { + log.Fatalf("Error getting cheatsheet: %v", err) + } + output, err := json.MarshalIndent(sheet, "", " ") + if err != nil { + log.Fatalf("Error generating JSON: %v", err) + } + fmt.Fprintln(os.Stdout, string(output)) +} + +func runKeybindsShow(cmd *cobra.Command, args []string) { + providerName := args[0] + customPath, _ := cmd.Flags().GetString("path") + + if customPath != "" { + provider := makeProviderWithPath(providerName, customPath) + if provider == nil { + log.Fatalf("Provider %s does not support custom path", providerName) + } + printCheatSheet(provider) + return + } + + provider, err := keybinds.GetDefaultRegistry().Get(providerName) + if err != nil { + log.Fatalf("Error: %v", err) + } + printCheatSheet(provider) +} + +func getWritableProvider(name string) keybinds.WritableProvider { + provider, err := keybinds.GetDefaultRegistry().Get(name) + if err != nil { + log.Fatalf("Error: %v", err) + } + writable, ok := provider.(keybinds.WritableProvider) + if !ok { + log.Fatalf("Provider %s does not support writing keybinds", name) + } + return writable +} + +func runKeybindsSet(cmd *cobra.Command, args []string) { + providerName, key, action := args[0], args[1], args[2] + writable := getWritableProvider(providerName) + + if replaceKey, _ := cmd.Flags().GetString("replace-key"); replaceKey != "" && replaceKey != key { + _ = writable.RemoveBind(replaceKey) + } + + options := make(map[string]any) + if v, _ := cmd.Flags().GetBool("allow-when-locked"); v { + options["allow-when-locked"] = true + } + if v, _ := cmd.Flags().GetInt("cooldown-ms"); v > 0 { + options["cooldown-ms"] = v + } + if v, _ := cmd.Flags().GetBool("no-repeat"); v { + options["repeat"] = false + } + if v, _ := cmd.Flags().GetBool("no-inhibiting"); v { + options["allow-inhibiting"] = false + } + if v, _ := cmd.Flags().GetString("flags"); v != "" { + options["flags"] = v + } + + desc, _ := cmd.Flags().GetString("desc") + if err := writable.SetBind(key, action, desc, options); err != nil { + log.Fatalf("Error setting keybind: %v", err) + } + + output, _ := json.MarshalIndent(map[string]any{ + "success": true, + "key": key, + "action": action, + "path": writable.GetOverridePath(), + }, "", " ") + fmt.Fprintln(os.Stdout, string(output)) +} + +func runKeybindsRemove(_ *cobra.Command, args []string) { + providerName, key := args[0], args[1] + writable := getWritableProvider(providerName) + + if err := writable.RemoveBind(key); err != nil { + log.Fatalf("Error removing keybind: %v", err) + } + + output, _ := json.MarshalIndent(map[string]any{ + "success": true, + "key": key, + "removed": true, + }, "", " ") + fmt.Fprintln(os.Stdout, string(output)) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_matugen.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_matugen.go new file mode 100644 index 0000000..566f360 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_matugen.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/spf13/cobra" +) + +var matugenCmd = &cobra.Command{ + Use: "matugen", + Short: "Generate Material Design themes", + Long: "Generate Material Design themes using matugen with dank16 color integration", +} + +var matugenGenerateCmd = &cobra.Command{ + Use: "generate", + Short: "Generate theme synchronously", + Run: runMatugenGenerate, +} + +var matugenQueueCmd = &cobra.Command{ + Use: "queue", + Short: "Queue theme generation (uses socket if available)", + Run: runMatugenQueue, +} + +var matugenCheckCmd = &cobra.Command{ + Use: "check", + Short: "Check which template apps are detected", + Run: runMatugenCheck, +} + +func init() { + matugenCmd.AddCommand(matugenGenerateCmd) + matugenCmd.AddCommand(matugenQueueCmd) + matugenCmd.AddCommand(matugenCheckCmd) + + for _, cmd := range []*cobra.Command{matugenGenerateCmd, matugenQueueCmd} { + cmd.Flags().String("state-dir", "", "State directory for cache files") + cmd.Flags().String("shell-dir", "", "DMS shell installation directory") + cmd.Flags().String("config-dir", "", "User config directory") + cmd.Flags().String("kind", "image", "Source type: image or hex") + cmd.Flags().String("value", "", "Wallpaper path or hex color") + cmd.Flags().String("mode", "dark", "Color mode: dark or light") + cmd.Flags().String("icon-theme", "System Default", "Icon theme name") + cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type") + cmd.Flags().Bool("run-user-templates", true, "Run user matugen templates") + cmd.Flags().String("stock-colors", "", "Stock theme colors JSON") + cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal") + cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant") + cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip") + cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)") + } + + matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion") + matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting") +} + +func buildMatugenOptions(cmd *cobra.Command) matugen.Options { + stateDir, _ := cmd.Flags().GetString("state-dir") + shellDir, _ := cmd.Flags().GetString("shell-dir") + configDir, _ := cmd.Flags().GetString("config-dir") + kind, _ := cmd.Flags().GetString("kind") + value, _ := cmd.Flags().GetString("value") + mode, _ := cmd.Flags().GetString("mode") + iconTheme, _ := cmd.Flags().GetString("icon-theme") + matugenType, _ := cmd.Flags().GetString("matugen-type") + runUserTemplates, _ := cmd.Flags().GetBool("run-user-templates") + stockColors, _ := cmd.Flags().GetString("stock-colors") + syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal") + terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark") + skipTemplates, _ := cmd.Flags().GetString("skip-templates") + contrast, _ := cmd.Flags().GetFloat64("contrast") + + return matugen.Options{ + StateDir: stateDir, + ShellDir: shellDir, + ConfigDir: configDir, + Kind: kind, + Value: value, + Mode: matugen.ColorMode(mode), + IconTheme: iconTheme, + MatugenType: matugenType, + Contrast: contrast, + RunUserTemplates: runUserTemplates, + StockColors: stockColors, + SyncModeWithPortal: syncModeWithPortal, + TerminalsAlwaysDark: terminalsAlwaysDark, + SkipTemplates: skipTemplates, + } +} + +func runMatugenGenerate(cmd *cobra.Command, args []string) { + opts := buildMatugenOptions(cmd) + err := matugen.Run(opts) + switch { + case errors.Is(err, matugen.ErrNoChanges): + os.Exit(2) + case err != nil: + log.Fatalf("Theme generation failed: %v", err) + } +} + +func runMatugenQueue(cmd *cobra.Command, args []string) { + opts := buildMatugenOptions(cmd) + wait, _ := cmd.Flags().GetBool("wait") + timeout, _ := cmd.Flags().GetDuration("timeout") + + request := models.Request{ + ID: 1, + Method: "matugen.queue", + Params: map[string]any{ + "stateDir": opts.StateDir, + "shellDir": opts.ShellDir, + "configDir": opts.ConfigDir, + "kind": opts.Kind, + "value": opts.Value, + "mode": opts.Mode, + "iconTheme": opts.IconTheme, + "matugenType": opts.MatugenType, + "runUserTemplates": opts.RunUserTemplates, + "stockColors": opts.StockColors, + "syncModeWithPortal": opts.SyncModeWithPortal, + "terminalsAlwaysDark": opts.TerminalsAlwaysDark, + "skipTemplates": opts.SkipTemplates, + "contrast": opts.Contrast, + "wait": wait, + }, + } + + if !wait { + if err := sendServerRequestFireAndForget(request); err != nil { + log.Info("Server unavailable, running synchronously") + err := matugen.Run(opts) + switch { + case errors.Is(err, matugen.ErrNoChanges): + os.Exit(2) + case err != nil: + log.Fatalf("Theme generation failed: %v", err) + } + return + } + fmt.Println("Theme generation queued") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + resultCh := make(chan error, 1) + go func() { + resp, ok := tryServerRequest(request) + if !ok { + log.Info("Server unavailable, running synchronously") + err := matugen.Run(opts) + switch { + case errors.Is(err, matugen.ErrNoChanges): + resultCh <- matugen.ErrNoChanges + case err != nil: + resultCh <- err + default: + resultCh <- nil + } + return + } + if resp.Error != "" { + resultCh <- fmt.Errorf("server error: %s", resp.Error) + return + } + resultCh <- nil + }() + + select { + case err := <-resultCh: + switch { + case errors.Is(err, matugen.ErrNoChanges): + os.Exit(2) + case err != nil: + log.Fatalf("Theme generation failed: %v", err) + } + fmt.Println("Theme generation completed") + case <-ctx.Done(): + log.Fatalf("Timeout waiting for theme generation") + } +} + +func runMatugenCheck(cmd *cobra.Command, args []string) { + checks := matugen.CheckTemplates(nil) + data, err := json.Marshal(checks) + if err != nil { + log.Fatalf("Failed to marshal check results: %v", err) + } + fmt.Println(string(data)) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_notify.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_notify.go new file mode 100644 index 0000000..e04b60f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_notify.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/notify" + "github.com/spf13/cobra" +) + +var ( + notifyAppName string + notifyIcon string + notifyFile string + notifyTimeout int +) + +var notifyCmd = &cobra.Command{ + Use: "notify [body]", + Short: "Send a desktop notification", + Long: `Send a desktop notification with optional actions. + +If --file is provided, the notification will have "Open" and "Open Folder" actions. + +Examples: + dms notify "Hello" "World" + dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone + dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`, + Args: cobra.MinimumNArgs(1), + Run: runNotify, +} + +var genericNotifyActionCmd = &cobra.Command{ + Use: "notify-action-generic", + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + notify.RunActionListener(args) + }, +} + +func init() { + notifyCmd.Flags().StringVar(¬ifyAppName, "app", "DMS", "Application name") + notifyCmd.Flags().StringVar(¬ifyIcon, "icon", "", "Icon name or path") + notifyCmd.Flags().StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)") + notifyCmd.Flags().IntVar(¬ifyTimeout, "timeout", 5000, "Timeout in milliseconds") +} + +func runNotify(cmd *cobra.Command, args []string) { + summary := args[0] + body := "" + if len(args) > 1 { + body = args[1] + } + + n := notify.Notification{ + AppName: notifyAppName, + Icon: notifyIcon, + Summary: summary, + Body: body, + FilePath: notifyFile, + Timeout: int32(notifyTimeout), + } + + if err := notify.Send(n); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_open.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_open.go new file mode 100644 index 0000000..8cc16cf --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_open.go @@ -0,0 +1,205 @@ +package main + +import ( + "fmt" + "mime" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/spf13/cobra" +) + +var ( + openMimeType string + openCategories []string + openRequestType string +) + +var openCmd = &cobra.Command{ + Use: "open [target]", + Short: "Open a file, URL, or resource with an application picker", + Long: `Open a target (URL, file, or other resource) using the DMS application picker. +By default, this opens URLs with the browser picker. You can customize the behavior +with flags to handle different MIME types or application categories. + +Examples: + dms open https://example.com # Open URL with browser picker + dms open file.pdf --mime application/pdf # Open PDF with compatible apps + dms open document.odt --category Office # Open with office applications + dms open --mime image/png image.png # Open image with image viewers`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runOpen(args[0]) + }, +} + +func init() { + rootCmd.AddCommand(openCmd) + openCmd.Flags().StringVar(&openMimeType, "mime", "", "MIME type for filtering applications") + openCmd.Flags().StringSliceVar(&openCategories, "category", []string{}, "Application categories to filter (e.g., WebBrowser, Office, Graphics)") + openCmd.Flags().StringVar(&openRequestType, "type", "url", "Request type (url, file, or custom)") + _ = openCmd.RegisterFlagCompletionFunc("type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"url", "file", "custom"}, cobra.ShellCompDirectiveNoFileComp + }) +} + +// mimeTypeToCategories maps MIME types to desktop file categories +func mimeTypeToCategories(mimeType string) []string { + // Split MIME type to get the main type + parts := strings.Split(mimeType, "/") + if len(parts) < 1 { + return nil + } + + mainType := parts[0] + + switch mainType { + case "image": + return []string{"Graphics", "Viewer"} + case "video": + return []string{"Video", "AudioVideo"} + case "audio": + return []string{"Audio", "AudioVideo"} + case "text": + if strings.Contains(mimeType, "html") { + return []string{"WebBrowser"} + } + return []string{"TextEditor", "Office"} + case "application": + if strings.Contains(mimeType, "pdf") { + return []string{"Office", "Viewer"} + } + if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") || + strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") || + strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") || + strings.Contains(mimeType, "opendocument") { + return []string{"Office"} + } + if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") || + strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") { + return []string{"Archiving", "Utility"} + } + return []string{"Office", "Viewer"} + } + + return nil +} + +func runOpen(target string) { + // Parse file:// URIs to extract the actual file path + actualTarget := target + detectedMimeType := openMimeType + detectedCategories := openCategories + detectedRequestType := openRequestType + + log.Infof("Processing target: %s", target) + + if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" { + // Extract file path from file:// URI and convert to absolute path + actualTarget = parsedURL.Path + if absPath, err := filepath.Abs(actualTarget); err == nil { + actualTarget = absPath + } + + if detectedRequestType == "url" || detectedRequestType == "" { + detectedRequestType = "file" + } + + log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget) + + // Auto-detect MIME type if not provided + if detectedMimeType == "" { + ext := filepath.Ext(actualTarget) + if ext != "" { + detectedMimeType = mime.TypeByExtension(ext) + log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType) + } + } + + // Auto-detect categories based on MIME type if not provided + if len(detectedCategories) == 0 && detectedMimeType != "" { + detectedCategories = mimeTypeToCategories(detectedMimeType) + log.Infof("Detected categories from MIME type: %v", detectedCategories) + } + } else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + // Handle HTTP(S) URLs + if detectedRequestType == "" { + detectedRequestType = "url" + } + log.Infof("Detected HTTP(S) URL") + } else if strings.HasPrefix(target, "dms://") { + // Handle DMS internal URLs (theme/plugin install, etc.) + if detectedRequestType == "" { + detectedRequestType = "url" + } + log.Infof("Detected DMS internal URL") + } else if _, err := os.Stat(target); err == nil { + // Handle local file paths directly (not file:// URIs) + // Convert to absolute path + if absPath, err := filepath.Abs(target); err == nil { + actualTarget = absPath + } + + if detectedRequestType == "url" || detectedRequestType == "" { + detectedRequestType = "file" + } + + log.Infof("Detected local file path, converted to absolute: %s", actualTarget) + + // Auto-detect MIME type if not provided + if detectedMimeType == "" { + ext := filepath.Ext(actualTarget) + if ext != "" { + detectedMimeType = mime.TypeByExtension(ext) + log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType) + } + } + + // Auto-detect categories based on MIME type if not provided + if len(detectedCategories) == 0 && detectedMimeType != "" { + detectedCategories = mimeTypeToCategories(detectedMimeType) + log.Infof("Detected categories from MIME type: %v", detectedCategories) + } + } + + params := map[string]any{ + "target": actualTarget, + } + + if detectedMimeType != "" { + params["mimeType"] = detectedMimeType + } + + if len(detectedCategories) > 0 { + params["categories"] = detectedCategories + } + + if detectedRequestType != "" { + params["requestType"] = detectedRequestType + } + + method := "apppicker.open" + if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "dms://")) { + method = "browser.open" + params["url"] = target + } + + req := models.Request{ + ID: 1, + Method: method, + Params: params, + } + + log.Infof("Sending request - Method: %s, Params: %+v", method, params) + + if err := sendServerRequestFireAndForget(req); err != nil { + fmt.Println("DMS is not running. Please start DMS first.") + os.Exit(1) + } + + log.Infof("Request sent successfully") +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_randr.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_randr.go new file mode 100644 index 0000000..fa0c990 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_randr.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var randrCmd = &cobra.Command{ + Use: "randr", + Short: "Query output display information", + Long: "Query Wayland compositor for output names, scales, resolutions and refresh rates via zwlr-output-management", + Run: runRandr, +} + +func init() { + randrCmd.Flags().Bool("json", false, "Output in JSON format") +} + +type randrJSON struct { + Outputs []randrOutput `json:"outputs"` +} + +func runRandr(cmd *cobra.Command, args []string) { + outputs, err := queryRandr() + if err != nil { + log.Fatalf("%v", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + + if jsonFlag { + data, err := json.Marshal(randrJSON{Outputs: outputs}) + if err != nil { + log.Fatalf("failed to marshal JSON: %v", err) + } + fmt.Println(string(data)) + return + } + + for i, out := range outputs { + if i > 0 { + fmt.Println() + } + status := "enabled" + if !out.Enabled { + status = "disabled" + } + fmt.Printf("%s (%s)\n", out.Name, status) + fmt.Printf(" Scale: %.4g\n", out.Scale) + fmt.Printf(" Resolution: %dx%d\n", out.Width, out.Height) + if out.Refresh > 0 { + fmt.Printf(" Refresh: %.2f Hz\n", float64(out.Refresh)/1000.0) + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_root.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_root.go new file mode 100644 index 0000000..b263ae6 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_root.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var customConfigPath string +var configPath string + +var rootCmd = &cobra.Command{ + Use: "dms", + Short: "dms CLI", + Long: "dms is the DankMaterialShell management CLI and backend server.", +} + +func init() { + rootCmd.PersistentFlags().StringVarP(&customConfigPath, "config", "c", "", "Specify a custom path to the DMS config directory") +} + +func findConfig(cmd *cobra.Command, args []string) error { + if customConfigPath != "" { + log.Debug("Custom config path provided via -c flag: %s", customConfigPath) + shellPath := filepath.Join(customConfigPath, "shell.qml") + + info, statErr := os.Stat(shellPath) + + if statErr == nil && !info.IsDir() { + configPath = customConfigPath + log.Debug("Using config from: %s", configPath) + return nil + } + + if statErr != nil { + return fmt.Errorf("custom config path error: %w", statErr) + } + + return fmt.Errorf("path is a directory, not a file: %s", shellPath) + } + + configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path") + if data, readErr := os.ReadFile(configStateFile); readErr == nil { + if len(getAllDMSPIDs()) == 0 { + os.Remove(configStateFile) + } else { + statePath := strings.TrimSpace(string(data)) + shellPath := filepath.Join(statePath, "shell.qml") + + if info, statErr := os.Stat(shellPath); statErr == nil && !info.IsDir() { + log.Debug("Using config from active session state file: %s", statePath) + configPath = statePath + log.Debug("Using config from: %s", configPath) + return nil + } + os.Remove(configStateFile) + } + } + + log.Debug("No custom path or active session, searching default XDG locations...") + var err error + configPath, err = config.LocateDMSConfig() + if err != nil { + return err + } + + log.Debug("Using config from: %s", configPath) + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_screenshot.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_screenshot.go new file mode 100644 index 0000000..2875019 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_screenshot.go @@ -0,0 +1,422 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot" + "github.com/spf13/cobra" +) + +var ( + ssOutputName string + ssCursor string + ssFormat string + ssQuality int + ssOutputDir string + ssFilename string + ssNoClipboard bool + ssNoFile bool + ssNoNotify bool + ssNoConfirm bool + ssReset bool + ssStdout bool +) + +var screenshotCmd = &cobra.Command{ + Use: "screenshot", + Short: "Capture screenshots", + Long: `Capture screenshots from Wayland displays. + +Modes: + region - Select a region interactively (default) + full - Capture the focused output + all - Capture all outputs combined + output - Capture a specific output by name + window - Capture the focused window (Hyprland/DWL) + last - Capture the last selected region + +Output format (--format): + png - PNG format (default) + jpg/jpeg - JPEG format + ppm - PPM format + +Examples: + dms screenshot # Region select, save file + clipboard + dms screenshot full # Full screen of focused output + dms screenshot all # All screens combined + dms screenshot output -o DP-1 # Specific output + dms screenshot window # Focused window (Hyprland) + dms screenshot last # Last region (pre-selected) + dms screenshot --reset # Reset last region pre-selection + dms screenshot --no-clipboard # Save file only + dms screenshot --no-file # Clipboard only + dms screenshot --no-confirm # Region capture on mouse release + dms screenshot --cursor=on # Include cursor + dms screenshot -f jpg -q 85 # JPEG with quality 85`, +} + +var ssRegionCmd = &cobra.Command{ + Use: "region", + Short: "Select a region interactively", + Run: runScreenshotRegion, +} + +var ssFullCmd = &cobra.Command{ + Use: "full", + Short: "Capture the focused output", + Run: runScreenshotFull, +} + +var ssAllCmd = &cobra.Command{ + Use: "all", + Short: "Capture all outputs combined", + Run: runScreenshotAll, +} + +var ssOutputCmd = &cobra.Command{ + Use: "output", + Short: "Capture a specific output", + Run: runScreenshotOutput, +} + +var ssLastCmd = &cobra.Command{ + Use: "last", + Short: "Capture the last selected region", + Long: `Capture the previously selected region without interactive selection. +If no previous region exists, falls back to interactive selection.`, + Run: runScreenshotLast, +} + +var ssWindowCmd = &cobra.Command{ + Use: "window", + Short: "Capture the focused window", + Long: `Capture the currently focused window. Supported on Hyprland and DWL.`, + Run: runScreenshotWindow, +} + +var ssListCmd = &cobra.Command{ + Use: "list", + Short: "List available outputs", + Run: runScreenshotList, +} + +var notifyActionCmd = &cobra.Command{ + Use: "notify-action", + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + screenshot.RunNotifyActionListener(args) + }, +} + +func init() { + screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode") + screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)") + screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)") + screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)") + screenshotCmd.PersistentFlags().StringVarP(&ssOutputDir, "dir", "d", "", "Output directory") + screenshotCmd.PersistentFlags().StringVar(&ssFilename, "filename", "", "Output filename (auto-generated if empty)") + screenshotCmd.PersistentFlags().BoolVar(&ssNoClipboard, "no-clipboard", false, "Don't copy to clipboard") + screenshotCmd.PersistentFlags().BoolVar(&ssNoFile, "no-file", false, "Don't save to file") + screenshotCmd.PersistentFlags().BoolVar(&ssNoNotify, "no-notify", false, "Don't show notification") + screenshotCmd.PersistentFlags().BoolVar(&ssNoConfirm, "no-confirm", false, "Region mode: capture on mouse release without Enter/Space confirmation") + screenshotCmd.PersistentFlags().BoolVar(&ssReset, "reset", false, "Reset saved last-region preselection before capturing") + screenshotCmd.PersistentFlags().BoolVar(&ssStdout, "stdout", false, "Output image to stdout (for piping to swappy, etc.)") + + screenshotCmd.AddCommand(ssRegionCmd) + screenshotCmd.AddCommand(ssFullCmd) + screenshotCmd.AddCommand(ssAllCmd) + screenshotCmd.AddCommand(ssOutputCmd) + screenshotCmd.AddCommand(ssLastCmd) + screenshotCmd.AddCommand(ssWindowCmd) + screenshotCmd.AddCommand(ssListCmd) + + screenshotCmd.Run = runScreenshotRegion +} + +func getScreenshotConfig(mode screenshot.Mode) screenshot.Config { + config := screenshot.DefaultConfig() + config.Mode = mode + config.OutputName = ssOutputName + if strings.EqualFold(ssCursor, "on") { + config.Cursor = screenshot.CursorOn + } + config.Clipboard = !ssNoClipboard + config.SaveFile = !ssNoFile + config.Notify = !ssNoNotify + config.NoConfirm = ssNoConfirm + config.Reset = ssReset + config.Stdout = ssStdout + + if ssOutputDir != "" { + config.OutputDir = ssOutputDir + } + if ssFilename != "" { + config.Filename = ssFilename + } + + switch strings.ToLower(ssFormat) { + case "jpg", "jpeg": + config.Format = screenshot.FormatJPEG + case "ppm": + config.Format = screenshot.FormatPPM + default: + config.Format = screenshot.FormatPNG + } + + if ssQuality < 1 { + ssQuality = 1 + } + if ssQuality > 100 { + ssQuality = 100 + } + config.Quality = ssQuality + + return config +} + +func runScreenshot(config screenshot.Config) { + sc := screenshot.New(config) + result, err := sc.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if result == nil { + os.Exit(0) + } + + defer result.Buffer.Close() + + if result.YInverted { + result.Buffer.FlipVertical() + } + + if config.Stdout { + if err := writeImageToStdout(result.Buffer, config.Format, config.Quality, result.Format); err != nil { + fmt.Fprintf(os.Stderr, "Error writing to stdout: %v\n", err) + os.Exit(1) + } + return + } + + var filePath string + + if config.SaveFile { + outputDir := config.OutputDir + if outputDir == "" { + outputDir = screenshot.GetOutputDir() + } + + filename := config.Filename + if filename == "" { + filename = screenshot.GenerateFilename(config.Format) + } + + filePath = filepath.Join(outputDir, filename) + if err := screenshot.WriteToFileWithFormat(result.Buffer, filePath, config.Format, config.Quality, result.Format); err != nil { + fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err) + os.Exit(1) + } + fmt.Println(filePath) + } + + if config.Clipboard { + if err := copyImageToClipboard(result.Buffer, config.Format, config.Quality, result.Format); err != nil { + fmt.Fprintf(os.Stderr, "Error copying to clipboard: %v\n", err) + os.Exit(1) + } + if !config.SaveFile { + fmt.Println("Copied to clipboard") + } + } + + if config.Notify { + thumbData, thumbW, thumbH := bufferToRGBThumbnail(result.Buffer, 256, result.Format) + screenshot.SendNotification(screenshot.NotifyResult{ + FilePath: filePath, + Clipboard: config.Clipboard, + ImageData: thumbData, + Width: thumbW, + Height: thumbH, + }) + } +} + +func copyImageToClipboard(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error { + var mimeType string + var data bytes.Buffer + + img := screenshot.BufferToImageWithFormat(buf, pixelFormat) + + switch format { + case screenshot.FormatJPEG: + mimeType = "image/jpeg" + if err := screenshot.EncodeJPEG(&data, img, quality); err != nil { + return err + } + default: + mimeType = "image/png" + if err := screenshot.EncodePNG(&data, img); err != nil { + return err + } + } + + return clipboard.Copy(data.Bytes(), mimeType) +} + +func writeImageToStdout(buf *screenshot.ShmBuffer, format screenshot.Format, quality int, pixelFormat uint32) error { + img := screenshot.BufferToImageWithFormat(buf, pixelFormat) + + switch format { + case screenshot.FormatJPEG: + return screenshot.EncodeJPEG(os.Stdout, img, quality) + default: + return screenshot.EncodePNG(os.Stdout, img) + } +} + +func bufferToRGBThumbnail(buf *screenshot.ShmBuffer, maxSize int, pixelFormat uint32) ([]byte, int, int) { + srcW, srcH := buf.Width, buf.Height + scale := 1.0 + if srcW > maxSize || srcH > maxSize { + if srcW > srcH { + scale = float64(maxSize) / float64(srcW) + } else { + scale = float64(maxSize) / float64(srcH) + } + } + + dstW := int(float64(srcW) * scale) + dstH := int(float64(srcH) * scale) + if dstW < 1 { + dstW = 1 + } + if dstH < 1 { + dstH = 1 + } + + data := buf.Data() + rgb := make([]byte, dstW*dstH*3) + + var swapRB bool + switch pixelFormat { + case uint32(screenshot.FormatABGR8888), uint32(screenshot.FormatXBGR8888): + swapRB = false + default: + swapRB = true + } + + for y := 0; y < dstH; y++ { + srcY := int(float64(y) / scale) + if srcY >= srcH { + srcY = srcH - 1 + } + for x := 0; x < dstW; x++ { + srcX := int(float64(x) / scale) + if srcX >= srcW { + srcX = srcW - 1 + } + si := srcY*buf.Stride + srcX*4 + di := (y*dstW + x) * 3 + if si+3 >= len(data) { + continue + } + if swapRB { + rgb[di+0] = data[si+2] + rgb[di+1] = data[si+1] + rgb[di+2] = data[si+0] + } else { + rgb[di+0] = data[si+0] + rgb[di+1] = data[si+1] + rgb[di+2] = data[si+2] + } + } + } + return rgb, dstW, dstH +} + +func runScreenshotRegion(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeRegion) + runScreenshot(config) +} + +func runScreenshotFull(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeFullScreen) + runScreenshot(config) +} + +func runScreenshotAll(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeAllScreens) + runScreenshot(config) +} + +func runScreenshotOutput(cmd *cobra.Command, args []string) { + if ssOutputName == "" && len(args) > 0 { + ssOutputName = args[0] + } + if ssOutputName == "" { + fmt.Fprintln(os.Stderr, "Error: output name required (use -o or provide as argument)") + os.Exit(1) + } + config := getScreenshotConfig(screenshot.ModeOutput) + runScreenshot(config) +} + +func runScreenshotLast(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeLastRegion) + runScreenshot(config) +} + +func runScreenshotWindow(cmd *cobra.Command, args []string) { + config := getScreenshotConfig(screenshot.ModeWindow) + runScreenshot(config) +} + +func runScreenshotList(cmd *cobra.Command, args []string) { + outputs, err := screenshot.ListOutputs() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + for _, o := range outputs { + scaleStr := fmt.Sprintf("%.2f", o.FractionalScale) + if o.FractionalScale == float64(int(o.FractionalScale)) { + scaleStr = fmt.Sprintf("%d", int(o.FractionalScale)) + } + + transformStr := transformName(o.Transform) + + fmt.Printf("%s: %dx%d+%d+%d scale=%s transform=%s\n", + o.Name, o.Width, o.Height, o.X, o.Y, scaleStr, transformStr) + } +} + +func transformName(t int32) string { + switch t { + case 0: + return "normal" + case 1: + return "90" + case 2: + return "180" + case 3: + return "270" + case 4: + return "flipped" + case 5: + return "flipped-90" + case 6: + return "flipped-180" + case 7: + return "flipped-270" + default: + return fmt.Sprintf("%d", t) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_setup.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_setup.go new file mode 100644 index 0000000..0f4a58d --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_setup.go @@ -0,0 +1,436 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/spf13/cobra" +) + +var setupCmd = &cobra.Command{ + Use: "setup", + Short: "Deploy DMS configurations", + Long: "Deploy compositor and terminal configurations with interactive prompts", + PersistentPreRunE: requireMutableSystemCommand, + Run: func(cmd *cobra.Command, args []string) { + if err := runSetup(); err != nil { + log.Fatalf("Error during setup: %v", err) + } + }, +} + +var setupBindsCmd = &cobra.Command{ + Use: "binds", + Short: "Deploy default keybinds config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("binds"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupLayoutCmd = &cobra.Command{ + Use: "layout", + Short: "Deploy default layout config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("layout"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupColorsCmd = &cobra.Command{ + Use: "colors", + Short: "Deploy default colors config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("colors"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupAlttabCmd = &cobra.Command{ + Use: "alttab", + Short: "Deploy default alt-tab config (niri only)", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("alttab"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupOutputsCmd = &cobra.Command{ + Use: "outputs", + Short: "Deploy default outputs config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("outputs"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupCursorCmd = &cobra.Command{ + Use: "cursor", + Short: "Deploy default cursor config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("cursor"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +var setupWindowrulesCmd = &cobra.Command{ + Use: "windowrules", + Short: "Deploy default window rules config", + Run: func(cmd *cobra.Command, args []string) { + if err := runSetupDmsConfig("windowrules"); err != nil { + log.Fatalf("Error: %v", err) + } + }, +} + +type dmsConfigSpec struct { + niriFile string + hyprFile string + niriContent func(terminal string) string + hyprContent func(terminal string) string +} + +var dmsConfigSpecs = map[string]dmsConfigSpec{ + "binds": { + niriFile: "binds.kdl", + hyprFile: "binds.conf", + niriContent: func(t string) string { + return strings.ReplaceAll(config.NiriBindsConfig, "{{TERMINAL_COMMAND}}", t) + }, + hyprContent: func(t string) string { + return strings.ReplaceAll(config.HyprBindsConfig, "{{TERMINAL_COMMAND}}", t) + }, + }, + "layout": { + niriFile: "layout.kdl", + hyprFile: "layout.conf", + niriContent: func(_ string) string { return config.NiriLayoutConfig }, + hyprContent: func(_ string) string { return config.HyprLayoutConfig }, + }, + "colors": { + niriFile: "colors.kdl", + hyprFile: "colors.conf", + niriContent: func(_ string) string { return config.NiriColorsConfig }, + hyprContent: func(_ string) string { return config.HyprColorsConfig }, + }, + "alttab": { + niriFile: "alttab.kdl", + niriContent: func(_ string) string { return config.NiriAlttabConfig }, + }, + "outputs": { + niriFile: "outputs.kdl", + hyprFile: "outputs.conf", + niriContent: func(_ string) string { return "" }, + hyprContent: func(_ string) string { return "" }, + }, + "cursor": { + niriFile: "cursor.kdl", + hyprFile: "cursor.conf", + niriContent: func(_ string) string { return "" }, + hyprContent: func(_ string) string { return "" }, + }, + "windowrules": { + niriFile: "windowrules.kdl", + hyprFile: "windowrules.conf", + niriContent: func(_ string) string { return "" }, + hyprContent: func(_ string) string { return "" }, + }, +} + +func detectTerminal() (string, error) { + terminals := []string{"ghostty", "foot", "kitty", "alacritty"} + var found []string + for _, t := range terminals { + if utils.CommandExists(t) { + found = append(found, t) + } + } + + switch len(found) { + case 0: + return "ghostty", nil + case 1: + return found[0], nil + } + + fmt.Println("Multiple terminals detected:") + for i, t := range found { + fmt.Printf("%d) %s\n", i+1, t) + } + fmt.Printf("\nChoice (1-%d): ", len(found)) + + var response string + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + choice := 0 + fmt.Sscanf(response, "%d", &choice) + if choice < 1 || choice > len(found) { + return "", fmt.Errorf("invalid choice") + } + return found[choice-1], nil +} + +func detectCompositorForSetup() (string, error) { + compositors := greeter.DetectCompositors() + + switch len(compositors) { + case 0: + return "", fmt.Errorf("no supported compositors found (niri or Hyprland required)") + case 1: + return strings.ToLower(compositors[0]), nil + } + + selected, err := greeter.PromptCompositorChoice(compositors) + if err != nil { + return "", err + } + return strings.ToLower(selected), nil +} + +func runSetupDmsConfig(name string) error { + spec, ok := dmsConfigSpecs[name] + if !ok { + return fmt.Errorf("unknown config: %s", name) + } + + compositor, err := detectCompositorForSetup() + if err != nil { + return err + } + + var filename string + var contentFn func(string) string + switch compositor { + case "niri": + filename = spec.niriFile + contentFn = spec.niriContent + case "hyprland": + filename = spec.hyprFile + contentFn = spec.hyprContent + default: + return fmt.Errorf("unsupported compositor: %s", compositor) + } + + if filename == "" { + return fmt.Errorf("%s is not supported for %s", name, compositor) + } + + var dmsDir string + switch compositor { + case "niri": + dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "niri", "dms") + case "hyprland": + dmsDir = filepath.Join(os.Getenv("HOME"), ".config", "hypr", "dms") + } + + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + return fmt.Errorf("failed to create dms directory: %w", err) + } + + path := filepath.Join(dmsDir, filename) + if info, err := os.Stat(path); err == nil && info.Size() > 0 { + return fmt.Errorf("%s already exists and is not empty: %s", name, path) + } + + terminal := "ghostty" + if contentFn != nil && name == "binds" { + terminal, err = detectTerminal() + if err != nil { + return err + } + } + + content := contentFn(terminal) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", filename, err) + } + + fmt.Printf("Deployed %s to %s\n", name, path) + return nil +} + +func runSetup() error { + fmt.Println("=== DMS Configuration Setup ===") + + wm, wmSelected := promptCompositor() + terminal, terminalSelected := promptTerminal() + useSystemd := promptSystemd() + + if !wmSelected && !terminalSelected { + fmt.Println("No configurations selected. Exiting.") + return nil + } + + if wmSelected || terminalSelected { + willBackup := checkExistingConfigs(wm, wmSelected, terminal, terminalSelected) + if willBackup { + fmt.Println("\n⚠ Existing configurations will be backed up with timestamps.") + } + + fmt.Print("\nProceed with deployment? (y/N): ") + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response != "y" && response != "yes" { + fmt.Println("Setup cancelled.") + return nil + } + } + + fmt.Println("\nDeploying configurations...") + logChan := make(chan string, 100) + deployer := config.NewConfigDeployer(logChan) + + go func() { + for msg := range logChan { + fmt.Println(" " + msg) + } + }() + + ctx := context.Background() + var results []config.DeploymentResult + var err error + + if wmSelected && terminalSelected { + results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, terminal, useSystemd) + } else if wmSelected { + results, err = deployer.DeployConfigurationsWithSystemd(ctx, wm, deps.TerminalGhostty, useSystemd) + if len(results) > 1 { + results = results[:1] + } + } else if terminalSelected { + results, err = deployer.DeployConfigurationsWithSystemd(ctx, deps.WindowManagerNiri, terminal, useSystemd) + if len(results) > 0 && results[0].ConfigType == "Niri" { + results = results[1:] + } + } + + close(logChan) + + if err != nil { + return fmt.Errorf("deployment failed: %w", err) + } + + fmt.Println("\n=== Deployment Complete ===") + for _, result := range results { + if result.Deployed { + fmt.Printf("✓ %s: %s\n", result.ConfigType, result.Path) + if result.BackupPath != "" { + fmt.Printf(" Backup: %s\n", result.BackupPath) + } + } + } + + return nil +} + +func promptCompositor() (deps.WindowManager, bool) { + fmt.Println("Select compositor:") + fmt.Println("1) Niri") + fmt.Println("2) Hyprland") + fmt.Println("3) None") + + var response string + fmt.Print("\nChoice (1-3): ") + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + switch response { + case "1": + return deps.WindowManagerNiri, true + case "2": + return deps.WindowManagerHyprland, true + default: + return deps.WindowManagerNiri, false + } +} + +func promptTerminal() (deps.Terminal, bool) { + fmt.Println("\nSelect terminal:") + fmt.Println("1) Ghostty") + fmt.Println("2) Kitty") + fmt.Println("3) Alacritty") + fmt.Println("4) None") + + var response string + fmt.Print("\nChoice (1-4): ") + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + switch response { + case "1": + return deps.TerminalGhostty, true + case "2": + return deps.TerminalKitty, true + case "3": + return deps.TerminalAlacritty, true + default: + return deps.TerminalGhostty, false + } +} + +func promptSystemd() bool { + fmt.Println("\nUse systemd for session management?") + fmt.Println("1) Yes (recommended for most distros)") + fmt.Println("2) No (standalone, no systemd integration)") + + var response string + fmt.Print("\nChoice (1-2): ") + fmt.Scanln(&response) + response = strings.TrimSpace(response) + + return response != "2" +} + +func checkExistingConfigs(wm deps.WindowManager, wmSelected bool, terminal deps.Terminal, terminalSelected bool) bool { + homeDir := os.Getenv("HOME") + willBackup := false + + if wmSelected { + var configPath string + switch wm { + case deps.WindowManagerNiri: + configPath = filepath.Join(homeDir, ".config", "niri", "config.kdl") + case deps.WindowManagerHyprland: + configPath = filepath.Join(homeDir, ".config", "hypr", "hyprland.conf") + } + + if _, err := os.Stat(configPath); err == nil { + willBackup = true + } + } + + if terminalSelected { + var configPath string + switch terminal { + case deps.TerminalGhostty: + configPath = filepath.Join(homeDir, ".config", "ghostty", "config") + case deps.TerminalKitty: + configPath = filepath.Join(homeDir, ".config", "kitty", "kitty.conf") + case deps.TerminalAlacritty: + configPath = filepath.Join(homeDir, ".config", "alacritty", "alacritty.toml") + } + + if _, err := os.Stat(configPath); err == nil { + willBackup = true + } + } + + return willBackup +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_windowrules.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_windowrules.go new file mode 100644 index 0000000..0883811 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/commands_windowrules.go @@ -0,0 +1,338 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" + "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules/providers" + "github.com/spf13/cobra" +) + +var windowrulesCmd = &cobra.Command{ + Use: "windowrules", + Short: "Manage window rules", +} + +var windowrulesListCmd = &cobra.Command{ + Use: "list [compositor]", + Short: "List all window rules", + Long: "List all window rules from compositor config file. Returns JSON with rules and DMS status.", + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesList, +} + +var windowrulesAddCmd = &cobra.Command{ + Use: "add ''", + Short: "Add a window rule to DMS file", + Long: "Add a new window rule to the DMS-managed rules file.", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // ! disabled hyprland return []string{"hyprland", "niri"}, cobra.ShellCompDirectiveNoFileComp + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesAdd, +} + +var windowrulesUpdateCmd = &cobra.Command{ + Use: "update ''", + Short: "Update a window rule in DMS file", + Long: "Update an existing window rule in the DMS-managed rules file.", + Args: cobra.ExactArgs(3), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesUpdate, +} + +var windowrulesRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a window rule from DMS file", + Long: "Remove a window rule from the DMS-managed rules file.", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesRemove, +} + +var windowrulesReorderCmd = &cobra.Command{ + Use: "reorder ''", + Short: "Reorder window rules in DMS file", + Long: "Reorder window rules by providing a JSON array of rule IDs in the desired order.", + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"niri"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: runWindowrulesReorder, +} + +func init() { + configCmd.AddCommand(windowrulesCmd) + windowrulesCmd.AddCommand(windowrulesListCmd) + windowrulesCmd.AddCommand(windowrulesAddCmd) + windowrulesCmd.AddCommand(windowrulesUpdateCmd) + windowrulesCmd.AddCommand(windowrulesRemoveCmd) + windowrulesCmd.AddCommand(windowrulesReorderCmd) +} + +type WindowRulesListResult struct { + Rules []windowrules.WindowRule `json:"rules"` + DMSStatus *windowrules.DMSRulesStatus `json:"dmsStatus,omitempty"` +} + +type WindowRuleWriteResult struct { + Success bool `json:"success"` + ID string `json:"id,omitempty"` + Path string `json:"path,omitempty"` + Error string `json:"error,omitempty"` +} + +func getCompositor(args []string) string { + if len(args) > 0 { + return strings.ToLower(args[0]) + } + if os.Getenv("NIRI_SOCKET") != "" { + return "niri" + } + // if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "" { + // return "hyprland" + // } + return "" +} + +func writeRuleError(errMsg string) { + result := WindowRuleWriteResult{Success: false, Error: errMsg} + output, _ := json.Marshal(result) + fmt.Fprintln(os.Stdout, string(output)) + os.Exit(1) +} + +func writeRuleSuccess(id, path string) { + result := WindowRuleWriteResult{Success: true, ID: id, Path: path} + output, _ := json.Marshal(result) + fmt.Fprintln(os.Stdout, string(output)) +} + +func runWindowrulesList(cmd *cobra.Command, args []string) { + compositor := getCompositor(args) + if compositor == "" { + log.Fatalf("Could not detect compositor. Please specify: hyprland or niri") + } + + var result WindowRulesListResult + + switch compositor { + case "niri": + configDir, err := utils.ExpandPath("$HOME/.config/niri") + if err != nil { + log.Fatalf("Failed to expand niri config path: %v", err) + } + + parseResult, err := providers.ParseNiriWindowRules(configDir) + if err != nil { + log.Fatalf("Failed to parse niri window rules: %v", err) + } + + allRules := providers.ConvertNiriRulesToWindowRules(parseResult.Rules) + + provider := providers.NewNiriWritableProvider(configDir) + dmsRulesPath := provider.GetOverridePath() + dmsRules, _ := provider.LoadDMSRules() + + dmsRuleMap := make(map[int]windowrules.WindowRule) + for i, dr := range dmsRules { + dmsRuleMap[i] = dr + } + + dmsIdx := 0 + for i, r := range allRules { + if r.Source == dmsRulesPath { + if dmr, ok := dmsRuleMap[dmsIdx]; ok { + allRules[i].ID = dmr.ID + allRules[i].Name = dmr.Name + } + dmsIdx++ + } + } + + result.Rules = allRules + result.DMSStatus = parseResult.DMSStatus + + case "hyprland": + log.Fatalf("Hyprland support is currently disabled.") // ! disabled hyprland + configDir, err := utils.ExpandPath("$HOME/.config/hypr") + if err != nil { + log.Fatalf("Failed to expand hyprland config path: %v", err) + } + + parseResult, err := providers.ParseHyprlandWindowRules(configDir) + if err != nil { + log.Fatalf("Failed to parse hyprland window rules: %v", err) + } + + allRules := providers.ConvertHyprlandRulesToWindowRules(parseResult.Rules) + + provider := providers.NewHyprlandWritableProvider(configDir) + dmsRulesPath := provider.GetOverridePath() + dmsRules, _ := provider.LoadDMSRules() + + dmsRuleMap := make(map[int]windowrules.WindowRule) + for i, dr := range dmsRules { + dmsRuleMap[i] = dr + } + + dmsIdx := 0 + for i, r := range allRules { + if r.Source == dmsRulesPath { + if dmr, ok := dmsRuleMap[dmsIdx]; ok { + allRules[i].ID = dmr.ID + allRules[i].Name = dmr.Name + } + dmsIdx++ + } + } + + result.Rules = allRules + result.DMSStatus = parseResult.DMSStatus + + default: + log.Fatalf("Unknown compositor: %s", compositor) + } + + output, _ := json.Marshal(result) + fmt.Fprintln(os.Stdout, string(output)) +} + +func runWindowrulesAdd(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + ruleJSON := args[1] + + var rule windowrules.WindowRule + if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil { + writeRuleError(fmt.Sprintf("Invalid JSON: %v", err)) + } + + if rule.ID == "" { + rule.ID = generateRuleID() + } + rule.Enabled = true + + provider := getWindowRulesProvider(compositor) + if provider == nil { + writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor)) + } + + if err := provider.SetRule(rule); err != nil { + writeRuleError(err.Error()) + } + + writeRuleSuccess(rule.ID, provider.GetOverridePath()) +} + +func runWindowrulesUpdate(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + ruleID := args[1] + ruleJSON := args[2] + + var rule windowrules.WindowRule + if err := json.Unmarshal([]byte(ruleJSON), &rule); err != nil { + writeRuleError(fmt.Sprintf("Invalid JSON: %v", err)) + } + + rule.ID = ruleID + + provider := getWindowRulesProvider(compositor) + if provider == nil { + writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor)) + } + + if err := provider.SetRule(rule); err != nil { + writeRuleError(err.Error()) + } + + writeRuleSuccess(rule.ID, provider.GetOverridePath()) +} + +func runWindowrulesRemove(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + ruleID := args[1] + + provider := getWindowRulesProvider(compositor) + if provider == nil { + writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor)) + } + + if err := provider.RemoveRule(ruleID); err != nil { + writeRuleError(err.Error()) + } + + writeRuleSuccess(ruleID, provider.GetOverridePath()) +} + +func runWindowrulesReorder(cmd *cobra.Command, args []string) { + compositor := strings.ToLower(args[0]) + idsJSON := args[1] + + var ids []string + if err := json.Unmarshal([]byte(idsJSON), &ids); err != nil { + writeRuleError(fmt.Sprintf("Invalid JSON array: %v", err)) + } + + provider := getWindowRulesProvider(compositor) + if provider == nil { + writeRuleError(fmt.Sprintf("Unknown compositor: %s", compositor)) + } + + if err := provider.ReorderRules(ids); err != nil { + writeRuleError(err.Error()) + } + + writeRuleSuccess("", provider.GetOverridePath()) +} + +func getWindowRulesProvider(compositor string) windowrules.WritableProvider { + switch compositor { + case "niri": + configDir, err := utils.ExpandPath("$HOME/.config/niri") + if err != nil { + return nil + } + return providers.NewNiriWritableProvider(configDir) + case "hyprland": + configDir, err := utils.ExpandPath("$HOME/.config/hypr") + if err != nil { + return nil + } + return providers.NewHyprlandWritableProvider(configDir) + default: + return nil + } +} + +func generateRuleID() string { + return fmt.Sprintf("wr_%d", time.Now().UnixNano()) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/dpms_client.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/dpms_client.go new file mode 100644 index 0000000..98f66d9 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/dpms_client.go @@ -0,0 +1,339 @@ +package main + +import ( + "fmt" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_power" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type cmd struct { + fn func() + done chan error +} + +type dpmsClient struct { + display *wlclient.Display + ctx *wlclient.Context + powerMgr *wlr_output_power.ZwlrOutputPowerManagerV1 + outputs map[string]*outputState + mu sync.Mutex + syncRound int + done bool + err error + cmdq chan cmd + stopChan chan struct{} + wg sync.WaitGroup +} + +type outputState struct { + wlOutput *wlclient.Output + powerCtrl *wlr_output_power.ZwlrOutputPowerV1 + name string + mode uint32 + failed bool + waitCh chan struct{} + wantMode *uint32 +} + +func (c *dpmsClient) post(fn func()) { + done := make(chan error, 1) + select { + case c.cmdq <- cmd{fn: fn, done: done}: + <-done + case <-c.stopChan: + } +} + +func (c *dpmsClient) waylandActor() { + defer c.wg.Done() + for { + select { + case <-c.stopChan: + return + case cmd := <-c.cmdq: + cmd.fn() + close(cmd.done) + } + } +} + +func newDPMSClient() (*dpmsClient, error) { + display, err := wlclient.Connect("") + if err != nil { + return nil, fmt.Errorf("failed to connect to Wayland: %w", err) + } + + c := &dpmsClient{ + display: display, + ctx: display.Context(), + outputs: make(map[string]*outputState), + cmdq: make(chan cmd, 128), + stopChan: make(chan struct{}), + } + + c.wg.Add(1) + go c.waylandActor() + + registry, err := display.GetRegistry() + if err != nil { + display.Context().Close() + return nil, fmt.Errorf("failed to get registry: %w", err) + } + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case wlr_output_power.ZwlrOutputPowerManagerV1InterfaceName: + powerMgr := wlr_output_power.NewZwlrOutputPowerManagerV1(c.ctx) + version := min(e.Version, 1) + if err := registry.Bind(e.Name, e.Interface, version, powerMgr); err == nil { + c.powerMgr = powerMgr + } + + case "wl_output": + output := wlclient.NewOutput(c.ctx) + version := min(e.Version, 4) + if err := registry.Bind(e.Name, e.Interface, version, output); err == nil { + outputID := fmt.Sprintf("output-%d", output.ID()) + state := &outputState{ + wlOutput: output, + name: outputID, + } + + c.mu.Lock() + c.outputs[outputID] = state + c.mu.Unlock() + + output.SetNameHandler(func(ev wlclient.OutputNameEvent) { + c.mu.Lock() + delete(c.outputs, state.name) + state.name = ev.Name + c.outputs[ev.Name] = state + c.mu.Unlock() + }) + } + } + }) + + syncCallback, err := display.Sync() + if err != nil { + c.Close() + return nil, fmt.Errorf("failed to sync display: %w", err) + } + syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) { + c.handleSync() + }) + + for !c.done { + if err := c.ctx.Dispatch(); err != nil { + c.Close() + return nil, fmt.Errorf("dispatch error: %w", err) + } + } + + if c.err != nil { + c.Close() + return nil, c.err + } + + return c, nil +} + +func (c *dpmsClient) handleSync() { + c.syncRound++ + + switch c.syncRound { + case 1: + if c.powerMgr == nil { + c.err = fmt.Errorf("wlr-output-power-management protocol not supported by compositor") + c.done = true + return + } + + c.mu.Lock() + for _, state := range c.outputs { + powerCtrl, err := c.powerMgr.GetOutputPower(state.wlOutput) + if err != nil { + continue + } + state.powerCtrl = powerCtrl + + powerCtrl.SetModeHandler(func(e wlr_output_power.ZwlrOutputPowerV1ModeEvent) { + c.mu.Lock() + defer c.mu.Unlock() + if state.powerCtrl == nil { + return + } + state.mode = e.Mode + if state.wantMode != nil && e.Mode == *state.wantMode && state.waitCh != nil { + close(state.waitCh) + state.wantMode = nil + } + }) + + powerCtrl.SetFailedHandler(func(e wlr_output_power.ZwlrOutputPowerV1FailedEvent) { + c.mu.Lock() + defer c.mu.Unlock() + if state.powerCtrl == nil { + return + } + state.failed = true + if state.waitCh != nil { + close(state.waitCh) + state.wantMode = nil + } + }) + } + c.mu.Unlock() + + syncCallback, err := c.display.Sync() + if err != nil { + c.err = fmt.Errorf("failed to sync display: %w", err) + c.done = true + return + } + syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) { + c.handleSync() + }) + + default: + c.done = true + } +} + +func (c *dpmsClient) ListOutputs() []string { + c.mu.Lock() + defer c.mu.Unlock() + + names := make([]string, 0, len(c.outputs)) + for name := range c.outputs { + names = append(names, name) + } + return names +} + +func (c *dpmsClient) SetDPMS(outputName string, on bool) error { + var mode uint32 + if on { + mode = uint32(wlr_output_power.ZwlrOutputPowerV1ModeOn) + } else { + mode = uint32(wlr_output_power.ZwlrOutputPowerV1ModeOff) + } + + var setErr error + c.post(func() { + c.mu.Lock() + var waitStates []*outputState + + if outputName == "" || outputName == "all" { + if len(c.outputs) == 0 { + c.mu.Unlock() + setErr = fmt.Errorf("no outputs found") + return + } + + for _, state := range c.outputs { + if state.powerCtrl == nil { + continue + } + state.wantMode = &mode + state.waitCh = make(chan struct{}) + state.failed = false + waitStates = append(waitStates, state) + state.powerCtrl.SetMode(mode) + } + } else { + state, ok := c.outputs[outputName] + if !ok { + c.mu.Unlock() + setErr = fmt.Errorf("output not found: %s", outputName) + return + } + if state.powerCtrl == nil { + c.mu.Unlock() + setErr = fmt.Errorf("output %s has nil powerCtrl", outputName) + return + } + state.wantMode = &mode + state.waitCh = make(chan struct{}) + state.failed = false + waitStates = append(waitStates, state) + state.powerCtrl.SetMode(mode) + } + c.mu.Unlock() + + deadline := time.Now().Add(10 * time.Second) + + for _, state := range waitStates { + c.mu.Lock() + ch := state.waitCh + c.mu.Unlock() + + done := false + for !done { + if err := c.ctx.Dispatch(); err != nil { + setErr = fmt.Errorf("dispatch error: %w", err) + return + } + + select { + case <-ch: + c.mu.Lock() + if state.failed { + setErr = fmt.Errorf("compositor reported failed for %s", state.name) + c.mu.Unlock() + return + } + c.mu.Unlock() + done = true + default: + if time.Now().After(deadline) { + setErr = fmt.Errorf("timeout waiting for mode change on %s", state.name) + return + } + time.Sleep(10 * time.Millisecond) + } + } + } + + c.mu.Lock() + for _, state := range waitStates { + if state.powerCtrl != nil { + state.powerCtrl.Destroy() + state.powerCtrl = nil + } + } + c.mu.Unlock() + + c.display.Roundtrip() + }) + + return setErr +} + +func (c *dpmsClient) Close() { + close(c.stopChan) + c.wg.Wait() + + c.mu.Lock() + defer c.mu.Unlock() + + for _, state := range c.outputs { + if state.powerCtrl != nil { + state.powerCtrl.Destroy() + } + } + c.outputs = nil + + if c.powerMgr != nil { + c.powerMgr.Destroy() + c.powerMgr = nil + } + + if c.display != nil { + c.ctx.Close() + c.display = nil + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/immutable_policy.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/immutable_policy.go new file mode 100644 index 0000000..5529c9f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/immutable_policy.go @@ -0,0 +1,271 @@ +package main + +import ( + "bufio" + _ "embed" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + + "github.com/spf13/cobra" +) + +const ( + cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json" + cliPolicyAdminPath = "/etc/dms/cli-policy.json" +) + +var ( + immutablePolicyOnce sync.Once + immutablePolicy immutableCommandPolicy + immutablePolicyErr error +) + +//go:embed assets/cli-policy.default.json +var defaultCLIPolicyJSON []byte + +type immutableCommandPolicy struct { + ImmutableSystem bool + ImmutableReason string + BlockedCommands []string + Message string +} + +type cliPolicyFile struct { + PolicyVersion int `json:"policy_version"` + ImmutableSystem *bool `json:"immutable_system"` + BlockedCommands *[]string `json:"blocked_commands"` + Message *string `json:"message"` +} + +func normalizeCommandSpec(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + normalized = strings.TrimPrefix(normalized, "dms ") + return strings.Join(strings.Fields(normalized), " ") +} + +func normalizeBlockedCommands(raw []string) []string { + normalized := make([]string, 0, len(raw)) + seen := make(map[string]bool) + + for _, cmd := range raw { + spec := normalizeCommandSpec(cmd) + if spec == "" || seen[spec] { + continue + } + seen[spec] = true + normalized = append(normalized, spec) + } + + return normalized +} + +func commandBlockedByPolicy(commandPath string, blocked []string) bool { + normalizedPath := normalizeCommandSpec(commandPath) + if normalizedPath == "" { + return false + } + + for _, entry := range blocked { + spec := normalizeCommandSpec(entry) + if spec == "" { + continue + } + if normalizedPath == spec || strings.HasPrefix(normalizedPath, spec+" ") { + return true + } + } + + return false +} + +func loadPolicyFile(path string) (*cliPolicyFile, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + var policy cliPolicyFile + if err := json.Unmarshal(data, &policy); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", path, err) + } + + return &policy, nil +} + +func mergePolicyFile(base *immutableCommandPolicy, path string) error { + policyFile, err := loadPolicyFile(path) + if err != nil { + return err + } + if policyFile == nil { + return nil + } + + if policyFile.ImmutableSystem != nil { + base.ImmutableSystem = *policyFile.ImmutableSystem + } + if policyFile.BlockedCommands != nil { + base.BlockedCommands = normalizeBlockedCommands(*policyFile.BlockedCommands) + } + if policyFile.Message != nil { + msg := strings.TrimSpace(*policyFile.Message) + if msg != "" { + base.Message = msg + } + } + + return nil +} + +func readOSReleaseMap(path string) map[string]string { + values := make(map[string]string) + + file, err := os.Open(path) + if err != nil { + return values + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.ToUpper(strings.TrimSpace(parts[0])) + value := strings.Trim(strings.TrimSpace(parts[1]), "\"") + values[key] = strings.ToLower(value) + } + + return values +} + +func hasAnyToken(text string, tokens ...string) bool { + if text == "" { + return false + } + for _, token := range tokens { + if strings.Contains(text, token) { + return true + } + } + return false +} + +func detectImmutableSystem() (bool, string) { + if _, err := os.Stat("/run/ostree-booted"); err == nil { + return true, "/run/ostree-booted is present" + } + + osRelease := readOSReleaseMap("/etc/os-release") + if len(osRelease) == 0 { + return false, "" + } + + id := osRelease["ID"] + idLike := osRelease["ID_LIKE"] + variantID := osRelease["VARIANT_ID"] + name := osRelease["NAME"] + prettyName := osRelease["PRETTY_NAME"] + + immutableIDs := map[string]bool{ + "bluefin": true, + "bazzite": true, + "silverblue": true, + "kinoite": true, + "sericea": true, + "onyx": true, + "aurora": true, + "fedora-iot": true, + "fedora-coreos": true, + } + if immutableIDs[id] { + return true, "os-release ID=" + id + } + + markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"} + if hasAnyToken(variantID, markers...) { + return true, "os-release VARIANT_ID=" + variantID + } + if hasAnyToken(idLike, "ostree", "rpm-ostree") { + return true, "os-release ID_LIKE=" + idLike + } + if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) { + return true, "os-release identifies an atomic/ostree variant" + } + + return false, "" +} + +func getImmutablePolicy() (*immutableCommandPolicy, error) { + immutablePolicyOnce.Do(func() { + detectedImmutable, reason := detectImmutableSystem() + immutablePolicy = immutableCommandPolicy{ + ImmutableSystem: detectedImmutable, + ImmutableReason: reason, + BlockedCommands: []string{"greeter install", "greeter enable", "setup"}, + Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.", + } + + var defaultPolicy cliPolicyFile + if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil { + immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err) + return + } + if defaultPolicy.BlockedCommands != nil { + immutablePolicy.BlockedCommands = normalizeBlockedCommands(*defaultPolicy.BlockedCommands) + } + if defaultPolicy.Message != nil { + msg := strings.TrimSpace(*defaultPolicy.Message) + if msg != "" { + immutablePolicy.Message = msg + } + } + + if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil { + immutablePolicyErr = err + return + } + if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil { + immutablePolicyErr = err + return + } + }) + + if immutablePolicyErr != nil { + return nil, immutablePolicyErr + } + return &immutablePolicy, nil +} + +func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error { + policy, err := getImmutablePolicy() + if err != nil { + return err + } + if !policy.ImmutableSystem { + return nil + } + + commandPath := normalizeCommandSpec(cmd.CommandPath()) + if !commandBlockedByPolicy(commandPath, policy.BlockedCommands) { + return nil + } + + reason := "" + if policy.ImmutableReason != "" { + reason = "Detected immutable system: " + policy.ImmutableReason + "\n" + } + + return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/main.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/main.go new file mode 100644 index 0000000..c818148 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/main.go @@ -0,0 +1,43 @@ +//go:build !distro_binary + +package main + +import ( + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +var Version = "dev" + +func init() { + runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") + runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process") + runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") + runCmd.Flags().MarkHidden("daemon-child") + + greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) + authCmd.AddCommand(authSyncCmd) + setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) + updateCmd.AddCommand(updateCheckCmd) + pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) + rootCmd.AddCommand(getCommonCommands()...) + + rootCmd.AddCommand(authCmd) + rootCmd.AddCommand(updateCmd) + + rootCmd.SetHelpTemplate(getHelpTemplate()) +} + +func main() { + clipboard.MaybeServeAndExit() + + if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) { + log.Fatal("This program should not be run as root. Exiting.") + } + + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/main_distro.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/main_distro.go new file mode 100644 index 0000000..2b544ff --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/main_distro.go @@ -0,0 +1,40 @@ +//go:build distro_binary + +package main + +import ( + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +var Version = "dev" + +func init() { + runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") + runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process") + runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") + runCmd.Flags().MarkHidden("daemon-child") + + greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) + authCmd.AddCommand(authSyncCmd) + setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) + pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) + rootCmd.AddCommand(getCommonCommands()...) + rootCmd.AddCommand(authCmd) + + rootCmd.SetHelpTemplate(getHelpTemplate()) +} + +func main() { + clipboard.MaybeServeAndExit() + + if os.Geteuid() == 0 && !isReadOnlyCommand(os.Args) { + log.Fatal("This program should not be run as root. Exiting.") + } + + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/randr_client.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/randr_client.go new file mode 100644 index 0000000..1a149ca --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/randr_client.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type randrOutput struct { + Name string `json:"name"` + Scale float64 `json:"scale"` + Width int32 `json:"width"` + Height int32 `json:"height"` + Refresh int32 `json:"refresh"` + Enabled bool `json:"enabled"` +} + +type randrHead struct { + name string + enabled bool + scale float64 + currentModeID uint32 + modeIDs []uint32 +} + +type randrMode struct { + width int32 + height int32 + refresh int32 +} + +type randrClient struct { + display *wlclient.Display + ctx *wlclient.Context + manager *wlr_output_management.ZwlrOutputManagerV1 + heads map[uint32]*randrHead + modes map[uint32]*randrMode + done bool + err error +} + +func queryRandr() ([]randrOutput, error) { + display, err := wlclient.Connect("") + if err != nil { + return nil, fmt.Errorf("failed to connect to Wayland: %w", err) + } + + c := &randrClient{ + display: display, + ctx: display.Context(), + heads: make(map[uint32]*randrHead), + modes: make(map[uint32]*randrMode), + } + defer c.ctx.Close() + + registry, err := display.GetRegistry() + if err != nil { + return nil, fmt.Errorf("failed to get registry: %w", err) + } + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName { + mgr := wlr_output_management.NewZwlrOutputManagerV1(c.ctx) + version := min(e.Version, 4) + + mgr.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) { + c.handleHead(e) + }) + + mgr.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) { + c.done = true + }) + + if err := registry.Bind(e.Name, e.Interface, version, mgr); err == nil { + c.manager = mgr + } + } + }) + + // First roundtrip: discover globals and bind manager + syncCallback, err := display.Sync() + if err != nil { + return nil, fmt.Errorf("failed to sync display: %w", err) + } + syncCallback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) { + if c.manager == nil { + c.err = fmt.Errorf("zwlr_output_manager_v1 protocol not supported by compositor") + c.done = true + } + // Otherwise wait for manager's DoneHandler + }) + + for !c.done { + if err := c.ctx.Dispatch(); err != nil { + return nil, fmt.Errorf("dispatch error: %w", err) + } + } + + if c.err != nil { + return nil, c.err + } + + return c.buildOutputs(), nil +} + +func (c *randrClient) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) { + handle := e.Head + headID := handle.ID() + + head := &randrHead{ + modeIDs: make([]uint32, 0), + } + c.heads[headID] = head + + handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) { + head.name = e.Name + }) + + handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) { + head.enabled = e.Enabled != 0 + }) + + handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) { + head.scale = e.Scale + }) + + handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) { + head.currentModeID = e.Mode.ID() + }) + + handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) { + modeHandle := e.Mode + modeID := modeHandle.ID() + + head.modeIDs = append(head.modeIDs, modeID) + + mode := &randrMode{} + c.modes[modeID] = mode + + modeHandle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) { + mode.width = e.Width + mode.height = e.Height + }) + + modeHandle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) { + mode.refresh = e.Refresh + }) + }) +} + +func (c *randrClient) buildOutputs() []randrOutput { + outputs := make([]randrOutput, 0, len(c.heads)) + + for _, head := range c.heads { + out := randrOutput{ + Name: head.name, + Scale: head.scale, + Enabled: head.enabled, + } + + if mode, ok := c.modes[head.currentModeID]; ok { + out.Width = mode.width + out.Height = mode.height + out.Refresh = mode.refresh + } + + outputs = append(outputs, out) + } + + return outputs +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/server_client.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/server_client.go new file mode 100644 index 0000000..871d21d --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/server_client.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +func sendServerRequest(req models.Request) (*models.Response[any], error) { + socketPath := getServerSocketPath() + + conn, err := net.Dial("unix", socketPath) + if err != nil { + return nil, fmt.Errorf("failed to connect to server (is it running?): %w", err) + } + defer conn.Close() + + scanner := bufio.NewScanner(conn) + scanner.Scan() // discard initial capabilities message + + reqData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(reqData); err != nil { + return nil, fmt.Errorf("failed to write request: %w", err) + } + + if _, err := conn.Write([]byte("\n")); err != nil { + return nil, fmt.Errorf("failed to write newline: %w", err) + } + + if !scanner.Scan() { + return nil, fmt.Errorf("failed to read response") + } + + var resp models.Response[any] + if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &resp, nil +} + +// sendServerRequestFireAndForget sends a request without waiting for a response. +// Useful for commands that trigger UI or async operations. +func sendServerRequestFireAndForget(req models.Request) error { + socketPath := getServerSocketPath() + + conn, err := net.Dial("unix", socketPath) + if err != nil { + return fmt.Errorf("failed to connect to server (is it running?): %w", err) + } + defer conn.Close() + + scanner := bufio.NewScanner(conn) + scanner.Scan() // discard initial capabilities message + + reqData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(reqData); err != nil { + return fmt.Errorf("failed to write request: %w", err) + } + + if _, err := conn.Write([]byte("\n")); err != nil { + return fmt.Errorf("failed to write newline: %w", err) + } + + return nil +} + +// tryServerRequest attempts to send a request but returns false if server unavailable. +// Does not log errors - caller can decide what to do on failure. +func tryServerRequest(req models.Request) (*models.Response[any], bool) { + resp, err := sendServerRequest(req) + if err != nil { + return nil, false + } + return resp, true +} + +func getServerSocketPath() string { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = os.TempDir() + } + + entries, err := os.ReadDir(runtimeDir) + if err != nil { + return filepath.Join(runtimeDir, "danklinux.sock") + } + + for _, entry := range entries { + name := entry.Name() + if name == "danklinux.sock" { + return filepath.Join(runtimeDir, name) + } + if len(name) > 10 && name[:10] == "danklinux-" && filepath.Ext(name) == ".sock" { + return filepath.Join(runtimeDir, name) + } + } + + return server.GetSocketPath() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/shell.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/shell.go new file mode 100644 index 0000000..7ce2003 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/shell.go @@ -0,0 +1,739 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server" +) + +type ipcTargets map[string]map[string][]string + +// getProcessExitCode returns the exit code from a ProcessState. +// For normal exits, returns the exit code directly. +// For signal termination, returns 128 + signal number (Unix convention). +func getProcessExitCode(state *os.ProcessState) int { + if state == nil { + return 1 + } + if code := state.ExitCode(); code != -1 { + return code + } + // Process was killed by signal - extract signal number + if status, ok := state.Sys().(syscall.WaitStatus); ok { + if status.Signaled() { + return 128 + int(status.Signal()) + } + } + return 1 +} + +var isSessionManaged bool + +func execDetachedRestart(targetPID int) { + selfPath, err := os.Executable() + if err != nil { + return + } + + cmd := exec.Command(selfPath, "restart-detached", strconv.Itoa(targetPID)) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + cmd.Start() +} + +func runDetachedRestart(targetPIDStr string) { + targetPID, err := strconv.Atoi(targetPIDStr) + if err != nil { + return + } + + time.Sleep(200 * time.Millisecond) + + proc, err := os.FindProcess(targetPID) + if err == nil { + proc.Signal(syscall.SIGTERM) + } + + time.Sleep(500 * time.Millisecond) + + killShell() + runShellDaemon(false) +} + +func getRuntimeDir() string { + if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" { + return runtime + } + return os.TempDir() +} + +func hasSystemdRun() bool { + _, err := exec.LookPath("systemd-run") + return err == nil +} + +func getPIDFilePath() string { + return filepath.Join(getRuntimeDir(), fmt.Sprintf("danklinux-%d.pid", os.Getpid())) +} + +func writePIDFile(childPID int) error { + pidFile := getPIDFilePath() + return os.WriteFile(pidFile, []byte(strconv.Itoa(childPID)), 0o644) +} + +func removePIDFile() { + pidFile := getPIDFilePath() + os.Remove(pidFile) +} + +func getAllDMSPIDs() []int { + dir := getRuntimeDir() + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var pids []int + + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") { + continue + } + + pidFile := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(pidFile) + if err != nil { + continue + } + + childPID, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + os.Remove(pidFile) + continue + } + + proc, err := os.FindProcess(childPID) + if err != nil { + os.Remove(pidFile) + continue + } + + if err := proc.Signal(syscall.Signal(0)); err != nil { + os.Remove(pidFile) + continue + } + + pids = append(pids, childPID) + + parentPIDStr := strings.TrimPrefix(entry.Name(), "danklinux-") + parentPIDStr = strings.TrimSuffix(parentPIDStr, ".pid") + if parentPID, err := strconv.Atoi(parentPIDStr); err == nil { + if parentProc, err := os.FindProcess(parentPID); err == nil { + if err := parentProc.Signal(syscall.Signal(0)); err == nil { + pids = append(pids, parentPID) + } + } + } + } + + return pids +} + +func runShellInteractive(session bool) { + isSessionManaged = session + go printASCII() + fmt.Fprintf(os.Stderr, "dms %s\n", Version) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + socketPath := server.GetSocketPath() + + configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path") + if err := os.WriteFile(configStateFile, []byte(configPath), 0o644); err != nil { + log.Warnf("Failed to write config state file: %v", err) + } + defer os.Remove(configStateFile) + + errChan := make(chan error, 2) + + go func() { + defer func() { + if r := recover(); r != nil { + errChan <- fmt.Errorf("server panic: %v", r) + } + }() + server.CLIVersion = Version + if err := server.Start(false); err != nil { + errChan <- fmt.Errorf("server error: %w", err) + } + }() + + log.Infof("Spawning quickshell with -p %s", configPath) + + cmd := exec.CommandContext(ctx, "qs", "-p", configPath) + cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath) + if os.Getenv("QT_LOGGING_RULES") == "" { + if qtRules := log.GetQtLoggingRules(); qtRules != "" { + cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules) + } + } + + // ! TODO - remove when QS 0.3 is up and we can use the pragma + cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms") + + if isSessionManaged && hasSystemdRun() { + cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope") + } + + homeDir, err := os.UserHomeDir() + if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" { + if !strings.HasPrefix(configPath, homeDir) { + cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1") + } + } + + if os.Getenv("QT_QPA_PLATFORMTHEME") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME=gtk3") + } + if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3") + } + if os.Getenv("QT_QPA_PLATFORM") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") + } + + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + log.Fatalf("Error starting quickshell: %v", err) + } + + // Write PID file for the quickshell child process + if err := writePIDFile(cmd.Process.Pid); err != nil { + log.Warnf("Failed to write PID file: %v", err) + } + defer removePIDFile() + + defer func() { + if cmd.Process != nil { + cmd.Process.Signal(syscall.SIGTERM) + } + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1) + + go func() { + if err := cmd.Wait(); err != nil { + errChan <- fmt.Errorf("quickshell exited: %w", err) + } else { + errChan <- fmt.Errorf("quickshell exited") + } + }() + + for { + select { + case sig := <-sigChan: + if sig == syscall.SIGUSR1 { + if isSessionManaged { + log.Infof("Received SIGUSR1, exiting for systemd restart...") + cancel() + cmd.Process.Signal(syscall.SIGTERM) + os.Remove(socketPath) + os.Exit(1) + } + log.Infof("Received SIGUSR1, spawning detached restart process...") + execDetachedRestart(os.Getpid()) + return + } + + // Check if qs already crashed before we got SIGTERM (systemd sends SIGTERM when D-Bus name is released) + select { + case <-errChan: + cancel() + os.Remove(socketPath) + os.Exit(getProcessExitCode(cmd.ProcessState)) + case <-time.After(500 * time.Millisecond): + } + + log.Infof("\nReceived signal %v, shutting down...", sig) + cancel() + cmd.Process.Signal(syscall.SIGTERM) + os.Remove(socketPath) + return + + case err := <-errChan: + log.Error(err) + cancel() + if cmd.Process != nil { + cmd.Process.Signal(syscall.SIGTERM) + } + os.Remove(socketPath) + os.Exit(getProcessExitCode(cmd.ProcessState)) + } + } +} + +func restartShell() { + pids := getAllDMSPIDs() + + if len(pids) == 0 { + log.Info("No running DMS shell instances found. Starting daemon...") + runShellDaemon(false) + return + } + + currentPid := os.Getpid() + uniquePids := make(map[int]bool) + + for _, pid := range pids { + if pid != currentPid { + uniquePids[pid] = true + } + } + + for pid := range uniquePids { + proc, err := os.FindProcess(pid) + if err != nil { + log.Errorf("Error finding process %d: %v", pid, err) + continue + } + + if err := proc.Signal(syscall.Signal(0)); err != nil { + continue + } + + if err := proc.Signal(syscall.SIGUSR1); err != nil { + log.Errorf("Error sending SIGUSR1 to process %d: %v", pid, err) + } else { + log.Infof("Sent SIGUSR1 to DMS process with PID %d", pid) + } + } +} + +func killShell() { + pids := getAllDMSPIDs() + + if len(pids) == 0 { + log.Info("No running DMS shell instances found.") + return + } + + currentPid := os.Getpid() + uniquePids := make(map[int]bool) + + for _, pid := range pids { + if pid != currentPid { + uniquePids[pid] = true + } + } + + for pid := range uniquePids { + proc, err := os.FindProcess(pid) + if err != nil { + log.Errorf("Error finding process %d: %v", pid, err) + continue + } + + if err := proc.Signal(syscall.Signal(0)); err != nil { + continue + } + + if err := proc.Kill(); err != nil { + log.Errorf("Error killing process %d: %v", pid, err) + } else { + log.Infof("Killed DMS process with PID %d", pid) + } + } + + dir := getRuntimeDir() + entries, err := os.ReadDir(dir) + if err != nil { + return + } + + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".pid") { + pidFile := filepath.Join(dir, entry.Name()) + os.Remove(pidFile) + } + } +} + +func runShellDaemon(session bool) { + isSessionManaged = session + isDaemonChild := slices.Contains(os.Args, "--daemon-child") + + if !isDaemonChild { + fmt.Fprintf(os.Stderr, "dms %s\n", Version) + + cmd := exec.Command(os.Args[0], "run", "-d", "--daemon-child") + cmd.Env = os.Environ() + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + + if err := cmd.Start(); err != nil { + log.Fatalf("Error starting daemon: %v", err) + } + + log.Infof("DMS shell daemon started (PID: %d)", cmd.Process.Pid) + return + } + + fmt.Fprintf(os.Stderr, "dms %s\n", Version) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + socketPath := server.GetSocketPath() + + configStateFile := filepath.Join(getRuntimeDir(), "danklinux.path") + if err := os.WriteFile(configStateFile, []byte(configPath), 0o644); err != nil { + log.Warnf("Failed to write config state file: %v", err) + } + defer os.Remove(configStateFile) + + errChan := make(chan error, 2) + + go func() { + defer func() { + if r := recover(); r != nil { + errChan <- fmt.Errorf("server panic: %v", r) + } + }() + server.CLIVersion = Version + if err := server.Start(false); err != nil { + errChan <- fmt.Errorf("server error: %w", err) + } + }() + + log.Infof("Spawning quickshell with -p %s", configPath) + + cmd := exec.CommandContext(ctx, "qs", "-p", configPath) + cmd.Env = append(os.Environ(), "DMS_SOCKET="+socketPath) + if os.Getenv("QT_LOGGING_RULES") == "" { + if qtRules := log.GetQtLoggingRules(); qtRules != "" { + cmd.Env = append(cmd.Env, "QT_LOGGING_RULES="+qtRules) + } + } + + // ! TODO - remove when QS 0.3 is up and we can use the pragma + cmd.Env = append(cmd.Env, "QS_APP_ID=com.danklinux.dms") + + if isSessionManaged && hasSystemdRun() { + cmd.Env = append(cmd.Env, "DMS_DEFAULT_LAUNCH_PREFIX=systemd-run --user --scope") + } + + homeDir, err := os.UserHomeDir() + if err == nil && os.Getenv("DMS_DISABLE_HOT_RELOAD") == "" { + if !strings.HasPrefix(configPath, homeDir) { + cmd.Env = append(cmd.Env, "DMS_DISABLE_HOT_RELOAD=1") + } + } + + if os.Getenv("QT_QPA_PLATFORMTHEME") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME=gtk3") + } + if os.Getenv("QT_QPA_PLATFORMTHEME_QT6") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORMTHEME_QT6=gtk3") + } + if os.Getenv("QT_QPA_PLATFORM") == "" { + cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") + } + + devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0) + if err != nil { + log.Fatalf("Error opening /dev/null: %v", err) + } + defer devNull.Close() + + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + + if err := cmd.Start(); err != nil { + log.Fatalf("Error starting daemon: %v", err) + } + + // Write PID file for the quickshell child process + if err := writePIDFile(cmd.Process.Pid); err != nil { + log.Warnf("Failed to write PID file: %v", err) + } + defer removePIDFile() + + defer func() { + if cmd.Process != nil { + cmd.Process.Signal(syscall.SIGTERM) + } + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1) + + go func() { + if err := cmd.Wait(); err != nil { + errChan <- fmt.Errorf("quickshell exited: %w", err) + } else { + errChan <- fmt.Errorf("quickshell exited") + } + }() + + for { + select { + case sig := <-sigChan: + if sig == syscall.SIGUSR1 { + if isSessionManaged { + log.Infof("Received SIGUSR1, exiting for systemd restart...") + cancel() + cmd.Process.Signal(syscall.SIGTERM) + os.Remove(socketPath) + os.Exit(1) + } + log.Infof("Received SIGUSR1, spawning detached restart process...") + execDetachedRestart(os.Getpid()) + return + } + + // Check if qs already crashed before we got SIGTERM (systemd sends SIGTERM when D-Bus name is released) + select { + case <-errChan: + cancel() + os.Remove(socketPath) + os.Exit(getProcessExitCode(cmd.ProcessState)) + case <-time.After(500 * time.Millisecond): + } + + cancel() + cmd.Process.Signal(syscall.SIGTERM) + os.Remove(socketPath) + return + + case <-errChan: + cancel() + if cmd.Process != nil { + cmd.Process.Signal(syscall.SIGTERM) + } + os.Remove(socketPath) + os.Exit(getProcessExitCode(cmd.ProcessState)) + } + } +} + +var qsHasAnyDisplay = sync.OnceValue(func() bool { + out, err := exec.Command("qs", "ipc", "--help").Output() + if err != nil { + return false + } + return strings.Contains(string(out), "--any-display") +}) + +func parseTargetsFromIPCShowOutput(output string) ipcTargets { + targets := make(ipcTargets) + var currentTarget string + for line := range strings.SplitSeq(output, "\n") { + if after, ok := strings.CutPrefix(line, "target "); ok { + currentTarget = strings.TrimSpace(after) + targets[currentTarget] = make(map[string][]string) + } + if strings.HasPrefix(line, " function") && currentTarget != "" { + argsList := []string{} + currentFunc := strings.TrimPrefix(line, " function ") + funcDef := strings.SplitN(currentFunc, "(", 2) + argList := strings.SplitN(funcDef[1], ")", 2)[0] + args := strings.Split(argList, ",") + if len(args) > 0 && strings.TrimSpace(args[0]) != "" { + argsList = append(argsList, funcDef[0]) + for _, arg := range args { + argName := strings.SplitN(strings.TrimSpace(arg), ":", 2)[0] + argsList = append(argsList, argName) + } + targets[currentTarget][funcDef[0]] = argsList + } else { + targets[currentTarget][funcDef[0]] = make([]string, 0) + } + } + } + return targets +} + +func getShellIPCCompletions(args []string, _ string) []string { + cmdArgs := []string{"ipc"} + if qsHasAnyDisplay() { + cmdArgs = append(cmdArgs, "--any-display") + } + cmdArgs = append(cmdArgs, "-p", configPath, "show") + cmd := exec.Command("qs", cmdArgs...) + var targets ipcTargets + + if output, err := cmd.Output(); err == nil { + targets = parseTargetsFromIPCShowOutput(string(output)) + } else { + log.Debugf("Error getting IPC show output for completions: %v", err) + return nil + } + + if len(args) > 0 && args[0] == "call" { + args = args[1:] + } + + if len(args) == 0 { + targetNames := make([]string, 0) + targetNames = append(targetNames, "call") + for k := range targets { + targetNames = append(targetNames, k) + } + return targetNames + } + if len(args) == 1 { + if targetFuncs, ok := targets[args[0]]; ok { + funcNames := make([]string, 0) + for k := range targetFuncs { + funcNames = append(funcNames, k) + } + return funcNames + } + return nil + } + if len(args) <= len(targets[args[0]]) { + funcArgs := targets[args[0]][args[1]] + if len(funcArgs) >= len(args) { + return []string{fmt.Sprintf("[%s]", funcArgs[len(args)-1])} + } + } + + return nil +} + +func getFirstDMSPID() (int, bool) { + dir := getRuntimeDir() + entries, err := os.ReadDir(dir) + if err != nil { + return 0, false + } + + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), "danklinux-") || !strings.HasSuffix(entry.Name(), ".pid") { + continue + } + + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + continue + } + + proc, err := os.FindProcess(pid) + if err != nil { + continue + } + + if proc.Signal(syscall.Signal(0)) != nil { + continue + } + + return pid, true + } + + return 0, false +} + +func runShellIPCCommand(args []string) { + if len(args) == 0 { + printIPCHelp() + return + } + + if args[0] != "call" { + args = append([]string{"call"}, args...) + } + + cmdArgs := []string{"ipc"} + + switch pid, ok := getFirstDMSPID(); { + case ok: + cmdArgs = append(cmdArgs, "--pid", strconv.Itoa(pid)) + default: + if err := findConfig(nil, nil); err != nil { + log.Fatalf("Error finding config: %v", err) + } + // ! TODO - remove check when QS 0.3 is released + if qsHasAnyDisplay() { + cmdArgs = append(cmdArgs, "--any-display") + } + cmdArgs = append(cmdArgs, "-p", configPath) + } + + cmdArgs = append(cmdArgs, args...) + cmd := exec.Command("qs", cmdArgs...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + log.Fatalf("Error running IPC command: %v", err) + } +} + +func printIPCHelp() { + fmt.Println("Usage: dms ipc [args...]") + fmt.Println() + + cmdArgs := []string{"ipc"} + if qsHasAnyDisplay() { + cmdArgs = append(cmdArgs, "--any-display") + } + cmdArgs = append(cmdArgs, "-p", configPath, "show") + cmd := exec.Command("qs", cmdArgs...) + + output, err := cmd.Output() + if err != nil { + fmt.Println("Could not retrieve available IPC targets (is DMS running?)") + return + } + + targets := parseTargetsFromIPCShowOutput(string(output)) + if len(targets) == 0 { + fmt.Println("No IPC targets available") + return + } + + fmt.Println("Targets:") + + targetNames := make([]string, 0, len(targets)) + for name := range targets { + targetNames = append(targetNames, name) + } + slices.Sort(targetNames) + + for _, targetName := range targetNames { + funcs := targets[targetName] + funcNames := make([]string, 0, len(funcs)) + for fn := range funcs { + funcNames = append(funcNames, fn) + } + slices.Sort(funcNames) + fmt.Printf(" %-16s %s\n", targetName, strings.Join(funcNames, ", ")) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/ui.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/ui.go new file mode 100644 index 0000000..c0b50e6 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/ui.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/tui" + "github.com/charmbracelet/lipgloss" +) + +func printASCII() { + fmt.Print(getThemedASCII()) +} + +func getThemedASCII() string { + theme := tui.TerminalTheme() + + logo := ` +██████╗ █████╗ ███╗ ██╗██╗ ██╗ +██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝ +██║ ██║███████║██╔██╗ ██║█████╔╝ +██║ ██║██╔══██║██║╚██╗██║██╔═██╗ +██████╔╝██║ ██║██║ ╚████║██║ ██╗ +╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝` + + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true) + + return style.Render(logo) + "\n" +} + +func getHelpTemplate() string { + return getThemedASCII() + ` +{{.Long}} + +Usage: + {{.UseLine}}{{if .HasAvailableSubCommands}} + +Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .Name .NamePadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/utils.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/utils.go new file mode 100644 index 0000000..9459e41 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/cmd/dms/utils.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "os/exec" + "slices" + "strings" +) + +// isReadOnlyCommand returns true if the CLI args indicate a command that is +// safe to run as root (e.g. shell completion, help). +func isReadOnlyCommand(args []string) bool { + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "-") { + continue + } + switch arg { + case "completion", "help", "__complete": + return true + } + return false + } + return false +} + +func isArchPackageInstalled(packageName string) bool { + cmd := exec.Command("pacman", "-Q", packageName) + err := cmd.Run() + return err == nil +} + +type systemdServiceState struct { + Name string + EnabledState string + NeedsDisable bool + Exists bool +} + +// checkSystemdServiceEnabled returns (state, should_disable, error) for a systemd service +func checkSystemdServiceEnabled(serviceName string) (string, bool, error) { + cmd := exec.Command("systemctl", "is-enabled", serviceName) + output, err := cmd.Output() + + stateStr := strings.TrimSpace(string(output)) + + if err != nil { + knownStates := []string{"disabled", "masked", "masked-runtime", "not-found", "enabled", "enabled-runtime", "static", "indirect", "alias"} + isKnownState := slices.Contains(knownStates, stateStr) + + if !isKnownState { + return stateStr, false, fmt.Errorf("systemctl is-enabled failed: %w (output: %s)", err, stateStr) + } + } + + shouldDisable := false + switch stateStr { + case "enabled", "enabled-runtime", "static", "indirect", "alias": + shouldDisable = true + case "disabled", "masked", "masked-runtime", "not-found": + shouldDisable = false + default: + shouldDisable = true + } + + return stateStr, shouldDisable, nil +} + +func getSystemdServiceState(serviceName string) (*systemdServiceState, error) { + state := &systemdServiceState{ + Name: serviceName, + Exists: false, + } + + enabledState, needsDisable, err := checkSystemdServiceEnabled(serviceName) + if err != nil { + return nil, fmt.Errorf("failed to check enabled state: %w", err) + } + + state.EnabledState = enabledState + state.NeedsDisable = needsDisable + + if enabledState == "not-found" { + state.Exists = false + return state, nil + } + + state.Exists = true + return state, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/go.mod b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/go.mod new file mode 100644 index 0000000..9f95be8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/go.mod @@ -0,0 +1,87 @@ +module github.com/AvengeMedia/DankMaterialShell/core + +go 1.26.0 + +toolchain go1.26.1 + +require ( + github.com/Wifx/gonetworkmanager/v2 v2.2.0 + github.com/alecthomas/chroma/v2 v2.23.1 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/log v0.4.2 + github.com/fsnotify/fsnotify v1.9.0 + github.com/godbus/dbus/v5 v5.2.2 + github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 + github.com/pilebones/go-udev v0.9.1 + github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + github.com/yeqown/go-qrcode/v2 v2.2.5 + github.com/yeqown/go-qrcode/writer/standard v1.3.0 + github.com/yuin/goldmark v1.7.16 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc + go.etcd.io/bbolt v1.4.3 + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a + golang.org/x/image v0.36.0 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/clipperhouse/displaywidth v0.10.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/go-git/gcfg/v2 v2.0.2 // indirect + github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect + github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/yeqown/reedsolomon v1.0.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/afero v1.15.0 + github.com/spf13/pflag v1.0.10 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.41.0 + golang.org/x/text v0.34.0 + gopkg.in/yaml.v3 v3.0.1 +) + +// v0.0.1 tag is missing a LICENSE file; master has it. +// See: https://github.com/mattn/go-localereader/issues/2 +replace github.com/mattn/go-localereader v0.0.1 => github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/go.sum b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/go.sum new file mode 100644 index 0000000..4817d4f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/go.sum @@ -0,0 +1,191 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U= +github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= +github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= +github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= +github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 h1:UU7oARtwQ5g85aFiCSwIUA6PBmAshYj0sytl/5CCBgs= +github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3/go.mod h1:ZW9JC5gionMP1kv5uiaOaV23q0FFmNrVOV8VW+y/acc= +github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII= +github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw= +github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f h1:TBkCJv9YwPOuXq1OG0r01bcxRrvs15Hp/DtZuPt4H6s= +github.com/go-git/go-git/v6 v6.0.0-20260216160506-e6a3f881772f/go.mod h1:B88nWzfnhTlIikoJ4d84Nc9noKS5mJoA7SgDdkt0aPU= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 h1:B+A58zGFuDrvEZpPN+yS6swJA0nzqgZvDzgl/OPyefU= +github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83/go.mod h1:iHAf8OIncO2gcQ8XOjS7CMJ2aPbX2Bs0wl5pZyanEqk= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= +github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8= +github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo= +github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= +github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 h1:JsjzqC6ymELkN4XlTjZPSahSAem21GySugLbKz6uF5E= +github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk= +github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw= +github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34= +github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ= +github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= +github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= +golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/install.sh b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/install.sh new file mode 100755 index 0000000..baca588 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/install.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +# Check for root privileges +if [ "$(id -u)" == "0" ]; then + printf "%bError: This script must not be run as root%b\n" "$RED" "$NC" + exit 1 +fi + +# Check if running on Linux +if [ "$(uname)" != "Linux" ]; then + printf "%bError: This installer only supports Linux systems%b\n" "$RED" "$NC" + exit 1 +fi + +# Detect architecture +ARCH=$(uname -m) +case "$ARCH" in +x86_64) + ARCH="amd64" + ;; +aarch64) + ARCH="arm64" + ;; +*) + printf "%bError: Unsupported architecture: %s%b\n" "$RED" "$ARCH" "$NC" + printf "This installer only supports x86_64 (amd64) and aarch64 (arm64) architectures\n" + exit 1 + ;; +esac + +# Get the latest release version +LATEST_VERSION=$(curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + +if [ -z "$LATEST_VERSION" ]; then + printf "%bError: Could not fetch latest version%b\n" "$RED" "$NC" + exit 1 +fi + +printf "%bInstalling Dankinstall %s for %s...%b\n" "$GREEN" "$LATEST_VERSION" "$ARCH" "$NC" + +# Download and install +TEMP_DIR=$(mktemp -d) +cd "$TEMP_DIR" || exit 1 + +# Download the gzipped binary and its checksum +printf "%bDownloading installer...%b\n" "$GREEN" "$NC" +curl -L "https://github.com/AvengeMedia/DankMaterialShell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz" -o "installer.gz" +curl -L "https://github.com/AvengeMedia/DankMaterialShell/releases/download/$LATEST_VERSION/dankinstall-$ARCH.gz.sha256" -o "expected.sha256" + +# Get the expected checksum +EXPECTED_CHECKSUM=$(awk '{print $1}' expected.sha256) + +# Calculate actual checksum +printf "%bVerifying checksum...%b\n" "$GREEN" "$NC" +ACTUAL_CHECKSUM=$(sha256sum installer.gz | awk '{print $1}') + +# Compare checksums +if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then + printf "%bError: Checksum verification failed%b\n" "$RED" "$NC" + printf "Expected: %s\n" "$EXPECTED_CHECKSUM" + printf "Got: %s\n" "$ACTUAL_CHECKSUM" + printf "The downloaded file may be corrupted or tampered with\n" + cd - >/dev/null + rm -rf "$TEMP_DIR" + exit 1 +fi + +# Decompress the binary +printf "%bDecompressing installer...%b\n" "$GREEN" "$NC" +gunzip installer.gz +chmod +x installer + +# Execute the installer +printf "%bRunning installer...%b\n" "$GREEN" "$NC" +./installer + +# Cleanup +cd - >/dev/null +rm -rf "$TEMP_DIR" diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/blur/probe.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/blur/probe.go new file mode 100644 index 0000000..e9c24b8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/blur/probe.go @@ -0,0 +1,35 @@ +package blur + +import ( + wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" + client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +const extBackgroundEffectInterface = "ext_background_effect_manager_v1" + +func ProbeSupport() (bool, error) { + display, err := client.Connect("") + if err != nil { + return false, err + } + defer display.Context().Close() + + registry, err := display.GetRegistry() + if err != nil { + return false, err + } + + found := false + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + switch e.Interface { + case extBackgroundEffectInterface: + found = true + } + }) + + if err := wlhelpers.Roundtrip(display, display.Context()); err != nil { + return false, err + } + + return found, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/clipboard.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/clipboard.go new file mode 100644 index 0000000..d27d6ff --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/clipboard.go @@ -0,0 +1,630 @@ +package clipboard + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "syscall" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +const envServe = "_DMS_CLIPBOARD_SERVE" +const envMime = "_DMS_CLIPBOARD_MIME" +const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE" +const envCacheFile = "_DMS_CLIPBOARD_CACHE" + +// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard +// child. Reads source data into memory, deletes any cache file, then serves. +func MaybeServeAndExit() { + if os.Getenv(envServe) == "" { + return + } + + mimeType := os.Getenv(envMime) + pasteOnce := os.Getenv(envPasteOnce) == "1" + cachePath := os.Getenv(envCacheFile) + + var data []byte + var err error + + switch { + case cachePath != "": + data, err = os.ReadFile(cachePath) + os.Remove(cachePath) + default: + data, err = io.ReadAll(os.Stdin) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "clipboard: read source: %v\n", err) + os.Exit(1) + } + + if err := serveClipboard(data, mimeType, pasteOnce); err != nil { + fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err) + os.Exit(1) + } + os.Exit(0) +} + +func Copy(data []byte, mimeType string) error { + return copyForkCached(data, mimeType, false) +} + +func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error { + if foreground { + return serveClipboard(data, mimeType, pasteOnce) + } + return copyForkCached(data, mimeType, pasteOnce) +} + +func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error { + if foreground { + buf, err := io.ReadAll(data) + if err != nil { + return fmt.Errorf("read source: %w", err) + } + return serveClipboard(buf, mimeType, pasteOnce) + } + return copyFork(data, mimeType, pasteOnce) +} + +func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd { + cmd := exec.Command(os.Args[0]) + cmd.Stderr = nil + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + cmd.Env = append(os.Environ(), + envServe+"=1", + envMime+"="+mimeType, + ) + if pasteOnce { + cmd.Env = append(cmd.Env, envPasteOnce+"=1") + } + cmd.Env = append(cmd.Env, extra...) + return cmd +} + +func waitReady(cmd *exec.Cmd) error { + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start: %w", err) + } + var buf [1]byte + if _, err := stdout.Read(buf[:]); err != nil { + return fmt.Errorf("waiting for clipboard ready: %w", err) + } + return nil +} + +func copyForkCached(data []byte, mimeType string, pasteOnce bool) error { + cacheFile, err := createClipboardCacheFile() + if err != nil { + return fmt.Errorf("create cache file: %w", err) + } + cachePath := cacheFile.Name() + + if _, err := cacheFile.Write(data); err != nil { + cacheFile.Close() + os.Remove(cachePath) + return fmt.Errorf("write cache file: %w", err) + } + if err := cacheFile.Close(); err != nil { + os.Remove(cachePath) + return fmt.Errorf("close cache file: %w", err) + } + + cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath) + cmd.Stdin = nil + if err := waitReady(cmd); err != nil { + os.Remove(cachePath) + return err + } + return nil +} + +func copyFork(data io.Reader, mimeType string, pasteOnce bool) error { + cmd := newForkCmd(mimeType, pasteOnce) + + switch src := data.(type) { + case *os.File: + cmd.Stdin = src + return waitReady(cmd) + + default: + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start: %w", err) + } + if _, err := io.Copy(stdin, data); err != nil { + stdin.Close() + return fmt.Errorf("write stdin: %w", err) + } + if err := stdin.Close(); err != nil { + return fmt.Errorf("close stdin: %w", err) + } + + var buf [1]byte + if _, err := stdout.Read(buf[:]); err != nil { + return fmt.Errorf("waiting for clipboard ready: %w", err) + } + return nil + } +} + +func signalReady() { + if os.Getenv(envServe) == "" { + return + } + os.Stdout.Write([]byte{1}) +} + +func createClipboardCacheFile() (*os.File, error) { + preferredDirs := []string{} + + if cacheDir, err := os.UserCacheDir(); err == nil { + preferredDirs = append(preferredDirs, filepath.Join(cacheDir, "dms", "clipboard")) + } + preferredDirs = append(preferredDirs, "/var/tmp/dms/clipboard") + + for _, dir := range preferredDirs { + if err := os.MkdirAll(dir, 0o700); err != nil { + continue + } + cachedData, err := os.CreateTemp(dir, "dms-clipboard-*") + if err == nil { + return cachedData, nil + } + } + return os.CreateTemp("", "dms-clipboard-*") +} + +func serveClipboard(data []byte, mimeType string, pasteOnce bool) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + ctx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + source, err := dataControlMgr.CreateDataSource() + if err != nil { + return fmt.Errorf("create data source: %w", err) + } + + if err := source.Offer(mimeType); err != nil { + return fmt.Errorf("offer mime type: %w", err) + } + if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" { + if err := source.Offer("text/plain"); err != nil { + return fmt.Errorf("offer text/plain: %w", err) + } + if err := source.Offer("text/plain;charset=utf-8"); err != nil { + return fmt.Errorf("offer text/plain;charset=utf-8: %w", err) + } + if err := source.Offer("UTF8_STRING"); err != nil { + return fmt.Errorf("offer UTF8_STRING: %w", err) + } + if err := source.Offer("STRING"); err != nil { + return fmt.Errorf("offer STRING: %w", err) + } + if err := source.Offer("TEXT"); err != nil { + return fmt.Errorf("offer TEXT: %w", err) + } + } + + cancelled := make(chan struct{}) + pasted := make(chan struct{}, 1) + + source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { + _ = syscall.SetNonblock(e.Fd, false) + file := os.NewFile(uintptr(e.Fd), "pipe") + defer file.Close() + _, _ = file.Write(data) + select { + case pasted <- struct{}{}: + default: + } + }) + + source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) { + close(cancelled) + }) + + if err := device.SetSelection(source); err != nil { + return fmt.Errorf("set selection: %w", err) + } + + display.Roundtrip() + signalReady() + + for { + select { + case <-cancelled: + return nil + case <-pasted: + if pasteOnce { + return nil + } + default: + if err := ctx.Dispatch(); err != nil { + return nil + } + } + } +} + +func CopyText(text string) error { + return Copy([]byte(text), "text/plain;charset=utf-8") +} + +func Paste() ([]byte, string, error) { + display, err := wlclient.Connect("") + if err != nil { + return nil, "", fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + ctx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return nil, "", fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return nil, "", fmt.Errorf("registry bind: %w", bindErr) + } + + if dataControlMgr == nil { + return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + + if seat == nil { + return nil, "", fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return nil, "", fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string) + + device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + offerMimeTypes[e.Id] = nil + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType) + }) + }) + + var selectionOffer *ext_data_control.ExtDataControlOfferV1 + gotSelection := false + + device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + selectionOffer = e.Id + gotSelection = true + }) + + display.Roundtrip() + display.Roundtrip() + + if !gotSelection || selectionOffer == nil { + return nil, "", fmt.Errorf("no clipboard data") + } + + mimeTypes := offerMimeTypes[selectionOffer] + selectedMime := selectPreferredMimeType(mimeTypes) + if selectedMime == "" { + return nil, "", fmt.Errorf("no supported mime type") + } + + r, w, err := os.Pipe() + if err != nil { + return nil, "", fmt.Errorf("create pipe: %w", err) + } + defer r.Close() + + if err := selectionOffer.Receive(selectedMime, int(w.Fd())); err != nil { + w.Close() + return nil, "", fmt.Errorf("receive: %w", err) + } + w.Close() + + display.Roundtrip() + + data, err := io.ReadAll(r) + if err != nil { + return nil, "", fmt.Errorf("read: %w", err) + } + + return data, selectedMime, nil +} + +func PasteText() (string, error) { + data, _, err := Paste() + if err != nil { + return "", err + } + return string(data), nil +} + +func selectPreferredMimeType(mimes []string) string { + preferred := []string{ + "text/plain;charset=utf-8", + "text/plain", + "UTF8_STRING", + "STRING", + "TEXT", + "image/png", + "image/jpeg", + } + + for _, pref := range preferred { + for _, mime := range mimes { + if mime == pref { + return mime + } + } + } + + if len(mimes) > 0 { + return mimes[0] + } + return "" +} + +func IsImageMimeType(mime string) bool { + return len(mime) > 6 && mime[:6] == "image/" +} + +type Offer struct { + MimeType string + Data []byte +} + +func CopyMulti(offers []Offer, foreground, pasteOnce bool) error { + if !foreground { + return copyMultiFork(offers, pasteOnce) + } + return copyMultiServe(offers, pasteOnce) +} + +func copyMultiFork(offers []Offer, pasteOnce bool) error { + args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"} + if pasteOnce { + args = append(args, "--paste-once") + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start: %w", err) + } + + for _, offer := range offers { + fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data)) + if _, err := stdin.Write(offer.Data); err != nil { + stdin.Close() + return fmt.Errorf("write offer data: %w", err) + } + } + stdin.Close() + + return nil +} + +func copyMultiServe(offers []Offer, pasteOnce bool) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + ctx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + source, err := dataControlMgr.CreateDataSource() + if err != nil { + return fmt.Errorf("create data source: %w", err) + } + + offerMap := make(map[string][]byte) + for _, offer := range offers { + if err := source.Offer(offer.MimeType); err != nil { + return fmt.Errorf("offer %s: %w", offer.MimeType, err) + } + offerMap[offer.MimeType] = offer.Data + } + + cancelled := make(chan struct{}) + pasted := make(chan struct{}, 1) + + source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { + _ = syscall.SetNonblock(e.Fd, false) + file := os.NewFile(uintptr(e.Fd), "pipe") + defer file.Close() + + if data, ok := offerMap[e.MimeType]; ok { + _, _ = file.Write(data) + } + + select { + case pasted <- struct{}{}: + default: + } + }) + + source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) { + close(cancelled) + }) + + if err := device.SetSelection(source); err != nil { + return fmt.Errorf("set selection: %w", err) + } + + display.Roundtrip() + + for { + select { + case <-cancelled: + return nil + case <-pasted: + if pasteOnce { + return nil + } + default: + if err := ctx.Dispatch(); err != nil { + return nil + } + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/store.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/store.go new file mode 100644 index 0000000..17ad7a9 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/store.go @@ -0,0 +1,248 @@ +package clipboard + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + "strings" + "time" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + "hash/fnv" + + bolt "go.etcd.io/bbolt" +) + +type StoreConfig struct { + MaxHistory int + MaxEntrySize int64 +} + +func DefaultStoreConfig() StoreConfig { + return StoreConfig{ + MaxHistory: 100, + MaxEntrySize: 5 * 1024 * 1024, + } +} + +type Entry struct { + ID uint64 + Data []byte + MimeType string + Preview string + Size int + Timestamp time.Time + IsImage bool + Hash uint64 +} + +func Store(data []byte, mimeType string) error { + return StoreWithConfig(data, mimeType, DefaultStoreConfig()) +} + +func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error { + if len(data) == 0 { + return nil + } + if int64(len(data)) > cfg.MaxEntrySize { + return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize) + } + + dbPath, err := GetDBPath() + if err != nil { + return fmt.Errorf("get db path: %w", err) + } + + db, err := bolt.Open(dbPath, 0o644, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + + entry := Entry{ + Data: data, + MimeType: mimeType, + Size: len(data), + Timestamp: time.Now(), + IsImage: IsImageMimeType(mimeType), + Hash: computeHash(data), + } + + switch { + case entry.IsImage: + entry.Preview = imagePreview(data, mimeType) + default: + entry.Preview = textPreview(data) + } + + return db.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte("clipboard")) + if err != nil { + return err + } + + if err := 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 trimLengthInTx(b, cfg.MaxHistory) + }) +} + +func GetDBPath() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + cacheDir = filepath.Join(homeDir, ".cache") + } + + newDir := filepath.Join(cacheDir, "DankMaterialShell", "clipboard") + newPath := filepath.Join(newDir, "db") + + if _, err := os.Stat(newPath); err == nil { + return newPath, nil + } + + oldDir := filepath.Join(cacheDir, "dms-clipboard") + oldPath := filepath.Join(oldDir, "db") + + if _, err := os.Stat(oldPath); err == nil { + if err := os.MkdirAll(newDir, 0o700); err != nil { + return "", err + } + if err := os.Rename(oldPath, newPath); err != nil { + return "", err + } + os.Remove(oldDir) + return newPath, nil + } + + if err := os.MkdirAll(newDir, 0o700); err != nil { + return "", err + } + return newPath, nil +} + +func 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 + } + if err := b.Delete(k); err != nil { + return err + } + } + return nil +} + +func trimLengthInTx(b *bolt.Bucket, maxHistory int) error { + c := b.Cursor() + var count int + for k, _ := c.Last(); k != nil; k, _ = c.Prev() { + if count < 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) + + return buf.Bytes(), 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) < 8 { + return 0 + } + return binary.BigEndian.Uint64(data[len(data)-8:]) +} + +func 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 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 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]) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/watch.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/watch.go new file mode 100644 index 0000000..327c5c5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/clipboard/watch.go @@ -0,0 +1,303 @@ +package clipboard + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type ClipboardChange struct { + Data []byte + MimeType string + MimeTypes []string +} + +func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + wlCtx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string) + + device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + offerMimeTypes[e.Id] = nil + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType) + }) + }) + + device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + if e.Id == nil { + return + } + + mimes := offerMimeTypes[e.Id] + selectedMime := selectPreferredMimeType(mimes) + if selectedMime == "" { + return + } + + r, w, err := os.Pipe() + if err != nil { + return + } + + if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil { + w.Close() + r.Close() + return + } + w.Close() + + go func() { + defer r.Close() + data, err := io.ReadAll(r) + if err != nil || len(data) == 0 { + return + } + callback(data, selectedMime) + }() + }) + + display.Roundtrip() + display.Roundtrip() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + return fmt.Errorf("set read deadline: %w", err) + } + if err := wlCtx.Dispatch(); err != nil { + if isTimeoutError(err) { + continue + } + return fmt.Errorf("dispatch: %w", err) + } + } + } +} + +func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, allMimeTypes []string)) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + wlCtx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string) + + device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + offerMimeTypes[e.Id] = nil + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType) + }) + }) + + device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + if e.Id == nil { + return + } + + mimes := offerMimeTypes[e.Id] + selectedMime := selectPreferredMimeType(mimes) + if selectedMime == "" { + return + } + + mimesCopy := make([]string, len(mimes)) + copy(mimesCopy, mimes) + + r, w, err := os.Pipe() + if err != nil { + return + } + + if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil { + w.Close() + r.Close() + return + } + w.Close() + + go func() { + defer r.Close() + data, err := io.ReadAll(r) + if err != nil || len(data) == 0 { + return + } + callback(data, selectedMime, mimesCopy) + }() + }) + + display.Roundtrip() + display.Roundtrip() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + return fmt.Errorf("set read deadline: %w", err) + } + if err := wlCtx.Dispatch(); err != nil { + if isTimeoutError(err) { + continue + } + return fmt.Errorf("dispatch: %w", err) + } + } + } +} + +func isTimeoutError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, os.ErrDeadlineExceeded) { + return true + } + if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + return true + } + return false +} + +func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) { + ch := make(chan ClipboardChange, 16) + errCh := make(chan error, 1) + + go func() { + defer close(ch) + err := Watch(ctx, func(data []byte, mimeType string) { + select { + case ch <- ClipboardChange{Data: data, MimeType: mimeType}: + default: + } + }) + if err != nil && err != context.Canceled { + errCh <- err + } + close(errCh) + }() + + time.Sleep(50 * time.Millisecond) + return ch, errCh +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/color.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/color.go new file mode 100644 index 0000000..461730a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/color.go @@ -0,0 +1,306 @@ +package colorpicker + +import ( + "encoding/json" + "fmt" + "math" + "strings" +) + +type Color struct { + R, G, B, A uint8 +} + +type OutputFormat int + +const ( + FormatHex OutputFormat = iota + FormatRGB + FormatHSL + FormatHSV + FormatCMYK +) + +func ParseFormat(s string) OutputFormat { + switch strings.ToLower(s) { + case "rgb": + return FormatRGB + case "hsl": + return FormatHSL + case "hsv": + return FormatHSV + case "cmyk": + return FormatCMYK + default: + return FormatHex + } +} + +func (c Color) ToHex(lowercase bool) string { + if lowercase { + return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B) + } + return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) +} + +func (c Color) ToRGB() string { + return fmt.Sprintf("%d %d %d", c.R, c.G, c.B) +} + +func (c Color) ToHSL() string { + h, s, l := rgbToHSL(c.R, c.G, c.B) + return fmt.Sprintf("%d %d%% %d%%", h, s, l) +} + +func (c Color) ToHSV() string { + h, s, v := rgbToHSV(c.R, c.G, c.B) + return fmt.Sprintf("%d %d%% %d%%", h, s, v) +} + +func (c Color) ToCMYK() string { + cy, m, y, k := rgbToCMYK(c.R, c.G, c.B) + return fmt.Sprintf("%d%% %d%% %d%% %d%%", cy, m, y, k) +} + +func (c Color) Format(format OutputFormat, lowercase bool, customFmt string) string { + if customFmt != "" { + return c.formatCustom(format, customFmt) + } + + switch format { + case FormatRGB: + return c.ToRGB() + case FormatHSL: + return c.ToHSL() + case FormatHSV: + return c.ToHSV() + case FormatCMYK: + return c.ToCMYK() + default: + return c.ToHex(lowercase) + } +} + +func (c Color) formatCustom(format OutputFormat, customFmt string) string { + switch format { + case FormatRGB: + return replaceArgs(customFmt, c.R, c.G, c.B) + case FormatHSL: + h, s, l := rgbToHSL(c.R, c.G, c.B) + return replaceArgs(customFmt, h, s, l) + case FormatHSV: + h, s, v := rgbToHSV(c.R, c.G, c.B) + return replaceArgs(customFmt, h, s, v) + case FormatCMYK: + cy, m, y, k := rgbToCMYK(c.R, c.G, c.B) + return replaceArgs4(customFmt, cy, m, y, k) + default: + if strings.Contains(customFmt, "{0}") { + r := fmt.Sprintf("%02X", c.R) + g := fmt.Sprintf("%02X", c.G) + b := fmt.Sprintf("%02X", c.B) + return replaceArgsStr(customFmt, r, g, b) + } + return c.ToHex(false) + } +} + +func replaceArgs[T any](format string, a, b, c T) string { + result := format + result = strings.ReplaceAll(result, "{0}", fmt.Sprintf("%v", a)) + result = strings.ReplaceAll(result, "{1}", fmt.Sprintf("%v", b)) + result = strings.ReplaceAll(result, "{2}", fmt.Sprintf("%v", c)) + return result +} + +func replaceArgs4[T any](format string, a, b, c, d T) string { + result := format + result = strings.ReplaceAll(result, "{0}", fmt.Sprintf("%v", a)) + result = strings.ReplaceAll(result, "{1}", fmt.Sprintf("%v", b)) + result = strings.ReplaceAll(result, "{2}", fmt.Sprintf("%v", c)) + result = strings.ReplaceAll(result, "{3}", fmt.Sprintf("%v", d)) + return result +} + +func replaceArgsStr(format, a, b, c string) string { + result := format + result = strings.ReplaceAll(result, "{0}", a) + result = strings.ReplaceAll(result, "{1}", b) + result = strings.ReplaceAll(result, "{2}", c) + return result +} + +func rgbToHSL(r, g, b uint8) (int, int, int) { + rf := float64(r) / 255.0 + gf := float64(g) / 255.0 + bf := float64(b) / 255.0 + + maxVal := math.Max(rf, math.Max(gf, bf)) + minVal := math.Min(rf, math.Min(gf, bf)) + l := (maxVal + minVal) / 2 + + if maxVal == minVal { + return 0, 0, int(math.Round(l * 100)) + } + + d := maxVal - minVal + var s float64 + if l > 0.5 { + s = d / (2 - maxVal - minVal) + } else { + s = d / (maxVal + minVal) + } + + var h float64 + switch maxVal { + case rf: + h = (gf - bf) / d + if gf < bf { + h += 6 + } + case gf: + h = (bf-rf)/d + 2 + case bf: + h = (rf-gf)/d + 4 + } + h /= 6 + + return int(math.Round(h * 360)), int(math.Round(s * 100)), int(math.Round(l * 100)) +} + +func rgbToHSV(r, g, b uint8) (int, int, int) { + rf := float64(r) / 255.0 + gf := float64(g) / 255.0 + bf := float64(b) / 255.0 + + maxVal := math.Max(rf, math.Max(gf, bf)) + minVal := math.Min(rf, math.Min(gf, bf)) + v := maxVal + d := maxVal - minVal + + var s float64 + if maxVal != 0 { + s = d / maxVal + } + + if maxVal == minVal { + return 0, int(math.Round(s * 100)), int(math.Round(v * 100)) + } + + var h float64 + switch maxVal { + case rf: + h = (gf - bf) / d + if gf < bf { + h += 6 + } + case gf: + h = (bf-rf)/d + 2 + case bf: + h = (rf-gf)/d + 4 + } + h /= 6 + + return int(math.Round(h * 360)), int(math.Round(s * 100)), int(math.Round(v * 100)) +} + +func rgbToCMYK(r, g, b uint8) (int, int, int, int) { + if r == 0 && g == 0 && b == 0 { + return 0, 0, 0, 100 + } + + rf := float64(r) / 255.0 + gf := float64(g) / 255.0 + bf := float64(b) / 255.0 + + k := 1 - math.Max(rf, math.Max(gf, bf)) + c := (1 - rf - k) / (1 - k) + m := (1 - gf - k) / (1 - k) + y := (1 - bf - k) / (1 - k) + + return int(math.Round(c * 100)), int(math.Round(m * 100)), int(math.Round(y * 100)), int(math.Round(k * 100)) +} + +func (c Color) Luminance() float64 { + r := float64(c.R) / 255.0 + g := float64(c.G) / 255.0 + b := float64(c.B) / 255.0 + + if r <= 0.03928 { + r = r / 12.92 + } else { + r = math.Pow((r+0.055)/1.055, 2.4) + } + + if g <= 0.03928 { + g = g / 12.92 + } else { + g = math.Pow((g+0.055)/1.055, 2.4) + } + + if b <= 0.03928 { + b = b / 12.92 + } else { + b = math.Pow((b+0.055)/1.055, 2.4) + } + + return 0.2126*r + 0.7152*g + 0.0722*b +} + +func (c Color) IsDark() bool { + return c.Luminance() < 0.179 +} + +type ColorJSON struct { + Hex string `json:"hex"` + RGB struct { + R int `json:"r"` + G int `json:"g"` + B int `json:"b"` + } `json:"rgb"` + HSL struct { + H int `json:"h"` + S int `json:"s"` + L int `json:"l"` + } `json:"hsl"` + HSV struct { + H int `json:"h"` + S int `json:"s"` + V int `json:"v"` + } `json:"hsv"` + CMYK struct { + C int `json:"c"` + M int `json:"m"` + Y int `json:"y"` + K int `json:"k"` + } `json:"cmyk"` +} + +func (c Color) ToJSON() (string, error) { + h, s, l := rgbToHSL(c.R, c.G, c.B) + hv, sv, v := rgbToHSV(c.R, c.G, c.B) + cy, m, y, k := rgbToCMYK(c.R, c.G, c.B) + + data := ColorJSON{ + Hex: c.ToHex(false), + } + data.RGB.R = int(c.R) + data.RGB.G = int(c.G) + data.RGB.B = int(c.B) + data.HSL.H = h + data.HSL.S = s + data.HSL.L = l + data.HSV.H = hv + data.HSV.S = sv + data.HSV.V = v + data.CMYK.C = cy + data.CMYK.M = m + data.CMYK.Y = y + data.CMYK.K = k + + bytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", err + } + return string(bytes), nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/picker.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/picker.go new file mode 100644 index 0000000..688c381 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/picker.go @@ -0,0 +1,744 @@ +package colorpicker + +import ( + "fmt" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/keyboard_shortcuts_inhibit" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter" + wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type Config struct { + Format OutputFormat + CustomFormat string + Lowercase bool + Autocopy bool + Notify bool +} + +type Output struct { + wlOutput *client.Output + name string + globalName uint32 + x, y int32 + width int32 + height int32 + scale int32 + fractionalScale float64 + transform int32 +} + +type LayerSurface struct { + output *Output + state *SurfaceState + wlSurface *client.Surface + layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1 + viewport *wp_viewporter.WpViewport + wlPools [2]*client.ShmPool + wlBuffers [2]*client.Buffer + slotBusy [2]bool + needsRedraw bool + scopyBuffer *client.Buffer + configured bool + hidden bool +} + +type Picker struct { + config Config + + display *client.Display + registry *client.Registry + ctx *client.Context + + compositor *client.Compositor + shm *client.Shm + seat *client.Seat + pointer *client.Pointer + keyboard *client.Keyboard + layerShell *wlr_layer_shell.ZwlrLayerShellV1 + screencopy *wlr_screencopy.ZwlrScreencopyManagerV1 + viewporter *wp_viewporter.WpViewporter + + shortcutsInhibitMgr *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitManagerV1 + shortcutsInhibitor *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitorV1 + + outputs map[uint32]*Output + outputsMu sync.Mutex + + surfaces []*LayerSurface + activeSurface *LayerSurface + + running bool + pickedColor *Color + err error +} + +func New(config Config) *Picker { + return &Picker{ + config: config, + outputs: make(map[uint32]*Output), + } +} + +func (p *Picker) Run() (*Color, error) { + if err := p.connect(); err != nil { + return nil, fmt.Errorf("wayland connect: %w", err) + } + defer p.cleanup() + + if err := p.setupRegistry(); err != nil { + return nil, fmt.Errorf("registry setup: %w", err) + } + + if err := p.roundtrip(); err != nil { + return nil, fmt.Errorf("roundtrip: %w", err) + } + + if p.screencopy == nil { + return nil, fmt.Errorf("compositor does not support wlr-screencopy-unstable-v1") + } + + if p.layerShell == nil { + return nil, fmt.Errorf("compositor does not support wlr-layer-shell-unstable-v1") + } + + if p.seat == nil { + return nil, fmt.Errorf("no seat available") + } + + if err := p.roundtrip(); err != nil { + return nil, fmt.Errorf("roundtrip: %w", err) + } + + // Extra roundtrip to ensure pointer/keyboard from seat capabilities are registered + if err := p.roundtrip(); err != nil { + return nil, fmt.Errorf("roundtrip after seat: %w", err) + } + + if err := p.createSurfaces(); err != nil { + return nil, fmt.Errorf("create surfaces: %w", err) + } + + if err := p.roundtrip(); err != nil { + return nil, fmt.Errorf("roundtrip: %w", err) + } + + p.running = true + for p.running { + if err := p.ctx.Dispatch(); err != nil { + p.err = err + break + } + + p.flushRedraws() + p.checkDone() + } + + if p.err != nil { + return nil, p.err + } + + return p.pickedColor, nil +} + +func (p *Picker) checkDone() { + for _, ls := range p.surfaces { + picked, cancelled := ls.state.IsDone() + switch { + case cancelled: + p.running = false + return + case picked: + color, ok := ls.state.PickColor() + if ok { + p.pickedColor = &color + } + p.running = false + return + } + } +} + +func (p *Picker) flushRedraws() { + for _, ls := range p.surfaces { + if !ls.needsRedraw { + continue + } + p.redrawSurface(ls) + } +} + +func (p *Picker) connect() error { + display, err := client.Connect("") + if err != nil { + return err + } + p.display = display + p.ctx = display.Context() + return nil +} + +func (p *Picker) roundtrip() error { + return wlhelpers.Roundtrip(p.display, p.ctx) +} + +func (p *Picker) setupRegistry() error { + registry, err := p.display.GetRegistry() + if err != nil { + return err + } + p.registry = registry + + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + p.handleGlobal(e) + }) + + registry.SetGlobalRemoveHandler(func(e client.RegistryGlobalRemoveEvent) { + p.outputsMu.Lock() + delete(p.outputs, e.Name) + p.outputsMu.Unlock() + }) + + return nil +} + +func (p *Picker) handleGlobal(e client.RegistryGlobalEvent) { + switch e.Interface { + case client.CompositorInterfaceName: + compositor := client.NewCompositor(p.ctx) + if err := p.registry.Bind(e.Name, e.Interface, e.Version, compositor); err == nil { + p.compositor = compositor + } + + case client.ShmInterfaceName: + shm := client.NewShm(p.ctx) + if err := p.registry.Bind(e.Name, e.Interface, e.Version, shm); err == nil { + p.shm = shm + } + + case client.SeatInterfaceName: + seat := client.NewSeat(p.ctx) + if err := p.registry.Bind(e.Name, e.Interface, e.Version, seat); err == nil { + p.seat = seat + p.setupInput() + } + + case client.OutputInterfaceName: + output := client.NewOutput(p.ctx) + version := min(e.Version, 4) + if err := p.registry.Bind(e.Name, e.Interface, version, output); err == nil { + p.outputsMu.Lock() + p.outputs[e.Name] = &Output{ + wlOutput: output, + globalName: e.Name, + scale: 1, + fractionalScale: 1.0, + } + p.outputsMu.Unlock() + p.setupOutputHandlers(e.Name, output) + } + + case wlr_layer_shell.ZwlrLayerShellV1InterfaceName: + layerShell := wlr_layer_shell.NewZwlrLayerShellV1(p.ctx) + version := min(e.Version, 4) + if err := p.registry.Bind(e.Name, e.Interface, version, layerShell); err == nil { + p.layerShell = layerShell + } + + case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName: + screencopy := wlr_screencopy.NewZwlrScreencopyManagerV1(p.ctx) + version := min(e.Version, 3) + if err := p.registry.Bind(e.Name, e.Interface, version, screencopy); err == nil { + p.screencopy = screencopy + } + + case wp_viewporter.WpViewporterInterfaceName: + viewporter := wp_viewporter.NewWpViewporter(p.ctx) + if err := p.registry.Bind(e.Name, e.Interface, e.Version, viewporter); err == nil { + p.viewporter = viewporter + } + + case keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitManagerV1InterfaceName: + mgr := keyboard_shortcuts_inhibit.NewZwpKeyboardShortcutsInhibitManagerV1(p.ctx) + if err := p.registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil { + p.shortcutsInhibitMgr = mgr + } + } +} + +func (p *Picker) setupOutputHandlers(name uint32, output *client.Output) { + output.SetGeometryHandler(func(e client.OutputGeometryEvent) { + p.outputsMu.Lock() + if o, ok := p.outputs[name]; ok { + o.x = e.X + o.y = e.Y + o.transform = int32(e.Transform) + } + p.outputsMu.Unlock() + }) + + output.SetModeHandler(func(e client.OutputModeEvent) { + if e.Flags&uint32(client.OutputModeCurrent) == 0 { + return + } + p.outputsMu.Lock() + if o, ok := p.outputs[name]; ok { + o.width = e.Width + o.height = e.Height + } + p.outputsMu.Unlock() + }) + + output.SetScaleHandler(func(e client.OutputScaleEvent) { + p.outputsMu.Lock() + if o, ok := p.outputs[name]; ok { + o.scale = e.Factor + o.fractionalScale = float64(e.Factor) + } + p.outputsMu.Unlock() + }) + + output.SetNameHandler(func(e client.OutputNameEvent) { + p.outputsMu.Lock() + if o, ok := p.outputs[name]; ok { + o.name = e.Name + } + p.outputsMu.Unlock() + }) +} + +func (p *Picker) createSurfaces() error { + p.outputsMu.Lock() + outputs := make([]*Output, 0, len(p.outputs)) + for _, o := range p.outputs { + outputs = append(outputs, o) + } + p.outputsMu.Unlock() + + for _, output := range outputs { + ls, err := p.createLayerSurface(output) + if err != nil { + return fmt.Errorf("output %s: %w", output.name, err) + } + p.surfaces = append(p.surfaces, ls) + } + + return nil +} + +func (p *Picker) createLayerSurface(output *Output) (*LayerSurface, error) { + surface, err := p.compositor.CreateSurface() + if err != nil { + return nil, fmt.Errorf("create surface: %w", err) + } + + layerSurf, err := p.layerShell.GetLayerSurface( + surface, + output.wlOutput, + uint32(wlr_layer_shell.ZwlrLayerShellV1LayerOverlay), + "dms-colorpicker", + ) + if err != nil { + return nil, fmt.Errorf("get layer surface: %w", err) + } + + ls := &LayerSurface{ + output: output, + state: NewSurfaceState(p.config.Format, p.config.Lowercase), + wlSurface: surface, + layerSurf: layerSurf, + hidden: true, // Start hidden, will show overlay when pointer enters + } + + if p.viewporter != nil { + vp, err := p.viewporter.GetViewport(surface) + if err == nil { + ls.viewport = vp + } + } + + if err := layerSurf.SetAnchor( + uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorTop) | + uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorBottom) | + uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorLeft) | + uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorRight), + ); err != nil { + log.Warn("failed to set layer anchor", "err", err) + } + if err := layerSurf.SetExclusiveZone(-1); err != nil { + log.Warn("failed to set exclusive zone", "err", err) + } + if err := layerSurf.SetKeyboardInteractivity(uint32(wlr_layer_shell.ZwlrLayerSurfaceV1KeyboardInteractivityExclusive)); err != nil { + log.Warn("failed to set keyboard interactivity", "err", err) + } + + layerSurf.SetConfigureHandler(func(e wlr_layer_shell.ZwlrLayerSurfaceV1ConfigureEvent) { + if err := layerSurf.AckConfigure(e.Serial); err != nil { + log.Warn("failed to ack configure", "err", err) + } + if err := ls.state.OnLayerConfigure(int(e.Width), int(e.Height)); err != nil { + log.Warn("failed to handle layer configure", "err", err) + } + ls.configured = true + + scale := p.computeSurfaceScale(ls) + ls.state.SetScale(scale) + + if !ls.state.IsReady() { + p.captureForSurface(ls) + } else { + p.redrawSurface(ls) + } + + // Request shortcut inhibition once surface is configured + p.ensureShortcutsInhibitor(ls) + }) + + layerSurf.SetClosedHandler(func(e wlr_layer_shell.ZwlrLayerSurfaceV1ClosedEvent) { + p.running = false + }) + + if err := surface.Commit(); err != nil { + log.Warn("failed to commit surface", "err", err) + } + return ls, nil +} + +func (p *Picker) computeSurfaceScale(ls *LayerSurface) int32 { + out := ls.output + if out == nil || out.scale <= 0 { + return 1 + } + return out.scale +} + +func (p *Picker) ensureShortcutsInhibitor(ls *LayerSurface) { + if p.shortcutsInhibitMgr == nil || p.seat == nil || p.shortcutsInhibitor != nil { + return + } + + inhibitor, err := p.shortcutsInhibitMgr.InhibitShortcuts(ls.wlSurface, p.seat) + if err != nil { + log.Debug("failed to create shortcuts inhibitor", "err", err) + return + } + + p.shortcutsInhibitor = inhibitor + + inhibitor.SetActiveHandler(func(e keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitorV1ActiveEvent) { + log.Debug("shortcuts inhibitor active") + }) + + inhibitor.SetInactiveHandler(func(e keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitorV1InactiveEvent) { + log.Debug("shortcuts inhibitor deactivated by compositor") + }) +} + +func (p *Picker) captureForSurface(ls *LayerSurface) { + frame, err := p.screencopy.CaptureOutput(0, ls.output.wlOutput) + if err != nil { + return + } + + frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) { + if err := ls.state.OnScreencopyBuffer(PixelFormat(e.Format), int(e.Width), int(e.Height), int(e.Stride)); err != nil { + log.Error("failed to create screencopy buffer", "err", err) + } + }) + + frame.SetBufferDoneHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferDoneEvent) { + screenBuf := ls.state.ScreenBuffer() + if screenBuf == nil { + return + } + + pool, err := p.shm.CreatePool(screenBuf.Fd(), int32(screenBuf.Size())) + if err != nil { + return + } + + wlBuffer, err := pool.CreateBuffer(0, int32(screenBuf.Width), int32(screenBuf.Height), int32(screenBuf.Stride), uint32(ls.state.screenFormat)) + if err != nil { + pool.Destroy() + return + } + + if ls.scopyBuffer != nil { + ls.scopyBuffer.Destroy() + } + ls.scopyBuffer = wlBuffer + wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) {}) + + if err := frame.Copy(wlBuffer); err != nil { + log.Error("failed to copy frame", "err", err) + } + pool.Destroy() + }) + + frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) { + ls.state.OnScreencopyFlags(e.Flags) + }) + + frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) { + ls.state.OnScreencopyReady() + + screenBuf := ls.state.ScreenBuffer() + if screenBuf != nil && ls.output.transform != TransformNormal { + invTransform := InverseTransform(ls.output.transform) + transformed, err := screenBuf.ApplyTransform(invTransform) + if err != nil { + log.Error("apply transform failed", "err", err) + } else if transformed != screenBuf { + ls.state.ReplaceScreenBuffer(transformed) + } + } + + logicalW, _ := ls.state.LogicalSize() + screenBuf = ls.state.ScreenBuffer() + if logicalW > 0 && screenBuf != nil { + ls.output.fractionalScale = float64(screenBuf.Width) / float64(logicalW) + } + + scale := p.computeSurfaceScale(ls) + ls.state.SetScale(scale) + frame.Destroy() + p.redrawSurface(ls) + }) + + frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) { + frame.Destroy() + }) +} + +func (p *Picker) redrawSurface(ls *LayerSurface) { + slot := ls.state.FrontIndex() + if ls.slotBusy[slot] { + ls.needsRedraw = true + return + } + + var renderBuf *ShmBuffer + switch { + case ls.hidden: + renderBuf = ls.state.RedrawScreenOnly() + default: + renderBuf = ls.state.Redraw() + } + if renderBuf == nil { + return + } + + ls.needsRedraw = false + + if ls.wlPools[slot] == nil { + pool, err := p.shm.CreatePool(renderBuf.Fd(), int32(renderBuf.Size())) + if err != nil { + return + } + ls.wlPools[slot] = pool + + wlBuffer, err := pool.CreateBuffer(0, int32(renderBuf.Width), int32(renderBuf.Height), int32(renderBuf.Stride), uint32(ls.state.ScreenFormat())) + if err != nil { + return + } + ls.wlBuffers[slot] = wlBuffer + + s := slot + wlBuffer.SetReleaseHandler(func(e client.BufferReleaseEvent) { + ls.slotBusy[s] = false + }) + } + + ls.slotBusy[slot] = true + + logicalW, logicalH := ls.state.LogicalSize() + if logicalW == 0 || logicalH == 0 { + logicalW = int(ls.output.width) + logicalH = int(ls.output.height) + } + + if ls.viewport != nil { + _ = ls.wlSurface.SetBufferScale(1) + _ = ls.viewport.SetSource(0, 0, float64(renderBuf.Width), float64(renderBuf.Height)) + _ = ls.viewport.SetDestination(int32(logicalW), int32(logicalH)) + } else { + bufferScale := ls.output.scale + if bufferScale <= 0 { + bufferScale = 1 + } + _ = ls.wlSurface.SetBufferScale(bufferScale) + } + _ = ls.wlSurface.Attach(ls.wlBuffers[slot], 0, 0) + _ = ls.wlSurface.Damage(0, 0, int32(logicalW), int32(logicalH)) + _ = ls.wlSurface.Commit() + + ls.state.SwapBuffers() +} + +func (p *Picker) hideSurface(ls *LayerSurface) { + if ls == nil || ls.wlSurface == nil || ls.hidden { + return + } + ls.hidden = true + // Redraw without the crosshair overlay + p.redrawSurface(ls) +} + +func (p *Picker) setupInput() { + if p.seat == nil { + return + } + + p.seat.SetCapabilitiesHandler(func(e client.SeatCapabilitiesEvent) { + if e.Capabilities&uint32(client.SeatCapabilityPointer) != 0 && p.pointer == nil { + pointer, err := p.seat.GetPointer() + if err != nil { + return + } + p.pointer = pointer + p.setupPointerHandlers() + } + if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && p.keyboard == nil { + keyboard, err := p.seat.GetKeyboard() + if err != nil { + return + } + p.keyboard = keyboard + p.setupKeyboardHandlers() + } + }) +} + +func (p *Picker) setupPointerHandlers() { + p.pointer.SetEnterHandler(func(e client.PointerEnterEvent) { + if err := p.pointer.SetCursor(e.Serial, nil, 0, 0); err != nil { + log.Debug("failed to hide cursor", "err", err) + } + + if e.Surface == nil { + return + } + + p.activeSurface = nil + surfaceID := e.Surface.ID() + for _, ls := range p.surfaces { + if ls.wlSurface.ID() == surfaceID { + p.activeSurface = ls + break + } + } + if p.activeSurface == nil { + return + } + + if p.activeSurface.hidden { + p.activeSurface.hidden = false + } + + p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY) + p.activeSurface.needsRedraw = true + }) + + p.pointer.SetLeaveHandler(func(e client.PointerLeaveEvent) { + if e.Surface == nil { + return + } + surfaceID := e.Surface.ID() + for _, ls := range p.surfaces { + if ls.wlSurface.ID() == surfaceID { + p.hideSurface(ls) + break + } + } + }) + + p.pointer.SetMotionHandler(func(e client.PointerMotionEvent) { + if p.activeSurface == nil { + return + } + p.activeSurface.state.OnPointerMotion(e.SurfaceX, e.SurfaceY) + p.activeSurface.needsRedraw = true + }) + + p.pointer.SetButtonHandler(func(e client.PointerButtonEvent) { + if p.activeSurface == nil { + return + } + p.activeSurface.state.OnPointerButton(e.Button, e.State) + }) +} + +func (p *Picker) setupKeyboardHandlers() { + p.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) { + for _, ls := range p.surfaces { + ls.state.OnKey(e.Key, e.State) + } + }) +} + +func (p *Picker) cleanup() { + for _, ls := range p.surfaces { + if ls.scopyBuffer != nil { + ls.scopyBuffer.Destroy() + } + for i := range ls.wlBuffers { + if ls.wlBuffers[i] != nil { + ls.wlBuffers[i].Destroy() + } + if ls.wlPools[i] != nil { + ls.wlPools[i].Destroy() + } + } + if ls.viewport != nil { + ls.viewport.Destroy() + } + if ls.layerSurf != nil { + ls.layerSurf.Destroy() + } + if ls.wlSurface != nil { + ls.wlSurface.Destroy() + } + if ls.state != nil { + ls.state.Destroy() + } + } + + if p.shortcutsInhibitor != nil { + if err := p.shortcutsInhibitor.Destroy(); err != nil { + log.Debug("failed to destroy shortcuts inhibitor", "err", err) + } + p.shortcutsInhibitor = nil + } + + if p.shortcutsInhibitMgr != nil { + if err := p.shortcutsInhibitMgr.Destroy(); err != nil { + log.Debug("failed to destroy shortcuts inhibit manager", "err", err) + } + p.shortcutsInhibitMgr = nil + } + + if p.viewporter != nil { + p.viewporter.Destroy() + } + + if p.screencopy != nil { + p.screencopy.Destroy() + } + + if p.pointer != nil { + p.pointer.Release() + } + + if p.keyboard != nil { + p.keyboard.Release() + } + + if p.display != nil { + p.ctx.Close() + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/shm.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/shm.go new file mode 100644 index 0000000..6ee9dc0 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/shm.go @@ -0,0 +1,55 @@ +package colorpicker + +import "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/shm" + +type ShmBuffer = shm.Buffer + +const ( + TransformNormal = shm.TransformNormal + Transform90 = shm.Transform90 + Transform180 = shm.Transform180 + Transform270 = shm.Transform270 + TransformFlipped = shm.TransformFlipped + TransformFlipped90 = shm.TransformFlipped90 + TransformFlipped180 = shm.TransformFlipped180 + TransformFlipped270 = shm.TransformFlipped270 +) + +func CreateShmBuffer(width, height, stride int) (*ShmBuffer, error) { + return shm.CreateBuffer(width, height, stride) +} + +func InverseTransform(transform int32) int32 { + return shm.InverseTransform(transform) +} + +func GetPixelColor(buf *ShmBuffer, x, y int) Color { + return GetPixelColorWithFormat(buf, x, y, FormatARGB8888) +} + +func GetPixelColorWithFormat(buf *ShmBuffer, x, y int, format PixelFormat) Color { + if x < 0 || x >= buf.Width || y < 0 || y >= buf.Height { + return Color{} + } + + data := buf.Data() + offset := y*buf.Stride + x*4 + if offset+3 >= len(data) { + return Color{} + } + + if format == FormatABGR8888 || format == FormatXBGR8888 { + return Color{ + R: data[offset], + G: data[offset+1], + B: data[offset+2], + A: data[offset+3], + } + } + return Color{ + B: data[offset], + G: data[offset+1], + R: data[offset+2], + A: data[offset+3], + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/state.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/state.go new file mode 100644 index 0000000..f518a5b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/state.go @@ -0,0 +1,1195 @@ +package colorpicker + +import ( + "fmt" + "math" + "strings" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/shm" +) + +type PixelFormat = shm.PixelFormat + +const ( + FormatARGB8888 = shm.FormatARGB8888 + FormatXRGB8888 = shm.FormatXRGB8888 + FormatABGR8888 = shm.FormatABGR8888 + FormatXBGR8888 = shm.FormatXBGR8888 + FormatRGB888 = shm.FormatRGB888 + FormatBGR888 = shm.FormatBGR888 +) + +type SurfaceState struct { + mu sync.Mutex + + screenBuf *ShmBuffer + screenFormat PixelFormat + yInverted bool + + logicalW int + logicalH int + + renderBufs [2]*ShmBuffer + front int + + scale int32 + scaleX float64 + scaleY float64 + + pointerX int + pointerY int + + displayFormat OutputFormat + lowercase bool + + readyForDisplay bool + colorPicked bool + cancelled bool +} + +func NewSurfaceState(format OutputFormat, lowercase bool) *SurfaceState { + return &SurfaceState{ + scale: 1, + displayFormat: format, + lowercase: lowercase, + } +} + +func (s *SurfaceState) SetScale(scale int32) { + s.mu.Lock() + defer s.mu.Unlock() + + if scale <= 0 { + scale = 1 + } + s.scale = scale +} + +func (s *SurfaceState) Scale() int32 { + s.mu.Lock() + defer s.mu.Unlock() + return s.scale +} + +func (s *SurfaceState) LogicalSize() (int, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.logicalW, s.logicalH +} + +func (s *SurfaceState) OnScreencopyBuffer(format PixelFormat, width, height, stride int) error { + s.mu.Lock() + defer s.mu.Unlock() + + bpp := format.BytesPerPixel() + if stride < width*bpp { + return fmt.Errorf("invalid stride %d for width %d (bpp=%d)", stride, width, bpp) + } + + if s.screenBuf != nil { + s.screenBuf.Close() + s.screenBuf = nil + } + + buf, err := CreateShmBuffer(width, height, stride) + if err != nil { + return err + } + + s.screenBuf = buf + s.screenBuf.Format = format + s.screenFormat = format + return nil +} + +func (s *SurfaceState) ScreenBuffer() *ShmBuffer { + s.mu.Lock() + defer s.mu.Unlock() + return s.screenBuf +} + +func (s *SurfaceState) ScreenFormat() PixelFormat { + s.mu.Lock() + defer s.mu.Unlock() + return s.screenFormat +} + +func (s *SurfaceState) ReplaceScreenBuffer(newBuf *ShmBuffer) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.screenBuf != nil { + s.screenBuf.Close() + } + s.screenBuf = newBuf + s.screenFormat = newBuf.Format + + s.recomputeScale() + s.ensureRenderBuffers() +} + +func (s *SurfaceState) OnScreencopyFlags(flags uint32) { + s.mu.Lock() + s.yInverted = (flags & 1) != 0 + s.mu.Unlock() +} + +func (s *SurfaceState) OnScreencopyReady() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.screenBuf == nil || s.logicalW == 0 || s.logicalH == 0 { + return + } + + if s.screenFormat.Is24Bit() { + converted, newFormat, err := s.screenBuf.ConvertTo32Bit(s.screenFormat) + if err == nil && converted != s.screenBuf { + s.screenBuf.Close() + s.screenBuf = converted + s.screenFormat = newFormat + } + } + + s.recomputeScale() + s.ensureRenderBuffers() + s.readyForDisplay = true +} + +func (s *SurfaceState) OnLayerConfigure(width, height int) error { + s.mu.Lock() + defer s.mu.Unlock() + + if width <= 0 || height <= 0 { + return nil + } + + if s.logicalW == width && s.logicalH == height { + return nil + } + + s.logicalW = width + s.logicalH = height + + s.recomputeScale() + s.ensureRenderBuffers() + + return nil +} + +func (s *SurfaceState) recomputeScale() { + if s.screenBuf == nil || s.logicalW == 0 || s.logicalH == 0 { + s.scaleX = 1 + s.scaleY = 1 + return + } + s.scaleX = float64(s.screenBuf.Width) / float64(s.logicalW) + s.scaleY = float64(s.screenBuf.Height) / float64(s.logicalH) +} + +func (s *SurfaceState) ensureRenderBuffers() { + if s.screenBuf == nil { + return + } + + width := s.screenBuf.Width + height := s.screenBuf.Height + stride := s.screenBuf.Stride + + for i := range s.renderBufs { + buf := s.renderBufs[i] + if buf != nil { + if buf.Width == width && buf.Height == height && buf.Stride == stride { + continue + } + buf.Close() + s.renderBufs[i] = nil + } + + newBuf, err := CreateShmBuffer(width, height, stride) + if err != nil { + continue + } + s.renderBufs[i] = newBuf + } +} + +func (s *SurfaceState) OnPointerMotion(x, y float64) { + s.mu.Lock() + s.pointerX = int(x) + s.pointerY = int(y) + s.mu.Unlock() +} + +func (s *SurfaceState) OnPointerButton(button, state uint32) { + if state != 1 { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + switch button { + case 0x110: // BTN_LEFT + if s.readyForDisplay && s.screenBuf != nil { + s.colorPicked = true + } + } +} + +func (s *SurfaceState) OnKey(key, state uint32) { + if state != 1 { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + switch key { + case 1: // KEY_ESC + s.cancelled = true + case 28: // KEY_ENTER + if s.readyForDisplay && s.screenBuf != nil { + s.colorPicked = true + } + } +} + +func (s *SurfaceState) IsDone() (picked, cancelled bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.colorPicked, s.cancelled +} + +func (s *SurfaceState) IsReady() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.readyForDisplay +} + +func (s *SurfaceState) FrontRenderBuffer() *ShmBuffer { + s.mu.Lock() + defer s.mu.Unlock() + return s.renderBufs[s.front] +} + +func (s *SurfaceState) FrontIndex() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.front +} + +func (s *SurfaceState) SwapBuffers() { + s.mu.Lock() + s.front ^= 1 + s.mu.Unlock() +} + +func (s *SurfaceState) Redraw() *ShmBuffer { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.readyForDisplay || s.screenBuf == nil { + return nil + } + + dst := s.renderBufs[s.front] + if dst == nil { + return nil + } + + dst.CopyFrom(s.screenBuf) + + px := int(math.Round(float64(s.pointerX) * s.scaleX)) + py := int(math.Round(float64(s.pointerY) * s.scaleY)) + + px = clamp(px, 0, dst.Width-1) + py = clamp(py, 0, dst.Height-1) + + sampleY := py + if s.yInverted { + sampleY = s.screenBuf.Height - 1 - py + } + + picked := GetPixelColorWithFormat(s.screenBuf, px, sampleY, s.screenFormat) + + drawMagnifierWithInversion( + dst.Data(), dst.Stride, dst.Width, dst.Height, + s.screenBuf.Data(), s.screenBuf.Stride, s.screenBuf.Width, s.screenBuf.Height, + px, py, picked, s.yInverted, s.screenFormat, + ) + + drawColorPreview(dst.Data(), dst.Stride, dst.Width, dst.Height, px, py, picked, s.displayFormat, s.lowercase, s.screenFormat) + + return dst +} + +// RedrawScreenOnly renders just the screenshot without any overlay (magnifier, preview). +// Used for when pointer leaves the surface. +func (s *SurfaceState) RedrawScreenOnly() *ShmBuffer { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.readyForDisplay || s.screenBuf == nil { + return nil + } + + dst := s.renderBufs[s.front] + if dst == nil { + return nil + } + + dst.CopyFrom(s.screenBuf) + return dst +} + +func (s *SurfaceState) PickColor() (Color, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.screenBuf == nil { + return Color{}, false + } + + sx := int(math.Round(float64(s.pointerX) * s.scaleX)) + sy := int(math.Round(float64(s.pointerY) * s.scaleY)) + + sx = clamp(sx, 0, s.screenBuf.Width-1) + sy = clamp(sy, 0, s.screenBuf.Height-1) + + if s.yInverted { + sy = s.screenBuf.Height - 1 - sy + } + + return GetPixelColorWithFormat(s.screenBuf, sx, sy, s.screenFormat), true +} + +func (s *SurfaceState) Destroy() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.screenBuf != nil { + s.screenBuf.Close() + s.screenBuf = nil + } + + for i := range s.renderBufs { + if s.renderBufs[i] != nil { + s.renderBufs[i].Close() + s.renderBufs[i] = nil + } + } +} + +func clamp(v, lo, hi int) int { + switch { + case v < lo: + return lo + case v > hi: + return hi + default: + return v + } +} + +func clampF(v, lo, hi float64) float64 { + switch { + case v < lo: + return lo + case v > hi: + return hi + default: + return v + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +func blendColors(bg, fg Color, alpha float64) Color { + alpha = clampF(alpha, 0, 1) + invAlpha := 1.0 - alpha + return Color{ + R: uint8(clampF(float64(bg.R)*invAlpha+float64(fg.R)*alpha, 0, 255)), + G: uint8(clampF(float64(bg.G)*invAlpha+float64(fg.G)*alpha, 0, 255)), + B: uint8(clampF(float64(bg.B)*invAlpha+float64(fg.B)*alpha, 0, 255)), + A: 255, + } +} + +func drawMagnifierWithInversion( + dst []byte, dstStride, dstW, dstH int, + src []byte, srcStride, srcW, srcH int, + cx, cy int, + borderColor Color, + yInverted bool, + format PixelFormat, +) { + if dstW <= 0 || dstH <= 0 || srcW <= 0 || srcH <= 0 { + return + } + + const ( + outerRadius = 80 + borderThickness = 4 + aaWidth = 1.5 + zoom = 8.0 + crossThickness = 2 + crossInnerRadius = 8 + ) + + innerRadius := float64(outerRadius - borderThickness) + outerRadiusF := float64(outerRadius) + + var rOff, bOff int + switch format { + case FormatABGR8888, FormatXBGR8888: + rOff, bOff = 0, 2 + default: + rOff, bOff = 2, 0 + } + + for dy := -outerRadius - 2; dy <= outerRadius+2; dy++ { + y := cy + dy + if y < 0 || y >= dstH { + continue + } + dstRowOff := y * dstStride + + for dx := -outerRadius - 2; dx <= outerRadius+2; dx++ { + x := cx + dx + if x < 0 || x >= dstW { + continue + } + + dist := math.Sqrt(float64(dx*dx + dy*dy)) + if dist > outerRadiusF+aaWidth { + continue + } + + dstOff := dstRowOff + x*4 + if dstOff+4 > len(dst) { + continue + } + + bgColor := Color{ + R: dst[dstOff+rOff], + G: dst[dstOff+1], + B: dst[dstOff+bOff], + A: dst[dstOff+3], + } + + var finalColor Color + + switch { + case dist > outerRadiusF: + alpha := clampF(1.0-(dist-outerRadiusF)/aaWidth, 0, 1) + finalColor = blendColors(bgColor, borderColor, alpha) + + case dist > innerRadius: + switch { + case dist > outerRadiusF-aaWidth: + alpha := clampF((outerRadiusF-dist)/aaWidth, 0, 1) + finalColor = blendColors(borderColor, borderColor, alpha) + case dist < innerRadius+aaWidth: + alpha := clampF((dist-innerRadius)/aaWidth, 0, 1) + fx := float64(dx) / zoom + fy := float64(dy) / zoom + sx := cx + int(math.Round(fx)) + sy := cy + int(math.Round(fy)) + sx = clamp(sx, 0, srcW-1) + sy = clamp(sy, 0, srcH-1) + if yInverted { + sy = srcH - 1 - sy + } + srcOff := sy*srcStride + sx*4 + if srcOff+4 <= len(src) { + magColor := Color{R: src[srcOff+rOff], G: src[srcOff+1], B: src[srcOff+bOff], A: 255} + finalColor = blendColors(magColor, borderColor, alpha) + } else { + finalColor = borderColor + } + default: + finalColor = borderColor + } + + default: + fx := float64(dx) / zoom + fy := float64(dy) / zoom + sx := cx + int(math.Round(fx)) + sy := cy + int(math.Round(fy)) + sx = clamp(sx, 0, srcW-1) + sy = clamp(sy, 0, srcH-1) + if yInverted { + sy = srcH - 1 - sy + } + srcOff := sy*srcStride + sx*4 + if srcOff+4 <= len(src) { + finalColor = Color{R: src[srcOff+rOff], G: src[srcOff+1], B: src[srcOff+bOff], A: 255} + } else { + continue + } + } + + dst[dstOff+rOff] = finalColor.R + dst[dstOff+1] = finalColor.G + dst[dstOff+bOff] = finalColor.B + dst[dstOff+3] = 255 + } + } + + drawMagnifierCrosshair(dst, dstStride, dstW, dstH, cx, cy, int(innerRadius), crossThickness, crossInnerRadius, format) +} + +func drawMagnifierCrosshair( + data []byte, stride, width, height, cx, cy, radius, thickness, innerRadius int, + format PixelFormat, +) { + if width <= 0 || height <= 0 { + return + } + + cx = clamp(cx, 0, width-1) + cy = clamp(cy, 0, height-1) + + innerR2 := innerRadius * innerRadius + + for dy := -radius; dy <= radius; dy++ { + y := cy + dy + if y < 0 || y >= height { + continue + } + rowOff := y * stride + + for dx := -radius; dx <= radius; dx++ { + x := cx + dx + if x < 0 || x >= width { + continue + } + + dist2 := dx*dx + dy*dy + if dist2 > innerR2 { + continue + } + + absX := abs(dx) + absY := abs(dy) + if absX > thickness && absY > thickness { + continue + } + + off := rowOff + x*4 + if off+4 > len(data) { + continue + } + + isOutline := absX == thickness || absY == thickness + if isOutline { + data[off+0] = 0 + data[off+1] = 0 + data[off+2] = 0 + data[off+3] = 255 + } else { + data[off+0] = 255 + data[off+1] = 255 + data[off+2] = 255 + data[off+3] = 255 + } + } + } +} + +const ( + fontW = 8 + fontH = 12 +) + +var fontGlyphs = map[rune][fontH]uint8{ + '0': { + 0b00111100, + 0b01100110, + 0b01100110, + 0b01101110, + 0b01110110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00111100, + 0b00000000, + 0b00000000, + }, + '1': { + 0b00011000, + 0b00111000, + 0b01111000, + 0b00011000, + 0b00011000, + 0b00011000, + 0b00011000, + 0b00011000, + 0b00011000, + 0b01111110, + 0b00000000, + 0b00000000, + }, + '2': { + 0b00111100, + 0b01100110, + 0b01100110, + 0b00000110, + 0b00001100, + 0b00011000, + 0b00110000, + 0b01100000, + 0b01100110, + 0b01111110, + 0b00000000, + 0b00000000, + }, + '3': { + 0b00111100, + 0b01100110, + 0b00000110, + 0b00000110, + 0b00011100, + 0b00000110, + 0b00000110, + 0b00000110, + 0b01100110, + 0b00111100, + 0b00000000, + 0b00000000, + }, + '4': { + 0b00001100, + 0b00011100, + 0b00111100, + 0b01101100, + 0b11001100, + 0b11001100, + 0b11111110, + 0b00001100, + 0b00001100, + 0b00011110, + 0b00000000, + 0b00000000, + }, + '5': { + 0b01111110, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01111100, + 0b00000110, + 0b00000110, + 0b00000110, + 0b01100110, + 0b00111100, + 0b00000000, + 0b00000000, + }, + '6': { + 0b00011100, + 0b00110000, + 0b01100000, + 0b01100000, + 0b01111100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00111100, + 0b00000000, + 0b00000000, + }, + '7': { + 0b01111110, + 0b01100110, + 0b00000110, + 0b00000110, + 0b00001100, + 0b00011000, + 0b00011000, + 0b00011000, + 0b00011000, + 0b00011000, + 0b00000000, + 0b00000000, + }, + '8': { + 0b00111100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00111100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00111100, + 0b00000000, + 0b00000000, + }, + '9': { + 0b00111100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00111110, + 0b00000110, + 0b00000110, + 0b00000110, + 0b00001100, + 0b00111000, + 0b00000000, + 0b00000000, + }, + 'A': { + 0b00011000, + 0b00111100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01111110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00000000, + 0b00000000, + }, + 'B': { + 0b01111100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01111100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01111100, + 0b00000000, + 0b00000000, + }, + 'C': { + 0b00111100, + 0b01100110, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100110, + 0b00111100, + 0b00000000, + 0b00000000, + }, + 'D': { + 0b01111000, + 0b01101100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01101100, + 0b01111000, + 0b00000000, + 0b00000000, + }, + 'E': { + 0b01111110, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01111100, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01111110, + 0b00000000, + 0b00000000, + }, + 'F': { + 0b01111110, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01111100, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b00000000, + 0b00000000, + }, + '#': { + 0b00000000, + 0b00100100, + 0b00100100, + 0b01111110, + 0b00100100, + 0b00100100, + 0b01111110, + 0b00100100, + 0b00100100, + 0b00000000, + 0b00000000, + 0b00000000, + }, + 'G': { + 0b00111100, + 0b01100110, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01101110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00111100, + 0b00000000, + 0b00000000, + }, + 'H': { + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01111110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00000000, + 0b00000000, + }, + 'K': { + 0b01100110, + 0b01100110, + 0b01101100, + 0b01111000, + 0b01110000, + 0b01111000, + 0b01101100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00000000, + 0b00000000, + }, + 'L': { + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01100000, + 0b01111110, + 0b00000000, + 0b00000000, + }, + 'M': { + 0b01100011, + 0b01110111, + 0b01111111, + 0b01101011, + 0b01100011, + 0b01100011, + 0b01100011, + 0b01100011, + 0b01100011, + 0b01100011, + 0b00000000, + 0b00000000, + }, + 'R': { + 0b01111100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01111100, + 0b01111000, + 0b01101100, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00000000, + 0b00000000, + }, + 'S': { + 0b00111100, + 0b01100110, + 0b01100000, + 0b01100000, + 0b00111100, + 0b00000110, + 0b00000110, + 0b00000110, + 0b01100110, + 0b00111100, + 0b00000000, + 0b00000000, + }, + 'V': { + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00111100, + 0b00011000, + 0b00011000, + 0b00000000, + 0b00000000, + }, + 'Y': { + 0b01100110, + 0b01100110, + 0b01100110, + 0b01100110, + 0b00111100, + 0b00011000, + 0b00011000, + 0b00011000, + 0b00011000, + 0b00011000, + 0b00000000, + 0b00000000, + }, + '(': { + 0b00001100, + 0b00011000, + 0b00110000, + 0b00110000, + 0b00110000, + 0b00110000, + 0b00110000, + 0b00110000, + 0b00011000, + 0b00001100, + 0b00000000, + 0b00000000, + }, + ')': { + 0b00110000, + 0b00011000, + 0b00001100, + 0b00001100, + 0b00001100, + 0b00001100, + 0b00001100, + 0b00001100, + 0b00011000, + 0b00110000, + 0b00000000, + 0b00000000, + }, + ',': { + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00011000, + 0b00011000, + 0b00110000, + 0b00000000, + 0b00000000, + }, + '%': { + 0b01100010, + 0b01100110, + 0b00001100, + 0b00001100, + 0b00011000, + 0b00011000, + 0b00110000, + 0b00110000, + 0b01100110, + 0b01000110, + 0b00000000, + 0b00000000, + }, + ' ': { + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + 0b00000000, + }, +} + +func drawColorPreview(data []byte, stride, width, height int, cx, cy int, c Color, format OutputFormat, lowercase bool, pixelFormat PixelFormat) { + text := formatColorForPreview(c, format, lowercase) + if len(text) == 0 { + return + } + + const ( + paddingX = 8 + paddingY = 4 + space = 2 + offset = 88 + ) + + textW := len(text)*(fontW+space) - space + textH := fontH + + boxW := textW + paddingX*2 + boxH := textH + paddingY*2 + + x := cx + offset + y := cy - boxH/2 + + if x+boxW >= width { + x = cx - boxW - offset + } + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + if y+boxH >= height { + y = height - boxH + } + + drawFilledRect(data, stride, width, height, x, y, boxW, boxH, c, pixelFormat) + + lum := 0.299*float64(c.R) + 0.587*float64(c.G) + 0.114*float64(c.B) + var fg Color + if lum > 128 { + fg = Color{R: 0, G: 0, B: 0, A: 255} + } else { + fg = Color{R: 255, G: 255, B: 255, A: 255} + } + drawText(data, stride, width, height, x+paddingX, y+paddingY, text, fg, pixelFormat) +} + +func formatColorForPreview(c Color, format OutputFormat, lowercase bool) string { + switch format { + case FormatRGB: + return strings.ToUpper(c.ToRGB()) + case FormatHSL: + return strings.ToUpper(c.ToHSL()) + case FormatHSV: + return strings.ToUpper(c.ToHSV()) + case FormatCMYK: + return strings.ToUpper(c.ToCMYK()) + default: + if lowercase { + return c.ToHex(true) + } + return c.ToHex(false) + } +} + +func drawFilledRect(data []byte, stride, width, height, x, y, w, h int, col Color, format PixelFormat) { + if w <= 0 || h <= 0 { + return + } + xEnd := clamp(x+w, 0, width) + yEnd := clamp(y+h, 0, height) + x = clamp(x, 0, width) + y = clamp(y, 0, height) + + var rOff, bOff int + switch format { + case FormatABGR8888, FormatXBGR8888: + rOff, bOff = 0, 2 + default: + rOff, bOff = 2, 0 + } + + for yy := y; yy < yEnd; yy++ { + rowOff := yy * stride + for xx := x; xx < xEnd; xx++ { + off := rowOff + xx*4 + if off+4 > len(data) { + continue + } + data[off+rOff] = col.R + data[off+1] = col.G + data[off+bOff] = col.B + data[off+3] = 255 + } + } +} + +func drawText(data []byte, stride, width, height, x, y int, text string, col Color, format PixelFormat) { + for i, r := range text { + drawGlyph(data, stride, width, height, x+i*(fontW+2), y, r, col, format) + } +} + +func drawGlyph(data []byte, stride, width, height, x, y int, r rune, col Color, format PixelFormat) { + g, ok := fontGlyphs[r] + if !ok { + return + } + + var rOff, bOff int + switch format { + case FormatABGR8888, FormatXBGR8888: + rOff, bOff = 0, 2 + default: + rOff, bOff = 2, 0 + } + + for row := range fontH { + yy := y + row + if yy < 0 || yy >= height { + continue + } + rowPattern := g[row] + dstRowOff := yy * stride + + for colIdx := range fontW { + if (rowPattern & (1 << (fontW - 1 - colIdx))) == 0 { + continue + } + + xx := x + colIdx + if xx < 0 || xx >= width { + continue + } + + off := dstRowOff + xx*4 + if off+4 > len(data) { + continue + } + + data[off+rOff] = col.R + data[off+1] = col.G + data[off+bOff] = col.B + data[off+3] = 255 + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/state_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/state_test.go new file mode 100644 index 0000000..a6599aa --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/colorpicker/state_test.go @@ -0,0 +1,314 @@ +package colorpicker + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSurfaceState_ConcurrentPointerMotion(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := range goroutines { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := range iterations { + s.OnPointerMotion(float64(id*10+j), float64(id*10+j)) + } + }(i) + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentScaleAccess(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 100 + + for i := range goroutines / 2 { + wg.Add(1) + go func(id int) { + defer wg.Done() + for range iterations { + s.SetScale(int32(id%3 + 1)) + } + }(i) + } + + for range goroutines / 2 { + wg.Add(1) + go func() { + defer wg.Done() + for range iterations { + scale := s.Scale() + assert.GreaterOrEqual(t, scale, int32(1)) + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentLogicalSize(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for i := range goroutines / 2 { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := range iterations { + _ = s.OnLayerConfigure(1920+id, 1080+j) + } + }(i) + } + + for range goroutines / 2 { + wg.Add(1) + go func() { + defer wg.Done() + for range iterations { + w, h := s.LogicalSize() + _ = w + _ = h + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentIsDone(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 100 + + for range goroutines / 3 { + wg.Add(1) + go func() { + defer wg.Done() + for range iterations { + s.OnPointerButton(0x110, 1) + } + }() + } + + for range goroutines / 3 { + wg.Add(1) + go func() { + defer wg.Done() + for range iterations { + s.OnKey(1, 1) + } + }() + } + + for range goroutines / 3 { + wg.Add(1) + go func() { + defer wg.Done() + for range iterations { + picked, cancelled := s.IsDone() + _ = picked + _ = cancelled + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentIsReady(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for range goroutines { + wg.Add(1) + go func() { + defer wg.Done() + for range iterations { + _ = s.IsReady() + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ConcurrentSwapBuffers(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for range goroutines { + wg.Add(1) + go func() { + defer wg.Done() + for range iterations { + s.SwapBuffers() + } + }() + } + + wg.Wait() +} + +func TestSurfaceState_ZeroScale(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + s.SetScale(0) + assert.Equal(t, int32(1), s.Scale()) +} + +func TestSurfaceState_NegativeScale(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + s.SetScale(-5) + assert.Equal(t, int32(1), s.Scale()) +} + +func TestSurfaceState_ZeroDimensionConfigure(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + + err := s.OnLayerConfigure(0, 100) + assert.NoError(t, err) + + err = s.OnLayerConfigure(100, 0) + assert.NoError(t, err) + + err = s.OnLayerConfigure(-1, 100) + assert.NoError(t, err) + + w, h := s.LogicalSize() + assert.Equal(t, 0, w) + assert.Equal(t, 0, h) +} + +func TestSurfaceState_PickColorNilBuffer(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + color, ok := s.PickColor() + assert.False(t, ok) + assert.Equal(t, Color{}, color) +} + +func TestSurfaceState_RedrawNilBuffer(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + buf := s.Redraw() + assert.Nil(t, buf) +} + +func TestSurfaceState_RedrawScreenOnlyNilBuffer(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + buf := s.RedrawScreenOnly() + assert.Nil(t, buf) +} + +func TestSurfaceState_FrontRenderBufferNil(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + buf := s.FrontRenderBuffer() + assert.Nil(t, buf) +} + +func TestSurfaceState_ScreenBufferNil(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + buf := s.ScreenBuffer() + assert.Nil(t, buf) +} + +func TestSurfaceState_DestroyMultipleTimes(t *testing.T) { + s := NewSurfaceState(FormatHex, false) + s.Destroy() + s.Destroy() +} + +func TestClamp(t *testing.T) { + tests := []struct { + v, lo, hi, expected int + }{ + {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 := clamp(tt.v, tt.lo, tt.hi) + assert.Equal(t, tt.expected, result) + } +} + +func TestClampF(t *testing.T) { + tests := []struct { + v, lo, hi, expected float64 + }{ + {5.0, 0.0, 10.0, 5.0}, + {-5.0, 0.0, 10.0, 0.0}, + {15.0, 0.0, 10.0, 10.0}, + {0.0, 0.0, 10.0, 0.0}, + {10.0, 0.0, 10.0, 10.0}, + } + + for _, tt := range tests { + result := clampF(tt.v, tt.lo, tt.hi) + assert.InDelta(t, tt.expected, result, 0.001) + } +} + +func TestAbs(t *testing.T) { + tests := []struct { + v, expected int + }{ + {5, 5}, + {-5, 5}, + {0, 0}, + } + + for _, tt := range tests { + result := abs(tt.v) + assert.Equal(t, tt.expected, result) + } +} + +func TestBlendColors(t *testing.T) { + bg := Color{R: 0, G: 0, B: 0, A: 255} + fg := Color{R: 255, G: 255, B: 255, A: 255} + + result := blendColors(bg, fg, 0.0) + assert.Equal(t, bg.R, result.R) + assert.Equal(t, bg.G, result.G) + assert.Equal(t, bg.B, result.B) + + result = blendColors(bg, fg, 1.0) + assert.Equal(t, fg.R, result.R) + assert.Equal(t, fg.G, result.G) + assert.Equal(t, fg.B, result.B) + + result = blendColors(bg, fg, 0.5) + assert.InDelta(t, 127, int(result.R), 1) + assert.InDelta(t, 127, int(result.G), 1) + assert.InDelta(t, 127, int(result.B), 1) + + result = blendColors(bg, fg, -1.0) + assert.Equal(t, bg.R, result.R) + + result = blendColors(bg, fg, 2.0) + assert.Equal(t, fg.R, result.R) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/deployer.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/deployer.go new file mode 100644 index 0000000..7cc513f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/deployer.go @@ -0,0 +1,718 @@ +package config + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +type ConfigDeployer struct { + logChan chan<- string +} + +type DeploymentResult struct { + ConfigType string + Path string + BackupPath string + Deployed bool + Error error +} + +func NewConfigDeployer(logChan chan<- string) *ConfigDeployer { + return &ConfigDeployer{ + logChan: logChan, + } +} + +func (cd *ConfigDeployer) log(message string) { + if cd.logChan != nil { + cd.logChan <- message + } +} + +// DeployConfigurations deploys all necessary configurations based on the chosen window manager +func (cd *ConfigDeployer) DeployConfigurations(ctx context.Context, wm deps.WindowManager) ([]DeploymentResult, error) { + return cd.DeployConfigurationsWithTerminal(ctx, wm, deps.TerminalGhostty) +} + +// DeployConfigurationsWithTerminal deploys all necessary configurations based on chosen window manager and terminal +func (cd *ConfigDeployer) DeployConfigurationsWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]DeploymentResult, error) { + return cd.DeployConfigurationsSelective(ctx, wm, terminal, nil, nil) +} + +// DeployConfigurationsWithSystemd deploys configurations with systemd option +func (cd *ConfigDeployer) DeployConfigurationsWithSystemd(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, useSystemd bool) ([]DeploymentResult, error) { + return cd.deployConfigurationsInternal(ctx, wm, terminal, nil, nil, nil, useSystemd) +} + +func (cd *ConfigDeployer) DeployConfigurationsSelective(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool) ([]DeploymentResult, error) { + return cd.DeployConfigurationsSelectiveWithReinstalls(ctx, wm, terminal, installedDeps, replaceConfigs, nil) +} + +func (cd *ConfigDeployer) DeployConfigurationsSelectiveWithReinstalls(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool) ([]DeploymentResult, error) { + return cd.deployConfigurationsInternal(ctx, wm, terminal, installedDeps, replaceConfigs, reinstallItems, true) +} + +func (cd *ConfigDeployer) deployConfigurationsInternal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal, installedDeps []deps.Dependency, replaceConfigs map[string]bool, reinstallItems map[string]bool, useSystemd bool) ([]DeploymentResult, error) { + var results []DeploymentResult + + // Primary config file paths used to detect fresh installs. + configPrimaryPaths := map[string]string{ + "Niri": filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"), + "Hyprland": filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"), + "Ghostty": filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"), + "Kitty": filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"), + "Alacritty": filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"), + } + + shouldReplaceConfig := func(configType string) bool { + if replaceConfigs == nil { + return true + } + replace, exists := replaceConfigs[configType] + if !exists || replace { + return true + } + // Config is explicitly set to "don't replace" — but still deploy + // if the config file doesn't exist yet (fresh install scenario). + if primaryPath, ok := configPrimaryPaths[configType]; ok { + if _, err := os.Stat(primaryPath); os.IsNotExist(err) { + return true + } + } + return false + } + + switch wm { + case deps.WindowManagerNiri: + if shouldReplaceConfig("Niri") { + result, err := cd.deployNiriConfig(terminal, useSystemd) + results = append(results, result) + if err != nil { + return results, fmt.Errorf("failed to deploy Niri config: %w", err) + } + } + case deps.WindowManagerHyprland: + if shouldReplaceConfig("Hyprland") { + result, err := cd.deployHyprlandConfig(terminal, useSystemd) + results = append(results, result) + if err != nil { + return results, fmt.Errorf("failed to deploy Hyprland config: %w", err) + } + } + } + + switch terminal { + case deps.TerminalGhostty: + if shouldReplaceConfig("Ghostty") { + ghosttyResults, err := cd.deployGhosttyConfig() + results = append(results, ghosttyResults...) + if err != nil { + return results, fmt.Errorf("failed to deploy Ghostty config: %w", err) + } + } + case deps.TerminalKitty: + if shouldReplaceConfig("Kitty") { + kittyResults, err := cd.deployKittyConfig() + results = append(results, kittyResults...) + if err != nil { + return results, fmt.Errorf("failed to deploy Kitty config: %w", err) + } + } + case deps.TerminalAlacritty: + if shouldReplaceConfig("Alacritty") { + alacrittyResults, err := cd.deployAlacrittyConfig() + results = append(results, alacrittyResults...) + if err != nil { + return results, fmt.Errorf("failed to deploy Alacritty config: %w", err) + } + } + } + + return results, nil +} + +func (cd *ConfigDeployer) deployNiriConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) { + result := DeploymentResult{ + ConfigType: "Niri", + Path: filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl"), + } + + configDir := filepath.Dir(result.Path) + if err := os.MkdirAll(configDir, 0o755); err != nil { + result.Error = fmt.Errorf("failed to create config directory: %w", err) + return result, result.Error + } + + dmsDir := filepath.Join(configDir, "dms") + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + result.Error = fmt.Errorf("failed to create dms directory: %w", err) + return result, result.Error + } + + var existingConfig string + if _, err := os.Stat(result.Path); err == nil { + cd.log("Found existing Niri configuration") + + existingData, err := os.ReadFile(result.Path) + if err != nil { + result.Error = fmt.Errorf("failed to read existing config: %w", err) + return result, result.Error + } + existingConfig = string(existingData) + + timestamp := time.Now().Format("2006-01-02_15-04-05") + result.BackupPath = result.Path + ".backup." + timestamp + if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil { + result.Error = fmt.Errorf("failed to create backup: %w", err) + return result, result.Error + } + cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath)) + } + + var terminalCommand string + switch terminal { + case deps.TerminalGhostty: + terminalCommand = "ghostty" + case deps.TerminalKitty: + terminalCommand = "kitty" + case deps.TerminalAlacritty: + terminalCommand = "alacritty" + default: + terminalCommand = "ghostty" + } + + newConfig := strings.ReplaceAll(NiriConfig, "{{TERMINAL_COMMAND}}", terminalCommand) + + if !useSystemd { + newConfig = cd.transformNiriConfigForNonSystemd(newConfig, terminalCommand) + } + + if existingConfig != "" { + mergedConfig, err := cd.mergeNiriOutputSections(newConfig, existingConfig, dmsDir) + if err != nil { + cd.log(fmt.Sprintf("Warning: Failed to merge output sections: %v", err)) + } else { + newConfig = mergedConfig + cd.log("Successfully merged existing output sections") + } + } + + if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil { + result.Error = fmt.Errorf("failed to write config: %w", err) + return result, result.Error + } + + if err := cd.deployNiriDmsConfigs(dmsDir, terminalCommand); err != nil { + result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) + return result, result.Error + } + + result.Deployed = true + cd.log("Successfully deployed Niri configuration") + return result, nil +} + +func (cd *ConfigDeployer) deployNiriDmsConfigs(dmsDir, terminalCommand string) error { + configs := []struct { + name string + content string + }{ + {"colors.kdl", NiriColorsConfig}, + {"layout.kdl", NiriLayoutConfig}, + {"alttab.kdl", NiriAlttabConfig}, + {"binds.kdl", strings.ReplaceAll(NiriBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)}, + {"outputs.kdl", ""}, + {"cursor.kdl", ""}, + {"windowrules.kdl", ""}, + } + + for _, cfg := range configs { + path := filepath.Join(dmsDir, cfg.name) + // Skip if file already exists and is not empty to preserve user modifications + if info, err := os.Stat(path); err == nil && info.Size() > 0 { + cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name)) + continue + } + if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", cfg.name, err) + } + cd.log(fmt.Sprintf("Deployed %s", cfg.name)) + } + + return nil +} + +func (cd *ConfigDeployer) deployGhosttyConfig() ([]DeploymentResult, error) { + var results []DeploymentResult + + mainResult := DeploymentResult{ + ConfigType: "Ghostty", + Path: filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config"), + } + + configDir := filepath.Dir(mainResult.Path) + if err := os.MkdirAll(configDir, 0o755); err != nil { + mainResult.Error = fmt.Errorf("failed to create config directory: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + if _, err := os.Stat(mainResult.Path); err == nil { + cd.log("Found existing Ghostty configuration") + + existingData, err := os.ReadFile(mainResult.Path) + if err != nil { + mainResult.Error = fmt.Errorf("failed to read existing config: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + timestamp := time.Now().Format("2006-01-02_15-04-05") + mainResult.BackupPath = mainResult.Path + ".backup." + timestamp + if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil { + mainResult.Error = fmt.Errorf("failed to create backup: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath)) + } + + if err := os.WriteFile(mainResult.Path, []byte(GhosttyConfig), 0o644); err != nil { + mainResult.Error = fmt.Errorf("failed to write config: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + mainResult.Deployed = true + cd.log("Successfully deployed Ghostty configuration") + results = append(results, mainResult) + + colorResult := DeploymentResult{ + ConfigType: "Ghostty Colors", + Path: filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "themes", "dankcolors"), + } + + themesDir := filepath.Dir(colorResult.Path) + if err := os.MkdirAll(themesDir, 0o755); err != nil { + mainResult.Error = fmt.Errorf("failed to create themes directory: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + if err := os.WriteFile(colorResult.Path, []byte(GhosttyColorConfig), 0o644); err != nil { + colorResult.Error = fmt.Errorf("failed to write color config: %w", err) + return results, colorResult.Error + } + + colorResult.Deployed = true + cd.log("Successfully deployed Ghostty color configuration") + results = append(results, colorResult) + + return results, nil +} + +func (cd *ConfigDeployer) deployKittyConfig() ([]DeploymentResult, error) { + var results []DeploymentResult + + mainResult := DeploymentResult{ + ConfigType: "Kitty", + Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf"), + } + + configDir := filepath.Dir(mainResult.Path) + if err := os.MkdirAll(configDir, 0o755); err != nil { + mainResult.Error = fmt.Errorf("failed to create config directory: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + if _, err := os.Stat(mainResult.Path); err == nil { + cd.log("Found existing Kitty configuration") + + existingData, err := os.ReadFile(mainResult.Path) + if err != nil { + mainResult.Error = fmt.Errorf("failed to read existing config: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + timestamp := time.Now().Format("2006-01-02_15-04-05") + mainResult.BackupPath = mainResult.Path + ".backup." + timestamp + if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil { + mainResult.Error = fmt.Errorf("failed to create backup: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath)) + } + + if err := os.WriteFile(mainResult.Path, []byte(KittyConfig), 0o644); err != nil { + mainResult.Error = fmt.Errorf("failed to write config: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + mainResult.Deployed = true + cd.log("Successfully deployed Kitty configuration") + results = append(results, mainResult) + + themeResult := DeploymentResult{ + ConfigType: "Kitty Theme", + Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-theme.conf"), + } + + if err := os.WriteFile(themeResult.Path, []byte(KittyThemeConfig), 0o644); err != nil { + themeResult.Error = fmt.Errorf("failed to write theme config: %w", err) + return results, themeResult.Error + } + + themeResult.Deployed = true + cd.log("Successfully deployed Kitty theme configuration") + results = append(results, themeResult) + + tabsResult := DeploymentResult{ + ConfigType: "Kitty Tabs", + Path: filepath.Join(os.Getenv("HOME"), ".config", "kitty", "dank-tabs.conf"), + } + + if err := os.WriteFile(tabsResult.Path, []byte(KittyTabsConfig), 0o644); err != nil { + tabsResult.Error = fmt.Errorf("failed to write tabs config: %w", err) + return results, tabsResult.Error + } + + tabsResult.Deployed = true + cd.log("Successfully deployed Kitty tabs configuration") + results = append(results, tabsResult) + + return results, nil +} + +func (cd *ConfigDeployer) deployAlacrittyConfig() ([]DeploymentResult, error) { + var results []DeploymentResult + + mainResult := DeploymentResult{ + ConfigType: "Alacritty", + Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml"), + } + + configDir := filepath.Dir(mainResult.Path) + if err := os.MkdirAll(configDir, 0o755); err != nil { + mainResult.Error = fmt.Errorf("failed to create config directory: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + if _, err := os.Stat(mainResult.Path); err == nil { + cd.log("Found existing Alacritty configuration") + + existingData, err := os.ReadFile(mainResult.Path) + if err != nil { + mainResult.Error = fmt.Errorf("failed to read existing config: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + timestamp := time.Now().Format("2006-01-02_15-04-05") + mainResult.BackupPath = mainResult.Path + ".backup." + timestamp + if err := os.WriteFile(mainResult.BackupPath, existingData, 0o644); err != nil { + mainResult.Error = fmt.Errorf("failed to create backup: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + cd.log(fmt.Sprintf("Backed up existing config to %s", mainResult.BackupPath)) + } + + if err := os.WriteFile(mainResult.Path, []byte(AlacrittyConfig), 0o644); err != nil { + mainResult.Error = fmt.Errorf("failed to write config: %w", err) + return []DeploymentResult{mainResult}, mainResult.Error + } + + mainResult.Deployed = true + cd.log("Successfully deployed Alacritty configuration") + results = append(results, mainResult) + + themeResult := DeploymentResult{ + ConfigType: "Alacritty Theme", + Path: filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "dank-theme.toml"), + } + + if err := os.WriteFile(themeResult.Path, []byte(AlacrittyThemeConfig), 0o644); err != nil { + themeResult.Error = fmt.Errorf("failed to write theme config: %w", err) + return results, themeResult.Error + } + + themeResult.Deployed = true + cd.log("Successfully deployed Alacritty theme configuration") + results = append(results, themeResult) + + return results, nil +} + +func (cd *ConfigDeployer) mergeNiriOutputSections(newConfig, existingConfig, dmsDir string) (string, error) { + outputRegex := regexp.MustCompile(`(?m)^(/-)?\s*output\s+"[^"]+"\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) + existingOutputs := outputRegex.FindAllString(existingConfig, -1) + + if len(existingOutputs) == 0 { + return newConfig, nil + } + + outputsPath := filepath.Join(dmsDir, "outputs.kdl") + if _, err := os.Stat(outputsPath); err != nil { + var outputsContent strings.Builder + for _, output := range existingOutputs { + outputsContent.WriteString(output) + outputsContent.WriteString("\n\n") + } + if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil { + cd.log(fmt.Sprintf("Warning: Failed to migrate outputs to %s: %v", outputsPath, err)) + } else { + cd.log("Migrated output sections to dms/outputs.kdl") + } + } + + exampleOutputRegex := regexp.MustCompile(`(?m)^/-output "eDP-2" \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) + mergedConfig := exampleOutputRegex.ReplaceAllString(newConfig, "") + + inputEndRegex := regexp.MustCompile(`(?m)^}$`) + inputMatches := inputEndRegex.FindAllStringIndex(newConfig, -1) + + if len(inputMatches) < 1 { + return "", fmt.Errorf("could not find insertion point for output sections") + } + + insertPos := inputMatches[0][1] + + var builder strings.Builder + builder.WriteString(mergedConfig[:insertPos]) + builder.WriteString("\n// Outputs from existing configuration\n") + + for _, output := range existingOutputs { + builder.WriteString(output) + builder.WriteString("\n") + } + + builder.WriteString(mergedConfig[insertPos:]) + + return builder.String(), nil +} + +// deployHyprlandConfig handles Hyprland configuration deployment with backup and merging +func (cd *ConfigDeployer) deployHyprlandConfig(terminal deps.Terminal, useSystemd bool) (DeploymentResult, error) { + result := DeploymentResult{ + ConfigType: "Hyprland", + Path: filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf"), + } + + configDir := filepath.Dir(result.Path) + if err := os.MkdirAll(configDir, 0o755); err != nil { + result.Error = fmt.Errorf("failed to create config directory: %w", err) + return result, result.Error + } + + dmsDir := filepath.Join(configDir, "dms") + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + result.Error = fmt.Errorf("failed to create dms directory: %w", err) + return result, result.Error + } + + var existingConfig string + if _, err := os.Stat(result.Path); err == nil { + cd.log("Found existing Hyprland configuration") + + existingData, err := os.ReadFile(result.Path) + if err != nil { + result.Error = fmt.Errorf("failed to read existing config: %w", err) + return result, result.Error + } + existingConfig = string(existingData) + + timestamp := time.Now().Format("2006-01-02_15-04-05") + result.BackupPath = result.Path + ".backup." + timestamp + if err := os.WriteFile(result.BackupPath, existingData, 0o644); err != nil { + result.Error = fmt.Errorf("failed to create backup: %w", err) + return result, result.Error + } + cd.log(fmt.Sprintf("Backed up existing config to %s", result.BackupPath)) + } + + var terminalCommand string + switch terminal { + case deps.TerminalGhostty: + terminalCommand = "ghostty" + case deps.TerminalKitty: + terminalCommand = "kitty" + case deps.TerminalAlacritty: + terminalCommand = "alacritty" + default: + terminalCommand = "ghostty" + } + + newConfig := strings.ReplaceAll(HyprlandConfig, "{{TERMINAL_COMMAND}}", terminalCommand) + + if !useSystemd { + newConfig = cd.transformHyprlandConfigForNonSystemd(newConfig, terminalCommand) + } + + if existingConfig != "" { + mergedConfig, err := cd.mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir) + if err != nil { + cd.log(fmt.Sprintf("Warning: Failed to merge monitor sections: %v", err)) + } else { + newConfig = mergedConfig + cd.log("Successfully merged existing monitor sections") + } + } + + if err := os.WriteFile(result.Path, []byte(newConfig), 0o644); err != nil { + result.Error = fmt.Errorf("failed to write config: %w", err) + return result, result.Error + } + + if err := cd.deployHyprlandDmsConfigs(dmsDir, terminalCommand); err != nil { + result.Error = fmt.Errorf("failed to deploy dms configs: %w", err) + return result, result.Error + } + + result.Deployed = true + cd.log("Successfully deployed Hyprland configuration") + return result, nil +} + +func (cd *ConfigDeployer) deployHyprlandDmsConfigs(dmsDir string, terminalCommand string) error { + configs := []struct { + name string + content string + }{ + {"colors.conf", HyprColorsConfig}, + {"layout.conf", HyprLayoutConfig}, + {"binds.conf", strings.ReplaceAll(HyprBindsConfig, "{{TERMINAL_COMMAND}}", terminalCommand)}, + {"outputs.conf", ""}, + {"cursor.conf", ""}, + {"windowrules.conf", ""}, + } + + for _, cfg := range configs { + path := filepath.Join(dmsDir, cfg.name) + // Skip if file already exists and is not empty to preserve user modifications + if info, err := os.Stat(path); err == nil && info.Size() > 0 { + cd.log(fmt.Sprintf("Skipping %s (already exists)", cfg.name)) + continue + } + if err := os.WriteFile(path, []byte(cfg.content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", cfg.name, err) + } + cd.log(fmt.Sprintf("Deployed %s", cfg.name)) + } + + return nil +} + +func (cd *ConfigDeployer) mergeHyprlandMonitorSections(newConfig, existingConfig, dmsDir string) (string, error) { + monitorRegex := regexp.MustCompile(`(?m)^#?\s*monitor\s*=.*$`) + existingMonitors := monitorRegex.FindAllString(existingConfig, -1) + + if len(existingMonitors) == 0 { + return newConfig, nil + } + + outputsPath := filepath.Join(dmsDir, "outputs.conf") + if _, err := os.Stat(outputsPath); err != nil { + var outputsContent strings.Builder + for _, monitor := range existingMonitors { + outputsContent.WriteString(monitor) + outputsContent.WriteString("\n") + } + if err := os.WriteFile(outputsPath, []byte(outputsContent.String()), 0o644); err != nil { + cd.log(fmt.Sprintf("Warning: Failed to migrate monitors to %s: %v", outputsPath, err)) + } else { + cd.log("Migrated monitor sections to dms/outputs.conf") + } + } + + exampleMonitorRegex := regexp.MustCompile(`(?m)^# monitor = eDP-2.*$`) + mergedConfig := exampleMonitorRegex.ReplaceAllString(newConfig, "") + + monitorHeaderRegex := regexp.MustCompile(`(?m)^# MONITOR CONFIG\n# ==================$`) + headerMatch := monitorHeaderRegex.FindStringIndex(mergedConfig) + + if headerMatch == nil { + return "", fmt.Errorf("could not find MONITOR CONFIG section") + } + + insertPos := headerMatch[1] + 1 + + var builder strings.Builder + builder.WriteString(mergedConfig[:insertPos]) + builder.WriteString("# Monitors from existing configuration\n") + + for _, monitor := range existingMonitors { + builder.WriteString(monitor) + builder.WriteString("\n") + } + + builder.WriteString(mergedConfig[insertPos:]) + + return builder.String(), nil +} + +func (cd *ConfigDeployer) transformHyprlandConfigForNonSystemd(config, terminalCommand string) string { + lines := strings.Split(config, "\n") + var result []string + startupSectionFound := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "exec-once = dbus-update-activation-environment") { + continue + } + if strings.HasPrefix(trimmed, "exec-once = systemctl --user start") { + startupSectionFound = true + result = append(result, "exec-once = dms run") + result = append(result, "env = QT_QPA_PLATFORM,wayland;xcb") + result = append(result, "env = ELECTRON_OZONE_PLATFORM_HINT,auto") + result = append(result, "env = QT_QPA_PLATFORMTHEME,gtk3") + result = append(result, "env = QT_QPA_PLATFORMTHEME_QT6,gtk3") + result = append(result, fmt.Sprintf("env = TERMINAL,%s", terminalCommand)) + continue + } + result = append(result, line) + } + + if !startupSectionFound { + for i, line := range result { + if strings.Contains(line, "STARTUP APPS") { + insertLines := []string{ + "exec-once = dms run", + "env = QT_QPA_PLATFORM,wayland;xcb", + "env = ELECTRON_OZONE_PLATFORM_HINT,auto", + "env = QT_QPA_PLATFORMTHEME,gtk3", + "env = QT_QPA_PLATFORMTHEME_QT6,gtk3", + fmt.Sprintf("env = TERMINAL,%s", terminalCommand), + } + result = append(result[:i+2], append(insertLines, result[i+2:]...)...) + break + } + } + } + + return strings.Join(result, "\n") +} + +func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalCommand string) string { + envVars := fmt.Sprintf(`environment { + XDG_CURRENT_DESKTOP "niri" + QT_QPA_PLATFORM "wayland;xcb" + ELECTRON_OZONE_PLATFORM_HINT "auto" + QT_QPA_PLATFORMTHEME "gtk3" + QT_QPA_PLATFORMTHEME_QT6 "gtk3" + TERMINAL "%s" +}`, terminalCommand) + + config = regexp.MustCompile(`environment \{[^}]*\}`).ReplaceAllString(config, envVars) + + spawnDms := `spawn-at-startup "dms" "run"` + if !strings.Contains(config, spawnDms) { + // Insert spawn-at-startup for dms after the environment block + envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`) + if loc := envBlockEnd.FindStringIndex(config); loc != nil { + config = config[:loc[1]] + "\n" + spawnDms + config[loc[1]:] + } + } + + return config +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/deployer_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/deployer_test.go new file mode 100644 index 0000000..3726634 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/deployer_test.go @@ -0,0 +1,792 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeNiriOutputSections(t *testing.T) { + cd := &ConfigDeployer{} + + tests := []struct { + name string + newConfig string + existingConfig string + wantError bool + wantContains []string + }{ + { + name: "no existing outputs", + newConfig: `input { + keyboard { + xkb { + } + } +} +layout { + gaps 5 +}`, + existingConfig: `input { + keyboard { + xkb { + } + } +} +layout { + gaps 10 +}`, + wantError: false, + wantContains: []string{"gaps 5"}, // Should keep new config + }, + { + name: "merge single output", + newConfig: `input { + keyboard { + xkb { + } + } +} +/-output "eDP-2" { + mode "2560x1600@239.998993" + position x=2560 y=0 +} +layout { + gaps 5 +}`, + existingConfig: `input { + keyboard { + xkb { + } + } +} +output "eDP-1" { + mode "1920x1080@60.000000" + position x=0 y=0 + scale 1.0 +} +layout { + gaps 10 +}`, + wantError: false, + wantContains: []string{ + "gaps 5", // New config preserved + `output "eDP-1"`, // Existing output merged + "1920x1080@60.000000", // Existing output details + "Outputs from existing configuration", // Comment added + }, + }, + { + name: "merge multiple outputs", + newConfig: `input { + keyboard { + xkb { + } + } +} +/-output "eDP-2" { + mode "2560x1600@239.998993" + position x=2560 y=0 +} +layout { + gaps 5 +}`, + existingConfig: `input { + keyboard { + xkb { + } + } +} +output "eDP-1" { + mode "1920x1080@60.000000" + position x=0 y=0 + scale 1.0 +} +/-output "HDMI-1" { + mode "1920x1080@60.000000" + position x=1920 y=0 +} +layout { + gaps 10 +}`, + wantError: false, + wantContains: []string{ + "gaps 5", // New config preserved + `output "eDP-1"`, // First existing output + `/-output "HDMI-1"`, // Second existing output (commented) + "1920x1080@60.000000", // Output details + }, + }, + { + name: "merge commented outputs", + newConfig: `input { + keyboard { + xkb { + } + } +} +/-output "eDP-2" { + mode "2560x1600@239.998993" + position x=2560 y=0 +} +layout { + gaps 5 +}`, + existingConfig: `input { + keyboard { + xkb { + } + } +} +/-output "eDP-1" { + mode "1920x1080@60.000000" + position x=0 y=0 + scale 1.0 +} +layout { + gaps 10 +}`, + wantError: false, + wantContains: []string{ + "gaps 5", // New config preserved + `/-output "eDP-1"`, // Commented output preserved + "1920x1080@60.000000", // Output details + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + result, err := cd.mergeNiriOutputSections(tt.newConfig, tt.existingConfig, tmpDir) + + if tt.wantError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + for _, want := range tt.wantContains { + assert.Contains(t, result, want, "merged config should contain: %s", want) + } + + assert.NotContains(t, result, `/-output "eDP-2"`, "example output should be removed") + }) + } +} + +func TestConfigDeploymentFlow(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + t.Run("deploy ghostty config to empty directory", func(t *testing.T) { + results, err := cd.deployGhosttyConfig() + require.NoError(t, err) + require.Len(t, results, 2) + + mainResult := results[0] + assert.Equal(t, "Ghostty", mainResult.ConfigType) + assert.True(t, mainResult.Deployed) + assert.Empty(t, mainResult.BackupPath) + assert.FileExists(t, mainResult.Path) + + content, err := os.ReadFile(mainResult.Path) + require.NoError(t, err) + assert.Contains(t, string(content), "window-decoration = false") + + colorResult := results[1] + assert.Equal(t, "Ghostty Colors", colorResult.ConfigType) + assert.True(t, colorResult.Deployed) + assert.FileExists(t, colorResult.Path) + + colorContent, err := os.ReadFile(colorResult.Path) + require.NoError(t, err) + assert.Contains(t, string(colorContent), "background = #101418") + }) + + t.Run("deploy ghostty config with existing file", func(t *testing.T) { + existingContent := "# Old config\nfont-size = 14\n" + ghosttyPath := getGhosttyPath() + err := os.MkdirAll(filepath.Dir(ghosttyPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(ghosttyPath, []byte(existingContent), 0o644) + require.NoError(t, err) + + results, err := cd.deployGhosttyConfig() + require.NoError(t, err) + require.Len(t, results, 2) + + mainResult := results[0] + assert.Equal(t, "Ghostty", mainResult.ConfigType) + assert.True(t, mainResult.Deployed) + assert.NotEmpty(t, mainResult.BackupPath) + assert.FileExists(t, mainResult.Path) + assert.FileExists(t, mainResult.BackupPath) + + backupContent, err := os.ReadFile(mainResult.BackupPath) + require.NoError(t, err) + assert.Equal(t, existingContent, string(backupContent)) + + newContent, err := os.ReadFile(mainResult.Path) + require.NoError(t, err) + assert.NotContains(t, string(newContent), "# Old config") + + colorResult := results[1] + assert.Equal(t, "Ghostty Colors", colorResult.ConfigType) + assert.True(t, colorResult.Deployed) + assert.FileExists(t, colorResult.Path) + }) +} + +func getGhosttyPath() string { + return filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config") +} + +func TestMergeHyprlandMonitorSections(t *testing.T) { + cd := &ConfigDeployer{} + + tests := []struct { + name string + newConfig string + existingConfig string + wantError bool + wantContains []string + wantNotContains []string + }{ + { + name: "no existing monitors", + newConfig: `# ================== +# MONITOR CONFIG +# ================== +# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1 + +# ================== +# ENVIRONMENT VARS +# ================== +env = XDG_CURRENT_DESKTOP,niri`, + existingConfig: `# Some other config +input { + kb_layout = us +}`, + wantError: false, + wantContains: []string{"MONITOR CONFIG", "ENVIRONMENT VARS"}, + }, + { + name: "merge single monitor", + newConfig: `# ================== +# MONITOR CONFIG +# ================== +# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1 + +# ================== +# ENVIRONMENT VARS +# ==================`, + existingConfig: `# My config +monitor = DP-1, 1920x1080@144, 0x0, 1 +input { + kb_layout = us +}`, + wantError: false, + wantContains: []string{ + "MONITOR CONFIG", + "monitor = DP-1, 1920x1080@144, 0x0, 1", + "Monitors from existing configuration", + }, + wantNotContains: []string{ + "monitor = eDP-2", // Example monitor should be removed + }, + }, + { + name: "merge multiple monitors", + newConfig: `# ================== +# MONITOR CONFIG +# ================== +# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1 + +# ================== +# ENVIRONMENT VARS +# ==================`, + existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1 +# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1 +monitor = eDP-1, 2560x1440@165, auto, 1.25`, + wantError: false, + wantContains: []string{ + "monitor = DP-1", + "# monitor = HDMI-A-1", // Commented monitor preserved + "monitor = eDP-1", + "Monitors from existing configuration", + }, + wantNotContains: []string{ + "monitor = eDP-2", // Example monitor should be removed + }, + }, + { + name: "preserve commented monitors", + newConfig: `# ================== +# MONITOR CONFIG +# ================== +# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1 + +# ==================`, + existingConfig: `# monitor = DP-1, 1920x1080@144, 0x0, 1 +# monitor = HDMI-A-1, 1920x1080@60, 1920x0, 1`, + wantError: false, + wantContains: []string{ + "# monitor = DP-1", + "# monitor = HDMI-A-1", + "Monitors from existing configuration", + }, + }, + { + name: "no monitor config section", + newConfig: `# Some config without monitor section +input { + kb_layout = us +}`, + existingConfig: `monitor = DP-1, 1920x1080@144, 0x0, 1`, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + result, err := cd.mergeHyprlandMonitorSections(tt.newConfig, tt.existingConfig, tmpDir) + + if tt.wantError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + for _, want := range tt.wantContains { + assert.Contains(t, result, want, "merged config should contain: %s", want) + } + + for _, notWant := range tt.wantNotContains { + assert.NotContains(t, result, notWant, "merged config should NOT contain: %s", notWant) + } + }) + } +} + +func TestHyprlandConfigDeployment(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-hyprland-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + t.Run("deploy hyprland config to empty directory", func(t *testing.T) { + result, err := cd.deployHyprlandConfig(deps.TerminalGhostty, true) + require.NoError(t, err) + + assert.Equal(t, "Hyprland", result.ConfigType) + assert.True(t, result.Deployed) + assert.Empty(t, result.BackupPath) + assert.FileExists(t, result.Path) + + content, err := os.ReadFile(result.Path) + require.NoError(t, err) + assert.Contains(t, string(content), "# MONITOR CONFIG") + assert.Contains(t, string(content), "source = ./dms/binds.conf") + assert.Contains(t, string(content), "exec-once = ") + }) + + t.Run("deploy hyprland config with existing monitors", func(t *testing.T) { + existingContent := `# My existing Hyprland config +monitor = DP-1, 1920x1080@144, 0x0, 1 +monitor = HDMI-A-1, 3840x2160@60, 1920x0, 1.5 + +general { + gaps_in = 10 +} +` + hyprPath := filepath.Join(tempDir, ".config", "hypr", "hyprland.conf") + err := os.MkdirAll(filepath.Dir(hyprPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(hyprPath, []byte(existingContent), 0o644) + require.NoError(t, err) + + result, err := cd.deployHyprlandConfig(deps.TerminalKitty, true) + require.NoError(t, err) + + assert.Equal(t, "Hyprland", result.ConfigType) + assert.True(t, result.Deployed) + assert.NotEmpty(t, result.BackupPath) + assert.FileExists(t, result.Path) + assert.FileExists(t, result.BackupPath) + + backupContent, err := os.ReadFile(result.BackupPath) + require.NoError(t, err) + assert.Equal(t, existingContent, string(backupContent)) + + newContent, err := os.ReadFile(result.Path) + require.NoError(t, err) + assert.Contains(t, string(newContent), "monitor = DP-1, 1920x1080@144") + assert.Contains(t, string(newContent), "monitor = HDMI-A-1, 3840x2160@60") + assert.Contains(t, string(newContent), "source = ./dms/binds.conf") + assert.NotContains(t, string(newContent), "monitor = eDP-2") + }) +} + +func TestNiriConfigStructure(t *testing.T) { + assert.Contains(t, NiriConfig, "input {") + assert.Contains(t, NiriConfig, "layout {") + + assert.Contains(t, NiriBindsConfig, "binds {") + assert.Contains(t, NiriBindsConfig, `spawn "{{TERMINAL_COMMAND}}"`) +} + +func TestHyprlandConfigStructure(t *testing.T) { + assert.Contains(t, HyprlandConfig, "# MONITOR CONFIG") + assert.Contains(t, HyprlandConfig, "# STARTUP APPS") + assert.Contains(t, HyprlandConfig, "# INPUT CONFIG") + assert.Contains(t, HyprlandConfig, "source = ./dms/binds.conf") +} + +func TestGhosttyConfigStructure(t *testing.T) { + assert.Contains(t, GhosttyConfig, "window-decoration = false") + assert.Contains(t, GhosttyConfig, "background-opacity = 1.0") + assert.Contains(t, GhosttyConfig, "theme = dankcolors") +} + +func TestGhosttyColorConfigStructure(t *testing.T) { + assert.Contains(t, GhosttyColorConfig, "background = #101418") + assert.Contains(t, GhosttyColorConfig, "foreground = #e0e2e8") + assert.Contains(t, GhosttyColorConfig, "cursor-color = #9dcbfb") + assert.Contains(t, GhosttyColorConfig, "palette = 0=#101418") + assert.Contains(t, GhosttyColorConfig, "palette = 15=#ffffff") +} + +func TestKittyConfigStructure(t *testing.T) { + assert.Contains(t, KittyConfig, "font_size 12.0") + assert.Contains(t, KittyConfig, "window_padding_width 12") + assert.Contains(t, KittyConfig, "background_opacity 1.0") + assert.Contains(t, KittyConfig, "include dank-tabs.conf") + assert.Contains(t, KittyConfig, "include dank-theme.conf") +} + +func TestKittyThemeConfigStructure(t *testing.T) { + assert.Contains(t, KittyThemeConfig, "foreground #e0e2e8") + assert.Contains(t, KittyThemeConfig, "background #101418") + assert.Contains(t, KittyThemeConfig, "cursor #e0e2e8") + assert.Contains(t, KittyThemeConfig, "color0 #101418") + assert.Contains(t, KittyThemeConfig, "color15 #ffffff") +} + +func TestKittyTabsConfigStructure(t *testing.T) { + assert.Contains(t, KittyTabsConfig, "tab_bar_style powerline") + assert.Contains(t, KittyTabsConfig, "tab_powerline_style slanted") + assert.Contains(t, KittyTabsConfig, "active_tab_background #124a73") + assert.Contains(t, KittyTabsConfig, "inactive_tab_background #101418") +} + +func TestAlacrittyConfigStructure(t *testing.T) { + assert.Contains(t, AlacrittyConfig, "[general]") + assert.Contains(t, AlacrittyConfig, "~/.config/alacritty/dank-theme.toml") + assert.Contains(t, AlacrittyConfig, "[window]") + assert.Contains(t, AlacrittyConfig, "decorations = \"None\"") + assert.Contains(t, AlacrittyConfig, "padding = { x = 12, y = 12 }") + assert.Contains(t, AlacrittyConfig, "[cursor]") + assert.Contains(t, AlacrittyConfig, "[keyboard]") +} + +func TestAlacrittyThemeConfigStructure(t *testing.T) { + assert.Contains(t, AlacrittyThemeConfig, "[colors.primary]") + assert.Contains(t, AlacrittyThemeConfig, "background = '#101418'") + assert.Contains(t, AlacrittyThemeConfig, "foreground = '#e0e2e8'") + assert.Contains(t, AlacrittyThemeConfig, "[colors.cursor]") + assert.Contains(t, AlacrittyThemeConfig, "cursor = '#9dcbfb'") + assert.Contains(t, AlacrittyThemeConfig, "[colors.normal]") + assert.Contains(t, AlacrittyThemeConfig, "[colors.bright]") +} + +func TestKittyConfigDeployment(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-kitty-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + t.Run("deploy kitty config to empty directory", func(t *testing.T) { + results, err := cd.deployKittyConfig() + require.NoError(t, err) + require.Len(t, results, 3) + + mainResult := results[0] + assert.Equal(t, "Kitty", mainResult.ConfigType) + assert.True(t, mainResult.Deployed) + assert.FileExists(t, mainResult.Path) + + content, err := os.ReadFile(mainResult.Path) + require.NoError(t, err) + assert.Contains(t, string(content), "include dank-theme.conf") + + themeResult := results[1] + assert.Equal(t, "Kitty Theme", themeResult.ConfigType) + assert.True(t, themeResult.Deployed) + assert.FileExists(t, themeResult.Path) + + tabsResult := results[2] + assert.Equal(t, "Kitty Tabs", tabsResult.ConfigType) + assert.True(t, tabsResult.Deployed) + assert.FileExists(t, tabsResult.Path) + }) +} + +func TestAlacrittyConfigDeployment(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-alacritty-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + t.Run("deploy alacritty config to empty directory", func(t *testing.T) { + results, err := cd.deployAlacrittyConfig() + require.NoError(t, err) + require.Len(t, results, 2) + + mainResult := results[0] + assert.Equal(t, "Alacritty", mainResult.ConfigType) + assert.True(t, mainResult.Deployed) + assert.FileExists(t, mainResult.Path) + + content, err := os.ReadFile(mainResult.Path) + require.NoError(t, err) + assert.Contains(t, string(content), "~/.config/alacritty/dank-theme.toml") + assert.Contains(t, string(content), "[window]") + + themeResult := results[1] + assert.Equal(t, "Alacritty Theme", themeResult.ConfigType) + assert.True(t, themeResult.Deployed) + assert.FileExists(t, themeResult.Path) + + themeContent, err := os.ReadFile(themeResult.Path) + require.NoError(t, err) + assert.Contains(t, string(themeContent), "[colors.primary]") + assert.Contains(t, string(themeContent), "background = '#101418'") + }) + + t.Run("deploy alacritty config with existing file", func(t *testing.T) { + existingContent := "# Old alacritty config\n[window]\nopacity = 0.9\n" + alacrittyPath := filepath.Join(tempDir, ".config", "alacritty", "alacritty.toml") + err := os.MkdirAll(filepath.Dir(alacrittyPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(alacrittyPath, []byte(existingContent), 0o644) + require.NoError(t, err) + + results, err := cd.deployAlacrittyConfig() + require.NoError(t, err) + require.Len(t, results, 2) + + mainResult := results[0] + assert.True(t, mainResult.Deployed) + assert.NotEmpty(t, mainResult.BackupPath) + assert.FileExists(t, mainResult.BackupPath) + + backupContent, err := os.ReadFile(mainResult.BackupPath) + require.NoError(t, err) + assert.Equal(t, existingContent, string(backupContent)) + + newContent, err := os.ReadFile(mainResult.Path) + require.NoError(t, err) + assert.NotContains(t, string(newContent), "# Old alacritty config") + assert.Contains(t, string(newContent), "decorations = \"None\"") + }) +} + +func TestShouldReplaceConfigDeployIfMissing(t *testing.T) { + allFalse := map[string]bool{ + "Niri": false, + "Hyprland": false, + "Ghostty": false, + "Kitty": false, + "Alacritty": false, + } + + t.Run("replaceConfigs nil deploys config", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-replace-nil-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + results, err := cd.DeployConfigurationsSelectiveWithReinstalls( + context.Background(), + deps.WindowManagerNiri, + deps.TerminalGhostty, + nil, // installedDeps + nil, // replaceConfigs + nil, // reinstallItems + ) + require.NoError(t, err) + + // With replaceConfigs=nil, all configs should be deployed + hasDeployed := false + for _, r := range results { + if r.Deployed { + hasDeployed = true + break + } + } + assert.True(t, hasDeployed, "expected at least one config to be deployed when replaceConfigs is nil") + }) + + t.Run("replaceConfigs all false and config missing deploys config", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-replace-missing-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + results, err := cd.DeployConfigurationsSelectiveWithReinstalls( + context.Background(), + deps.WindowManagerNiri, + deps.TerminalGhostty, + nil, // installedDeps + allFalse, // replaceConfigs — all false + nil, // reinstallItems + ) + require.NoError(t, err) + + // Config files don't exist on disk, so they should still be deployed + hasDeployed := false + for _, r := range results { + if r.Deployed { + hasDeployed = true + break + } + } + assert.True(t, hasDeployed, "expected configs to be deployed when files are missing, even with replaceConfigs all false") + }) + + t.Run("replaceConfigs false and config exists skips config", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-replace-exists-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Create the Ghostty primary config file so shouldReplaceConfig returns false + ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config") + err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644) + require.NoError(t, err) + + // Also create the Niri primary config file + niriPath := filepath.Join(tempDir, ".config", "niri", "config.kdl") + err = os.MkdirAll(filepath.Dir(niriPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(niriPath, []byte("// existing niri config\n"), 0o644) + require.NoError(t, err) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + results, err := cd.DeployConfigurationsSelectiveWithReinstalls( + context.Background(), + deps.WindowManagerNiri, + deps.TerminalGhostty, + nil, // installedDeps + allFalse, // replaceConfigs — all false + nil, // reinstallItems + ) + require.NoError(t, err) + + // Both Niri and Ghostty config files exist, so with all false they should be skipped + for _, r := range results { + assert.Fail(t, "expected no configs to be deployed", "got deployed config: %s", r.ConfigType) + } + }) + + t.Run("replaceConfigs true and config exists deploys config", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "dankinstall-replace-true-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + originalHome := os.Getenv("HOME") + os.Setenv("HOME", tempDir) + defer os.Setenv("HOME", originalHome) + + // Create the Ghostty primary config file + ghosttyPath := filepath.Join(tempDir, ".config", "ghostty", "config") + err = os.MkdirAll(filepath.Dir(ghosttyPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(ghosttyPath, []byte("# existing ghostty config\n"), 0o644) + require.NoError(t, err) + + logChan := make(chan string, 100) + cd := NewConfigDeployer(logChan) + + replaceConfigs := map[string]bool{ + "Niri": false, + "Hyprland": false, + "Ghostty": true, // explicitly true + "Kitty": false, + "Alacritty": false, + } + + results, err := cd.DeployConfigurationsSelectiveWithReinstalls( + context.Background(), + deps.WindowManagerNiri, + deps.TerminalGhostty, + nil, // installedDeps + replaceConfigs, // Ghostty=true, rest=false + nil, // reinstallItems + ) + require.NoError(t, err) + + // Ghostty should be deployed because replaceConfigs["Ghostty"]=true + foundGhostty := false + for _, r := range results { + if r.ConfigType == "Ghostty" && r.Deployed { + foundGhostty = true + } + } + assert.True(t, foundGhostty, "expected Ghostty config to be deployed when replaceConfigs is true") + }) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/dms.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/dms.go new file mode 100644 index 0000000..7842fa5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/dms.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func LocateDMSConfig() (string, error) { + var primaryPaths []string + + configHome, err := os.UserConfigDir() + if err == nil && configHome != "" { + primaryPaths = append(primaryPaths, filepath.Join(configHome, "quickshell", "dms")) + } + + // System data directories + dataDirs := os.Getenv("XDG_DATA_DIRS") + if dataDirs == "" { + dataDirs = "/usr/local/share:/usr/share" + } + + for dir := range strings.SplitSeq(dataDirs, ":") { + if dir != "" { + primaryPaths = append(primaryPaths, filepath.Join(dir, "quickshell", "dms")) + } + } + + // System config directories (fallback) + configDirs := os.Getenv("XDG_CONFIG_DIRS") + if configDirs == "" { + configDirs = "/etc/xdg" + } + + for dir := range strings.SplitSeq(configDirs, ":") { + if dir != "" { + primaryPaths = append(primaryPaths, filepath.Join(dir, "quickshell", "dms")) + } + } + + // Build search paths with secondary (monorepo) paths interleaved + var searchPaths []string + for _, path := range primaryPaths { + searchPaths = append(searchPaths, path) + searchPaths = append(searchPaths, filepath.Join(path, "quickshell")) + } + + for _, path := range searchPaths { + shellPath := filepath.Join(path, "shell.qml") + if info, err := os.Stat(shellPath); err == nil && !info.IsDir() { + return path, nil + } + } + + return "", fmt.Errorf("could not find DMS config (shell.qml) in any valid config path") +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/alacritty-theme.toml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/alacritty-theme.toml new file mode 100644 index 0000000..4048457 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/alacritty-theme.toml @@ -0,0 +1,31 @@ +[colors.primary] +background = '#101418' +foreground = '#e0e2e8' + +[colors.selection] +text = '#e0e2e8' +background = '#124a73' + +[colors.cursor] +text = '#101418' +cursor = '#9dcbfb' + +[colors.normal] +black = '#101418' +red = '#d75a59' +green = '#8ed88c' +yellow = '#e0d99d' +blue = '#4087bc' +magenta = '#839fbc' +cyan = '#9dcbfb' +white = '#abb2bf' + +[colors.bright] +black = '#5c6370' +red = '#e57e7e' +green = '#a2e5a0' +yellow = '#efe9b3' +blue = '#a7d9ff' +magenta = '#3d8197' +cyan = '#5c7ba3' +white = '#ffffff' diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/alacritty.toml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/alacritty.toml new file mode 100644 index 0000000..b666177 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/alacritty.toml @@ -0,0 +1,37 @@ +[general] +import = [ + "~/.config/alacritty/dank-theme.toml" +] + +[window] +decorations = "None" +padding = { x = 12, y = 12 } +opacity = 1.0 + +[scrolling] +history = 3023 + +[cursor] +style = { shape = "Block", blinking = "On" } +blink_interval = 500 +unfocused_hollow = true + +[mouse] +hide_when_typing = true + +[selection] +save_to_clipboard = false + +[bell] +duration = 0 + +[keyboard] +bindings = [ + { key = "C", mods = "Control|Shift", action = "Copy" }, + { key = "V", mods = "Control|Shift", action = "Paste" }, + { key = "N", mods = "Control|Shift", action = "SpawnNewInstance" }, + { key = "Equals", mods = "Control|Shift", action = "IncreaseFontSize" }, + { key = "Minus", mods = "Control", action = "DecreaseFontSize" }, + { key = "Key0", mods = "Control", action = "ResetFontSize" }, + { key = "Enter", mods = "Shift", chars = "\n" }, +] diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/ghostty-colors.conf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/ghostty-colors.conf new file mode 100644 index 0000000..56dca5f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/ghostty-colors.conf @@ -0,0 +1,21 @@ +background = #101418 +foreground = #e0e2e8 +cursor-color = #9dcbfb +selection-background = #124a73 +selection-foreground = #e0e2e8 +palette = 0=#101418 +palette = 1=#d75a59 +palette = 2=#8ed88c +palette = 3=#e0d99d +palette = 4=#4087bc +palette = 5=#839fbc +palette = 6=#9dcbfb +palette = 7=#abb2bf +palette = 8=#5c6370 +palette = 9=#e57e7e +palette = 10=#a2e5a0 +palette = 11=#efe9b3 +palette = 12=#a7d9ff +palette = 13=#3d8197 +palette = 14=#5c7ba3 +palette = 15=#ffffff diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/ghostty.conf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/ghostty.conf new file mode 100644 index 0000000..8ca05a2 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/ghostty.conf @@ -0,0 +1,51 @@ +# Font Configuration +font-size = 12 + +# Window Configuration +window-decoration = false +window-padding-x = 12 +window-padding-y = 12 +background-opacity = 1.0 +background-blur-radius = 32 + +# Cursor Configuration +cursor-style = block +cursor-style-blink = true + +# Scrollback +scrollback-limit = 3023 + +# Terminal features +mouse-hide-while-typing = true +copy-on-select = false +confirm-close-surface = false + +# Disable annoying copied to clipboard +app-notifications = no-clipboard-copy,no-config-reload + +# Key bindings for common actions +#keybind = ctrl+c=copy_to_clipboard +#keybind = ctrl+v=paste_from_clipboard +keybind = ctrl+shift+n=new_window +keybind = ctrl+t=new_tab +keybind = ctrl+plus=increase_font_size:1 +keybind = ctrl+minus=decrease_font_size:1 +keybind = ctrl+zero=reset_font_size + +# Material 3 UI elements +unfocused-split-opacity = 0.7 +unfocused-split-fill = #44464f + +# Tab configuration +gtk-titlebar = false + +# Shell integration +shell-integration = detect +shell-integration-features = cursor,sudo,title,no-cursor +keybind = shift+enter=text:\n + +# Rando stuff +gtk-single-instance = true + +# Dank color generation +theme = dankcolors diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-binds.conf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-binds.conf new file mode 100644 index 0000000..dfe5630 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-binds.conf @@ -0,0 +1,162 @@ +# === Application Launchers === +bind = SUPER, T, exec, {{TERMINAL_COMMAND}} +bind = SUPER, space, exec, dms ipc call spotlight toggle +bind = SUPER, V, exec, dms ipc call clipboard toggle +bind = SUPER, M, exec, dms ipc call processlist focusOrToggle +bind = SUPER, comma, exec, dms ipc call settings focusOrToggle +bind = SUPER, N, exec, dms ipc call notifications toggle +bind = SUPER SHIFT, N, exec, dms ipc call notepad toggle +bind = SUPER, Y, exec, dms ipc call dankdash wallpaper +bind = SUPER, TAB, exec, dms ipc call hypr toggleOverview +bind = SUPER, X, exec, dms ipc call powermenu toggle + +# === Cheat sheet +bind = SUPER SHIFT, Slash, exec, dms ipc call keybinds toggle hyprland + +# === Security === +bind = SUPER ALT, L, exec, dms ipc call lock lock +bind = SUPER SHIFT, E, exit +bind = CTRL ALT, Delete, exec, dms ipc call processlist focusOrToggle + +# === Audio Controls === +bindel = , XF86AudioRaiseVolume, exec, dms ipc call audio increment 3 +bindel = , XF86AudioLowerVolume, exec, dms ipc call audio decrement 3 +bindl = , XF86AudioMute, exec, dms ipc call audio mute +bindl = , XF86AudioMicMute, exec, dms ipc call audio micmute +bindl = , XF86AudioPause, exec, dms ipc call mpris playPause +bindl = , XF86AudioPlay, exec, dms ipc call mpris playPause +bindl = , XF86AudioPrev, exec, dms ipc call mpris previous +bindl = , XF86AudioNext, exec, dms ipc call mpris next +bindel = CTRL, XF86AudioRaiseVolume, exec, dms ipc call mpris increment 3 +bindel = CTRL, XF86AudioLowerVolume, exec, dms ipc call mpris decrement 3 + +# === Brightness Controls === +bindel = , XF86MonBrightnessUp, exec, dms ipc call brightness increment 5 "" +bindel = , XF86MonBrightnessDown, exec, dms ipc call brightness decrement 5 "" + +# === Window Management === +bind = SUPER, Q, killactive +bind = SUPER, F, fullscreen, 1 +bind = SUPER SHIFT, F, fullscreen, 0 +bind = SUPER SHIFT, T, togglefloating +bind = SUPER, W, togglegroup +bind = SUPER SHIFT, W, exec, dms ipc call window-rules toggle + +# === Focus Navigation === +bind = SUPER, left, movefocus, l +bind = SUPER, down, movefocus, d +bind = SUPER, up, movefocus, u +bind = SUPER, right, movefocus, r +bind = SUPER, H, movefocus, l +bind = SUPER, J, movefocus, d +bind = SUPER, K, movefocus, u +bind = SUPER, L, movefocus, r + +# === Window Movement === +bind = SUPER SHIFT, left, movewindow, l +bind = SUPER SHIFT, down, movewindow, d +bind = SUPER SHIFT, up, movewindow, u +bind = SUPER SHIFT, right, movewindow, r +bind = SUPER SHIFT, H, movewindow, l +bind = SUPER SHIFT, J, movewindow, d +bind = SUPER SHIFT, K, movewindow, u +bind = SUPER SHIFT, L, movewindow, r + +# === Column Navigation === +bind = SUPER, Home, focuswindow, first +bind = SUPER, End, focuswindow, last + +# === Monitor Navigation === +bind = SUPER CTRL, left, focusmonitor, l +bind = SUPER CTRL, right, focusmonitor, r +bind = SUPER CTRL, H, focusmonitor, l +bind = SUPER CTRL, J, focusmonitor, d +bind = SUPER CTRL, K, focusmonitor, u +bind = SUPER CTRL, L, focusmonitor, r + +# === Move to Monitor === +bind = SUPER SHIFT CTRL, left, movewindow, mon:l +bind = SUPER SHIFT CTRL, down, movewindow, mon:d +bind = SUPER SHIFT CTRL, up, movewindow, mon:u +bind = SUPER SHIFT CTRL, right, movewindow, mon:r +bind = SUPER SHIFT CTRL, H, movewindow, mon:l +bind = SUPER SHIFT CTRL, J, movewindow, mon:d +bind = SUPER SHIFT CTRL, K, movewindow, mon:u +bind = SUPER SHIFT CTRL, L, movewindow, mon:r + +# === Workspace Navigation === +bind = SUPER, Page_Down, workspace, e+1 +bind = SUPER, Page_Up, workspace, e-1 +bind = SUPER, U, workspace, e+1 +bind = SUPER, I, workspace, e-1 +bind = SUPER CTRL, down, movetoworkspace, e+1 +bind = SUPER CTRL, up, movetoworkspace, e-1 +bind = SUPER CTRL, U, movetoworkspace, e+1 +bind = SUPER CTRL, I, movetoworkspace, e-1 + +# === Workspace Management === +bind = CTRL SHIFT, R, exec, dms ipc call workspace-rename open + +# === Move Workspaces === +bind = SUPER SHIFT, Page_Down, movetoworkspace, e+1 +bind = SUPER SHIFT, Page_Up, movetoworkspace, e-1 +bind = SUPER SHIFT, U, movetoworkspace, e+1 +bind = SUPER SHIFT, I, movetoworkspace, e-1 + +# === Mouse Wheel Navigation === +bind = SUPER, mouse_down, workspace, e+1 +bind = SUPER, mouse_up, workspace, e-1 +bind = SUPER CTRL, mouse_down, movetoworkspace, e+1 +bind = SUPER CTRL, mouse_up, movetoworkspace, e-1 + +# === Numbered Workspaces === +bind = SUPER, 1, workspace, 1 +bind = SUPER, 2, workspace, 2 +bind = SUPER, 3, workspace, 3 +bind = SUPER, 4, workspace, 4 +bind = SUPER, 5, workspace, 5 +bind = SUPER, 6, workspace, 6 +bind = SUPER, 7, workspace, 7 +bind = SUPER, 8, workspace, 8 +bind = SUPER, 9, workspace, 9 + +# === Move to Numbered Workspaces === +bind = SUPER SHIFT, 1, movetoworkspace, 1 +bind = SUPER SHIFT, 2, movetoworkspace, 2 +bind = SUPER SHIFT, 3, movetoworkspace, 3 +bind = SUPER SHIFT, 4, movetoworkspace, 4 +bind = SUPER SHIFT, 5, movetoworkspace, 5 +bind = SUPER SHIFT, 6, movetoworkspace, 6 +bind = SUPER SHIFT, 7, movetoworkspace, 7 +bind = SUPER SHIFT, 8, movetoworkspace, 8 +bind = SUPER SHIFT, 9, movetoworkspace, 9 + +# === Column Management === +bind = SUPER, bracketleft, layoutmsg, preselect l +bind = SUPER, bracketright, layoutmsg, preselect r + +# === Sizing & Layout === +bind = SUPER, R, layoutmsg, togglesplit +bind = SUPER CTRL, F, resizeactive, exact 100% 100% + +# === Move/resize windows with mainMod + LMB/RMB and dragging === +bindmd = SUPER, mouse:272, Move window, movewindow +bindmd = SUPER, mouse:273, Resize window, resizewindow + +# === Move/resize windows with mainMod + LMB/RMB and dragging === +bindd = SUPER, code:20, Expand window left, resizeactive, -100 0 +bindd = SUPER, code:21, Shrink window left, resizeactive, 100 0 + +# === Manual Sizing === +binde = SUPER, minus, resizeactive, -10% 0 +binde = SUPER, equal, resizeactive, 10% 0 +binde = SUPER SHIFT, minus, resizeactive, 0 -10% +binde = SUPER SHIFT, equal, resizeactive, 0 10% + +# === Screenshots === +bind = , Print, exec, dms screenshot +bind = CTRL, Print, exec, dms screenshot full +bind = ALT, Print, exec, dms screenshot window + +# === System Controls === +bind = SUPER SHIFT, P, dpms, toggle diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-colors.conf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-colors.conf new file mode 100644 index 0000000..38bcb0e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-colors.conf @@ -0,0 +1,25 @@ +# ! Auto-generated file. Do not edit directly. +# Remove source = ./dms/colors.conf from your config to override. + +$primary = rgb(d0bcff) +$outline = rgb(948f99) +$error = rgb(f2b8b5) + +general { + col.active_border = $primary + col.inactive_border = $outline +} + +group { + col.border_active = $primary + col.border_inactive = $outline + col.border_locked_active = $error + col.border_locked_inactive = $outline + + groupbar { + col.active = $primary + col.inactive = $outline + col.locked_active = $error + col.locked_inactive = $outline + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-layout.conf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-layout.conf new file mode 100644 index 0000000..b9a8ad9 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hypr-layout.conf @@ -0,0 +1,11 @@ +# Auto-generated by DMS - do not edit manually + +general { + gaps_in = 4 + gaps_out = 4 + border_size = 2 +} + +decoration { + rounding = 12 +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hyprland.conf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hyprland.conf new file mode 100644 index 0000000..f4e7a03 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/hyprland.conf @@ -0,0 +1,121 @@ +# Hyprland Configuration +# https://wiki.hypr.land/Configuring/ + +# ================== +# MONITOR CONFIG +# ================== +# monitor = eDP-2, 2560x1600@239.998993, 2560x0, 1, vrr, 1 +monitor = , preferred,auto,auto + +# ================== +# STARTUP APPS +# ================== +exec-once = dbus-update-activation-environment --systemd --all +exec-once = systemctl --user start hyprland-session.target + +# ================== +# INPUT CONFIG +# ================== +input { + kb_layout = us + numlock_by_default = true +} + +# ================== +# GENERAL LAYOUT +# ================== +general { + gaps_in = 5 + gaps_out = 5 + border_size = 2 + + layout = dwindle +} + +# ================== +# DECORATION +# ================== +decoration { + rounding = 12 + + active_opacity = 1.0 + inactive_opacity = 1.0 + + shadow { + enabled = true + range = 30 + render_power = 5 + offset = 0 5 + color = rgba(00000070) + } +} + +# ================== +# ANIMATIONS +# ================== +animations { + enabled = true + + animation = windowsIn, 1, 3, default + animation = windowsOut, 1, 3, default + animation = workspaces, 1, 5, default + animation = windowsMove, 1, 4, default + animation = fade, 1, 3, default + animation = border, 1, 3, default +} + +# ================== +# LAYOUTS +# ================== +dwindle { + preserve_split = true +} + +master { + mfact = 0.5 +} + +# ================== +# MISC +# ================== +misc { + disable_hyprland_logo = true + disable_splash_rendering = true +} + +# ================== +# WINDOW RULES +# ================== +windowrule = tile on, match:class ^(org\.wezfurlong\.wezterm)$ + +windowrule = rounding 12, match:class ^(org\.gnome\.) + +windowrule = tile on, match:class ^(gnome-control-center)$ +windowrule = tile on, match:class ^(pavucontrol)$ +windowrule = tile on, match:class ^(nm-connection-editor)$ + +windowrule = float on, match:class ^(org\.gnome\.Calculator)$ +windowrule = float on, match:class ^(gnome-calculator)$ +windowrule = float on, match:class ^(galculator)$ +windowrule = float on, match:class ^(blueman-manager)$ +windowrule = float on, match:class ^(org\.gnome\.Nautilus)$ +windowrule = float on, match:class ^(xdg-desktop-portal)$ + +windowrule = no_initial_focus on, match:class ^(steam)$, match:title ^(notificationtoasts) +windowrule = pin on, match:class ^(steam)$, match:title ^(notificationtoasts) + +windowrule = float on, match:class ^(firefox)$, match:title ^(Picture-in-Picture)$ +windowrule = float on, match:class ^(zoom)$ + +# DMS windows floating by default +# ! Hyprland doesn't size these windows correctly so disabling by default here +# windowrule = float on, match:class ^(org.quickshell)$ + +layerrule = no_anim on, match:namespace ^(quickshell)$ +layerrule = no_anim on, match:namespace ^dms:.* + +source = ./dms/colors.conf +source = ./dms/outputs.conf +source = ./dms/layout.conf +source = ./dms/cursor.conf +source = ./dms/binds.conf diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty-tabs.conf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty-tabs.conf new file mode 100644 index 0000000..b2305c8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty-tabs.conf @@ -0,0 +1,24 @@ +tab_bar_edge top +tab_bar_style powerline +tab_powerline_style slanted +tab_bar_align left +tab_bar_min_tabs 2 +tab_bar_margin_width 0.0 +tab_bar_margin_height 2.5 1.5 +tab_bar_margin_color #101418 + +tab_bar_background #101418 + +active_tab_foreground #cfe5ff +active_tab_background #124a73 +active_tab_font_style bold + +inactive_tab_foreground #c2c7cf +inactive_tab_background #101418 +inactive_tab_font_style normal + +tab_activity_symbol " ● " +tab_numbers_style 1 + +tab_title_template "{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title[:30]}{title[30:] and '…'} [{index}]" +active_tab_title_template "{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title[:30]}{title[30:] and '…'} [{index}]" diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty-theme.conf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty-theme.conf new file mode 100644 index 0000000..2587bcf --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty-theme.conf @@ -0,0 +1,24 @@ +cursor #e0e2e8 +cursor_text_color #c2c7cf + +foreground #e0e2e8 +background #101418 +selection_foreground #243240 +selection_background #b9c8da +url_color #9dcbfb +color0 #101418 +color1 #d75a59 +color2 #8ed88c +color3 #e0d99d +color4 #4087bc +color5 #839fbc +color6 #9dcbfb +color7 #abb2bf +color8 #5c6370 +color9 #e57e7e +color10 #a2e5a0 +color11 #efe9b3 +color12 #a7d9ff +color13 #3d8197 +color14 #5c7ba3 +color15 #ffffff diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty.conf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty.conf new file mode 100644 index 0000000..2ae96a0 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/kitty.conf @@ -0,0 +1,37 @@ +# Font Configuration +font_size 12.0 + +# Window Configuration +window_padding_width 12 +background_opacity 1.0 +background_blur 32 +hide_window_decorations yes + +# Cursor Configuration +cursor_shape block +cursor_blink_interval 1 + +# Scrollback +scrollback_lines 3000 + +# Terminal features +copy_on_select yes +strip_trailing_spaces smart + +# Key bindings for common actions +map ctrl+shift+n new_window +map ctrl+t new_tab +map ctrl+plus change_font_size all +1.0 +map ctrl+minus change_font_size all -1.0 +map ctrl+0 change_font_size all 0 + +# Tab configuration +tab_bar_style powerline +tab_bar_align left + +# Shell integration +shell_integration enabled + +# Dank color generation +include dank-tabs.conf +include dank-theme.conf diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-alttab.kdl b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-alttab.kdl new file mode 100644 index 0000000..5f9bdcd --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-alttab.kdl @@ -0,0 +1,10 @@ +// ! DO NOT EDIT ! +// ! AUTO-GENERATED BY DMS ! +// ! CHANGES WILL BE OVERWRITTEN ! +// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE ! + +recent-windows { + highlight { + corner-radius 12 + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-binds.kdl b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-binds.kdl new file mode 100644 index 0000000..27cb860 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-binds.kdl @@ -0,0 +1,221 @@ +binds { + // === System & Overview === + Mod+D repeat=false { toggle-overview; } + Mod+Tab repeat=false { toggle-overview; } + Mod+Shift+Slash { show-hotkey-overlay; } + + // === Application Launchers === + Mod+T hotkey-overlay-title="Open Terminal" { spawn "{{TERMINAL_COMMAND}}"; } + Mod+Space hotkey-overlay-title="Application Launcher" { + spawn "dms" "ipc" "call" "spotlight" "toggle"; + } + Mod+V hotkey-overlay-title="Clipboard Manager" { + spawn "dms" "ipc" "call" "clipboard" "toggle"; + } + Mod+M hotkey-overlay-title="Task Manager" { + spawn "dms" "ipc" "call" "processlist" "focusOrToggle"; + } + + Super+X hotkey-overlay-title="Power Menu: Toggle" { spawn "dms" "ipc" "call" "powermenu" "toggle"; } + Mod+Comma hotkey-overlay-title="Settings" { + spawn "dms" "ipc" "call" "settings" "focusOrToggle"; + } + Mod+Y hotkey-overlay-title="Browse Wallpapers" { + spawn "dms" "ipc" "call" "dankdash" "wallpaper"; + } + Mod+N hotkey-overlay-title="Notification Center" { spawn "dms" "ipc" "call" "notifications" "toggle"; } + Mod+Shift+N hotkey-overlay-title="Notepad" { spawn "dms" "ipc" "call" "notepad" "toggle"; } + + // === Security === + Mod+Alt+L hotkey-overlay-title="Lock Screen" { + spawn "dms" "ipc" "call" "lock" "lock"; + } + Mod+Shift+E { quit; } + Ctrl+Alt+Delete hotkey-overlay-title="Task Manager" { + spawn "dms" "ipc" "call" "processlist" "focusOrToggle"; + } + + // === Audio Controls === + XF86AudioRaiseVolume allow-when-locked=true { + spawn "dms" "ipc" "call" "audio" "increment" "3"; + } + XF86AudioLowerVolume allow-when-locked=true { + spawn "dms" "ipc" "call" "audio" "decrement" "3"; + } + XF86AudioMute allow-when-locked=true { + spawn "dms" "ipc" "call" "audio" "mute"; + } + XF86AudioMicMute allow-when-locked=true { + spawn "dms" "ipc" "call" "audio" "micmute"; + } + XF86AudioPause allow-when-locked=true { + spawn "dms" "ipc" "call" "mpris" "playPause"; + } + XF86AudioPlay allow-when-locked=true { + spawn "dms" "ipc" "call" "mpris" "playPause"; + } + XF86AudioPrev allow-when-locked=true { + spawn "dms" "ipc" "call" "mpris" "previous"; + } + XF86AudioNext allow-when-locked=true { + spawn "dms" "ipc" "call" "mpris" "next"; + } + Ctrl+XF86AudioRaiseVolume allow-when-locked=true { + spawn "dms" "ipc" "call" "mpris" "increment" "3"; + } + Ctrl+XF86AudioLowerVolume allow-when-locked=true { + spawn "dms" "ipc" "call" "mpris" "decrement" "3"; + } + + // === Brightness Controls === + XF86MonBrightnessUp allow-when-locked=true { + spawn "dms" "ipc" "call" "brightness" "increment" "5" ""; + } + XF86MonBrightnessDown allow-when-locked=true { + spawn "dms" "ipc" "call" "brightness" "decrement" "5" ""; + } + + // === Window Management === + Mod+Q repeat=false { close-window; } + Mod+F { maximize-column; } + Mod+Shift+F { fullscreen-window; } + Mod+Shift+T { toggle-window-floating; } + Mod+Shift+V { switch-focus-between-floating-and-tiling; } + Mod+W { toggle-column-tabbed-display; } + Mod+Shift+W hotkey-overlay-title="Create window rule" { spawn "dms" "ipc" "call" "window-rules" "toggle"; } + + // === Focus Navigation === + Mod+Left { focus-column-left; } + Mod+Down { focus-window-down; } + Mod+Up { focus-window-up; } + Mod+Right { focus-column-right; } + Mod+H { focus-column-left; } + Mod+J { focus-window-down; } + Mod+K { focus-window-up; } + Mod+L { focus-column-right; } + + // === Window Movement === + Mod+Shift+Left { move-column-left; } + Mod+Shift+Down { move-window-down; } + Mod+Shift+Up { move-window-up; } + Mod+Shift+Right { move-column-right; } + Mod+Shift+H { move-column-left; } + Mod+Shift+J { move-window-down; } + Mod+Shift+K { move-window-up; } + Mod+Shift+L { move-column-right; } + + // === Column Navigation === + Mod+Home { focus-column-first; } + Mod+End { focus-column-last; } + Mod+Ctrl+Home { move-column-to-first; } + Mod+Ctrl+End { move-column-to-last; } + + // === Monitor Navigation === + Mod+Ctrl+Left { focus-monitor-left; } + //Mod+Ctrl+Down { focus-monitor-down; } + //Mod+Ctrl+Up { focus-monitor-up; } + Mod+Ctrl+Right { focus-monitor-right; } + Mod+Ctrl+H { focus-monitor-left; } + Mod+Ctrl+J { focus-monitor-down; } + Mod+Ctrl+K { focus-monitor-up; } + Mod+Ctrl+L { focus-monitor-right; } + + // === Move to Monitor === + Mod+Shift+Ctrl+Left { move-column-to-monitor-left; } + Mod+Shift+Ctrl+Down { move-column-to-monitor-down; } + Mod+Shift+Ctrl+Up { move-column-to-monitor-up; } + Mod+Shift+Ctrl+Right { move-column-to-monitor-right; } + Mod+Shift+Ctrl+H { move-column-to-monitor-left; } + Mod+Shift+Ctrl+J { move-column-to-monitor-down; } + Mod+Shift+Ctrl+K { move-column-to-monitor-up; } + Mod+Shift+Ctrl+L { move-column-to-monitor-right; } + + // === Workspace Navigation === + Mod+Page_Down { focus-workspace-down; } + Mod+Page_Up { focus-workspace-up; } + Mod+U { focus-workspace-down; } + Mod+I { focus-workspace-up; } + Mod+Ctrl+Down { move-column-to-workspace-down; } + Mod+Ctrl+Up { move-column-to-workspace-up; } + Mod+Ctrl+U { move-column-to-workspace-down; } + Mod+Ctrl+I { move-column-to-workspace-up; } + + // === Workspace Management === + Ctrl+Shift+R hotkey-overlay-title="Rename Workspace" { + spawn "dms" "ipc" "call" "workspace-rename" "open"; + } + + // === Move Workspaces === + Mod+Shift+Page_Down { move-workspace-down; } + Mod+Shift+Page_Up { move-workspace-up; } + Mod+Shift+U { move-workspace-down; } + Mod+Shift+I { move-workspace-up; } + + // === Mouse Wheel Navigation === + Mod+WheelScrollDown cooldown-ms=150 { focus-workspace-down; } + Mod+WheelScrollUp cooldown-ms=150 { focus-workspace-up; } + Mod+Ctrl+WheelScrollDown cooldown-ms=150 { move-column-to-workspace-down; } + Mod+Ctrl+WheelScrollUp cooldown-ms=150 { move-column-to-workspace-up; } + + Mod+WheelScrollRight { focus-column-right; } + Mod+WheelScrollLeft { focus-column-left; } + Mod+Ctrl+WheelScrollRight { move-column-right; } + Mod+Ctrl+WheelScrollLeft { move-column-left; } + + Mod+Shift+WheelScrollDown { focus-column-right; } + Mod+Shift+WheelScrollUp { focus-column-left; } + Mod+Ctrl+Shift+WheelScrollDown { move-column-right; } + Mod+Ctrl+Shift+WheelScrollUp { move-column-left; } + + // === Numbered Workspaces === + Mod+1 { focus-workspace 1; } + Mod+2 { focus-workspace 2; } + Mod+3 { focus-workspace 3; } + Mod+4 { focus-workspace 4; } + Mod+5 { focus-workspace 5; } + Mod+6 { focus-workspace 6; } + Mod+7 { focus-workspace 7; } + Mod+8 { focus-workspace 8; } + Mod+9 { focus-workspace 9; } + + // === Move to Numbered Workspaces === + Mod+Shift+1 { move-column-to-workspace 1; } + Mod+Shift+2 { move-column-to-workspace 2; } + Mod+Shift+3 { move-column-to-workspace 3; } + Mod+Shift+4 { move-column-to-workspace 4; } + Mod+Shift+5 { move-column-to-workspace 5; } + Mod+Shift+6 { move-column-to-workspace 6; } + Mod+Shift+7 { move-column-to-workspace 7; } + Mod+Shift+8 { move-column-to-workspace 8; } + Mod+Shift+9 { move-column-to-workspace 9; } + + // === Column Management === + Mod+BracketLeft { consume-or-expel-window-left; } + Mod+BracketRight { consume-or-expel-window-right; } + Mod+Period { expel-window-from-column; } + + // === Sizing & Layout === + Mod+R { switch-preset-column-width; } + Mod+Shift+R { switch-preset-window-height; } + Mod+Ctrl+R { reset-window-height; } + Mod+Ctrl+F { expand-column-to-available-width; } + Mod+C { center-column; } + Mod+Ctrl+C { center-visible-columns; } + + // === Manual Sizing === + Mod+Minus { set-column-width "-10%"; } + Mod+Equal { set-column-width "+10%"; } + Mod+Shift+Minus { set-window-height "-10%"; } + Mod+Shift+Equal { set-window-height "+10%"; } + + // === Screenshots === + XF86Launch1 { screenshot; } + Ctrl+XF86Launch1 { screenshot-screen; } + Alt+XF86Launch1 { screenshot-window; } + Print { screenshot; } + Ctrl+Print { screenshot-screen; } + Alt+Print { screenshot-window; } + // === System Controls === + Mod+Escape allow-inhibiting=false { toggle-keyboard-shortcuts-inhibit; } + Mod+Shift+P { power-off-monitors; } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-colors.kdl b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-colors.kdl new file mode 100644 index 0000000..145a179 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-colors.kdl @@ -0,0 +1,39 @@ +// ! Auto-generated file. Do not edit directly. +// Remove `include "dms/colors.kdl"` from your config to override. + +layout { + background-color "transparent" + + focus-ring { + active-color "#d0bcff" + inactive-color "#948f99" + urgent-color "#f2b8b5" + } + + border { + active-color "#d0bcff" + inactive-color "#948f99" + urgent-color "#f2b8b5" + } + + shadow { + color "#00000070" + } + + tab-indicator { + active-color "#d0bcff" + inactive-color "#948f99" + urgent-color "#f2b8b5" + } + + insert-hint { + color "#d0bcff80" + } +} + +recent-windows { + highlight { + active-color "#4f378b" + urgent-color "#f2b8b5" + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-greeter.kdl b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-greeter.kdl new file mode 100644 index 0000000..5653a16 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-greeter.kdl @@ -0,0 +1,17 @@ +hotkey-overlay { + skip-at-startup +} + +environment { + DMS_RUN_GREETER "1" +} + +gestures { + hot-corners { + off + } +} + +layout { + background-color "#000000" +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-layout.kdl b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-layout.kdl new file mode 100644 index 0000000..1951500 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri-layout.kdl @@ -0,0 +1,22 @@ +// ! DO NOT EDIT ! +// ! AUTO-GENERATED BY DMS ! +// ! CHANGES WILL BE OVERWRITTEN ! +// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE ! + +layout { + gaps 4 + + border { + width 2 + } + + focus-ring { + width 2 + } +} +window-rule { + geometry-corner-radius 12 + clip-to-geometry true + tiled-state true + draw-border-with-background false +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri.kdl b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri.kdl new file mode 100644 index 0000000..0a759e4 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/niri.kdl @@ -0,0 +1,279 @@ +// This config is in the KDL format: https://kdl.dev +// "/-" comments out the following node. +// Check the wiki for a full description of the configuration: +// https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction +config-notification { + disable-failed +} + +gestures { + hot-corners { + off + } +} + +// Input device configuration. +// Find the full list of options on the wiki: +// https://github.com/YaLTeR/niri/wiki/Configuration:-Input +input { + keyboard { + xkb { + // You can set rules, model, layout, variant and options. + // For more information, see xkeyboard-config(7). + + // For example: + // layout "us,ru" + // options "grp:win_space_toggle,compose:ralt,ctrl:nocaps" + + // If this section is empty, niri will fetch xkb settings + // from org.freedesktop.locale1. You can control these using + // localectl set-x11-keymap. + } + + // Enable numlock on startup, omitting this setting disables it. + numlock + } + + // Next sections include libinput settings. + // Omitting settings disables them, or leaves them at their default values. + // All commented-out settings here are examples, not defaults. + touchpad { + // off + tap + // dwt + // dwtp + // drag false + // drag-lock + natural-scroll + // accel-speed 0.2 + // accel-profile "flat" + // scroll-method "two-finger" + // disabled-on-external-mouse + } + + mouse { + // off + // natural-scroll + // accel-speed 0.2 + // accel-profile "flat" + // scroll-method "no-scroll" + } + + trackpoint { + // off + // natural-scroll + // accel-speed 0.2 + // accel-profile "flat" + // scroll-method "on-button-down" + // scroll-button 273 + // scroll-button-lock + // middle-emulation + } + + // Uncomment this to make the mouse warp to the center of newly focused windows. + // warp-mouse-to-focus + + // Focus windows and outputs automatically when moving the mouse into them. + // Setting max-scroll-amount="0%" makes it work only on windows already fully on screen. + // focus-follows-mouse max-scroll-amount="0%" +} +// You can configure outputs by their name, which you can find +// by running `niri msg outputs` while inside a niri instance. +// The built-in laptop monitor is usually called "eDP-1". +// Find more information on the wiki: +// https://github.com/YaLTeR/niri/wiki/Configuration:-Outputs +// Remember to uncomment the node by removing "/-"! +/-output "eDP-2" { + mode "2560x1600@239.998993" + position x=2560 y=0 + variable-refresh-rate +} +// Settings that influence how windows are positioned and sized. +// Find more information on the wiki: +// https://github.com/YaLTeR/niri/wiki/Configuration:-Layout +layout { + // Set gaps around windows in logical pixels. + background-color "transparent" + // When to center a column when changing focus, options are: + // - "never", default behavior, focusing an off-screen column will keep at the left + // or right edge of the screen. + // - "always", the focused column will always be centered. + // - "on-overflow", focusing a column will center it if it doesn't fit + // together with the previously focused column. + center-focused-column "never" + // You can customize the widths that "switch-preset-column-width" (Mod+R) toggles between. + preset-column-widths { + // Proportion sets the width as a fraction of the output width, taking gaps into account. + // For example, you can perfectly fit four windows sized "proportion 0.25" on an output. + // The default preset widths are 1/3, 1/2 and 2/3 of the output. + proportion 0.33333 + proportion 0.5 + proportion 0.66667 + // Fixed sets the width in logical pixels exactly. + // fixed 1920 + } + // You can also customize the heights that "switch-preset-window-height" (Mod+Shift+R) toggles between. + // preset-window-heights { } + // You can change the default width of the new windows. + default-column-width { proportion 0.5; } + // If you leave the brackets empty, the windows themselves will decide their initial width. + // default-column-width {} + // By default focus ring and border are rendered as a solid background rectangle + // behind windows. That is, they will show up through semitransparent windows. + // This is because windows using client-side decorations can have an arbitrary shape. + // + // If you don't like that, you should uncomment `prefer-no-csd` below. + // Niri will draw focus ring and border *around* windows that agree to omit their + // client-side decorations. + // + // Alternatively, you can override it with a window rule called + // `draw-border-with-background`. + border { + off + width 4 + active-color "#707070" // Neutral gray + inactive-color "#d0d0d0" // Light gray + urgent-color "#cc4444" // Softer red + } + shadow { + softness 30 + spread 5 + offset x=0 y=5 + color "#0007" + } + struts { + } +} +layer-rule { + match namespace="^quickshell$" + place-within-backdrop true +} +overview { + workspace-shadow { + off + } +} +// Add lines like this to spawn processes at startup. +// Note that running niri as a session supports xdg-desktop-autostart, +// which may be more convenient to use. +// See the binds section below for more spawn examples. +// This line starts waybar, a commonly used bar for Wayland compositors. +environment { + XDG_CURRENT_DESKTOP "niri" +} +hotkey-overlay { + skip-at-startup +} +prefer-no-csd +screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png" +animations { + workspace-switch { + spring damping-ratio=0.80 stiffness=523 epsilon=0.0001 + } + window-open { + duration-ms 150 + curve "ease-out-expo" + } + window-close { + duration-ms 150 + curve "ease-out-quad" + } + horizontal-view-movement { + spring damping-ratio=0.85 stiffness=423 epsilon=0.0001 + } + window-movement { + spring damping-ratio=0.75 stiffness=323 epsilon=0.0001 + } + window-resize { + spring damping-ratio=0.85 stiffness=423 epsilon=0.0001 + } + config-notification-open-close { + spring damping-ratio=0.65 stiffness=923 epsilon=0.001 + } + screenshot-ui-open { + duration-ms 200 + curve "ease-out-quad" + } + overview-open-close { + spring damping-ratio=0.85 stiffness=800 epsilon=0.0001 + } +} +// Window rules let you adjust behavior for individual windows. +// Find more information on the wiki: +// https://github.com/YaLTeR/niri/wiki/Configuration:-Window-Rules +// Work around WezTerm's initial configure bug +// by setting an empty default-column-width. +window-rule { + // This regular expression is intentionally made as specific as possible, + // since this is the default config, and we want no false positives. + // You can get away with just app-id="wezterm" if you want. + match app-id=r#"^org\.wezfurlong\.wezterm$"# + default-column-width {} +} +window-rule { + match app-id=r#"^org\.gnome\."# + draw-border-with-background false + geometry-corner-radius 12 + clip-to-geometry true +} +window-rule { + match app-id=r#"^gnome-control-center$"# + match app-id=r#"^pavucontrol$"# + match app-id=r#"^nm-connection-editor$"# + default-column-width { proportion 0.5; } + open-floating false +} +window-rule { + match app-id=r#"^org\.gnome\.Calculator$"# + match app-id=r#"^gnome-calculator$"# + match app-id=r#"^galculator$"# + match app-id=r#"^blueman-manager$"# + match app-id=r#"^org\.gnome\.Nautilus$"# + match app-id=r#"^xdg-desktop-portal$"# + open-floating true +} +window-rule { + match app-id=r#"^steam$"# title=r#"^notificationtoasts_\d+_desktop$"# + default-floating-position x=10 y=10 relative-to="bottom-right" + open-focused false +} +window-rule { + match app-id=r#"^org\.wezfurlong\.wezterm$"# + match app-id="Alacritty" + match app-id="zen" + match app-id="com.mitchellh.ghostty" + match app-id="kitty" + draw-border-with-background false +} +window-rule { + match app-id=r#"firefox$"# title="^Picture-in-Picture$" + match app-id="zoom" + open-floating true +} +// Open dms windows as floating by default +window-rule { + match app-id=r#"org.quickshell$"# + match app-id=r#"com.danklinux.dms$"# + open-floating true +} +debug { + honor-xdg-activation-with-invalid-serial +} + +// Override to disable super+tab +recent-windows { + binds { + Alt+Tab { next-window scope="output"; } + Alt+Shift+Tab { previous-window scope="output"; } + Alt+grave { next-window filter="app-id"; } + Alt+Shift+grave { previous-window filter="app-id"; } + } +} + +// Include dms files +include "dms/colors.kdl" +include "dms/layout.kdl" +include "dms/alttab.kdl" +include "dms/binds.kdl" +include "dms/outputs.kdl" +include "dms/cursor.kdl" diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/testpage.pdf b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/testpage.pdf new file mode 100644 index 0000000..03e05c4 Binary files /dev/null and b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/embedded/testpage.pdf differ diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/hyprland.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/hyprland.go new file mode 100644 index 0000000..f6bb964 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/hyprland.go @@ -0,0 +1,15 @@ +package config + +import _ "embed" + +//go:embed embedded/hyprland.conf +var HyprlandConfig string + +//go:embed embedded/hypr-colors.conf +var HyprColorsConfig string + +//go:embed embedded/hypr-layout.conf +var HyprLayoutConfig string + +//go:embed embedded/hypr-binds.conf +var HyprBindsConfig string diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/niri.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/niri.go new file mode 100644 index 0000000..01801ca --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/niri.go @@ -0,0 +1,21 @@ +package config + +import _ "embed" + +//go:embed embedded/niri.kdl +var NiriConfig string + +//go:embed embedded/niri-colors.kdl +var NiriColorsConfig string + +//go:embed embedded/niri-layout.kdl +var NiriLayoutConfig string + +//go:embed embedded/niri-alttab.kdl +var NiriAlttabConfig string + +//go:embed embedded/niri-binds.kdl +var NiriBindsConfig string + +//go:embed embedded/niri-greeter.kdl +var NiriGreeterConfig string diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/terminals.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/terminals.go new file mode 100644 index 0000000..a5bba04 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/terminals.go @@ -0,0 +1,24 @@ +package config + +import _ "embed" + +//go:embed embedded/ghostty.conf +var GhosttyConfig string + +//go:embed embedded/ghostty-colors.conf +var GhosttyColorConfig string + +//go:embed embedded/kitty.conf +var KittyConfig string + +//go:embed embedded/kitty-theme.conf +var KittyThemeConfig string + +//go:embed embedded/kitty-tabs.conf +var KittyTabsConfig string + +//go:embed embedded/alacritty.toml +var AlacrittyConfig string + +//go:embed embedded/alacritty-theme.toml +var AlacrittyThemeConfig string diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/testpage.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/testpage.go new file mode 100644 index 0000000..2c5bbc7 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/config/testpage.go @@ -0,0 +1,6 @@ +package config + +import _ "embed" + +//go:embed embedded/testpage.pdf +var TestPage string diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/dank16.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/dank16.go new file mode 100644 index 0000000..fedd783 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/dank16.go @@ -0,0 +1,651 @@ +package dank16 + +import ( + "fmt" + "math" + + "github.com/lucasb-eyer/go-colorful" +) + +type RGB struct { + R, G, B float64 +} + +type HSV struct { + H, S, V float64 +} + +type ColorInfo struct { + Hex string `json:"hex"` + HexStripped string `json:"hex_stripped"` + R int `json:"r"` + G int `json:"g"` + B int `json:"b"` +} + +type VariantColorValue struct { + Hex string `json:"hex"` + HexStripped string `json:"hex_stripped"` +} + +type VariantColorInfo struct { + Dark VariantColorValue `json:"dark"` + Light VariantColorValue `json:"light"` + Default VariantColorValue `json:"default"` +} + +type Palette struct { + Color0 ColorInfo `json:"color0"` + Color1 ColorInfo `json:"color1"` + Color2 ColorInfo `json:"color2"` + Color3 ColorInfo `json:"color3"` + Color4 ColorInfo `json:"color4"` + Color5 ColorInfo `json:"color5"` + Color6 ColorInfo `json:"color6"` + Color7 ColorInfo `json:"color7"` + Color8 ColorInfo `json:"color8"` + Color9 ColorInfo `json:"color9"` + Color10 ColorInfo `json:"color10"` + Color11 ColorInfo `json:"color11"` + Color12 ColorInfo `json:"color12"` + Color13 ColorInfo `json:"color13"` + Color14 ColorInfo `json:"color14"` + Color15 ColorInfo `json:"color15"` +} + +type VariantPalette struct { + Color0 VariantColorInfo `json:"color0"` + Color1 VariantColorInfo `json:"color1"` + Color2 VariantColorInfo `json:"color2"` + Color3 VariantColorInfo `json:"color3"` + Color4 VariantColorInfo `json:"color4"` + Color5 VariantColorInfo `json:"color5"` + Color6 VariantColorInfo `json:"color6"` + Color7 VariantColorInfo `json:"color7"` + Color8 VariantColorInfo `json:"color8"` + Color9 VariantColorInfo `json:"color9"` + Color10 VariantColorInfo `json:"color10"` + Color11 VariantColorInfo `json:"color11"` + Color12 VariantColorInfo `json:"color12"` + Color13 VariantColorInfo `json:"color13"` + Color14 VariantColorInfo `json:"color14"` + Color15 VariantColorInfo `json:"color15"` +} + +func NewColorInfo(hex string) ColorInfo { + rgb := HexToRGB(hex) + stripped := hex + if len(hex) > 0 && hex[0] == '#' { + stripped = hex[1:] + } + return ColorInfo{ + Hex: hex, + HexStripped: stripped, + R: int(math.Round(rgb.R * 255)), + G: int(math.Round(rgb.G * 255)), + B: int(math.Round(rgb.B * 255)), + } +} + +func HexToRGB(hex string) RGB { + if hex[0] == '#' { + hex = hex[1:] + } + var r, g, b uint8 + fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) + return RGB{ + R: float64(r) / 255.0, + G: float64(g) / 255.0, + B: float64(b) / 255.0, + } +} + +func RGBToHex(rgb RGB) string { + r := math.Max(0, math.Min(1, rgb.R)) + g := math.Max(0, math.Min(1, rgb.G)) + b := math.Max(0, math.Min(1, rgb.B)) + return fmt.Sprintf("#%02x%02x%02x", int(r*255), int(g*255), int(b*255)) +} + +func RGBToHSV(rgb RGB) HSV { + max := math.Max(math.Max(rgb.R, rgb.G), rgb.B) + min := math.Min(math.Min(rgb.R, rgb.G), rgb.B) + delta := max - min + + var h float64 + switch { + case delta == 0: + h = 0 + case max == rgb.R: + h = math.Mod((rgb.G-rgb.B)/delta, 6.0) / 6.0 + case max == rgb.G: + h = ((rgb.B-rgb.R)/delta + 2.0) / 6.0 + default: + h = ((rgb.R-rgb.G)/delta + 4.0) / 6.0 + } + + if h < 0 { + h += 1.0 + } + + var s float64 + if max == 0 { + s = 0 + } else { + s = delta / max + } + + return HSV{H: h, S: s, V: max} +} + +func HSVToRGB(hsv HSV) RGB { + h := hsv.H * 6.0 + c := hsv.V * hsv.S + x := c * (1.0 - math.Abs(math.Mod(h, 2.0)-1.0)) + m := hsv.V - c + + var r, g, b float64 + switch int(h) { + case 0: + r, g, b = c, x, 0 + case 1: + r, g, b = x, c, 0 + case 2: + r, g, b = 0, c, x + case 3: + r, g, b = 0, x, c + case 4: + r, g, b = x, 0, c + case 5: + r, g, b = c, 0, x + default: + r, g, b = c, 0, x + } + + return RGB{R: r + m, G: g + m, B: b + m} +} + +func sRGBToLinear(c float64) float64 { + if c <= 0.04045 { + return c / 12.92 + } + return math.Pow((c+0.055)/1.055, 2.4) +} + +func Luminance(hex string) float64 { + rgb := HexToRGB(hex) + return 0.2126*sRGBToLinear(rgb.R) + 0.7152*sRGBToLinear(rgb.G) + 0.0722*sRGBToLinear(rgb.B) +} + +func ContrastRatio(hexFg, hexBg string) float64 { + lumFg := Luminance(hexFg) + lumBg := Luminance(hexBg) + lighter := math.Max(lumFg, lumBg) + darker := math.Min(lumFg, lumBg) + return (lighter + 0.05) / (darker + 0.05) +} + +func getLstar(hex string) float64 { + rgb := HexToRGB(hex) + col := colorful.Color{R: rgb.R, G: rgb.G, B: rgb.B} + L, _, _ := col.Lab() + return L * 100.0 // go-colorful uses 0-1, we need 0-100 for DPS +} + +// Lab to hex, clamping if needed +func labToHex(L, a, b float64) string { + c := colorful.Lab(L/100.0, a, b) // back to 0-1 for go-colorful + r, g, b2 := c.Clamped().RGB255() + return fmt.Sprintf("#%02x%02x%02x", r, g, b2) +} + +func DeltaPhiStar(hexFg, hexBg string, negativePolarity bool) float64 { + Lf := getLstar(hexFg) + Lb := getLstar(hexBg) + + phi := 1.618 + inv := 0.618 + lc := math.Pow(math.Abs(math.Pow(Lb, phi)-math.Pow(Lf, phi)), inv)*1.414 - 40 + + if negativePolarity { + lc += 5 + } + + return lc +} + +func DeltaPhiStarContrast(hexFg, hexBg string, isLightMode bool) float64 { + negativePolarity := !isLightMode + return DeltaPhiStar(hexFg, hexBg, negativePolarity) +} + +func EnsureContrast(hexColor, hexBg string, minRatio float64, isLightMode bool) string { + currentRatio := ContrastRatio(hexColor, hexBg) + if currentRatio >= minRatio { + return hexColor + } + + rgb := HexToRGB(hexColor) + hsv := RGBToHSV(rgb) + + for step := 1; step < 30; step++ { + delta := float64(step) * 0.02 + + if isLightMode { + newV := math.Max(0, hsv.V-delta) + candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) + if ContrastRatio(candidate, hexBg) >= minRatio { + return candidate + } + + newV = math.Min(1, hsv.V+delta) + candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) + if ContrastRatio(candidate, hexBg) >= minRatio { + return candidate + } + } else { + newV := math.Min(1, hsv.V+delta) + candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) + if ContrastRatio(candidate, hexBg) >= minRatio { + return candidate + } + + newV = math.Max(0, hsv.V-delta) + candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) + if ContrastRatio(candidate, hexBg) >= minRatio { + return candidate + } + } + } + + return hexColor +} + +func EnsureContrastDPS(hexColor, hexBg string, minLc float64, isLightMode bool) string { + currentLc := DeltaPhiStarContrast(hexColor, hexBg, isLightMode) + if currentLc >= minLc { + return hexColor + } + + rgb := HexToRGB(hexColor) + hsv := RGBToHSV(rgb) + + for step := 1; step < 50; step++ { + delta := float64(step) * 0.015 + + if isLightMode { + newV := math.Max(0, hsv.V-delta) + candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) + if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc { + return candidate + } + + newV = math.Min(1, hsv.V+delta) + candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) + if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc { + return candidate + } + } else { + newV := math.Min(1, hsv.V+delta) + candidate := RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) + if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc { + return candidate + } + + newV = math.Max(0, hsv.V-delta) + candidate = RGBToHex(HSVToRGB(HSV{H: hsv.H, S: hsv.S, V: newV})) + if DeltaPhiStarContrast(candidate, hexBg, isLightMode) >= minLc { + return candidate + } + } + } + + return hexColor +} + +// Nudge L* until contrast is good enough. Keeps hue intact unlike HSV fiddling. +func EnsureContrastDPSLstar(hexColor, hexBg string, minLc float64, isLightMode bool) string { + current := DeltaPhiStarContrast(hexColor, hexBg, isLightMode) + if current >= minLc { + return hexColor + } + + fg := HexToRGB(hexColor) + cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B} + Lf, af, bf := cf.Lab() + + dir := 1.0 + if isLightMode { + dir = -1.0 // light mode = darker text + } + + step := 0.5 + for range 120 { + Lf = math.Max(0, math.Min(100, Lf+dir*step)) + cand := labToHex(Lf, af, bf) + if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc { + return cand + } + } + + return hexColor +} + +// Bidirectional contrast - tries both lighter and darker, picks closest to original +func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLightMode bool) string { + current := DeltaPhiStarContrast(hexColor, hexBg, isLightMode) + if current >= minLc { + return hexColor + } + + fg := HexToRGB(hexColor) + cf := colorful.Color{R: fg.R, G: fg.G, B: fg.B} + origL, af, bf := cf.Lab() + + var darkerResult, lighterResult string + darkerL, lighterL := origL, origL + darkerFound, lighterFound := false, false + + step := 0.5 + for i := range 120 { + if !darkerFound { + darkerL = math.Max(0, origL-float64(i)*step) + cand := labToHex(darkerL, af, bf) + if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc { + darkerResult = cand + darkerFound = true + } + } + if !lighterFound { + lighterL = math.Min(100, origL+float64(i)*step) + cand := labToHex(lighterL, af, bf) + if DeltaPhiStarContrast(cand, hexBg, isLightMode) >= minLc { + lighterResult = cand + lighterFound = true + } + } + if darkerFound && lighterFound { + break + } + } + + if darkerFound && lighterFound { + if math.Abs(darkerL-origL) <= math.Abs(lighterL-origL) { + return darkerResult + } + return lighterResult + } + if darkerFound { + return darkerResult + } + if lighterFound { + return lighterResult + } + return hexColor +} + +type PaletteOptions struct { + IsLight bool + Background string + UseDPS bool +} + +func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOptions) string { + if opts.UseDPS { + return EnsureContrastDPSLstar(hexColor, hexBg, target, opts.IsLight) + } + return EnsureContrast(hexColor, hexBg, target, opts.IsLight) +} + +func ensureContrastBidirectional(hexColor, hexBg string, target float64, opts PaletteOptions) string { + if opts.UseDPS { + return EnsureContrastDPSBidirectional(hexColor, hexBg, target, opts.IsLight) + } + return EnsureContrast(hexColor, hexBg, target, opts.IsLight) +} + +func blendHue(base, target, factor float64) float64 { + diff := target - base + if diff > 0.5 { + diff -= 1.0 + } else if diff < -0.5 { + diff += 1.0 + } + result := base + diff*factor + if result < 0 { + result += 1.0 + } else if result >= 1.0 { + result -= 1.0 + } + return result +} + +func DeriveContainer(primary string, isLight bool) string { + rgb := HexToRGB(primary) + hsv := RGBToHSV(rgb) + + if isLight { + containerV := math.Min(hsv.V*1.77, 1.0) + containerS := hsv.S * 0.32 + return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV})) + } + containerV := hsv.V * 0.463 + containerS := math.Min(hsv.S*1.834, 1.0) + return RGBToHex(HSVToRGB(HSV{H: hsv.H, S: containerS, V: containerV})) +} + +func GeneratePalette(primaryColor string, opts PaletteOptions) Palette { + baseColor := DeriveContainer(primaryColor, opts.IsLight) + + rgb := HexToRGB(baseColor) + hsv := RGBToHSV(rgb) + + pr := HexToRGB(primaryColor) + ph := RGBToHSV(pr) + + var palette Palette + + var normalTextTarget, secondaryTarget float64 + if opts.UseDPS { + normalTextTarget = 40.0 + secondaryTarget = 35.0 + } else { + normalTextTarget = 4.5 + secondaryTarget = 3.0 + } + + var bgColor string + if opts.Background != "" { + bgColor = opts.Background + } else if opts.IsLight { + bgColor = "#f8f8f8" + } else { + bgColor = "#1a1a1a" + } + palette.Color0 = NewColorInfo(bgColor) + + baseSat := math.Max(ph.S, 0.5) + baseVal := math.Max(ph.V, 0.5) + + redH := blendHue(0.0, ph.H, 0.12) + greenH := blendHue(0.33, ph.H, 0.10) + yellowH := blendHue(0.14, ph.H, 0.04) + + accentTarget := secondaryTarget * 0.7 + + if opts.IsLight { + redS := math.Min(baseSat*1.2, 1.0) + redV := baseVal * 0.95 + palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts)) + + greenS := math.Min(baseSat*1.3, 1.0) + greenV := baseVal * 0.75 + palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts)) + + yellowS := math.Min(baseSat*1.5, 1.0) + yellowV := math.Min(baseVal*1.2, 1.0) + palette.Color3 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, accentTarget, opts)) + + blueS := math.Min(ph.S*1.05, 1.0) + blueV := math.Min(ph.V*1.05, 1.0) + palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts)) + + // Color5 matches primary_container exactly (light container in light mode) + container5 := DeriveContainer(primaryColor, true) + palette.Color5 = NewColorInfo(container5) + + palette.Color6 = NewColorInfo(primaryColor) + + gray7S := baseSat * 0.08 + gray7V := baseVal * 0.28 + palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts)) + + gray8S := baseSat * 0.05 + gray8V := baseVal * 0.85 + dimTarget := secondaryTarget * 0.5 + palette.Color8 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, dimTarget, opts)) + + brightRedS := math.Min(baseSat*1.0, 1.0) + brightRedV := math.Min(baseVal*1.2, 1.0) + palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts)) + + brightGreenS := math.Min(baseSat*1.1, 1.0) + brightGreenV := math.Min(baseVal*1.1, 1.0) + palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts)) + + brightYellowS := math.Min(baseSat*1.4, 1.0) + brightYellowV := math.Min(baseVal*1.3, 1.0) + palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts)) + + brightBlueS := math.Min(ph.S*1.1, 1.0) + brightBlueV := math.Min(ph.V*1.15, 1.0) + palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts)) + + lightContainer := DeriveContainer(primaryColor, true) + palette.Color13 = NewColorInfo(lightContainer) + + brightCyanS := ph.S * 0.5 + brightCyanV := math.Min(ph.V*1.3, 1.0) + palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightCyanS, V: brightCyanV}))) + + white15S := baseSat * 0.04 + white15V := math.Min(baseVal*1.5, 1.0) + palette.Color15 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V}))) + } else { + redS := math.Min(baseSat*1.1, 1.0) + redV := math.Min(baseVal*1.15, 1.0) + palette.Color1 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: redH, S: redS, V: redV})), bgColor, normalTextTarget, opts)) + + greenS := math.Min(baseSat*1.0, 1.0) + greenV := math.Min(baseVal*1.0, 1.0) + palette.Color2 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: greenH, S: greenS, V: greenV})), bgColor, normalTextTarget, opts)) + + yellowS := math.Min(baseSat*1.1, 1.0) + yellowV := math.Min(baseVal*1.25, 1.0) + palette.Color3 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: yellowH, S: yellowS, V: yellowV})), bgColor, normalTextTarget, opts)) + + // Slightly more saturated variant of primary + blueS := math.Min(ph.S*1.2, 1.0) + blueV := ph.V * 0.95 + palette.Color4 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: ph.H, S: blueS, V: blueV})), bgColor, normalTextTarget, opts)) + + // Color5 matches primary_container exactly (dark container in dark mode) + darkContainer := DeriveContainer(primaryColor, false) + palette.Color5 = NewColorInfo(darkContainer) + + palette.Color6 = NewColorInfo(primaryColor) + + gray7S := baseSat * 0.12 + gray7V := math.Min(baseVal*1.05, 1.0) + palette.Color7 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray7S, V: gray7V})), bgColor, normalTextTarget, opts)) + + gray8S := baseSat * 0.15 + gray8V := baseVal * 0.65 + palette.Color8 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: gray8S, V: gray8V})), bgColor, secondaryTarget, opts)) + + brightRedS := math.Min(baseSat*0.75, 1.0) + brightRedV := math.Min(baseVal*1.35, 1.0) + palette.Color9 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: redH, S: brightRedS, V: brightRedV})), bgColor, accentTarget, opts)) + + brightGreenS := math.Min(baseSat*0.7, 1.0) + brightGreenV := math.Min(baseVal*1.2, 1.0) + palette.Color10 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: greenH, S: brightGreenS, V: brightGreenV})), bgColor, accentTarget, opts)) + + brightYellowS := math.Min(baseSat*0.7, 1.0) + brightYellowV := math.Min(baseVal*1.5, 1.0) + palette.Color11 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: yellowH, S: brightYellowS, V: brightYellowV})), bgColor, accentTarget, opts)) + + // Create a gradient of primary variants: Color12 -> Color13 -> Color14 -> Color15 (near white) + // Color12: Start of the lighter gradient - slightly desaturated + brightBlueS := ph.S * 0.85 + brightBlueV := math.Min(ph.V*1.1, 1.0) + palette.Color12 = NewColorInfo(ensureContrastBidirectional(RGBToHex(HSVToRGB(HSV{H: ph.H, S: brightBlueS, V: brightBlueV})), bgColor, accentTarget, opts)) + + // Medium-high saturation pastel primary + color13S := ph.S * 0.7 + color13V := math.Min(ph.V*1.3, 1.0) + palette.Color13 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color13S, V: color13V}))) + + // Lower saturation, lighter variant + color14S := ph.S * 0.45 + color14V := math.Min(ph.V*1.4, 1.0) + palette.Color14 = NewColorInfo(RGBToHex(HSVToRGB(HSV{H: ph.H, S: color14S, V: color14V}))) + + white15S := baseSat * 0.05 + white15V := math.Min(baseVal*1.45, 1.0) + palette.Color15 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})), bgColor, normalTextTarget, opts)) + } + + return palette +} + +type VariantOptions struct { + PrimaryDark string + PrimaryLight string + Background string + UseDPS bool + IsLightMode bool +} + +func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo { + darkVal := VariantColorValue{Hex: dark.Hex, HexStripped: dark.HexStripped} + lightVal := VariantColorValue{Hex: light.Hex, HexStripped: light.HexStripped} + + defaultVal := darkVal + if isLightMode { + defaultVal = lightVal + } + + return VariantColorInfo{ + Dark: darkVal, + Light: lightVal, + Default: defaultVal, + } +} + +func GenerateVariantPalette(opts VariantOptions) VariantPalette { + darkOpts := PaletteOptions{IsLight: false, Background: opts.Background, UseDPS: opts.UseDPS} + lightOpts := PaletteOptions{IsLight: true, Background: opts.Background, UseDPS: opts.UseDPS} + + dark := GeneratePalette(opts.PrimaryDark, darkOpts) + light := GeneratePalette(opts.PrimaryLight, lightOpts) + + return VariantPalette{ + Color0: mergeColorInfo(dark.Color0, light.Color0, opts.IsLightMode), + Color1: mergeColorInfo(dark.Color1, light.Color1, opts.IsLightMode), + Color2: mergeColorInfo(dark.Color2, light.Color2, opts.IsLightMode), + Color3: mergeColorInfo(dark.Color3, light.Color3, opts.IsLightMode), + Color4: mergeColorInfo(dark.Color4, light.Color4, opts.IsLightMode), + Color5: mergeColorInfo(dark.Color5, light.Color5, opts.IsLightMode), + Color6: mergeColorInfo(dark.Color6, light.Color6, opts.IsLightMode), + Color7: mergeColorInfo(dark.Color7, light.Color7, opts.IsLightMode), + Color8: mergeColorInfo(dark.Color8, light.Color8, opts.IsLightMode), + Color9: mergeColorInfo(dark.Color9, light.Color9, opts.IsLightMode), + Color10: mergeColorInfo(dark.Color10, light.Color10, opts.IsLightMode), + Color11: mergeColorInfo(dark.Color11, light.Color11, opts.IsLightMode), + Color12: mergeColorInfo(dark.Color12, light.Color12, opts.IsLightMode), + Color13: mergeColorInfo(dark.Color13, light.Color13, opts.IsLightMode), + Color14: mergeColorInfo(dark.Color14, light.Color14, opts.IsLightMode), + Color15: mergeColorInfo(dark.Color15, light.Color15, opts.IsLightMode), + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/dank16_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/dank16_test.go new file mode 100644 index 0000000..9e6b5a8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/dank16_test.go @@ -0,0 +1,681 @@ +package dank16 + +import ( + "math" + "testing" +) + +func TestHexToRGB(t *testing.T) { + tests := []struct { + name string + input string + expected RGB + }{ + { + name: "black with hash", + input: "#000000", + expected: RGB{R: 0.0, G: 0.0, B: 0.0}, + }, + { + name: "white with hash", + input: "#ffffff", + expected: RGB{R: 1.0, G: 1.0, B: 1.0}, + }, + { + name: "red without hash", + input: "ff0000", + expected: RGB{R: 1.0, G: 0.0, B: 0.0}, + }, + { + name: "purple", + input: "#625690", + expected: RGB{R: 0.3843137254901961, G: 0.33725490196078434, B: 0.5647058823529412}, + }, + { + name: "mid gray", + input: "#808080", + expected: RGB{R: 0.5019607843137255, G: 0.5019607843137255, B: 0.5019607843137255}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HexToRGB(tt.input) + if !floatEqual(result.R, tt.expected.R) || !floatEqual(result.G, tt.expected.G) || !floatEqual(result.B, tt.expected.B) { + t.Errorf("HexToRGB(%s) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestRGBToHex(t *testing.T) { + tests := []struct { + name string + input RGB + expected string + }{ + { + name: "black", + input: RGB{R: 0.0, G: 0.0, B: 0.0}, + expected: "#000000", + }, + { + name: "white", + input: RGB{R: 1.0, G: 1.0, B: 1.0}, + expected: "#ffffff", + }, + { + name: "red", + input: RGB{R: 1.0, G: 0.0, B: 0.0}, + expected: "#ff0000", + }, + { + name: "clamping above 1.0", + input: RGB{R: 1.5, G: 0.5, B: 0.5}, + expected: "#ff7f7f", + }, + { + name: "clamping below 0.0", + input: RGB{R: -0.5, G: 0.5, B: 0.5}, + expected: "#007f7f", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RGBToHex(tt.input) + if result != tt.expected { + t.Errorf("RGBToHex(%v) = %s, expected %s", tt.input, result, tt.expected) + } + }) + } +} + +func TestRGBToHSV(t *testing.T) { + tests := []struct { + name string + input RGB + expected HSV + }{ + { + name: "black", + input: RGB{R: 0.0, G: 0.0, B: 0.0}, + expected: HSV{H: 0.0, S: 0.0, V: 0.0}, + }, + { + name: "white", + input: RGB{R: 1.0, G: 1.0, B: 1.0}, + expected: HSV{H: 0.0, S: 0.0, V: 1.0}, + }, + { + name: "red", + input: RGB{R: 1.0, G: 0.0, B: 0.0}, + expected: HSV{H: 0.0, S: 1.0, V: 1.0}, + }, + { + name: "green", + input: RGB{R: 0.0, G: 1.0, B: 0.0}, + expected: HSV{H: 0.3333333333333333, S: 1.0, V: 1.0}, + }, + { + name: "blue", + input: RGB{R: 0.0, G: 0.0, B: 1.0}, + expected: HSV{H: 0.6666666666666666, S: 1.0, V: 1.0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RGBToHSV(tt.input) + if !floatEqual(result.H, tt.expected.H) || !floatEqual(result.S, tt.expected.S) || !floatEqual(result.V, tt.expected.V) { + t.Errorf("RGBToHSV(%v) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestHSVToRGB(t *testing.T) { + tests := []struct { + name string + input HSV + expected RGB + }{ + { + name: "black", + input: HSV{H: 0.0, S: 0.0, V: 0.0}, + expected: RGB{R: 0.0, G: 0.0, B: 0.0}, + }, + { + name: "white", + input: HSV{H: 0.0, S: 0.0, V: 1.0}, + expected: RGB{R: 1.0, G: 1.0, B: 1.0}, + }, + { + name: "red", + input: HSV{H: 0.0, S: 1.0, V: 1.0}, + expected: RGB{R: 1.0, G: 0.0, B: 0.0}, + }, + { + name: "green", + input: HSV{H: 0.3333333333333333, S: 1.0, V: 1.0}, + expected: RGB{R: 0.0, G: 1.0, B: 0.0}, + }, + { + name: "blue", + input: HSV{H: 0.6666666666666666, S: 1.0, V: 1.0}, + expected: RGB{R: 0.0, G: 0.0, B: 1.0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HSVToRGB(tt.input) + if !floatEqual(result.R, tt.expected.R) || !floatEqual(result.G, tt.expected.G) || !floatEqual(result.B, tt.expected.B) { + t.Errorf("HSVToRGB(%v) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestLuminance(t *testing.T) { + tests := []struct { + name string + input string + expected float64 + }{ + { + name: "black", + input: "#000000", + expected: 0.0, + }, + { + name: "white", + input: "#ffffff", + expected: 1.0, + }, + { + name: "red", + input: "#ff0000", + expected: 0.2126, + }, + { + name: "green", + input: "#00ff00", + expected: 0.7152, + }, + { + name: "blue", + input: "#0000ff", + expected: 0.0722, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Luminance(tt.input) + if !floatEqual(result, tt.expected) { + t.Errorf("Luminance(%s) = %f, expected %f", tt.input, result, tt.expected) + } + }) + } +} + +func TestContrastRatio(t *testing.T) { + tests := []struct { + name string + fg string + bg string + expected float64 + }{ + { + name: "black on white", + fg: "#000000", + bg: "#ffffff", + expected: 21.0, + }, + { + name: "white on black", + fg: "#ffffff", + bg: "#000000", + expected: 21.0, + }, + { + name: "same color", + fg: "#808080", + bg: "#808080", + expected: 1.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ContrastRatio(tt.fg, tt.bg) + if !floatEqual(result, tt.expected) { + t.Errorf("ContrastRatio(%s, %s) = %f, expected %f", tt.fg, tt.bg, result, tt.expected) + } + }) + } +} + +func TestEnsureContrast(t *testing.T) { + tests := []struct { + name string + color string + bg string + minRatio float64 + isLightMode bool + }{ + { + name: "already sufficient contrast dark mode", + color: "#ffffff", + bg: "#000000", + minRatio: 4.5, + isLightMode: false, + }, + { + name: "already sufficient contrast light mode", + color: "#000000", + bg: "#ffffff", + minRatio: 4.5, + isLightMode: true, + }, + { + name: "needs adjustment dark mode", + color: "#404040", + bg: "#1a1a1a", + minRatio: 4.5, + isLightMode: false, + }, + { + name: "needs adjustment light mode", + color: "#c0c0c0", + bg: "#f8f8f8", + minRatio: 4.5, + isLightMode: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := EnsureContrast(tt.color, tt.bg, tt.minRatio, tt.isLightMode) + actualRatio := ContrastRatio(result, tt.bg) + if actualRatio < tt.minRatio { + t.Errorf("EnsureContrast(%s, %s, %f, %t) = %s with ratio %f, expected ratio >= %f", + tt.color, tt.bg, tt.minRatio, tt.isLightMode, result, actualRatio, tt.minRatio) + } + }) + } +} + +func TestGeneratePalette(t *testing.T) { + tests := []struct { + name string + base string + opts PaletteOptions + }{ + { + name: "dark theme default", + base: "#625690", + opts: PaletteOptions{IsLight: false}, + }, + { + name: "light theme default", + base: "#625690", + opts: PaletteOptions{IsLight: true}, + }, + { + name: "light theme with custom background", + base: "#625690", + opts: PaletteOptions{ + IsLight: true, + Background: "#fafafa", + }, + }, + { + name: "dark theme with custom background", + base: "#625690", + opts: PaletteOptions{ + IsLight: false, + Background: "#0a0a0a", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GeneratePalette(tt.base, tt.opts) + + colors := []ColorInfo{ + result.Color0, result.Color1, result.Color2, result.Color3, + result.Color4, result.Color5, result.Color6, result.Color7, + result.Color8, result.Color9, result.Color10, result.Color11, + result.Color12, result.Color13, result.Color14, result.Color15, + } + + for i, color := range colors { + if len(color.Hex) != 7 || color.Hex[0] != '#' { + t.Errorf("Color at index %d (%s) is not a valid hex color", i, color.Hex) + } + } + + if tt.opts.Background != "" && result.Color0.Hex != tt.opts.Background { + t.Errorf("Background color = %s, expected %s", result.Color0.Hex, tt.opts.Background) + } else if !tt.opts.IsLight && tt.opts.Background == "" && result.Color0.Hex != "#1a1a1a" { + t.Errorf("Dark mode background = %s, expected #1a1a1a", result.Color0.Hex) + } else if tt.opts.IsLight && tt.opts.Background == "" && result.Color0.Hex != "#f8f8f8" { + t.Errorf("Light mode background = %s, expected #f8f8f8", result.Color0.Hex) + } + + // Color15 is now derived from primary, so just verify it's a valid color + // and has appropriate luminance for the mode (now theme-tinted, not pure white/black) + color15Lum := Luminance(result.Color15.Hex) + if tt.opts.IsLight { + // Light mode: Color15 should still be relatively light + if color15Lum < 0.5 { + t.Errorf("Light mode Color15 = %s (lum %.2f) is too dark", result.Color15.Hex, color15Lum) + } + } else { + // Dark mode: Color15 should be light (but may have theme tint, so lower threshold) + if color15Lum < 0.5 { + t.Errorf("Dark mode Color15 = %s (lum %.2f) is too dark", result.Color15.Hex, color15Lum) + } + } + }) + } +} + +func TestRoundTripConversion(t *testing.T) { + testColors := []string{"#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff", "#625690", "#808080"} + + for _, hex := range testColors { + t.Run(hex, func(t *testing.T) { + rgb := HexToRGB(hex) + result := RGBToHex(rgb) + if result != hex { + t.Errorf("Round trip %s -> RGB -> %s failed", hex, result) + } + }) + } +} + +func TestRGBHSVRoundTrip(t *testing.T) { + testCases := []RGB{ + {R: 0.0, G: 0.0, B: 0.0}, + {R: 1.0, G: 1.0, B: 1.0}, + {R: 1.0, G: 0.0, B: 0.0}, + {R: 0.0, G: 1.0, B: 0.0}, + {R: 0.0, G: 0.0, B: 1.0}, + {R: 0.5, G: 0.5, B: 0.5}, + {R: 0.3843137254901961, G: 0.33725490196078434, B: 0.5647058823529412}, + } + + for _, rgb := range testCases { + t.Run("", func(t *testing.T) { + hsv := RGBToHSV(rgb) + result := HSVToRGB(hsv) + if !floatEqual(result.R, rgb.R) || !floatEqual(result.G, rgb.G) || !floatEqual(result.B, rgb.B) { + t.Errorf("Round trip RGB->HSV->RGB failed: %v -> %v -> %v", rgb, hsv, result) + } + }) + } +} + +func floatEqual(a, b float64) bool { + return math.Abs(a-b) < 1e-9 +} + +func TestDeltaPhiStar(t *testing.T) { + tests := []struct { + name string + fg string + bg string + negativePolarity bool + minExpected float64 + }{ + { + name: "white on black (negative polarity)", + fg: "#ffffff", + bg: "#000000", + negativePolarity: true, + minExpected: 100.0, + }, + { + name: "black on white (positive polarity)", + fg: "#000000", + bg: "#ffffff", + negativePolarity: false, + minExpected: 100.0, + }, + { + name: "low contrast same color", + fg: "#808080", + bg: "#808080", + negativePolarity: false, + minExpected: -40.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DeltaPhiStar(tt.fg, tt.bg, tt.negativePolarity) + if result < tt.minExpected { + t.Errorf("DeltaPhiStar(%s, %s, %v) = %f, expected >= %f", + tt.fg, tt.bg, tt.negativePolarity, result, tt.minExpected) + } + }) + } +} + +func TestDeltaPhiStarContrast(t *testing.T) { + tests := []struct { + name string + fg string + bg string + isLightMode bool + minExpected float64 + }{ + { + name: "white on black (dark mode)", + fg: "#ffffff", + bg: "#000000", + isLightMode: false, + minExpected: 100.0, + }, + { + name: "black on white (light mode)", + fg: "#000000", + bg: "#ffffff", + isLightMode: true, + minExpected: 100.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DeltaPhiStarContrast(tt.fg, tt.bg, tt.isLightMode) + if result < tt.minExpected { + t.Errorf("DeltaPhiStarContrast(%s, %s, %v) = %f, expected >= %f", + tt.fg, tt.bg, tt.isLightMode, result, tt.minExpected) + } + }) + } +} + +func TestEnsureContrastDPS(t *testing.T) { + tests := []struct { + name string + color string + bg string + minLc float64 + isLightMode bool + }{ + { + name: "already sufficient contrast dark mode", + color: "#ffffff", + bg: "#000000", + minLc: 60.0, + isLightMode: false, + }, + { + name: "already sufficient contrast light mode", + color: "#000000", + bg: "#ffffff", + minLc: 60.0, + isLightMode: true, + }, + { + name: "needs adjustment dark mode", + color: "#404040", + bg: "#1a1a1a", + minLc: 60.0, + isLightMode: false, + }, + { + name: "needs adjustment light mode", + color: "#c0c0c0", + bg: "#f8f8f8", + minLc: 60.0, + isLightMode: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := EnsureContrastDPS(tt.color, tt.bg, tt.minLc, tt.isLightMode) + actualLc := DeltaPhiStarContrast(result, tt.bg, tt.isLightMode) + if actualLc < tt.minLc { + t.Errorf("EnsureContrastDPS(%s, %s, %f, %t) = %s with Lc %f, expected Lc >= %f", + tt.color, tt.bg, tt.minLc, tt.isLightMode, result, actualLc, tt.minLc) + } + }) + } +} + +func TestGeneratePaletteWithDPS(t *testing.T) { + tests := []struct { + name string + base string + opts PaletteOptions + }{ + { + name: "dark theme with DPS", + base: "#625690", + opts: PaletteOptions{IsLight: false, UseDPS: true}, + }, + { + name: "light theme with DPS", + base: "#625690", + opts: PaletteOptions{IsLight: true, UseDPS: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GeneratePalette(tt.base, tt.opts) + + colors := []ColorInfo{ + result.Color0, result.Color1, result.Color2, result.Color3, + result.Color4, result.Color5, result.Color6, result.Color7, + result.Color8, result.Color9, result.Color10, result.Color11, + result.Color12, result.Color13, result.Color14, result.Color15, + } + + for i, color := range colors { + if len(color.Hex) != 7 || color.Hex[0] != '#' { + t.Errorf("Color at index %d (%s) is not a valid hex color", i, color.Hex) + } + } + + bgColor := result.Color0.Hex + for i := 1; i < 8; i++ { + // Skip Color5 (container) and Color6 (exact primary) - intentionally not contrast-adjusted + if i == 5 || i == 6 { + continue + } + lc := DeltaPhiStarContrast(colors[i].Hex, bgColor, tt.opts.IsLight) + minLc := 30.0 + if lc < minLc && lc > 0 { + t.Errorf("Color %d (%s) has insufficient DPS contrast %f with background %s (expected >= %f)", + i, colors[i].Hex, lc, bgColor, minLc) + } + } + }) + } +} + +func TestDeriveContainer(t *testing.T) { + tests := []struct { + name string + primary string + isLight bool + expected string + }{ + { + name: "dark mode", + primary: "#ccbdff", + isLight: false, + expected: "#4a3e76", + }, + { + name: "light mode", + primary: "#625690", + isLight: true, + expected: "#e7deff", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DeriveContainer(tt.primary, tt.isLight) + + resultRGB := HexToRGB(result) + expectedRGB := HexToRGB(tt.expected) + + rDiff := math.Abs(resultRGB.R - expectedRGB.R) + gDiff := math.Abs(resultRGB.G - expectedRGB.G) + bDiff := math.Abs(resultRGB.B - expectedRGB.B) + + tolerance := 0.02 + if rDiff > tolerance || gDiff > tolerance || bDiff > tolerance { + t.Errorf("DeriveContainer(%s, %v) = %s, expected %s (RGB diff: R:%.4f G:%.4f B:%.4f)", + tt.primary, tt.isLight, result, tt.expected, rDiff, gDiff, bDiff) + } + }) + } +} + +func TestContrastAlgorithmComparison(t *testing.T) { + base := "#625690" + + optsWCAG := PaletteOptions{IsLight: false, UseDPS: false} + optsDPS := PaletteOptions{IsLight: false, UseDPS: true} + + paletteWCAG := GeneratePalette(base, optsWCAG) + paletteDPS := GeneratePalette(base, optsDPS) + + wcagColors := []ColorInfo{ + paletteWCAG.Color0, paletteWCAG.Color1, paletteWCAG.Color2, paletteWCAG.Color3, + paletteWCAG.Color4, paletteWCAG.Color5, paletteWCAG.Color6, paletteWCAG.Color7, + paletteWCAG.Color8, paletteWCAG.Color9, paletteWCAG.Color10, paletteWCAG.Color11, + paletteWCAG.Color12, paletteWCAG.Color13, paletteWCAG.Color14, paletteWCAG.Color15, + } + dpsColors := []ColorInfo{ + paletteDPS.Color0, paletteDPS.Color1, paletteDPS.Color2, paletteDPS.Color3, + paletteDPS.Color4, paletteDPS.Color5, paletteDPS.Color6, paletteDPS.Color7, + paletteDPS.Color8, paletteDPS.Color9, paletteDPS.Color10, paletteDPS.Color11, + paletteDPS.Color12, paletteDPS.Color13, paletteDPS.Color14, paletteDPS.Color15, + } + + if paletteWCAG.Color0.Hex != paletteDPS.Color0.Hex { + t.Errorf("Background colors differ: WCAG=%s, DPS=%s", paletteWCAG.Color0.Hex, paletteDPS.Color0.Hex) + } + + differentCount := 0 + for i := range 16 { + if wcagColors[i].Hex != dpsColors[i].Hex { + differentCount++ + } + } + + t.Logf("WCAG and DPS palettes differ in %d/16 colors", differentCount) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/terminals.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/terminals.go new file mode 100644 index 0000000..9423311 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/dank16/terminals.go @@ -0,0 +1,135 @@ +package dank16 + +import ( + "encoding/json" + "fmt" + "strings" +) + +func GenerateJSON(p Palette) string { + marshalled, _ := json.Marshal(p) + return string(marshalled) +} + +func GenerateVariantJSON(p VariantPalette) string { + marshalled, _ := json.Marshal(p) + return string(marshalled) +} + +func GenerateKittyTheme(p Palette) string { + var result strings.Builder + fmt.Fprintf(&result, "color0 %s\n", p.Color0.Hex) + fmt.Fprintf(&result, "color1 %s\n", p.Color1.Hex) + fmt.Fprintf(&result, "color2 %s\n", p.Color2.Hex) + fmt.Fprintf(&result, "color3 %s\n", p.Color3.Hex) + fmt.Fprintf(&result, "color4 %s\n", p.Color4.Hex) + fmt.Fprintf(&result, "color5 %s\n", p.Color5.Hex) + fmt.Fprintf(&result, "color6 %s\n", p.Color6.Hex) + fmt.Fprintf(&result, "color7 %s\n", p.Color7.Hex) + fmt.Fprintf(&result, "color8 %s\n", p.Color8.Hex) + fmt.Fprintf(&result, "color9 %s\n", p.Color9.Hex) + fmt.Fprintf(&result, "color10 %s\n", p.Color10.Hex) + fmt.Fprintf(&result, "color11 %s\n", p.Color11.Hex) + fmt.Fprintf(&result, "color12 %s\n", p.Color12.Hex) + fmt.Fprintf(&result, "color13 %s\n", p.Color13.Hex) + fmt.Fprintf(&result, "color14 %s\n", p.Color14.Hex) + fmt.Fprintf(&result, "color15 %s\n", p.Color15.Hex) + return result.String() +} + +func GenerateFootTheme(p Palette) string { + var result strings.Builder + fmt.Fprintf(&result, "regular0=%s\n", p.Color0.HexStripped) + fmt.Fprintf(&result, "regular1=%s\n", p.Color1.HexStripped) + fmt.Fprintf(&result, "regular2=%s\n", p.Color2.HexStripped) + fmt.Fprintf(&result, "regular3=%s\n", p.Color3.HexStripped) + fmt.Fprintf(&result, "regular4=%s\n", p.Color4.HexStripped) + fmt.Fprintf(&result, "regular5=%s\n", p.Color5.HexStripped) + fmt.Fprintf(&result, "regular6=%s\n", p.Color6.HexStripped) + fmt.Fprintf(&result, "regular7=%s\n", p.Color7.HexStripped) + fmt.Fprintf(&result, "bright0=%s\n", p.Color8.HexStripped) + fmt.Fprintf(&result, "bright1=%s\n", p.Color9.HexStripped) + fmt.Fprintf(&result, "bright2=%s\n", p.Color10.HexStripped) + fmt.Fprintf(&result, "bright3=%s\n", p.Color11.HexStripped) + fmt.Fprintf(&result, "bright4=%s\n", p.Color12.HexStripped) + fmt.Fprintf(&result, "bright5=%s\n", p.Color13.HexStripped) + fmt.Fprintf(&result, "bright6=%s\n", p.Color14.HexStripped) + fmt.Fprintf(&result, "bright7=%s\n", p.Color15.HexStripped) + return result.String() +} + +func GenerateAlacrittyTheme(p Palette) string { + var result strings.Builder + result.WriteString("[colors.normal]\n") + fmt.Fprintf(&result, "black = '%s'\n", p.Color0.Hex) + fmt.Fprintf(&result, "red = '%s'\n", p.Color1.Hex) + fmt.Fprintf(&result, "green = '%s'\n", p.Color2.Hex) + fmt.Fprintf(&result, "yellow = '%s'\n", p.Color3.Hex) + fmt.Fprintf(&result, "blue = '%s'\n", p.Color4.Hex) + fmt.Fprintf(&result, "magenta = '%s'\n", p.Color5.Hex) + fmt.Fprintf(&result, "cyan = '%s'\n", p.Color6.Hex) + fmt.Fprintf(&result, "white = '%s'\n", p.Color7.Hex) + result.WriteString("\n[colors.bright]\n") + fmt.Fprintf(&result, "black = '%s'\n", p.Color8.Hex) + fmt.Fprintf(&result, "red = '%s'\n", p.Color9.Hex) + fmt.Fprintf(&result, "green = '%s'\n", p.Color10.Hex) + fmt.Fprintf(&result, "yellow = '%s'\n", p.Color11.Hex) + fmt.Fprintf(&result, "blue = '%s'\n", p.Color12.Hex) + fmt.Fprintf(&result, "magenta = '%s'\n", p.Color13.Hex) + fmt.Fprintf(&result, "cyan = '%s'\n", p.Color14.Hex) + fmt.Fprintf(&result, "white = '%s'\n", p.Color15.Hex) + return result.String() +} + +func GenerateGhosttyTheme(p Palette) string { + var result strings.Builder + fmt.Fprintf(&result, "palette = 0=%s\n", p.Color0.Hex) + fmt.Fprintf(&result, "palette = 1=%s\n", p.Color1.Hex) + fmt.Fprintf(&result, "palette = 2=%s\n", p.Color2.Hex) + fmt.Fprintf(&result, "palette = 3=%s\n", p.Color3.Hex) + fmt.Fprintf(&result, "palette = 4=%s\n", p.Color4.Hex) + fmt.Fprintf(&result, "palette = 5=%s\n", p.Color5.Hex) + fmt.Fprintf(&result, "palette = 6=%s\n", p.Color6.Hex) + fmt.Fprintf(&result, "palette = 7=%s\n", p.Color7.Hex) + fmt.Fprintf(&result, "palette = 8=%s\n", p.Color8.Hex) + fmt.Fprintf(&result, "palette = 9=%s\n", p.Color9.Hex) + fmt.Fprintf(&result, "palette = 10=%s\n", p.Color10.Hex) + fmt.Fprintf(&result, "palette = 11=%s\n", p.Color11.Hex) + fmt.Fprintf(&result, "palette = 12=%s\n", p.Color12.Hex) + fmt.Fprintf(&result, "palette = 13=%s\n", p.Color13.Hex) + fmt.Fprintf(&result, "palette = 14=%s\n", p.Color14.Hex) + fmt.Fprintf(&result, "palette = 15=%s\n", p.Color15.Hex) + return result.String() +} + +func GenerateWeztermTheme(p Palette) string { + var result strings.Builder + fmt.Fprintf(&result, "ansi = ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']\n", + p.Color0.Hex, p.Color1.Hex, p.Color2.Hex, p.Color3.Hex, + p.Color4.Hex, p.Color5.Hex, p.Color6.Hex, p.Color7.Hex) + fmt.Fprintf(&result, "brights = ['%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']\n", + p.Color8.Hex, p.Color9.Hex, p.Color10.Hex, p.Color11.Hex, + p.Color12.Hex, p.Color13.Hex, p.Color14.Hex, p.Color15.Hex) + return result.String() +} + +func GenerateNeovimTheme(p Palette) string { + var result strings.Builder + fmt.Fprintf(&result, "vim.g.terminal_color_0 = \"%s\"\n", p.Color0.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_1 = \"%s\"\n", p.Color1.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_2 = \"%s\"\n", p.Color2.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_3 = \"%s\"\n", p.Color3.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_4 = \"%s\"\n", p.Color4.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_5 = \"%s\"\n", p.Color5.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_6 = \"%s\"\n", p.Color6.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_7 = \"%s\"\n", p.Color7.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_8 = \"%s\"\n", p.Color8.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_9 = \"%s\"\n", p.Color9.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_10 = \"%s\"\n", p.Color10.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_11 = \"%s\"\n", p.Color11.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_12 = \"%s\"\n", p.Color12.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_13 = \"%s\"\n", p.Color13.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_14 = \"%s\"\n", p.Color14.Hex) + fmt.Fprintf(&result, "vim.g.terminal_color_15 = \"%s\"\n", p.Color15.Hex) + return result.String() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/deps/detector.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/deps/detector.go new file mode 100644 index 0000000..e4f54a7 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/deps/detector.go @@ -0,0 +1,51 @@ +package deps + +import ( + "context" +) + +type DependencyStatus int + +const ( + StatusMissing DependencyStatus = iota + StatusInstalled + StatusNeedsUpdate + StatusNeedsReinstall +) + +type PackageVariant int + +const ( + VariantStable PackageVariant = iota + VariantGit +) + +type Dependency struct { + Name string + Status DependencyStatus + Version string + Description string + Required bool + Variant PackageVariant + CanToggle bool +} + +type WindowManager int + +const ( + WindowManagerHyprland WindowManager = iota + WindowManagerNiri +) + +type Terminal int + +const ( + TerminalGhostty Terminal = iota + TerminalKitty + TerminalAlacritty +) + +type DependencyDetector interface { + DetectDependencies(ctx context.Context, wm WindowManager) ([]Dependency, error) + DetectDependenciesWithTerminal(ctx context.Context, wm WindowManager, terminal Terminal) ([]Dependency, error) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/arch.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/arch.go new file mode 100644 index 0000000..b412f88 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/arch.go @@ -0,0 +1,807 @@ +package distros + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +func init() { + Register("arch", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("archarm", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("archcraft", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("cachyos", "#08A283", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("catos", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("endeavouros", "#7F3FBF", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("manjaro", "#35BF5C", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("obarun", "#2494be", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("garuda", "#cba6f7", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("artix", "#1793D1", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) + Register("XeroLinux", "#888fe2", FamilyArch, func(config DistroConfig, logChan chan<- string) Distribution { + return NewArchDistribution(config, logChan) + }) +} + +type ArchDistribution struct { + *BaseDistribution + *ManualPackageInstaller + config DistroConfig +} + +func NewArchDistribution(config DistroConfig, logChan chan<- string) *ArchDistribution { + base := NewBaseDistribution(logChan) + return &ArchDistribution{ + BaseDistribution: base, + ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base}, + config: config, + } +} + +func (a *ArchDistribution) GetID() string { + return a.config.ID +} + +func (a *ArchDistribution) GetColorHex() string { + return a.config.ColorHex +} + +func (a *ArchDistribution) GetFamily() DistroFamily { + return a.config.Family +} + +func (a *ArchDistribution) GetPackageManager() PackageManagerType { + return PackageManagerPacman +} + +func (a *ArchDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) { + return a.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty) +} + +func (a *ArchDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) { + var dependencies []deps.Dependency + + // DMS at the top (shell is prominent) + dependencies = append(dependencies, a.detectDMS()) + + // Terminal with choice support + dependencies = append(dependencies, a.detectSpecificTerminal(terminal)) + + // Common detections using base methods + dependencies = append(dependencies, a.detectGit()) + dependencies = append(dependencies, a.detectWindowManager(wm)) + dependencies = append(dependencies, a.detectQuickshell()) + dependencies = append(dependencies, a.detectDMSGreeter()) + dependencies = append(dependencies, a.detectXDGPortal()) + dependencies = append(dependencies, a.detectAccountsService()) + + // Hyprland-specific tools + if wm == deps.WindowManagerHyprland { + dependencies = append(dependencies, a.detectHyprlandTools()...) + } + + // Niri-specific tools + if wm == deps.WindowManagerNiri { + dependencies = append(dependencies, a.detectXwaylandSatellite()) + } + + dependencies = append(dependencies, a.detectMatugen()) + dependencies = append(dependencies, a.detectDgop()) + + return dependencies, nil +} + +func (a *ArchDistribution) detectXDGPortal() deps.Dependency { + return a.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", a.packageInstalled("xdg-desktop-portal-gtk")) +} + +func (a *ArchDistribution) detectAccountsService() deps.Dependency { + return a.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", a.packageInstalled("accountsservice")) +} + +func (a *ArchDistribution) detectDMSGreeter() deps.Dependency { + return a.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", a.packageInstalled("greetd-dms-greeter-git")) +} + +func (a *ArchDistribution) packageInstalled(pkg string) bool { + cmd := exec.Command("pacman", "-Q", pkg) + err := cmd.Run() + return err == nil +} + +// parseSRCINFODeps reads a .SRCINFO file and returns runtime dep and makedep package +func parseSRCINFODeps(srcinfoPath string) (deps []string, makedeps []string, err error) { + data, err := os.ReadFile(srcinfoPath) + if err != nil { + return nil, nil, err + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + var pkg string + var target *[]string + switch { + case strings.HasPrefix(line, "makedepends = "): + pkg = strings.TrimPrefix(line, "makedepends = ") + target = &makedeps + case strings.HasPrefix(line, "depends = "): + pkg = strings.TrimPrefix(line, "depends = ") + target = &deps + default: + continue + } + // Strip version constraint (>=, <=, >, <, =) and colon-descriptions + if idx := strings.IndexAny(pkg, "><:="); idx >= 0 { + pkg = pkg[:idx] + } + pkg = strings.TrimSpace(pkg) + if pkg != "" { + *target = append(*target, pkg) + } + } + return deps, makedeps, nil +} + +func (a *ArchDistribution) isInSystemRepo(pkg string) bool { + return exec.Command("pacman", "-Si", pkg).Run() == nil +} + +func (a *ArchDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { + return a.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) +} + +func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping { + packages := map[string]PackageMapping{ + "dms (DankMaterialShell)": a.getDMSMapping(variants["dms (DankMaterialShell)"]), + "git": {Name: "git", Repository: RepoTypeSystem}, + "quickshell": a.getQuickshellMapping(variants["quickshell"]), + "dms-greeter": {Name: "greetd-dms-greeter-git", Repository: RepoTypeAUR}, + "matugen": a.getMatugenMapping(variants["matugen"]), + "dgop": {Name: "dgop", Repository: RepoTypeSystem}, + "ghostty": {Name: "ghostty", Repository: RepoTypeSystem}, + "kitty": {Name: "kitty", Repository: RepoTypeSystem}, + "alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, + "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, + "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, + } + + switch wm { + case deps.WindowManagerHyprland: + packages["hyprland"] = a.getHyprlandMapping(variants["hyprland"]) + packages["hyprctl"] = a.getHyprlandMapping(variants["hyprland"]) + packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} + case deps.WindowManagerNiri: + packages["niri"] = a.getNiriMapping(variants["niri"]) + packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} + } + + return packages +} + +func (a *ArchDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { + if forceQuickshellGit || variant == deps.VariantGit { + return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR} + } + // ! TODO - for now we're only forcing quickshell-git on ARCH, as other distros use DL repos which pin a newer quickshell + return PackageMapping{Name: "quickshell-git", Repository: RepoTypeAUR} +} + +func (a *ArchDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { + return PackageMapping{Name: "hyprland", Repository: RepoTypeSystem} +} + +func (a *ArchDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "niri-git", Repository: RepoTypeAUR} + } + return PackageMapping{Name: "niri", Repository: RepoTypeSystem} +} + +func (a *ArchDistribution) getMatugenMapping(variant deps.PackageVariant) PackageMapping { + if runtime.GOARCH == "arm64" { + return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR} + } + + if variant == deps.VariantGit { + return PackageMapping{Name: "matugen-git", Repository: RepoTypeAUR} + } + return PackageMapping{Name: "matugen", Repository: RepoTypeSystem} +} + +func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMapping { + if forceDMSGit || variant == deps.VariantGit { + return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR} + } + + if a.packageInstalled("dms-shell-git") { + return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR} + } + + return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem} +} + +func (a *ArchDistribution) detectXwaylandSatellite() deps.Dependency { + status := deps.StatusMissing + if a.commandExists("xwayland-satellite") { + status = deps.StatusInstalled + } + + return deps.Dependency{ + Name: "xwayland-satellite", + Status: status, + Description: "Xwayland support", + Required: true, + } +} + +func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.06, + Step: "Checking base-devel...", + IsComplete: false, + LogOutput: "Checking if base-devel is installed", + } + + checkCmd := exec.CommandContext(ctx, "pacman", "-Qq", "base-devel") + if err := checkCmd.Run(); err == nil { + a.log("base-devel already installed") + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.10, + Step: "base-devel already installed", + IsComplete: false, + LogOutput: "base-devel is already installed on the system", + } + return nil + } + + a.log("Installing base-devel...") + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.08, + Step: "Installing base-devel...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo pacman -S --needed --noconfirm base-devel", + LogOutput: "Installing base-devel development tools", + } + + cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel") + if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil { + return fmt.Errorf("failed to install base-devel: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.12, + Step: "base-devel installation complete", + IsComplete: false, + LogOutput: "base-devel successfully installed", + } + + return nil +} + +func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error { + // Phase 1: Check Prerequisites + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.05, + Step: "Checking system prerequisites...", + IsComplete: false, + LogOutput: "Starting prerequisite check...", + } + + if err := a.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install prerequisites: %w", err) + } + + systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags) + + if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") { + if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to preinstall quickshell-git: %w", err) + } + aurPkgs = slices.DeleteFunc(aurPkgs, func(p string) bool { return p == "quickshell-git" }) + } + + // Phase 3: System Packages + if len(systemPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.35, + Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)), + IsComplete: false, + NeedsSudo: true, + LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")), + } + if err := a.installSystemPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install system packages: %w", err) + } + } + + // Phase 4: AUR Packages + if len(aurPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.65, + Step: fmt.Sprintf("Installing %d AUR packages...", len(aurPkgs)), + IsComplete: false, + LogOutput: fmt.Sprintf("Installing AUR packages: %s", strings.Join(aurPkgs, ", ")), + } + if err := a.installAURPackages(ctx, aurPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install AUR packages: %w", err) + } + } + + // Phase 5: Manual Builds + if len(manualPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.85, + Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)), + IsComplete: false, + LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")), + } + if err := a.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install manual packages: %w", err) + } + } + + // Phase 6: Configuration + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.90, + Step: "Configuring system...", + IsComplete: false, + LogOutput: "Starting post-installation configuration...", + } + + terminal := a.DetectTerminalFromDeps(dependencies) + if err := a.WriteEnvironmentConfig(terminal); err != nil { + a.log(fmt.Sprintf("Warning: failed to write environment config: %v", err)) + } + + if err := a.WriteWindowManagerConfig(wm); err != nil { + a.log(fmt.Sprintf("Warning: failed to write window manager config: %v", err)) + } + + if err := a.EnableDMSService(ctx, wm); err != nil { + a.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err)) + } + + // Phase 7: Complete + progressChan <- InstallProgressMsg{ + Phase: PhaseComplete, + Progress: 1.0, + Step: "Installation complete!", + IsComplete: true, + LogOutput: "All packages installed and configured successfully", + } + + return nil +} + +func (a *ArchDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []string, []string, map[string]deps.PackageVariant) { + systemPkgs := []string{} + aurPkgs := []string{} + manualPkgs := []string{} + + variantMap := make(map[string]deps.PackageVariant) + for _, dep := range dependencies { + variantMap[dep.Name] = dep.Variant + } + + packageMap := a.GetPackageMappingWithVariants(wm, variantMap) + + for _, dep := range dependencies { + if disabledFlags[dep.Name] { + continue + } + + if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] { + continue + } + + pkgInfo, exists := packageMap[dep.Name] + if !exists { + manualPkgs = append(manualPkgs, dep.Name) + continue + } + + switch pkgInfo.Repository { + case RepoTypeAUR: + aurPkgs = append(aurPkgs, pkgInfo.Name) + case RepoTypeSystem: + systemPkgs = append(systemPkgs, pkgInfo.Name) + case RepoTypeManual: + manualPkgs = append(manualPkgs, dep.Name) + } + } + + return systemPkgs, aurPkgs, manualPkgs, variantMap +} + +func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if a.packageInstalled("quickshell-git") { + return nil + } + + if a.packageInstalled("quickshell") { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.15, + Step: "Removing stable quickshell...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo pacman -Rdd --noconfirm quickshell", + LogOutput: "Removing stable quickshell so quickshell-git can be installed", + } + cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell") + if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil { + return fmt.Errorf("failed to remove stable quickshell: %w", err) + } + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.18, + Step: "Building quickshell-git before system packages...", + IsComplete: false, + CommandInfo: "Installing quickshell-git ahead of dms-shell to avoid conflict", + } + return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32) +} + +func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + a.log(fmt.Sprintf("Installing system packages: %s", strings.Join(packages, ", "))) + + args := []string{"pacman", "-S", "--needed", "--noconfirm"} + if slices.Contains(packages, "dms-shell") { + args = append(args, "--assume-installed", "dms-shell-compositor=1") + } + args = append(args, packages...) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.40, + Step: "Installing system packages...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60) +} + +func (a *ArchDistribution) installAURPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + a.log(fmt.Sprintf("Installing AUR packages manually: %s", strings.Join(packages, ", "))) + + hasNiri := false + for _, pkg := range packages { + if pkg == "niri-git" { + hasNiri = true + } + } + + // If niri is in the list, install makepkg-git-lfs-proto first if not already installed + if hasNiri { + if !a.packageInstalled("makepkg-git-lfs-proto") { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.65, + Step: "Installing makepkg-git-lfs-proto for niri...", + IsComplete: false, + CommandInfo: "Installing prerequisite for niri-git", + } + + if err := a.installSingleAURPackage(ctx, "makepkg-git-lfs-proto", sudoPassword, progressChan, 0.65, 0.67); err != nil { + return fmt.Errorf("failed to install makepkg-git-lfs-proto prerequisite for niri: %w", err) + } + } + } + + // Reorder packages to ensure dms-shell-git dependencies are installed first + orderedPackages := a.reorderAURPackages(packages) + + baseProgress := 0.67 + progressStep := 0.13 / float64(len(orderedPackages)) + + for i, pkg := range orderedPackages { + currentProgress := baseProgress + (float64(i) * progressStep) + + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: currentProgress, + Step: fmt.Sprintf("Installing AUR package %s (%d/%d)...", pkg, i+1, len(packages)), + IsComplete: false, + CommandInfo: fmt.Sprintf("Building and installing %s", pkg), + } + + if err := a.installSingleAURPackage(ctx, pkg, sudoPassword, progressChan, currentProgress, currentProgress+progressStep); err != nil { + return fmt.Errorf("failed to install AUR package %s: %w", pkg, err) + } + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.80, + Step: "All AUR packages installed successfully", + IsComplete: false, + LogOutput: fmt.Sprintf("Successfully installed AUR packages: %s", strings.Join(packages, ", ")), + } + + return nil +} + +func (a *ArchDistribution) reorderAURPackages(packages []string) []string { + dmsDepencies := []string{"quickshell", "quickshell-git", "dgop"} + + var deps []string + var others []string + var dmsShell []string + + for _, pkg := range packages { + if pkg == "dms-shell-git" { + dmsShell = append(dmsShell, pkg) + } else { + isDep := false + if slices.Contains(dmsDepencies, pkg) { + deps = append(deps, pkg) + isDep = true + } + if !isDep { + others = append(others, pkg) + } + } + } + + result := append(deps, others...) + result = append(result, dmsShell...) + return result +} + +func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64) error { + return a.installSingleAURPackageInternal(ctx, pkg, sudoPassword, progressChan, startProgress, endProgress, make(map[string]bool)) +} + +func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64, visited map[string]bool) error { + if visited[pkg] { + a.log(fmt.Sprintf("Skipping %s (already being installed, cycle detected)", pkg)) + return nil + } + visited[pkg] = true + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "aur-builds", pkg) + + // Clean up any existing cache first + if err := os.RemoveAll(buildDir); err != nil { + a.log(fmt.Sprintf("Warning: failed to clean existing cache for %s: %v", pkg, err)) + } + + if err := os.MkdirAll(buildDir, 0o755); err != nil { + return fmt.Errorf("failed to create build directory: %w", err) + } + defer func() { + if removeErr := os.RemoveAll(buildDir); removeErr != nil { + a.log(fmt.Sprintf("Warning: failed to cleanup build directory %s: %v", buildDir, removeErr)) + } + }() + + // Clone the AUR package + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: startProgress + 0.1*(endProgress-startProgress), + Step: fmt.Sprintf("Cloning %s from AUR...", pkg), + IsComplete: false, + CommandInfo: fmt.Sprintf("git clone https://aur.archlinux.org/%s.git", pkg), + } + + cloneCmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("https://aur.archlinux.org/%s.git", pkg), filepath.Join(buildDir, pkg)) + if err := a.runWithProgress(cloneCmd, progressChan, PhaseAURPackages, startProgress+0.1*(endProgress-startProgress), startProgress+0.2*(endProgress-startProgress)); err != nil { + return fmt.Errorf("failed to clone %s: %w", pkg, err) + } + + packageDir := filepath.Join(buildDir, pkg) + + if pkg == "niri-git" { + pkgbuildPath := filepath.Join(packageDir, "PKGBUILD") + sedCmd := exec.CommandContext(ctx, "sed", "-i", "s/makepkg-git-lfs-proto//g", pkgbuildPath) + if err := sedCmd.Run(); err != nil { + return fmt.Errorf("failed to patch PKGBUILD for niri-git: %w", err) + } + + srcinfoPath := filepath.Join(packageDir, ".SRCINFO") + sedCmd2 := exec.CommandContext(ctx, "sed", "-i", "/makedepends = makepkg-git-lfs-proto/d", srcinfoPath) + if err := sedCmd2.Run(); err != nil { + return fmt.Errorf("failed to patch .SRCINFO for niri-git: %w", err) + } + } + + if pkg == "dms-shell-git" { + srcinfoPath := filepath.Join(packageDir, ".SRCINFO") + depsToRemove := []string{ + "depends = quickshell", + "depends = dgop", + } + + for _, dep := range depsToRemove { + sedCmd := exec.CommandContext(ctx, "sed", "-i", fmt.Sprintf("/%s/d", dep), srcinfoPath) + if err := sedCmd.Run(); err != nil { + return fmt.Errorf("failed to remove dependency %s from .SRCINFO for %s: %w", dep, pkg, err) + } + } + } + + // Remove all optdepends from .SRCINFO for all packages + srcinfoPath := filepath.Join(packageDir, ".SRCINFO") + optdepsCmd := exec.CommandContext(ctx, "sed", "-i", "/^[[:space:]]*optdepends = /d", srcinfoPath) + if err := optdepsCmd.Run(); err != nil { + return fmt.Errorf("failed to remove optdepends from .SRCINFO for %s: %w", pkg, err) + } + + srcinfoPath = filepath.Join(packageDir, ".SRCINFO") + { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: startProgress + 0.3*(endProgress-startProgress), + Step: fmt.Sprintf("Resolving dependencies for %s...", pkg), + IsComplete: false, + CommandInfo: "Classifying dependencies as system or AUR", + } + + runtimeDeps, makeDeps, err := parseSRCINFODeps(srcinfoPath) + if err != nil { + return fmt.Errorf("failed to parse .SRCINFO for %s: %w", pkg, err) + } + + seen := make(map[string]bool) + var systemPkgs []string + var aurPkgs []string + + for _, dep := range append(runtimeDeps, makeDeps...) { + if seen[dep] || a.packageInstalled(dep) { + continue + } + seen[dep] = true + if a.isInSystemRepo(dep) { + systemPkgs = append(systemPkgs, dep) + } else { + aurPkgs = append(aurPkgs, dep) + } + } + + if len(systemPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: startProgress + 0.32*(endProgress-startProgress), + Step: fmt.Sprintf("Installing %d system dependencies for %s...", len(systemPkgs), pkg), + IsComplete: false, + CommandInfo: fmt.Sprintf("sudo pacman -S --needed --noconfirm %s", strings.Join(systemPkgs, " ")), + } + if err := a.installSystemPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install system dependencies for %s: %w", pkg, err) + } + } + + for _, aurDep := range aurPkgs { + a.log(fmt.Sprintf("Dependency %s is AUR-only, building from source...", aurDep)) + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: startProgress + 0.35*(endProgress-startProgress), + Step: fmt.Sprintf("Installing AUR dependency %s for %s...", aurDep, pkg), + IsComplete: false, + CommandInfo: fmt.Sprintf("Building AUR dependency: %s", aurDep), + } + if err := a.installSingleAURPackageInternal(ctx, aurDep, sudoPassword, progressChan, + startProgress+0.35*(endProgress-startProgress), + startProgress+0.39*(endProgress-startProgress), + visited, + ); err != nil { + return fmt.Errorf("failed to install AUR dependency %s for %s: %w", aurDep, pkg, err) + } + } + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: startProgress + 0.4*(endProgress-startProgress), + Step: fmt.Sprintf("Building %s...", pkg), + IsComplete: false, + CommandInfo: "makepkg --noconfirm", + } + + buildCmd := exec.CommandContext(ctx, "makepkg", "--noconfirm") + buildCmd.Dir = packageDir + buildCmd.Env = append(os.Environ(), "PKGEXT=.pkg.tar") + + if err := a.runWithProgress(buildCmd, progressChan, PhaseAURPackages, startProgress+0.4*(endProgress-startProgress), startProgress+0.7*(endProgress-startProgress)); err != nil { + return fmt.Errorf("failed to build %s: %w", pkg, err) + } + + // Find built package file + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: startProgress + 0.7*(endProgress-startProgress), + Step: fmt.Sprintf("Installing %s...", pkg), + IsComplete: false, + CommandInfo: "sudo pacman -U built-package", + } + + var files []string + matches, _ := filepath.Glob(filepath.Join(packageDir, "*.pkg.tar*")) + files = matches + + if len(files) == 0 { + return fmt.Errorf("no package files found after building %s", pkg) + } + + installArgs := []string{"pacman", "-U", "--noconfirm"} + installArgs = append(installArgs, files...) + + installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " ")) + + fileNames := make([]string, len(files)) + for i, f := range files { + fileNames[i] = filepath.Base(f) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: startProgress + 0.7*(endProgress-startProgress), + LogOutput: fmt.Sprintf("Installing packages: %s", strings.Join(fileNames, ", ")), + } + + if err := a.runWithProgress(installCmd, progressChan, PhaseAURPackages, startProgress+0.7*(endProgress-startProgress), endProgress); err != nil { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: startProgress, + LogOutput: fmt.Sprintf("ERROR: pacman -U failed for %s with error: %v", pkg, err), + Error: err, + } + return fmt.Errorf("failed to install built package %s: %w", pkg, err) + } + + a.log(fmt.Sprintf("Successfully installed AUR package: %s", pkg)) + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/base.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/base.go new file mode 100644 index 0000000..63555c8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/base.go @@ -0,0 +1,721 @@ +package distros + +import ( + "bufio" + "context" + _ "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/version" +) + +const ( + forceQuickshellGit = false + forceDMSGit = false +) + +// BaseDistribution provides common functionality for all distributions +type BaseDistribution struct { + logChan chan<- string +} + +// NewBaseDistribution creates a new base distribution +func NewBaseDistribution(logChan chan<- string) *BaseDistribution { + return &BaseDistribution{ + logChan: logChan, + } +} + +// Common helper methods +func (b *BaseDistribution) commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func (b *BaseDistribution) CommandExists(cmd string) bool { + return b.commandExists(cmd) +} + +func (b *BaseDistribution) log(message string) { + if b.logChan != nil { + b.logChan <- message + } +} + +func (b *BaseDistribution) logError(message string, err error) { + errorMsg := fmt.Sprintf("ERROR: %s: %v", message, err) + b.log(errorMsg) +} + +// escapeSingleQuotes escapes single quotes in a string for safe use in bash single-quoted strings. +// It replaces each ' with '\” which closes the quote, adds an escaped quote, and reopens the quote. +// This prevents shell injection and syntax errors when passwords contain single quotes or apostrophes. +func escapeSingleQuotes(s string) string { + return strings.ReplaceAll(s, "'", "'\\''") +} + +// MakeSudoCommand creates a command string that safely passes password to sudo. +// This helper escapes special characters in the password to prevent shell injection +// and syntax errors when passwords contain single quotes, apostrophes, or other special chars. +func MakeSudoCommand(sudoPassword string, command string) string { + return fmt.Sprintf("echo '%s' | sudo -S %s", escapeSingleQuotes(sudoPassword), command) +} + +// ExecSudoCommand creates an exec.Cmd that runs a command with sudo using the provided password. +// The password is properly escaped to prevent shell injection and syntax errors. +func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *exec.Cmd { + cmdStr := MakeSudoCommand(sudoPassword, command) + return exec.CommandContext(ctx, "bash", "-c", cmdStr) +} + +func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency { + status := deps.StatusMissing + if b.commandExists(name) { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: name, + Status: status, + Description: description, + Required: true, + } +} + +func (b *BaseDistribution) detectPackage(name, description string, installed bool) deps.Dependency { + status := deps.StatusMissing + if installed { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: name, + Status: status, + Description: description, + Required: true, + } +} + +func (b *BaseDistribution) detectOptionalPackage(name, description string, installed bool) deps.Dependency { + status := deps.StatusMissing + if installed { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: name, + Status: status, + Description: description, + Required: false, + } +} + +func (b *BaseDistribution) detectGit() deps.Dependency { + return b.detectCommand("git", "Version control system") +} + +func (b *BaseDistribution) detectMatugen() deps.Dependency { + return b.detectCommand("matugen", "Material Design color generation tool") +} + +func (b *BaseDistribution) detectDgop() deps.Dependency { + return b.detectCommand("dgop", "Desktop portal management tool") +} + +func (b *BaseDistribution) detectDMS() deps.Dependency { + dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms") + + status := deps.StatusMissing + currentVersion := "" + + if _, err := os.Stat(dmsPath); err == nil { + status = deps.StatusInstalled + + // Only get current version, don't check for updates (lazy loading) + current, err := version.GetCurrentDMSVersion() + if err == nil { + currentVersion = current + } + } + + dep := deps.Dependency{ + Name: "dms (DankMaterialShell)", + Status: status, + Description: "Desktop Management System configuration", + Required: true, + CanToggle: true, + } + + if currentVersion != "" { + dep.Version = currentVersion + } + + return dep +} + +func (b *BaseDistribution) detectSpecificTerminal(terminal deps.Terminal) deps.Dependency { + switch terminal { + case deps.TerminalGhostty: + status := deps.StatusMissing + if b.commandExists("ghostty") { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: "ghostty", + Status: status, + Description: "A fast, native terminal emulator built in Zig.", + Required: true, + } + case deps.TerminalKitty: + status := deps.StatusMissing + if b.commandExists("kitty") { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: "kitty", + Status: status, + Description: "A feature-rich, customizable terminal emulator.", + Required: true, + } + case deps.TerminalAlacritty: + status := deps.StatusMissing + if b.commandExists("alacritty") { + status = deps.StatusInstalled + } + return deps.Dependency{ + Name: "alacritty", + Status: status, + Description: "A simple terminal emulator. (No dynamic theming)", + Required: true, + } + default: + return b.detectSpecificTerminal(deps.TerminalGhostty) + } +} + +func (b *BaseDistribution) detectHyprlandTools() []deps.Dependency { + var dependencies []deps.Dependency + + tools := []struct { + name string + description string + }{ + {"hyprctl", "Hyprland control utility"}, + {"jq", "JSON processor"}, + } + + for _, tool := range tools { + status := deps.StatusMissing + if b.commandExists(tool.name) { + status = deps.StatusInstalled + } + + dependencies = append(dependencies, deps.Dependency{ + Name: tool.name, + Status: status, + Description: tool.description, + Required: true, + }) + } + + return dependencies +} + +func (b *BaseDistribution) detectQuickshell() deps.Dependency { + if !b.commandExists("qs") { + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusMissing, + Description: "QtQuick based desktop shell toolkit", + Required: true, + Variant: deps.VariantStable, + CanToggle: true, + } + } + + cmd := exec.Command("qs", "--version") + output, err := cmd.Output() + if err != nil { + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusNeedsReinstall, + Description: "QtQuick based desktop shell toolkit (version check failed)", + Required: true, + Variant: deps.VariantStable, + CanToggle: true, + } + } + + versionStr := string(output) + versionRegex := regexp.MustCompile(`quickshell (\d+\.\d+\.\d+)`) + matches := versionRegex.FindStringSubmatch(versionStr) + + if len(matches) < 2 { + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusNeedsReinstall, + Description: "QtQuick based desktop shell toolkit (unknown version)", + Required: true, + Variant: deps.VariantStable, + CanToggle: true, + } + } + + version := matches[1] + variant := deps.VariantStable + if strings.Contains(versionStr, "git") || strings.Contains(versionStr, "+") { + variant = deps.VariantGit + } + + if b.versionCompare(version, "0.2.0") >= 0 { + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusInstalled, + Version: version, + Description: "QtQuick based desktop shell toolkit", + Required: true, + Variant: variant, + CanToggle: true, + } + } + + return deps.Dependency{ + Name: "quickshell", + Status: deps.StatusNeedsUpdate, + Variant: variant, + CanToggle: true, + Version: version, + Description: "QtQuick based desktop shell toolkit (needs 0.2.0+)", + Required: true, + } +} + +func (b *BaseDistribution) detectWindowManager(wm deps.WindowManager) deps.Dependency { + switch wm { + case deps.WindowManagerHyprland: + status := deps.StatusMissing + variant := deps.VariantStable + version := "" + + if b.commandExists("hyprland") || b.commandExists("Hyprland") { + status = deps.StatusInstalled + cmd := exec.Command("hyprctl", "version") + if output, err := cmd.Output(); err == nil { + outStr := string(output) + if strings.Contains(outStr, "git") || strings.Contains(outStr, "dirty") { + variant = deps.VariantGit + } + if versionRegex := regexp.MustCompile(`v(\d+\.\d+\.\d+)`); versionRegex.MatchString(outStr) { + matches := versionRegex.FindStringSubmatch(outStr) + if len(matches) > 1 { + version = matches[1] + } + } + } + } + return deps.Dependency{ + Name: "hyprland", + Status: status, + Version: version, + Description: "Dynamic tiling Wayland compositor", + Required: true, + Variant: variant, + CanToggle: true, + } + case deps.WindowManagerNiri: + status := deps.StatusMissing + variant := deps.VariantStable + version := "" + + if b.commandExists("niri") { + status = deps.StatusInstalled + cmd := exec.Command("niri", "--version") + if output, err := cmd.Output(); err == nil { + outStr := string(output) + if strings.Contains(outStr, "git") || strings.Contains(outStr, "+") { + variant = deps.VariantGit + } + if versionRegex := regexp.MustCompile(`niri (\d+\.\d+)`); versionRegex.MatchString(outStr) { + matches := versionRegex.FindStringSubmatch(outStr) + if len(matches) > 1 { + version = matches[1] + } + } + } + } + return deps.Dependency{ + Name: "niri", + Status: status, + Version: version, + Description: "Scrollable-tiling Wayland compositor", + Required: true, + Variant: variant, + CanToggle: true, + } + default: + return deps.Dependency{ + Name: "unknown-wm", + Status: deps.StatusMissing, + Description: "Unknown window manager", + Required: true, + } + } +} + +// Version comparison helper +func (b *BaseDistribution) versionCompare(v1, v2 string) int { + parts1 := strings.Split(v1, ".") + parts2 := strings.Split(v2, ".") + + for i := 0; i < len(parts1) && i < len(parts2); i++ { + if parts1[i] < parts2[i] { + return -1 + } + if parts1[i] > parts2[i] { + return 1 + } + } + + if len(parts1) < len(parts2) { + return -1 + } + if len(parts1) > len(parts2) { + return 1 + } + + return 0 +} + +// Common installation helper +func (b *BaseDistribution) runWithProgress(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64) error { + return b.runWithProgressTimeout(cmd, progressChan, phase, startProgress, endProgress, 20*time.Minute) +} + +func (b *BaseDistribution) runWithProgressTimeout(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, timeout time.Duration) error { + return b.runWithProgressStepTimeout(cmd, progressChan, phase, startProgress, endProgress, "Installing...", timeout) +} + +func (b *BaseDistribution) runWithProgressStep(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, stepMessage string) error { + return b.runWithProgressStepTimeout(cmd, progressChan, phase, startProgress, endProgress, stepMessage, 20*time.Minute) +} + +func (b *BaseDistribution) runWithProgressStepTimeout(cmd *exec.Cmd, progressChan chan<- InstallProgressMsg, phase InstallPhase, startProgress, endProgress float64, stepMessage string, timeoutDuration time.Duration) error { + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return err + } + + outputChan := make(chan string, 100) + done := make(chan error, 1) + + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + b.log(line) + outputChan <- line + } + }() + + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + b.log(line) + outputChan <- line + } + }() + + go func() { + done <- cmd.Wait() + close(outputChan) + }() + + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + progress := startProgress + progressStep := (endProgress - startProgress) / 50 + lastOutput := "" + + var timeout *time.Timer + var timeoutChan <-chan time.Time + if timeoutDuration > 0 { + timeout = time.NewTimer(timeoutDuration) + defer timeout.Stop() + timeoutChan = timeout.C + } + + for { + select { + case err := <-done: + if err != nil { + b.logError("Command execution failed", err) + b.log(fmt.Sprintf("Last output before failure: %s", lastOutput)) + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: startProgress, + Step: "Command failed", + IsComplete: false, + LogOutput: lastOutput, + Error: err, + } + return err + } + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: endProgress, + Step: "Installation step complete", + IsComplete: false, + LogOutput: lastOutput, + } + return nil + case output, ok := <-outputChan: + if ok { + lastOutput = output + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: progress, + Step: stepMessage, + IsComplete: false, + LogOutput: output, + } + if timeout != nil { + timeout.Reset(timeoutDuration) + } + } + case <-timeoutChan: + if cmd.Process != nil { + cmd.Process.Kill() + } + err := fmt.Errorf("installation timed out after %v", timeoutDuration) + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: startProgress, + Step: "Installation timed out", + IsComplete: false, + LogOutput: lastOutput, + Error: err, + } + return err + case <-ticker.C: + if progress < endProgress-0.01 { + progress += progressStep + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: progress, + Step: "Installing...", + IsComplete: false, + LogOutput: lastOutput, + } + } + } + } +} + +func (b *BaseDistribution) DetectTerminalFromDeps(dependencies []deps.Dependency) deps.Terminal { + for _, dep := range dependencies { + switch dep.Name { + case "ghostty": + return deps.TerminalGhostty + case "kitty": + return deps.TerminalKitty + case "alacritty": + return deps.TerminalAlacritty + } + } + return deps.TerminalGhostty +} + +func (b *BaseDistribution) WriteEnvironmentConfig(terminal deps.Terminal) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + envDir := filepath.Join(homeDir, ".config", "environment.d") + if err := os.MkdirAll(envDir, 0o755); err != nil { + return fmt.Errorf("failed to create environment.d directory: %w", err) + } + + var terminalCmd string + switch terminal { + case deps.TerminalGhostty: + terminalCmd = "ghostty" + case deps.TerminalKitty: + terminalCmd = "kitty" + case deps.TerminalAlacritty: + terminalCmd = "alacritty" + default: + terminalCmd = "ghostty" + } + + content := fmt.Sprintf(`ELECTRON_OZONE_PLATFORM_HINT=auto +TERMINAL=%s +`, terminalCmd) + + envFile := filepath.Join(envDir, "90-dms.conf") + if err := os.WriteFile(envFile, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write environment config: %w", err) + } + + b.log(fmt.Sprintf("Wrote environment config to %s", envFile)) + return nil +} + +func (b *BaseDistribution) EnableDMSService(ctx context.Context, wm deps.WindowManager) error { + switch wm { + case deps.WindowManagerNiri: + if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms").Run(); err != nil { + b.log("Warning: failed to add dms as a want for niri.service") + } + case deps.WindowManagerHyprland: + if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms").Run(); err != nil { + b.log("Warning: failed to add dms as a want for hyprland-session.target") + } + } + + return nil +} + +func (b *BaseDistribution) WriteWindowManagerConfig(wm deps.WindowManager) error { + if wm == deps.WindowManagerHyprland { + if err := b.WriteHyprlandSessionTarget(); err != nil { + return fmt.Errorf("failed to write hyprland session target: %w", err) + } + } + return nil +} + +func (b *BaseDistribution) WriteHyprlandSessionTarget() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + targetDir := filepath.Join(homeDir, ".config", "systemd", "user") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return fmt.Errorf("failed to create systemd user directory: %w", err) + } + + targetPath := filepath.Join(targetDir, "hyprland-session.target") + content := `[Unit] +Description=Hyprland Session Target +Requires=graphical-session.target +After=graphical-session.target +` + + if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write hyprland-session.target: %w", err) + } + + b.log(fmt.Sprintf("Wrote hyprland-session.target to %s", targetPath)) + return nil +} + +// installDMSBinary installs the DMS binary from GitHub releases +func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + b.log("Installing/updating DMS binary...") + + // Detect architecture + arch := runtime.GOARCH + switch arch { + case "amd64": + case "arm64": + default: + return fmt.Errorf("unsupported architecture for DMS: %s", arch) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.80, + Step: "Downloading DMS binary...", + IsComplete: false, + CommandInfo: fmt.Sprintf("Downloading dms-%s.gz", arch), + } + + // Get latest release version + latestVersionCmd := exec.CommandContext(ctx, "bash", "-c", + `curl -s https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'`) + versionOutput, err := latestVersionCmd.Output() + if err != nil { + return fmt.Errorf("failed to get latest DMS version: %w", err) + } + version := strings.TrimSpace(string(versionOutput)) + if version == "" { + return fmt.Errorf("could not determine latest DMS version") + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "manual-builds") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Download the gzipped binary + downloadURL := fmt.Sprintf("https://github.com/AvengeMedia/DankMaterialShell/releases/download/%s/dms-cli-%s.gz", version, arch) + gzPath := filepath.Join(tmpDir, "dms.gz") + + downloadCmd := exec.CommandContext(ctx, "curl", "-L", downloadURL, "-o", gzPath) + if err := downloadCmd.Run(); err != nil { + return fmt.Errorf("failed to download DMS binary: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.85, + Step: "Extracting DMS binary...", + IsComplete: false, + CommandInfo: "gunzip dms.gz", + } + + // Extract the binary + extractCmd := exec.CommandContext(ctx, "gunzip", gzPath) + if err := extractCmd.Run(); err != nil { + return fmt.Errorf("failed to extract DMS binary: %w", err) + } + + binaryPath := filepath.Join(tmpDir, "dms") + + // Make it executable + chmodCmd := exec.CommandContext(ctx, "chmod", "+x", binaryPath) + if err := chmodCmd.Run(); err != nil { + return fmt.Errorf("failed to make DMS binary executable: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.88, + Step: "Installing DMS to /usr/local/bin...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo cp dms /usr/local/bin/", + } + + // Install to /usr/local/bin + installCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath)) + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install DMS binary: %w", err) + } + + b.log("DMS binary installed successfully") + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/base_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/base_test.go new file mode 100644 index 0000000..51e86d7 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/base_test.go @@ -0,0 +1,216 @@ +package distros + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func TestBaseDistribution_detectDMS_NotInstalled(t *testing.T) { + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + + tempDir := t.TempDir() + os.Setenv("HOME", tempDir) + + logChan := make(chan string, 10) + defer close(logChan) + + base := NewBaseDistribution(logChan) + dep := base.detectDMS() + + if dep.Status != deps.StatusMissing { + t.Errorf("Expected StatusMissing, got %d", dep.Status) + } + + if dep.Name != "dms (DankMaterialShell)" { + t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name) + } + + if !dep.Required { + t.Error("Expected Required to be true") + } +} + +func TestBaseDistribution_detectDMS_Installed(t *testing.T) { + if !utils.CommandExists("git") { + t.Skip("git not available") + } + + tempDir := t.TempDir() + dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") + os.MkdirAll(dmsPath, 0o755) + + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + os.Setenv("HOME", tempDir) + + exec.Command("git", "init", dmsPath).Run() + exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run() + exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run() + exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run() + + testFile := filepath.Join(dmsPath, "test.txt") + os.WriteFile(testFile, []byte("test"), 0o644) + exec.Command("git", "-C", dmsPath, "add", ".").Run() + exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run() + + logChan := make(chan string, 10) + defer close(logChan) + + base := NewBaseDistribution(logChan) + dep := base.detectDMS() + + if dep.Status == deps.StatusMissing { + t.Error("Expected DMS to be detected as installed") + } + + if dep.Name != "dms (DankMaterialShell)" { + t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name) + } + + if !dep.Required { + t.Error("Expected Required to be true") + } + + t.Logf("Status: %d, Version: %s", dep.Status, dep.Version) +} + +func TestBaseDistribution_detectDMS_NeedsUpdate(t *testing.T) { + if !utils.CommandExists("git") { + t.Skip("git not available") + } + + tempDir := t.TempDir() + dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") + os.MkdirAll(dmsPath, 0o755) + + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + os.Setenv("HOME", tempDir) + + exec.Command("git", "init", dmsPath).Run() + exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run() + exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run() + exec.Command("git", "-C", dmsPath, "remote", "add", "origin", "https://github.com/AvengeMedia/DankMaterialShell.git").Run() + + testFile := filepath.Join(dmsPath, "test.txt") + os.WriteFile(testFile, []byte("test"), 0o644) + exec.Command("git", "-C", dmsPath, "add", ".").Run() + exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run() + exec.Command("git", "-C", dmsPath, "tag", "v0.0.1").Run() + exec.Command("git", "-C", dmsPath, "checkout", "v0.0.1").Run() + + logChan := make(chan string, 10) + defer close(logChan) + + base := NewBaseDistribution(logChan) + dep := base.detectDMS() + + if dep.Name != "dms (DankMaterialShell)" { + t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name) + } + + if !dep.Required { + t.Error("Expected Required to be true") + } + + t.Logf("Status: %d, Version: %s", dep.Status, dep.Version) +} + +func TestBaseDistribution_detectDMS_DirectoryWithoutGit(t *testing.T) { + tempDir := t.TempDir() + dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") + os.MkdirAll(dmsPath, 0o755) + + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + os.Setenv("HOME", tempDir) + + logChan := make(chan string, 10) + defer close(logChan) + + base := NewBaseDistribution(logChan) + dep := base.detectDMS() + + if dep.Status == deps.StatusMissing { + t.Error("Expected DMS to be detected as present") + } + + if dep.Name != "dms (DankMaterialShell)" { + t.Errorf("Expected name 'dms (DankMaterialShell)', got %s", dep.Name) + } + + if !dep.Required { + t.Error("Expected Required to be true") + } +} + +func TestBaseDistribution_NewBaseDistribution(t *testing.T) { + logChan := make(chan string, 10) + defer close(logChan) + + base := NewBaseDistribution(logChan) + + if base == nil { + t.Fatal("NewBaseDistribution returned nil") + } + + if base.logChan == nil { + t.Error("logChan was not set") + } +} + +func TestBaseDistribution_versionCompare(t *testing.T) { + logChan := make(chan string, 10) + defer close(logChan) + + base := NewBaseDistribution(logChan) + + tests := []struct { + v1 string + v2 string + expected int + }{ + {"0.1.0", "0.1.0", 0}, + {"0.1.0", "0.1.1", -1}, + {"0.1.1", "0.1.0", 1}, + {"0.2.0", "0.1.9", 1}, + {"1.0.0", "0.9.9", 1}, + } + + for _, tt := range tests { + result := base.versionCompare(tt.v1, tt.v2) + if result != tt.expected { + t.Errorf("versionCompare(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected) + } + } +} + +func TestBaseDistribution_versionCompare_WithPrefix(t *testing.T) { + logChan := make(chan string, 10) + defer close(logChan) + + base := NewBaseDistribution(logChan) + + tests := []struct { + v1 string + v2 string + expected int + }{ + {"v0.1.0", "v0.1.0", 0}, + {"v0.1.0", "v0.1.1", -1}, + {"v0.1.1", "v0.1.0", 1}, + } + + for _, tt := range tests { + result := base.versionCompare(tt.v1, tt.v2) + if result != tt.expected { + t.Errorf("versionCompare(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected) + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/debian.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/debian.go new file mode 100644 index 0000000..a3537ff --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/debian.go @@ -0,0 +1,706 @@ +package distros + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +func init() { + Register("debian", "#A80030", FamilyDebian, func(config DistroConfig, logChan chan<- string) Distribution { + return NewDebianDistribution(config, logChan) + }) +} + +type DebianDistribution struct { + *BaseDistribution + *ManualPackageInstaller + config DistroConfig +} + +func NewDebianDistribution(config DistroConfig, logChan chan<- string) *DebianDistribution { + base := NewBaseDistribution(logChan) + return &DebianDistribution{ + BaseDistribution: base, + ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base}, + config: config, + } +} + +func (d *DebianDistribution) GetID() string { + return d.config.ID +} + +func (d *DebianDistribution) GetColorHex() string { + return d.config.ColorHex +} + +func (d *DebianDistribution) GetFamily() DistroFamily { + return d.config.Family +} + +func (d *DebianDistribution) GetPackageManager() PackageManagerType { + return PackageManagerAPT +} + +func (d *DebianDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) { + return d.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty) +} + +func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) { + var dependencies []deps.Dependency + + dependencies = append(dependencies, d.detectDMS()) + + dependencies = append(dependencies, d.detectSpecificTerminal(terminal)) + + dependencies = append(dependencies, d.detectGit()) + dependencies = append(dependencies, d.detectWindowManager(wm)) + dependencies = append(dependencies, d.detectQuickshell()) + dependencies = append(dependencies, d.detectDMSGreeter()) + dependencies = append(dependencies, d.detectXDGPortal()) + dependencies = append(dependencies, d.detectAccountsService()) + + if wm == deps.WindowManagerNiri { + dependencies = append(dependencies, d.detectXwaylandSatellite()) + } + + dependencies = append(dependencies, d.detectMatugen()) + dependencies = append(dependencies, d.detectDgop()) + + return dependencies, nil +} + +func (d *DebianDistribution) detectXDGPortal() deps.Dependency { + return d.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", d.packageInstalled("xdg-desktop-portal-gtk")) +} + +func (d *DebianDistribution) detectXwaylandSatellite() deps.Dependency { + return d.detectCommand("xwayland-satellite", "Xwayland support") +} + +func (d *DebianDistribution) detectAccountsService() deps.Dependency { + return d.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", d.packageInstalled("accountsservice")) +} + +func (d *DebianDistribution) detectDMSGreeter() deps.Dependency { + return d.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", d.packageInstalled("dms-greeter")) +} + +func (d *DebianDistribution) packageInstalled(pkg string) bool { + return debianPackageInstalledPrecisely(pkg) +} + +func debianPackageInstalledPrecisely(pkg string) bool { + cmd := exec.Command("dpkg-query", "-W", "-f=${db:Status-Status}", pkg) + output, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(output)) == "installed" +} + +func debianRepoArchitecture(arch string) string { + switch arch { + case "amd64", "x86_64": + return "amd64" + case "arm64", "aarch64": + return "arm64" + default: + return arch + } +} + +func (d *DebianDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { + return d.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) +} + +func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping { + packages := map[string]PackageMapping{ + // Standard APT packages + "git": {Name: "git", Repository: RepoTypeSystem}, + "kitty": {Name: "kitty", Repository: RepoTypeSystem}, + "alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, + "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, + "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, + + // DMS packages from OBS with variant support + "dms (DankMaterialShell)": d.getDmsMapping(variants["dms (DankMaterialShell)"]), + "quickshell": d.getQuickshellMapping(variants["quickshell"]), + "dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, + "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, + "dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, + "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, + } + + if wm == deps.WindowManagerNiri { + niriVariant := variants["niri"] + packages["niri"] = d.getNiriMapping(niriVariant) + packages["xwayland-satellite"] = d.getXwaylandSatelliteMapping(niriVariant) + } + + return packages +} + +func (d *DebianDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms-git"} + } + return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms"} +} + +func (d *DebianDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { + if forceQuickshellGit || variant == deps.VariantGit { + return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + } + return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} +} + +func (d *DebianDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + } + return PackageMapping{Name: "niri", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} +} + +func (d *DebianDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + } + return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} +} + +func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.06, + Step: "Updating package lists...", + IsComplete: false, + LogOutput: "Updating APT package lists", + } + + updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil { + return fmt.Errorf("failed to update package lists: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.08, + Step: "Installing build-essential...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get install -y build-essential", + LogOutput: "Installing build tools", + } + + checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential") + if err := checkCmd.Run(); err != nil { + cmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential") + if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil { + return fmt.Errorf("failed to install build-essential: %w", err) + } + } + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.10, + Step: "Installing development dependencies...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev", + LogOutput: "Installing additional development tools", + } + + devToolsCmd := ExecSudoCommand(ctx, sudoPassword, + "DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev") + if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil { + return fmt.Errorf("failed to install development tools: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.12, + Step: "Prerequisites installation complete", + IsComplete: false, + LogOutput: "Prerequisites successfully installed", + } + + return nil +} + +func (d *DebianDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error { + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.05, + Step: "Checking system prerequisites...", + IsComplete: false, + LogOutput: "Starting prerequisite check...", + } + + if err := d.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install prerequisites: %w", err) + } + + systemPkgs, obsPkgs, manualPkgs, variantMap := d.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags) + + // Enable OBS repositories + if len(obsPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.15, + Step: "Enabling OBS repositories...", + IsComplete: false, + LogOutput: "Setting up OBS repositories for additional packages", + } + if err := d.enableOBSRepos(ctx, obsPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to enable OBS repositories: %w", err) + } + } + + // System Packages + if len(systemPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.35, + Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)), + IsComplete: false, + NeedsSudo: true, + LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")), + } + if err := d.installAPTPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install APT packages: %w", err) + } + } + + // OBS Packages + obsPkgNames := d.extractPackageNames(obsPkgs) + if len(obsPkgNames) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.65, + Step: fmt.Sprintf("Installing %d OBS packages...", len(obsPkgNames)), + IsComplete: false, + LogOutput: fmt.Sprintf("Installing OBS packages: %s", strings.Join(obsPkgNames, ", ")), + } + if err := d.installAPTPackages(ctx, obsPkgNames, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install OBS packages: %w", err) + } + } + + // Manual Builds + if len(manualPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.80, + Step: "Installing build dependencies...", + IsComplete: false, + LogOutput: "Installing build tools for manual compilation", + } + if err := d.installBuildDependencies(ctx, manualPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install build dependencies: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.85, + Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)), + IsComplete: false, + LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")), + } + if err := d.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install manual packages: %w", err) + } + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.90, + Step: "Configuring system...", + IsComplete: false, + LogOutput: "Starting post-installation configuration...", + } + + terminal := d.DetectTerminalFromDeps(dependencies) + if err := d.WriteEnvironmentConfig(terminal); err != nil { + d.log(fmt.Sprintf("Warning: failed to write environment config: %v", err)) + } + + if err := d.WriteWindowManagerConfig(wm); err != nil { + d.log(fmt.Sprintf("Warning: failed to write window manager config: %v", err)) + } + + if err := d.EnableDMSService(ctx, wm); err != nil { + d.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err)) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseComplete, + Progress: 1.0, + Step: "Installation complete!", + IsComplete: true, + LogOutput: "All packages installed and configured successfully", + } + + return nil +} + +func (d *DebianDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping, []string, map[string]deps.PackageVariant) { + systemPkgs := []string{} + obsPkgs := []PackageMapping{} + manualPkgs := []string{} + + variantMap := make(map[string]deps.PackageVariant) + for _, dep := range dependencies { + variantMap[dep.Name] = dep.Variant + } + + packageMap := d.GetPackageMappingWithVariants(wm, variantMap) + + for _, dep := range dependencies { + if disabledFlags[dep.Name] { + continue + } + + if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] { + continue + } + + pkgInfo, exists := packageMap[dep.Name] + if !exists { + d.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name)) + continue + } + + switch pkgInfo.Repository { + case RepoTypeSystem: + systemPkgs = append(systemPkgs, pkgInfo.Name) + case RepoTypeOBS: + obsPkgs = append(obsPkgs, pkgInfo) + case RepoTypeManual: + manualPkgs = append(manualPkgs, dep.Name) + } + } + + return systemPkgs, obsPkgs, manualPkgs, variantMap +} + +func (d *DebianDistribution) extractPackageNames(packages []PackageMapping) []string { + names := make([]string, len(packages)) + for i, pkg := range packages { + names[i] = pkg.Name + } + return names +} + +func (d *DebianDistribution) aptInstallArgs(packages []string, minimal bool) []string { + args := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y"} + if minimal { + args = append(args, "--no-install-recommends") + } + return append(args, packages...) +} + +func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + enabledRepos := make(map[string]bool) + + osInfo, err := GetOSInfo() + if err != nil { + return fmt.Errorf("failed to get OS info: %w", err) + } + + // Determine Debian version for OBS repository URL + debianVersion := "Debian_13" + if osInfo.VersionID == "testing" { + debianVersion = "Debian_Testing" + } else if osInfo.VersionCodename == "sid" || osInfo.VersionID == "sid" || strings.Contains(strings.ToLower(osInfo.PrettyName), "sid") || strings.Contains(strings.ToLower(osInfo.PrettyName), "unstable") { + debianVersion = "Debian_Unstable" + } + + for _, pkg := range obsPkgs { + if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] { + d.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL)) + + // RepoURL format: "home:AvengeMedia:danklinux" + repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/") + repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-") + baseURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s", repoPath, debianVersion) + + // Check if repository already exists + listFile := fmt.Sprintf("/etc/apt/sources.list.d/%s.list", repoName) + checkCmd := exec.CommandContext(ctx, "test", "-f", listFile) + if checkCmd.Run() == nil { + d.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL)) + enabledRepos[pkg.RepoURL] = true + continue + } + + keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName) + + // Create keyrings directory if it doesn't exist + mkdirCmd := ExecSudoCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings") + if err := mkdirCmd.Run(); err != nil { + d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err)) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.18, + Step: fmt.Sprintf("Adding OBS GPG key for %s...", pkg.RepoURL), + NeedsSudo: true, + CommandInfo: fmt.Sprintf("curl & gpg to add key for %s", pkg.RepoURL), + } + + keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath) + cmd := ExecSudoCommand(ctx, sudoPassword, keyCmd) + if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil { + return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err) + } + + // Add repository + repoLine := fmt.Sprintf("deb [signed-by=%s arch=%s] %s/ /", keyringPath, debianRepoArchitecture(osInfo.Architecture), baseURL) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.20, + Step: fmt.Sprintf("Adding OBS repository %s...", pkg.RepoURL), + NeedsSudo: true, + CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile), + } + + addRepoCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile)) + if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { + return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err) + } + + enabledRepos[pkg.RepoURL] = true + d.log(fmt.Sprintf("OBS repo %s enabled successfully", pkg.RepoURL)) + } + } + + if len(enabledRepos) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.25, + Step: "Updating package lists...", + NeedsSudo: true, + CommandInfo: "sudo apt-get update", + } + + updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + if err := d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil { + return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err) + } + } + + return nil +} + +func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + d.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", "))) + + groups := orderedMinimalInstallGroups(packages) + totalGroups := len(groups) + + groupIndex := 0 + installGroup := func(groupPackages []string, minimal bool) error { + if len(groupPackages) == 0 { + return nil + } + + groupIndex++ + startProgress := 0.40 + endProgress := 0.60 + if totalGroups > 1 { + if groupIndex == 1 { + endProgress = 0.50 + } else { + startProgress = 0.50 + } + } + + args := d.aptInstallArgs(groupPackages, minimal) + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: startProgress, + Step: "Installing system packages...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress) + } + + for _, group := range groups { + if err := installGroup(group.packages, group.minimal); err != nil { + return err + } + } + return nil +} + +func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + buildDeps := make(map[string]bool) + + for _, pkg := range manualPkgs { + switch pkg { + case "niri": + buildDeps["curl"] = true + buildDeps["libxkbcommon-dev"] = true + buildDeps["libwayland-dev"] = true + buildDeps["libudev-dev"] = true + buildDeps["libinput-dev"] = true + buildDeps["libdisplay-info-dev"] = true + buildDeps["libpango1.0-dev"] = true + buildDeps["libcairo-dev"] = true + buildDeps["libpipewire-0.3-dev"] = true + buildDeps["libc6-dev"] = true + buildDeps["clang"] = true + buildDeps["libseat-dev"] = true + buildDeps["libgbm-dev"] = true + buildDeps["alacritty"] = true + buildDeps["fuzzel"] = true + case "quickshell": + buildDeps["qt6-base-dev"] = true + buildDeps["qt6-base-private-dev"] = true + buildDeps["qt6-declarative-dev"] = true + buildDeps["qt6-declarative-private-dev"] = true + buildDeps["qt6-wayland-dev"] = true + buildDeps["qt6-wayland-private-dev"] = true + buildDeps["qt6-tools-dev"] = true + buildDeps["libqt6svg6-dev"] = true + buildDeps["qt6-shadertools-dev"] = true + buildDeps["spirv-tools"] = true + buildDeps["libcli11-dev"] = true + buildDeps["libjemalloc-dev"] = true + buildDeps["libwayland-dev"] = true + buildDeps["wayland-protocols"] = true + buildDeps["libdrm-dev"] = true + buildDeps["libgbm-dev"] = true + buildDeps["libegl-dev"] = true + buildDeps["libgles2-mesa-dev"] = true + buildDeps["libgl1-mesa-dev"] = true + buildDeps["libxcb1-dev"] = true + buildDeps["libpipewire-0.3-dev"] = true + buildDeps["libpam0g-dev"] = true + case "ghostty": + buildDeps["curl"] = true + case "matugen": + buildDeps["curl"] = true + } + } + + for _, pkg := range manualPkgs { + switch pkg { + case "niri", "matugen": + if err := d.installRust(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install Rust: %w", err) + } + case "dgop": + if err := d.installGo(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install Go: %w", err) + } + } + } + + if len(buildDeps) == 0 { + return nil + } + + depList := make([]string, 0, len(buildDeps)) + for dep := range buildDeps { + depList = append(depList, dep) + } + + args := []string{"apt-get", "install", "-y"} + args = append(args, depList...) + + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82) +} + +func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if d.commandExists("cargo") { + return nil + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.82, + Step: "Installing rustup...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get install rustup", + } + + rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup") + if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil { + return fmt.Errorf("failed to install rustup: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.83, + Step: "Installing stable Rust toolchain...", + IsComplete: false, + CommandInfo: "rustup install stable", + } + + rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable") + if err := d.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil { + return fmt.Errorf("failed to install Rust toolchain: %w", err) + } + + if !d.commandExists("cargo") { + d.log("Warning: cargo not found in PATH after Rust installation, trying to source environment") + } + + return nil +} + +func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if d.commandExists("go") { + return nil + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.87, + Step: "Installing Go...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get install golang-go", + } + + installCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go") + return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90) +} + +func (d *DebianDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + d.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", "))) + + for _, pkg := range packages { + switch pkg { + default: + if err := d.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, variantMap, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install %s: %w", pkg, err) + } + } + } + + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/factory.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/factory.go new file mode 100644 index 0000000..9c3826a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/factory.go @@ -0,0 +1,19 @@ +package distros + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +// NewDependencyDetector creates a DependencyDetector for the specified distribution +func NewDependencyDetector(distribution string, logChan chan<- string) (deps.DependencyDetector, error) { + distro, err := NewDistribution(distribution, logChan) + if err != nil { + return nil, err + } + return distro, nil +} + +// NewPackageInstaller creates a Distribution for package installation +func NewPackageInstaller(distribution string, logChan chan<- string) (Distribution, error) { + return NewDistribution(distribution, logChan) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/fedora.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/fedora.go new file mode 100644 index 0000000..4c16d4e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/fedora.go @@ -0,0 +1,550 @@ +package distros + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +func init() { + Register("fedora", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { + return NewFedoraDistribution(config, logChan) + }) + Register("evernight", "#72B8DC", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { + return NewFedoraDistribution(config, logChan) + }) + Register("nobara", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { + return NewFedoraDistribution(config, logChan) + }) + Register("fedora-asahi-remix", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { + return NewFedoraDistribution(config, logChan) + }) + Register("bluefin", "#0B57A4", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { + return NewFedoraDistribution(config, logChan) + }) + Register("ultramarine", "#00078b", FamilyFedora, func(config DistroConfig, logChan chan<- string) Distribution { + return NewFedoraDistribution(config, logChan) + }) +} + +type FedoraDistribution struct { + *BaseDistribution + *ManualPackageInstaller + config DistroConfig +} + +func NewFedoraDistribution(config DistroConfig, logChan chan<- string) *FedoraDistribution { + base := NewBaseDistribution(logChan) + return &FedoraDistribution{ + BaseDistribution: base, + ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base}, + config: config, + } +} + +func (f *FedoraDistribution) GetID() string { + return f.config.ID +} + +func (f *FedoraDistribution) GetColorHex() string { + return f.config.ColorHex +} + +func (f *FedoraDistribution) GetFamily() DistroFamily { + return f.config.Family +} + +func (f *FedoraDistribution) GetPackageManager() PackageManagerType { + return PackageManagerDNF +} + +func (f *FedoraDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) { + return f.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty) +} + +func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) { + var dependencies []deps.Dependency + + // DMS at the top (shell is prominent) + dependencies = append(dependencies, f.detectDMS()) + + // Terminal with choice support + dependencies = append(dependencies, f.detectSpecificTerminal(terminal)) + + // Common detections using base methods + dependencies = append(dependencies, f.detectGit()) + dependencies = append(dependencies, f.detectWindowManager(wm)) + dependencies = append(dependencies, f.detectQuickshell()) + dependencies = append(dependencies, f.detectDMSGreeter()) + dependencies = append(dependencies, f.detectXDGPortal()) + dependencies = append(dependencies, f.detectAccountsService()) + + // Hyprland-specific tools + if wm == deps.WindowManagerHyprland { + dependencies = append(dependencies, f.detectHyprlandTools()...) + } + + // Niri-specific tools + if wm == deps.WindowManagerNiri { + dependencies = append(dependencies, f.detectXwaylandSatellite()) + } + + dependencies = append(dependencies, f.detectMatugen()) + dependencies = append(dependencies, f.detectDgop()) + + return dependencies, nil +} + +func (f *FedoraDistribution) detectXDGPortal() deps.Dependency { + return f.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", f.packageInstalled("xdg-desktop-portal-gtk")) +} + +func (f *FedoraDistribution) packageInstalled(pkg string) bool { + cmd := exec.Command("rpm", "-q", pkg) + err := cmd.Run() + return err == nil +} + +func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { + return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) +} + +func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping { + packages := map[string]PackageMapping{ + // Standard DNF packages + "git": {Name: "git", Repository: RepoTypeSystem}, + "ghostty": {Name: "ghostty", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, + "kitty": {Name: "kitty", Repository: RepoTypeSystem}, + "alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, + "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, + "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, + + // COPR packages + "quickshell": f.getQuickshellMapping(variants["quickshell"]), + "dms-greeter": {Name: "dms-greeter", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, + "matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, + "dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]), + "dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, + } + + switch wm { + case deps.WindowManagerHyprland: + packages["hyprland"] = f.getHyprlandMapping(variants["hyprland"]) + packages["hyprctl"] = f.getHyprlandMapping(variants["hyprland"]) + packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} + case deps.WindowManagerNiri: + packages["niri"] = f.getNiriMapping(variants["niri"]) + packages["xwayland-satellite"] = PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} + } + + return packages +} + +func (f *FedoraDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { + if forceQuickshellGit || variant == deps.VariantGit { + return PackageMapping{Name: "quickshell-git", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"} + } + return PackageMapping{Name: "quickshell", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"} +} + +func (f *FedoraDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms-git"} + } + return PackageMapping{Name: "dms", Repository: RepoTypeCOPR, RepoURL: "avengemedia/dms"} +} + +func (f *FedoraDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { + return PackageMapping{Name: "hyprland", Repository: RepoTypeCOPR, RepoURL: "sdegler/hyprland"} +} + +func (f *FedoraDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "niri", Repository: RepoTypeCOPR, RepoURL: "yalter/niri-git"} + } + return PackageMapping{Name: "niri", Repository: RepoTypeCOPR, RepoURL: "yalter/niri"} +} + +func (f *FedoraDistribution) detectXwaylandSatellite() deps.Dependency { + status := deps.StatusMissing + if f.commandExists("xwayland-satellite") { + status = deps.StatusInstalled + } + + return deps.Dependency{ + Name: "xwayland-satellite", + Status: status, + Description: "Xwayland support", + Required: true, + } +} + +func (f *FedoraDistribution) detectAccountsService() deps.Dependency { + status := deps.StatusMissing + if f.packageInstalled("accountsservice") { + status = deps.StatusInstalled + } + + return deps.Dependency{ + Name: "accountsservice", + Status: status, + Description: "D-Bus interface for user account query and manipulation", + Required: true, + } +} + +func (f *FedoraDistribution) detectDMSGreeter() deps.Dependency { + return f.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", f.packageInstalled("dms-greeter")) +} + +func (f *FedoraDistribution) getPrerequisites() []string { + return []string{ + "dnf-plugins-core", + "make", + "unzip", + "libwayland-server", + } +} + +func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + prerequisites := f.getPrerequisites() + var missingPkgs []string + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.06, + Step: "Checking prerequisites...", + IsComplete: false, + LogOutput: "Checking prerequisite packages", + } + + for _, pkg := range prerequisites { + checkCmd := exec.CommandContext(ctx, "rpm", "-q", pkg) + if err := checkCmd.Run(); err != nil { + missingPkgs = append(missingPkgs, pkg) + } + } + + _, err := exec.LookPath("go") + if err != nil { + f.log("go not found in PATH, will install golang-bin") + missingPkgs = append(missingPkgs, "golang-bin") + } else { + f.log("go already available in PATH") + } + + if len(missingPkgs) == 0 { + f.log("All prerequisites already installed") + return nil + } + + f.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", "))) + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.08, + Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)), + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo dnf install -y %s", strings.Join(missingPkgs, " ")), + LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")), + } + + args := []string{"dnf", "install", "-y"} + args = append(args, missingPkgs...) + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + output, err := cmd.CombinedOutput() + if err != nil { + f.logError("failed to install prerequisites", err) + f.log(fmt.Sprintf("Prerequisites command output: %s", string(output))) + return fmt.Errorf("failed to install prerequisites: %w", err) + } + f.log(fmt.Sprintf("Prerequisites install output: %s", string(output))) + + return nil +} + +func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error { + // Phase 1: Check Prerequisites + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.05, + Step: "Checking system prerequisites...", + IsComplete: false, + LogOutput: "Starting prerequisite check...", + } + + if err := f.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install prerequisites: %w", err) + } + + dnfPkgs, coprPkgs, manualPkgs, variantMap := f.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags) + + // Phase 2: Enable COPR repositories + if len(coprPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.15, + Step: "Enabling COPR repositories...", + IsComplete: false, + LogOutput: "Setting up COPR repositories for additional packages", + } + if err := f.enableCOPRRepos(ctx, coprPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to enable COPR repositories: %w", err) + } + } + + // Phase 3: System Packages (DNF) + if len(dnfPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.35, + Step: fmt.Sprintf("Installing %d system packages...", len(dnfPkgs)), + IsComplete: false, + NeedsSudo: true, + LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(dnfPkgs, ", ")), + } + if err := f.installDNFPackages(ctx, dnfPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install DNF packages: %w", err) + } + } + + // Phase 4: COPR Packages + coprPkgNames := f.extractPackageNames(coprPkgs) + if len(coprPkgNames) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, // Reusing AUR phase for COPR + Progress: 0.65, + Step: fmt.Sprintf("Installing %d COPR packages...", len(coprPkgNames)), + IsComplete: false, + LogOutput: fmt.Sprintf("Installing COPR packages: %s", strings.Join(coprPkgNames, ", ")), + } + if err := f.installCOPRPackages(ctx, coprPkgNames, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install COPR packages: %w", err) + } + } + + // Phase 5: Manual Builds + if len(manualPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.85, + Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)), + IsComplete: false, + LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")), + } + if err := f.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install manual packages: %w", err) + } + } + + // Phase 6: Configuration + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.90, + Step: "Configuring system...", + IsComplete: false, + LogOutput: "Starting post-installation configuration...", + } + + terminal := f.DetectTerminalFromDeps(dependencies) + if err := f.WriteEnvironmentConfig(terminal); err != nil { + f.log(fmt.Sprintf("Warning: failed to write environment config: %v", err)) + } + + if err := f.WriteWindowManagerConfig(wm); err != nil { + f.log(fmt.Sprintf("Warning: failed to write window manager config: %v", err)) + } + + if err := f.EnableDMSService(ctx, wm); err != nil { + f.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err)) + } + + // Phase 7: Complete + progressChan <- InstallProgressMsg{ + Phase: PhaseComplete, + Progress: 1.0, + Step: "Installation complete!", + IsComplete: true, + LogOutput: "All packages installed and configured successfully", + } + + return nil +} + +func (f *FedoraDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping, []string, map[string]deps.PackageVariant) { + dnfPkgs := []string{} + coprPkgs := []PackageMapping{} + manualPkgs := []string{} + + variantMap := make(map[string]deps.PackageVariant) + for _, dep := range dependencies { + variantMap[dep.Name] = dep.Variant + } + + packageMap := f.GetPackageMappingWithVariants(wm, variantMap) + + for _, dep := range dependencies { + if disabledFlags[dep.Name] { + continue + } + + if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] { + continue + } + + pkgInfo, exists := packageMap[dep.Name] + if !exists { + f.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name)) + continue + } + + switch pkgInfo.Repository { + case RepoTypeSystem: + dnfPkgs = append(dnfPkgs, pkgInfo.Name) + case RepoTypeCOPR: + coprPkgs = append(coprPkgs, pkgInfo) + case RepoTypeManual: + manualPkgs = append(manualPkgs, dep.Name) + } + } + + return dnfPkgs, coprPkgs, manualPkgs, variantMap +} + +func (f *FedoraDistribution) extractPackageNames(packages []PackageMapping) []string { + names := make([]string, len(packages)) + for i, pkg := range packages { + names[i] = pkg.Name + } + return names +} + +func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + enabledRepos := make(map[string]bool) + + for _, pkg := range coprPkgs { + if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] { + f.log(fmt.Sprintf("Enabling COPR repository: %s", pkg.RepoURL)) + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.20, + Step: fmt.Sprintf("Enabling COPR repo %s...", pkg.RepoURL), + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL)) + output, err := cmd.CombinedOutput() + if err != nil { + f.logError(fmt.Sprintf("failed to enable COPR repo %s", pkg.RepoURL), err) + f.log(fmt.Sprintf("COPR enable command output: %s", string(output))) + return fmt.Errorf("failed to enable COPR repo %s: %w", pkg.RepoURL, err) + } + f.log(fmt.Sprintf("COPR repo %s enabled successfully: %s", pkg.RepoURL, string(output))) + enabledRepos[pkg.RepoURL] = true + + // Special handling for niri COPR repo - set priority=1 + if pkg.RepoURL == "yalter/niri-git" { + f.log("Setting priority=1 for niri-git COPR repo...") + repoFile := "/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:yalter:niri-git.repo" + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.22, + Step: "Setting niri COPR repo priority...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile), + } + + priorityCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile)) + priorityOutput, err := priorityCmd.CombinedOutput() + if err != nil { + f.logError("failed to set niri COPR repo priority", err) + f.log(fmt.Sprintf("Priority command output: %s", string(priorityOutput))) + return fmt.Errorf("failed to set niri COPR repo priority: %w", err) + } + f.log(fmt.Sprintf("niri COPR repo priority set successfully: %s", string(priorityOutput))) + } + } + } + + return nil +} + +func (f *FedoraDistribution) installDNFPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + f.log(fmt.Sprintf("Installing DNF packages: %s", strings.Join(packages, ", "))) + + return f.installDNFGroups(ctx, packages, sudoPassword, progressChan, PhaseSystemPackages, "Installing system packages...", 0.40, 0.60) +} + +func (f *FedoraDistribution) installCOPRPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + f.log(fmt.Sprintf("Installing COPR packages: %s", strings.Join(packages, ", "))) + + return f.installDNFGroups(ctx, packages, sudoPassword, progressChan, PhaseAURPackages, "Installing COPR packages...", 0.70, 0.85) +} + +func (f *FedoraDistribution) dnfInstallArgs(packages []string, minimal bool) []string { + args := []string{"dnf", "install", "-y"} + if minimal { + args = append(args, "--setopt=install_weak_deps=False") + } + return append(args, packages...) +} + +func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg, phase InstallPhase, step string, startProgress float64, endProgress float64) error { + groups := orderedMinimalInstallGroups(packages) + totalGroups := len(groups) + + groupIndex := 0 + installGroup := func(groupPackages []string, minimal bool) error { + if len(groupPackages) == 0 { + return nil + } + + groupIndex++ + groupStart := startProgress + groupEnd := endProgress + if totalGroups > 1 { + midpoint := startProgress + ((endProgress - startProgress) / 2) + if groupIndex == 1 { + groupEnd = midpoint + } else { + groupStart = midpoint + } + } + + args := f.dnfInstallArgs(groupPackages, minimal) + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: groupStart, + Step: step, + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) + } + + for _, group := range groups { + if err := installGroup(group.packages, group.minimal); err != nil { + return err + } + } + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/gentoo.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/gentoo.go new file mode 100644 index 0000000..e0a2a9c --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/gentoo.go @@ -0,0 +1,700 @@ +package distros + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +var GentooGlobalUseFlags = []string{ + "dbus", + "udev", + "alsa", + "policykit", + "jpeg", + "png", + "webp", + "gif", + "tiff", + "svg", + "brotli", + "gdbm", + "accessibility", + "gtk", + "qt6", + "egl", + "gbm", +} + +func init() { + Register("gentoo", "#54487A", FamilyGentoo, func(config DistroConfig, logChan chan<- string) Distribution { + return NewGentooDistribution(config, logChan) + }) +} + +type GentooDistribution struct { + *BaseDistribution + *ManualPackageInstaller + config DistroConfig + skipGlobalUseFlags bool +} + +func NewGentooDistribution(config DistroConfig, logChan chan<- string) *GentooDistribution { + base := NewBaseDistribution(logChan) + return &GentooDistribution{ + BaseDistribution: base, + ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base}, + config: config, + } +} + +func (g *GentooDistribution) getArchKeyword() string { + arch := runtime.GOARCH + switch arch { + case "amd64": + return "~amd64" + case "arm64": + return "~arm64" + default: + return "~amd64" + } +} + +func (g *GentooDistribution) GetID() string { + return g.config.ID +} + +func (g *GentooDistribution) GetColorHex() string { + return g.config.ColorHex +} + +func (g *GentooDistribution) GetFamily() DistroFamily { + return g.config.Family +} + +func (g *GentooDistribution) GetPackageManager() PackageManagerType { + return PackageManagerPortage +} + +func (g *GentooDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) { + return g.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty) +} + +func (g *GentooDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) { + var dependencies []deps.Dependency + + dependencies = append(dependencies, g.detectDMS()) + + dependencies = append(dependencies, g.detectSpecificTerminal(terminal)) + + dependencies = append(dependencies, g.detectGit()) + dependencies = append(dependencies, g.detectWindowManager(wm)) + dependencies = append(dependencies, g.detectQuickshell()) + dependencies = append(dependencies, g.detectXDGPortal()) + dependencies = append(dependencies, g.detectAccountsService()) + + if wm == deps.WindowManagerHyprland { + dependencies = append(dependencies, g.detectHyprlandTools()...) + } + + if wm == deps.WindowManagerNiri { + dependencies = append(dependencies, g.detectXwaylandSatellite()) + } + + dependencies = append(dependencies, g.detectMatugen()) + dependencies = append(dependencies, g.detectDgop()) + + return dependencies, nil +} + +func (g *GentooDistribution) detectXDGPortal() deps.Dependency { + return g.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", g.packageInstalled("sys-apps/xdg-desktop-portal-gtk")) +} + +func (g *GentooDistribution) detectXwaylandSatellite() deps.Dependency { + return g.detectPackage("xwayland-satellite", "Xwayland support", g.packageInstalled("gui-apps/xwayland-satellite")) +} + +func (g *GentooDistribution) detectAccountsService() deps.Dependency { + return g.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", g.packageInstalled("sys-apps/accountsservice")) +} + +func (g *GentooDistribution) packageInstalled(pkg string) bool { + cmd := exec.Command("qlist", "-I", pkg) + err := cmd.Run() + return err == nil +} + +func (g *GentooDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { + return g.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) +} + +func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping { + archKeyword := g.getArchKeyword() + packages := map[string]PackageMapping{ + "git": {Name: "dev-vcs/git", Repository: RepoTypeSystem}, + "kitty": {Name: "x11-terms/kitty", Repository: RepoTypeSystem, UseFlags: "X wayland"}, + "alacritty": {Name: "x11-terms/alacritty", Repository: RepoTypeSystem, UseFlags: "X wayland"}, + "xdg-desktop-portal-gtk": {Name: "sys-apps/xdg-desktop-portal-gtk", Repository: RepoTypeSystem, UseFlags: "wayland X"}, + "accountsservice": {Name: "sys-apps/accountsservice", Repository: RepoTypeSystem}, + + "qtbase": {Name: "dev-qt/qtbase", Repository: RepoTypeSystem, UseFlags: "wayland opengl vulkan widgets"}, + "qtdeclarative": {Name: "dev-qt/qtdeclarative", Repository: RepoTypeSystem, UseFlags: "opengl vulkan"}, + "qtwayland": {Name: "dev-qt/qtwayland", Repository: RepoTypeSystem}, + "mesa": {Name: "media-libs/mesa", Repository: RepoTypeSystem, UseFlags: "opengl vulkan"}, + + "quickshell": g.getQuickshellMapping(variants["quickshell"]), + "matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}, + "dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]), + "dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"}, + } + + switch wm { + case deps.WindowManagerHyprland: + packages["hyprland"] = g.getHyprlandMapping(variants["hyprland"]) + packages["hyprctl"] = g.getHyprlandMapping(variants["hyprland"]) + packages["jq"] = PackageMapping{Name: "app-misc/jq", Repository: RepoTypeSystem} + case deps.WindowManagerNiri: + packages["niri"] = g.getNiriMapping(variants["niri"]) + packages["xwayland-satellite"] = PackageMapping{Name: "gui-apps/xwayland-satellite", Repository: RepoTypeGURU, AcceptKeywords: archKeyword} + } + + return packages +} + +func (g *GentooDistribution) getQuickshellMapping(_ deps.PackageVariant) PackageMapping { + return PackageMapping{Name: "gui-apps/quickshell", Repository: RepoTypeGURU, UseFlags: "breakpad jemalloc sockets wayland layer-shell session-lock toplevel-management screencopy X pipewire tray mpris pam hyprland hyprland-global-shortcuts hyprland-focus-grab i3 i3-ipc bluetooth", AcceptKeywords: "**"} +} + +func (g *GentooDistribution) getDmsMapping(_ deps.PackageVariant) PackageMapping { + return PackageMapping{Name: "dms", Repository: RepoTypeManual, BuildFunc: "installDankMaterialShell"} +} + +func (g *GentooDistribution) getHyprlandMapping(_ deps.PackageVariant) PackageMapping { + return PackageMapping{Name: "gui-wm/hyprland", Repository: RepoTypeSystem, UseFlags: "X", AcceptKeywords: g.getArchKeyword()} +} + +func (g *GentooDistribution) getNiriMapping(_ deps.PackageVariant) PackageMapping { + return PackageMapping{Name: "gui-wm/niri", Repository: RepoTypeGURU, UseFlags: "dbus screencast", AcceptKeywords: g.getArchKeyword()} +} + +func (g *GentooDistribution) getPrerequisites() []string { + return []string{ + "app-eselect/eselect-repository", + "dev-vcs/git", + "dev-build/make", + "app-arch/unzip", + "dev-util/pkgconf", + "dev-qt/qtdeclarative", + } +} + +func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword string) error { + useFlags := strings.Join(GentooGlobalUseFlags, " ") + + checkCmd := exec.CommandContext(ctx, "grep", "-q", "^USE=", "/etc/portage/make.conf") + hasUse := checkCmd.Run() == nil + + var cmd *exec.Cmd + if hasUse { + cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags)) + } else { + cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags)) + } + + output, err := cmd.CombinedOutput() + if err != nil { + g.log(fmt.Sprintf("Failed to set global USE flags: %s", string(output))) + return err + } + + g.log(fmt.Sprintf("Set global USE flags: %s", useFlags)) + return nil +} + +func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + prerequisites := g.getPrerequisites() + var missingPkgs []string + + if !g.skipGlobalUseFlags { + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.05, + Step: "Setting global USE flags...", + IsComplete: false, + LogOutput: "Configuring global USE flags in /etc/portage/make.conf", + } + + if err := g.setGlobalUseFlags(ctx, sudoPassword); err != nil { + g.logError("failed to set global USE flags", err) + return fmt.Errorf("failed to set global USE flags: %w", err) + } + } else { + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.05, + Step: "Skipping global USE flags...", + IsComplete: false, + LogOutput: "Skipping global USE flags configuration (using existing configuration)", + } + } + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.06, + Step: "Checking prerequisites...", + IsComplete: false, + LogOutput: "Checking prerequisite packages", + } + + for _, pkg := range prerequisites { + checkCmd := exec.CommandContext(ctx, "qlist", "-I", pkg) + if err := checkCmd.Run(); err != nil { + missingPkgs = append(missingPkgs, pkg) + } + } + + _, err := exec.LookPath("go") + if err != nil { + g.log("go not found in PATH, will install dev-lang/go") + missingPkgs = append(missingPkgs, "dev-lang/go") + } else { + g.log("go already available in PATH") + } + + if len(missingPkgs) == 0 { + g.log("All prerequisites already installed") + return nil + } + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.07, + Step: "Syncing Portage tree...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo emerge --sync", + LogOutput: "Syncing Portage tree with emerge --sync", + } + + syncCmd := ExecSudoCommand(ctx, sudoPassword, "emerge --sync --quiet") + syncOutput, syncErr := syncCmd.CombinedOutput() + if syncErr != nil { + g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput))) + return fmt.Errorf("failed to sync Portage tree: %w\nOutput: %s", syncErr, string(syncOutput)) + } + g.log("Portage tree synced successfully") + + g.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", "))) + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.08, + Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)), + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo emerge --ask=n %s", strings.Join(missingPkgs, " ")), + LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")), + } + + args := []string{"emerge", "--ask=n", "--quiet"} + args = append(args, missingPkgs...) + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + output, err := cmd.CombinedOutput() + if err != nil { + g.logError("failed to install prerequisites", err) + g.log(fmt.Sprintf("Prerequisites command output: %s", string(output))) + return fmt.Errorf("failed to install prerequisites: %w\nOutput: %s", err, string(output)) + } + g.log(fmt.Sprintf("Prerequisites install output: %s", string(output))) + + return nil +} + +func (g *GentooDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error { + g.skipGlobalUseFlags = skipGlobalUseFlags + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.05, + Step: "Checking system prerequisites...", + IsComplete: false, + LogOutput: "Starting prerequisite check...", + } + + if err := g.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install prerequisites: %w", err) + } + + systemPkgs, guruPkgs, manualPkgs, variantMap := g.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags) + + g.log(fmt.Sprintf("CATEGORIZED PACKAGES: system=%d, guru=%d, manual=%d", len(systemPkgs), len(guruPkgs), len(manualPkgs))) + + if len(systemPkgs) > 0 { + systemPkgNames := g.extractPackageNames(systemPkgs) + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.35, + Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)), + IsComplete: false, + NeedsSudo: true, + LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgNames, ", ")), + } + if err := g.installPortagePackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install Portage packages: %w", err) + } + } + + if len(guruPkgs) > 0 { + g.log(fmt.Sprintf("FOUND %d GURU PACKAGES - WILL SYNC GURU REPO", len(guruPkgs))) + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.60, + Step: "Syncing GURU repository...", + IsComplete: false, + LogOutput: "Syncing GURU repository to fetch latest ebuilds", + } + g.log("ABOUT TO CALL syncGURURepo") + if err := g.syncGURURepo(ctx, sudoPassword, progressChan); err != nil { + g.log(fmt.Sprintf("syncGURURepo RETURNED ERROR: %v", err)) + return fmt.Errorf("failed to sync GURU repository: %w", err) + } + g.log("syncGURURepo COMPLETED SUCCESSFULLY") + + guruPkgNames := g.extractPackageNames(guruPkgs) + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.65, + Step: fmt.Sprintf("Installing %d GURU packages...", len(guruPkgs)), + IsComplete: false, + LogOutput: fmt.Sprintf("Installing GURU packages: %s", strings.Join(guruPkgNames, ", ")), + } + if err := g.installGURUPackages(ctx, guruPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install GURU packages: %w", err) + } + } + + if len(manualPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.85, + Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)), + IsComplete: false, + LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")), + } + if err := g.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install manual packages: %w", err) + } + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.90, + Step: "Configuring system...", + IsComplete: false, + LogOutput: "Starting post-installation configuration...", + } + + terminal := g.DetectTerminalFromDeps(dependencies) + if err := g.WriteEnvironmentConfig(terminal); err != nil { + g.log(fmt.Sprintf("Warning: failed to write environment config: %v", err)) + } + + if err := g.WriteWindowManagerConfig(wm); err != nil { + g.log(fmt.Sprintf("Warning: failed to write window manager config: %v", err)) + } + + if err := g.EnableDMSService(ctx, wm); err != nil { + g.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err)) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseComplete, + Progress: 1.0, + Step: "Installation complete!", + IsComplete: true, + LogOutput: "All packages installed and configured successfully", + } + + return nil +} + +func (g *GentooDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]PackageMapping, []PackageMapping, []string, map[string]deps.PackageVariant) { + systemPkgs := []PackageMapping{} + guruPkgs := []PackageMapping{} + manualPkgs := []string{} + + variantMap := make(map[string]deps.PackageVariant) + for _, dep := range dependencies { + variantMap[dep.Name] = dep.Variant + } + + packageMap := g.GetPackageMappingWithVariants(wm, variantMap) + + for _, dep := range dependencies { + if disabledFlags[dep.Name] { + continue + } + + if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] { + continue + } + + pkgInfo, exists := packageMap[dep.Name] + if !exists { + g.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name)) + continue + } + + switch pkgInfo.Repository { + case RepoTypeSystem: + systemPkgs = append(systemPkgs, pkgInfo) + case RepoTypeGURU: + guruPkgs = append(guruPkgs, pkgInfo) + case RepoTypeManual: + manualPkgs = append(manualPkgs, dep.Name) + } + } + + return systemPkgs, guruPkgs, manualPkgs, variantMap +} + +func (g *GentooDistribution) extractPackageNames(packages []PackageMapping) []string { + names := make([]string, len(packages)) + for i, pkg := range packages { + names[i] = pkg.Name + } + return names +} + +func (g *GentooDistribution) installPortagePackages(ctx context.Context, packages []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + packageNames := g.extractPackageNames(packages) + g.log(fmt.Sprintf("Installing Portage packages: %s", strings.Join(packageNames, ", "))) + + for _, pkg := range packages { + if pkg.AcceptKeywords != "" { + if err := g.setPackageAcceptKeywords(ctx, pkg.Name, pkg.AcceptKeywords, sudoPassword); err != nil { + return fmt.Errorf("failed to set accept keywords for %s: %w", pkg.Name, err) + } + } + if pkg.UseFlags != "" { + if err := g.setPackageUseFlags(ctx, pkg.Name, pkg.UseFlags, sudoPassword); err != nil { + return fmt.Errorf("failed to set USE flags for %s: %w", pkg.Name, err) + } + } + } + + args := []string{"emerge", "--ask=n", "--quiet"} + args = append(args, packageNames...) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.40, + Step: "Installing system packages...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0) +} + +func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error { + packageUseDir := "/etc/portage/package.use" + + mkdirCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("mkdir -p %s", packageUseDir)) + if output, err := mkdirCmd.CombinedOutput(); err != nil { + g.log(fmt.Sprintf("mkdir output: %s", string(output))) + return fmt.Errorf("failed to create package.use directory: %w", err) + } + + useFlagLine := fmt.Sprintf("%s %s", packageName, useFlags) + + checkExistingCmd := exec.CommandContext(ctx, "bash", "-c", + fmt.Sprintf("grep -q '^%s ' %s/danklinux 2>/dev/null", packageName, packageUseDir)) + if checkExistingCmd.Run() == nil { + g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName)) + escapedPkg := strings.ReplaceAll(packageName, "/", "\\/") + replaceCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir)) + if output, err := replaceCmd.CombinedOutput(); err != nil { + g.log(fmt.Sprintf("sed delete output: %s", string(output))) + return fmt.Errorf("failed to remove old USE flags: %w", err) + } + } + + appendCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir)) + + output, err := appendCmd.CombinedOutput() + if err != nil { + g.log(fmt.Sprintf("append output: %s", string(output))) + return fmt.Errorf("failed to write USE flags to package.use: %w", err) + } + + g.log(fmt.Sprintf("Set USE flags for %s: %s", packageName, useFlags)) + return nil +} + +func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.55, + Step: "Enabling GURU repository...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo eselect repository enable guru", + LogOutput: "Enabling GURU repository with eselect", + } + + // Enable GURU repository + enableCmd := ExecSudoCommand(ctx, sudoPassword, + "eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code") + output, err := enableCmd.CombinedOutput() + + g.log(fmt.Sprintf("eselect repository enable guru output:\n%s", string(output))) + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.55, + LogOutput: "GURU repository enabled", + } + + if err != nil { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.55, + LogOutput: fmt.Sprintf("ERROR enabling GURU: %v", err), + Error: err, + } + return fmt.Errorf("failed to enable GURU repository: %w\nOutput: %s", err, string(output)) + } + + // Sync GURU repository + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.57, + Step: "Syncing GURU repository...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo emaint sync --repo guru", + LogOutput: "Syncing GURU repository", + } + + syncCmd := ExecSudoCommand(ctx, sudoPassword, + "emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code") + syncOutput, syncErr := syncCmd.CombinedOutput() + + g.log(fmt.Sprintf("emaint sync --repo guru output:\n%s", string(syncOutput))) + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.57, + LogOutput: "GURU repository synced", + } + + if syncErr != nil { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.57, + LogOutput: fmt.Sprintf("ERROR syncing GURU: %v", syncErr), + Error: syncErr, + } + return fmt.Errorf("failed to sync GURU repository: %w\nOutput: %s", syncErr, string(syncOutput)) + } + + return nil +} + +func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packageName, keywords, sudoPassword string) error { + checkCmd := exec.CommandContext(ctx, "portageq", "match", "/", packageName) + if output, err := checkCmd.CombinedOutput(); err == nil && len(output) > 0 { + g.log(fmt.Sprintf("Package %s is already available (may already be unmasked)", packageName)) + return nil + } + + acceptKeywordsDir := "/etc/portage/package.accept_keywords" + + mkdirCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("mkdir -p %s", acceptKeywordsDir)) + if output, err := mkdirCmd.CombinedOutput(); err != nil { + g.log(fmt.Sprintf("mkdir output: %s", string(output))) + return fmt.Errorf("failed to create package.accept_keywords directory: %w", err) + } + + keywordLine := fmt.Sprintf("%s %s", packageName, keywords) + + checkExistingCmd := exec.CommandContext(ctx, "bash", "-c", + fmt.Sprintf("grep -q '^%s ' %s/danklinux 2>/dev/null", packageName, acceptKeywordsDir)) + if checkExistingCmd.Run() == nil { + g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName)) + escapedPkg := strings.ReplaceAll(packageName, "/", "\\/") + replaceCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir)) + if output, err := replaceCmd.CombinedOutput(); err != nil { + g.log(fmt.Sprintf("sed delete output: %s", string(output))) + return fmt.Errorf("failed to remove old accept keywords: %w", err) + } + } + + appendCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir)) + + output, err := appendCmd.CombinedOutput() + if err != nil { + g.log(fmt.Sprintf("append output: %s", string(output))) + return fmt.Errorf("failed to write accept keywords: %w", err) + } + + g.log(fmt.Sprintf("Set accept keywords for %s: %s", packageName, keywords)) + return nil +} + +func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + packageNames := g.extractPackageNames(packages) + g.log(fmt.Sprintf("Installing GURU packages: %s", strings.Join(packageNames, ", "))) + + for _, pkg := range packages { + if pkg.AcceptKeywords != "" { + if err := g.setPackageAcceptKeywords(ctx, pkg.Name, pkg.AcceptKeywords, sudoPassword); err != nil { + return fmt.Errorf("failed to set accept keywords for %s: %w", pkg.Name, err) + } + } + if pkg.UseFlags != "" { + if err := g.setPackageUseFlags(ctx, pkg.Name, pkg.UseFlags, sudoPassword); err != nil { + return fmt.Errorf("failed to set USE flags for %s: %w", pkg.Name, err) + } + } + } + + guruPackages := make([]string, len(packageNames)) + for i, pkg := range packageNames { + guruPackages[i] = pkg + "::guru" + } + + args := []string{"emerge", "--ask=n", "--quiet"} + args = append(args, guruPackages...) + + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.70, + Step: "Installing GURU packages...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/interface.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/interface.go new file mode 100644 index 0000000..f719f1b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/interface.go @@ -0,0 +1,158 @@ +package distros + +import ( + "context" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +// DistroFamily represents a family of related distributions +type DistroFamily string + +const ( + FamilyArch DistroFamily = "arch" + FamilyFedora DistroFamily = "fedora" + FamilySUSE DistroFamily = "suse" + FamilyUbuntu DistroFamily = "ubuntu" + FamilyDebian DistroFamily = "debian" + FamilyNix DistroFamily = "nix" + FamilyGentoo DistroFamily = "gentoo" +) + +// PackageManagerType defines the package manager a distro uses +type PackageManagerType string + +const ( + PackageManagerPacman PackageManagerType = "pacman" + PackageManagerDNF PackageManagerType = "dnf" + PackageManagerAPT PackageManagerType = "apt" + PackageManagerZypper PackageManagerType = "zypper" + PackageManagerNix PackageManagerType = "nix" + PackageManagerPortage PackageManagerType = "portage" +) + +// RepositoryType defines the type of repository for a package +type RepositoryType string + +const ( + RepoTypeSystem RepositoryType = "system" // Standard system repo (pacman, dnf, apt) + RepoTypeAUR RepositoryType = "aur" // Arch User Repository + RepoTypeCOPR RepositoryType = "copr" // Fedora COPR + RepoTypePPA RepositoryType = "ppa" // Ubuntu PPA + RepoTypeOBS RepositoryType = "obs" // OpenBuild Service (Debian/OpenSUSE) + RepoTypeFlake RepositoryType = "flake" // Nix flake + RepoTypeGURU RepositoryType = "guru" // Gentoo GURU + RepoTypeManual RepositoryType = "manual" // Manual build from source +) + +// InstallPhase represents the current phase of installation +type InstallPhase int + +const ( + PhasePrerequisites InstallPhase = iota + PhaseAURHelper + PhaseSystemPackages + PhaseAURPackages + PhaseCursorTheme + PhaseConfiguration + PhaseGreeterSetup + PhaseComplete +) + +// InstallProgressMsg represents progress during package installation +type InstallProgressMsg struct { + Phase InstallPhase + Progress float64 + Step string + IsComplete bool + NeedsSudo bool + CommandInfo string + LogOutput string + Error error +} + +// PackageMapping defines how to install a package on a specific distro +type PackageMapping struct { + Name string // Package name to install + Repository RepositoryType // Repository type + RepoURL string // Repository URL if needed (e.g., COPR repo, PPA) + BuildFunc string // Name of manual build function if RepoTypeManual + UseFlags string // USE flags for Gentoo packages + AcceptKeywords string // Accept keywords for Gentoo packages (e.g., "~amd64") +} + +// Distribution defines a Linux distribution with all its specific configurations +type Distribution interface { + // Metadata + GetID() string + GetColorHex() string + GetFamily() DistroFamily + GetPackageManager() PackageManagerType + + // Dependency Detection + DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) + DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) + + // Package Installation + InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error + + // Package Mapping + GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping + + // Prerequisites + InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error +} + +// DistroConfig holds configuration for a distribution +type DistroConfig struct { + ID string + ColorHex string + Family DistroFamily + Constructor func(config DistroConfig, logChan chan<- string) Distribution +} + +// Registry holds all supported distributions +var Registry = make(map[string]DistroConfig) + +// Register adds a distribution to the registry +func Register(id, colorHex string, family DistroFamily, constructor func(config DistroConfig, logChan chan<- string) Distribution) { + Registry[id] = DistroConfig{ + ID: id, + ColorHex: colorHex, + Family: family, + Constructor: constructor, + } +} + +// GetSupportedDistros returns a list of all supported distribution IDs +func GetSupportedDistros() []string { + ids := make([]string, 0, len(Registry)) + for id := range Registry { + ids = append(ids, id) + } + return ids +} + +// IsDistroSupported checks if a distribution ID is supported +func IsDistroSupported(id string) bool { + _, exists := Registry[id] + return exists +} + +// NewDistribution creates a distribution instance by ID +func NewDistribution(id string, logChan chan<- string) (Distribution, error) { + config, exists := Registry[id] + if !exists { + return nil, &UnsupportedDistributionError{ID: id} + } + return config.Constructor(config, logChan), nil +} + +// UnsupportedDistributionError is returned when a distribution is not supported +type UnsupportedDistributionError struct { + ID string +} + +func (e *UnsupportedDistributionError) Error() string { + return "unsupported distribution: " + e.ID +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/manual_packages.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/manual_packages.go new file mode 100644 index 0000000..2f38161 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/manual_packages.go @@ -0,0 +1,663 @@ +package distros + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +// ManualPackageInstaller provides methods for installing packages from source +type ManualPackageInstaller struct { + *BaseDistribution +} + +// parseLatestTagFromGitOutput parses git ls-remote output and returns the latest tag +func (m *ManualPackageInstaller) parseLatestTagFromGitOutput(output string) string { + lines := strings.SplitSeq(output, "\n") + for line := range lines { + if strings.Contains(line, "refs/tags/") && !strings.Contains(line, "^{}") { + parts := strings.Split(line, "refs/tags/") + if len(parts) > 1 { + latestTag := strings.TrimSpace(parts[1]) + return latestTag + } + } + } + return "" +} + +// getLatestQuickshellTag fetches the latest tag from the quickshell repository +func (m *ManualPackageInstaller) getLatestQuickshellTag(ctx context.Context) string { + tagCmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", "--sort=-v:refname", + "https://github.com/quickshell-mirror/quickshell.git") + tagOutput, err := tagCmd.Output() + if err != nil { + m.log(fmt.Sprintf("Warning: failed to fetch quickshell tags: %v", err)) + return "" + } + + return m.parseLatestTagFromGitOutput(string(tagOutput)) +} + +func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + m.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", "))) + + for _, pkg := range packages { + variant := variantMap[pkg] + switch pkg { + case "dms (DankMaterialShell)", "dms": + if err := m.installDankMaterialShell(ctx, variant, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install DankMaterialShell: %w", err) + } + case "dgop": + if err := m.installDgop(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install dgop: %w", err) + } + case "niri": + if err := m.installNiri(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install niri: %w", err) + } + case "quickshell": + if err := m.installQuickshell(ctx, variant, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install quickshell: %w", err) + } + case "hyprland": + if err := m.installHyprland(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install hyprland: %w", err) + } + case "ghostty": + if err := m.installGhostty(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install ghostty: %w", err) + } + case "matugen": + if err := m.installMatugen(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install matugen: %w", err) + } + case "xwayland-satellite": + if err := m.installXwaylandSatellite(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install xwayland-satellite: %w", err) + } + default: + m.log(fmt.Sprintf("Warning: No manual build method for %s", pkg)) + } + } + + return nil +} + +func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + m.log("Installing dgop from source...") + + homeDir := os.Getenv("HOME") + if homeDir == "" { + return fmt.Errorf("HOME environment variable not set") + } + + cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + tmpDir := filepath.Join(cacheDir, "dgop-build") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.1, + Step: "Cloning dgop repository...", + IsComplete: false, + CommandInfo: "git clone https://github.com/AvengeMedia/dgop.git", + } + + cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/AvengeMedia/dgop.git", tmpDir) + if err := cloneCmd.Run(); err != nil { + m.logError("failed to clone dgop repository", err) + return fmt.Errorf("failed to clone dgop repository: %w", err) + } + + buildCmd := exec.CommandContext(ctx, "make") + buildCmd.Dir = tmpDir + buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir) + if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.7, "Building dgop..."); err != nil { + return fmt.Errorf("failed to build dgop: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.7, + Step: "Installing dgop...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo make install", + } + + installCmd := ExecSudoCommand(ctx, sudoPassword, "make install") + installCmd.Dir = tmpDir + if err := installCmd.Run(); err != nil { + m.logError("failed to install dgop", err) + return fmt.Errorf("failed to install dgop: %w", err) + } + + m.log("dgop installed successfully from source") + return nil +} + +func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + m.log("Installing niri from source...") + + homeDir, _ := os.UserHomeDir() + buildDir := filepath.Join(homeDir, ".cache", "dankinstall", "niri-build") + tmpDir := filepath.Join(homeDir, ".cache", "dankinstall", "tmp") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + return fmt.Errorf("failed to create build directory: %w", err) + } + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer func() { + os.RemoveAll(buildDir) + os.RemoveAll(tmpDir) + }() + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.2, + Step: "Cloning niri repository...", + IsComplete: false, + CommandInfo: "git clone https://github.com/YaLTeR/niri.git", + } + + cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/YaLTeR/niri.git", buildDir) + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("failed to clone niri: %w", err) + } + + checkoutCmd := exec.CommandContext(ctx, "git", "-C", buildDir, "checkout", "v25.08") + if err := checkoutCmd.Run(); err != nil { + m.log(fmt.Sprintf("Warning: failed to checkout v25.08, using main: %v", err)) + } + + if !m.commandExists("cargo-deb") { + cargoDebInstallCmd := exec.CommandContext(ctx, "cargo", "install", "cargo-deb") + cargoDebInstallCmd.Env = append(os.Environ(), "TMPDIR="+tmpDir) + if err := m.runWithProgressStep(cargoDebInstallCmd, progressChan, PhaseSystemPackages, 0.3, 0.35, "Installing cargo-deb..."); err != nil { + return fmt.Errorf("failed to install cargo-deb: %w", err) + } + } + + buildDebCmd := exec.CommandContext(ctx, "cargo", "deb") + buildDebCmd.Dir = buildDir + buildDebCmd.Env = append(os.Environ(), "TMPDIR="+tmpDir) + if err := m.runWithProgressStep(buildDebCmd, progressChan, PhaseSystemPackages, 0.35, 0.95, "Building niri deb package..."); err != nil { + return fmt.Errorf("failed to build niri deb: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.95, + Step: "Installing niri deb package...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "dpkg -i niri.deb", + } + + installDebCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir)) + + output, err := installDebCmd.CombinedOutput() + if err != nil { + m.log(fmt.Sprintf("dpkg install failed. Output:\n%s", string(output))) + return fmt.Errorf("failed to install niri deb package: %w\nOutput:\n%s", err, string(output)) + } + + m.log(fmt.Sprintf("dpkg install successful. Output:\n%s", string(output))) + + m.log("niri installed successfully from source") + return nil +} + +func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + m.log("Installing quickshell from source...") + + homeDir := os.Getenv("HOME") + if homeDir == "" { + return fmt.Errorf("HOME environment variable not set") + } + + cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + tmpDir := filepath.Join(cacheDir, "quickshell-build") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.1, + Step: "Cloning quickshell repository...", + IsComplete: false, + CommandInfo: "git clone https://github.com/quickshell-mirror/quickshell.git", + } + + var cloneCmd *exec.Cmd + if forceQuickshellGit || variant == deps.VariantGit { + cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir) + } else { + latestTag := m.getLatestQuickshellTag(ctx) + if latestTag != "" { + m.log(fmt.Sprintf("Using latest quickshell tag: %s", latestTag)) + cloneCmd = exec.CommandContext(ctx, "git", "clone", "--branch", latestTag, "https://github.com/quickshell-mirror/quickshell.git", tmpDir) + } else { + m.log("Warning: failed to fetch latest tag, using default branch") + cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir) + } + } + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("failed to clone quickshell: %w", err) + } + + buildDir := tmpDir + "/build" + if err := os.MkdirAll(buildDir, 0o755); err != nil { + return fmt.Errorf("failed to create build directory: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.3, + Step: "Configuring quickshell build...", + IsComplete: false, + CommandInfo: "cmake -B build -S . -G Ninja", + } + + configureCmd := exec.CommandContext(ctx, "cmake", "-GNinja", "-B", "build", + "-DCMAKE_BUILD_TYPE=RelWithDebInfo", + "-DCRASH_REPORTER=off", + "-DCMAKE_CXX_STANDARD=20") + configureCmd.Dir = tmpDir + configureCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir) + + output, err := configureCmd.CombinedOutput() + if err != nil { + m.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output))) + return fmt.Errorf("failed to configure quickshell: %w\nCMake output:\n%s", err, string(output)) + } + + m.log(fmt.Sprintf("cmake configure successful. Output:\n%s", string(output))) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.4, + Step: "Building quickshell (this may take a while)...", + IsComplete: false, + CommandInfo: "cmake --build build", + } + + buildCmd := exec.CommandContext(ctx, "cmake", "--build", "build") + buildCmd.Dir = tmpDir + buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir) + if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.8, "Building quickshell..."); err != nil { + return fmt.Errorf("failed to build quickshell: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.8, + Step: "Installing quickshell...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo cmake --install build", + } + + installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build") + installCmd.Dir = tmpDir + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install quickshell: %w", err) + } + + m.log("quickshell installed successfully from source") + return nil +} + +func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + m.log("Installing Hyprland from source...") + + homeDir := os.Getenv("HOME") + if homeDir == "" { + return fmt.Errorf("HOME environment variable not set") + } + + cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + tmpDir := filepath.Join(cacheDir, "hyprland-build") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.1, + Step: "Cloning Hyprland repository...", + IsComplete: false, + CommandInfo: "git clone --recursive https://github.com/hyprwm/Hyprland.git", + } + + cloneCmd := exec.CommandContext(ctx, "git", "clone", "--recursive", "https://github.com/hyprwm/Hyprland.git", tmpDir) + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("failed to clone Hyprland: %w", err) + } + + checkoutCmd := exec.CommandContext(ctx, "git", "-C", tmpDir, "checkout", "v0.50.1") + if err := checkoutCmd.Run(); err != nil { + m.log(fmt.Sprintf("Warning: failed to checkout v0.50.1, using main: %v", err)) + } + + buildCmd := exec.CommandContext(ctx, "make", "all") + buildCmd.Dir = tmpDir + buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir) + if err := m.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.2, 0.8, "Building Hyprland..."); err != nil { + return fmt.Errorf("failed to build Hyprland: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.8, + Step: "Installing Hyprland...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo make install", + } + + installCmd := ExecSudoCommand(ctx, sudoPassword, "make install") + installCmd.Dir = tmpDir + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install Hyprland: %w", err) + } + + m.log("Hyprland installed successfully from source") + return nil +} + +func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + m.log("Installing Ghostty from source...") + + homeDir := os.Getenv("HOME") + if homeDir == "" { + return fmt.Errorf("HOME environment variable not set") + } + + cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + tmpDir := filepath.Join(cacheDir, "ghostty-build") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.1, + Step: "Cloning Ghostty repository...", + IsComplete: false, + CommandInfo: "git clone https://github.com/ghostty-org/ghostty.git", + } + + cloneCmd := exec.CommandContext(ctx, "git", "clone", "https://github.com/ghostty-org/ghostty.git", tmpDir) + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("failed to clone Ghostty: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.2, + Step: "Building Ghostty (this may take a while)...", + IsComplete: false, + CommandInfo: "zig build -Doptimize=ReleaseFast", + } + + buildCmd := exec.CommandContext(ctx, "zig", "build", "-Doptimize=ReleaseFast") + buildCmd.Dir = tmpDir + buildCmd.Env = append(os.Environ(), "TMPDIR="+cacheDir) + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("failed to build Ghostty: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.8, + Step: "Installing Ghostty...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/", + } + + installCmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir)) + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install Ghostty: %w", err) + } + + m.log("Ghostty installed successfully from source") + return nil +} + +func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + m.log("Installing matugen from source...") + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.1, + Step: "Installing matugen via cargo...", + IsComplete: false, + CommandInfo: "cargo install matugen", + } + + installCmd := exec.CommandContext(ctx, "cargo", "install", "matugen") + if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building matugen..."); err != nil { + return fmt.Errorf("failed to install matugen: %w", err) + } + + homeDir := os.Getenv("HOME") + sourcePath := filepath.Join(homeDir, ".cargo", "bin", "matugen") + targetPath := "/usr/local/bin/matugen" + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.7, + Step: "Installing matugen binary to system...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath), + } + + copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath) + copyCmd.Stdin = strings.NewReader(sudoPassword + "\n") + if err := copyCmd.Run(); err != nil { + return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err) + } + + // Make it executable + chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath) + chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n") + if err := chmodCmd.Run(); err != nil { + return fmt.Errorf("failed to make matugen executable: %w", err) + } + + m.log("matugen installed successfully from source") + return nil +} + +func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, variant deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + m.log("Installing DankMaterialShell (DMS)...") + + if err := m.installDMSBinary(ctx, sudoPassword, progressChan); err != nil { + m.logError("Failed to install DMS binary", err) + } + + dmsPath := filepath.Join(os.Getenv("HOME"), ".config/quickshell/dms") + + if _, err := os.Stat(dmsPath); os.IsNotExist(err) { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.90, + Step: "Cloning DankMaterialShell...", + IsComplete: false, + CommandInfo: "git clone https://github.com/AvengeMedia/DankMaterialShell.git", + } + + configDir := filepath.Dir(dmsPath) + if err := os.MkdirAll(configDir, 0o755); err != nil { + return fmt.Errorf("failed to create quickshell config directory: %w", err) + } + + cloneCmd := exec.CommandContext(ctx, "git", "clone", + "https://github.com/AvengeMedia/DankMaterialShell.git", dmsPath) + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("failed to clone DankMaterialShell: %w", err) + } + + if forceDMSGit || variant == deps.VariantGit { + m.log("Using git variant (master branch)") + return nil + } + + tagCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "describe", "--tags", "--abbrev=0", "origin/master") + tagOutput, err := tagCmd.Output() + if err != nil { + m.log("Using default branch (no tags found)") + return nil + } + + latestTag := strings.TrimSpace(string(tagOutput)) + checkoutCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "checkout", latestTag) + if err := checkoutCmd.Run(); err != nil { + m.logError(fmt.Sprintf("Failed to checkout tag %s", latestTag), err) + return nil + } + + m.log(fmt.Sprintf("Checked out latest tag: %s", latestTag)) + m.log("DankMaterialShell cloned successfully") + return nil + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.90, + Step: "Updating DankMaterialShell...", + IsComplete: false, + CommandInfo: "Updating ~/.config/quickshell/dms", + } + + fetchCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "fetch", "origin", "--tags", "--force") + if err := fetchCmd.Run(); err != nil { + m.logError("Failed to fetch updates", err) + return nil + } + + if forceDMSGit || variant == deps.VariantGit { + branchCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "rev-parse", "--abbrev-ref", "HEAD") + branchOutput, err := branchCmd.Output() + if err != nil { + m.logError("Failed to get current branch", err) + return nil + } + + branch := strings.TrimSpace(string(branchOutput)) + if branch == "" { + branch = "master" + } + + pullCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "pull", "origin", branch) + if err := pullCmd.Run(); err != nil { + m.logError("Failed to pull updates", err) + return nil + } + + m.log("DankMaterialShell updated successfully (git variant)") + return nil + } + + latestTagCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "describe", "--tags", "--abbrev=0", "origin/master") + tagOutput, err := latestTagCmd.Output() + if err != nil { + m.logError("Failed to get latest tag", err) + return nil + } + + latestTag := strings.TrimSpace(string(tagOutput)) + checkoutCmd := exec.CommandContext(ctx, "git", "-C", dmsPath, "checkout", latestTag) + if err := checkoutCmd.Run(); err != nil { + m.logError(fmt.Sprintf("Failed to checkout tag %s", latestTag), err) + return nil + } + + m.log(fmt.Sprintf("Updated to tag: %s", latestTag)) + return nil +} + +func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + m.log("Installing xwayland-satellite from source...") + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.1, + Step: "Installing xwayland-satellite via cargo...", + IsComplete: false, + CommandInfo: "cargo install --git https://github.com/Supreeeme/xwayland-satellite --tag v0.7", + } + + installCmd := exec.CommandContext(ctx, "cargo", "install", "--git", "https://github.com/Supreeeme/xwayland-satellite", "--tag", "v0.7") + if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building xwayland-satellite..."); err != nil { + return fmt.Errorf("failed to install xwayland-satellite: %w", err) + } + + homeDir := os.Getenv("HOME") + sourcePath := filepath.Join(homeDir, ".cargo", "bin", "xwayland-satellite") + targetPath := "/usr/local/bin/xwayland-satellite" + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.7, + Step: "Installing xwayland-satellite binary to system...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath), + } + + copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath) + copyCmd.Stdin = strings.NewReader(sudoPassword + "\n") + if err := copyCmd.Run(); err != nil { + return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err) + } + + chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath) + chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n") + if err := chmodCmd.Run(); err != nil { + return fmt.Errorf("failed to make xwayland-satellite executable: %w", err) + } + + m.log("xwayland-satellite installed successfully from source") + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/manual_packages_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/manual_packages_test.go new file mode 100644 index 0000000..4d9fc62 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/manual_packages_test.go @@ -0,0 +1,122 @@ +package distros + +import ( + "testing" +) + +func TestManualPackageInstaller_parseLatestTagFromGitOutput(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "normal tag output", + input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1 +a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0 +703a3789083d2f990c4e99cd25c97c2a4cccbd81 refs/tags/v0.1.0`, + expected: "v0.2.1", + }, + { + name: "annotated tags with ^{}", + input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1 +b1b150fab00a93ea983aaca5df55304bc837f51c refs/tags/v0.2.1^{} +a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`, + expected: "v0.2.1", + }, + { + name: "mixed tags", + input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.3.0 +b1b150fab00a93ea983aaca5df55304bc837f51c refs/tags/v0.3.0^{} +a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0 +c1c150fab00a93ea983aaca5df55304bc837f51d refs/tags/beta-1`, + expected: "v0.3.0", + }, + { + name: "empty output", + input: "", + expected: "", + }, + { + name: "no tags", + input: "some other output\nwithout tags", + expected: "", + }, + { + name: "only annotated tags", + input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1^{} +a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0^{}`, + expected: "", + }, + { + name: "single tag", + input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v1.0.0`, + expected: "v1.0.0", + }, + { + name: "tag with extra whitespace", + input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1 +a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`, + expected: "v0.2.1", + }, + { + name: "beta and rc tags", + input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.3.0-beta.1 +a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v0.2.0`, + expected: "v0.3.0-beta.1", + }, + { + name: "tags without v prefix", + input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/0.2.1 +a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/0.2.0`, + expected: "0.2.1", + }, + { + name: "multiple lines with spaces", + input: ` +a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v1.2.3 +a5431dd02dc23d9ef1680e67777fed00fe5f7cda refs/tags/v1.2.2 +`, + expected: "v1.2.3", + }, + { + name: "tag at end of line", + input: `a1a150fab00a93ea983aaca5df55304bc837f51b refs/tags/v0.2.1`, + expected: "v0.2.1", + }, + } + + logChan := make(chan string, 100) + defer close(logChan) + + base := NewBaseDistribution(logChan) + installer := &ManualPackageInstaller{BaseDistribution: base} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := installer.parseLatestTagFromGitOutput(tt.input) + + if result != tt.expected { + t.Errorf("parseLatestTagFromGitOutput() = %q, expected %q", result, tt.expected) + } + }) + } +} + +func TestManualPackageInstaller_parseLatestTagFromGitOutput_EmptyInstaller(t *testing.T) { + // Test that parsing works even with a minimal installer setup + logChan := make(chan string, 10) + defer close(logChan) + + base := NewBaseDistribution(logChan) + installer := &ManualPackageInstaller{BaseDistribution: base} + + input := `abc123 refs/tags/v1.0.0 +def456 refs/tags/v0.9.0` + + result := installer.parseLatestTagFromGitOutput(input) + + if result != "v1.0.0" { + t.Errorf("Expected v1.0.0, got %s", result) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/minimal_install.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/minimal_install.go new file mode 100644 index 0000000..029f1ba --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/minimal_install.go @@ -0,0 +1,44 @@ +package distros + +type minimalInstallGroup struct { + packages []string + minimal bool +} + +func shouldPreferMinimalInstall(pkg string) bool { + switch pkg { + case "niri", "niri-git": + return true + default: + return false + } +} + +func splitMinimalInstallPackages(packages []string) (normal []string, minimal []string) { + for _, pkg := range packages { + if shouldPreferMinimalInstall(pkg) { + minimal = append(minimal, pkg) + continue + } + normal = append(normal, pkg) + } + return normal, minimal +} + +func orderedMinimalInstallGroups(packages []string) []minimalInstallGroup { + normal, minimal := splitMinimalInstallPackages(packages) + groups := make([]minimalInstallGroup, 0, 2) + if len(minimal) > 0 { + groups = append(groups, minimalInstallGroup{ + packages: minimal, + minimal: true, + }) + } + if len(normal) > 0 { + groups = append(groups, minimalInstallGroup{ + packages: normal, + minimal: false, + }) + } + return groups +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/opensuse.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/opensuse.go new file mode 100644 index 0000000..62f40cd --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/opensuse.go @@ -0,0 +1,856 @@ +package distros + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +func init() { + Register("opensuse-tumbleweed", "#73BA25", FamilySUSE, func(config DistroConfig, logChan chan<- string) Distribution { + return NewOpenSUSEDistribution(config, logChan) + }) + Register("opensuse-leap", "#73BA25", FamilySUSE, func(config DistroConfig, logChan chan<- string) Distribution { + return NewOpenSUSEDistribution(config, logChan) + }) + Register("opensuse-slowroll", "#73BA25", FamilySUSE, func(config DistroConfig, logChan chan<- string) Distribution { + return NewOpenSUSEDistribution(config, logChan) + }) +} + +type OpenSUSEDistribution struct { + *BaseDistribution + *ManualPackageInstaller + config DistroConfig +} + +const openSUSENiriWaylandServerPackage = "libwayland-server0" + +func NewOpenSUSEDistribution(config DistroConfig, logChan chan<- string) *OpenSUSEDistribution { + base := NewBaseDistribution(logChan) + return &OpenSUSEDistribution{ + BaseDistribution: base, + ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base}, + config: config, + } +} + +func (o *OpenSUSEDistribution) GetID() string { + return o.config.ID +} + +func (o *OpenSUSEDistribution) GetColorHex() string { + return o.config.ColorHex +} + +func (o *OpenSUSEDistribution) GetFamily() DistroFamily { + return o.config.Family +} + +func (o *OpenSUSEDistribution) GetPackageManager() PackageManagerType { + return PackageManagerZypper +} + +func (o *OpenSUSEDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) { + return o.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty) +} + +func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) { + var dependencies []deps.Dependency + + // DMS at the top (shell is prominent) + dependencies = append(dependencies, o.detectDMS()) + + // Terminal with choice support + dependencies = append(dependencies, o.detectSpecificTerminal(terminal)) + + // Common detections using base methods + dependencies = append(dependencies, o.detectGit()) + dependencies = append(dependencies, o.detectWindowManager(wm)) + dependencies = append(dependencies, o.detectQuickshell()) + dependencies = append(dependencies, o.detectDMSGreeter()) + dependencies = append(dependencies, o.detectXDGPortal()) + dependencies = append(dependencies, o.detectAccountsService()) + + // Hyprland-specific tools + if wm == deps.WindowManagerHyprland { + dependencies = append(dependencies, o.detectHyprlandTools()...) + } + + // Niri-specific tools + if wm == deps.WindowManagerNiri { + dependencies = append(dependencies, o.detectXwaylandSatellite()) + } + + dependencies = append(dependencies, o.detectMatugen()) + dependencies = append(dependencies, o.detectDgop()) + + return dependencies, nil +} + +func (o *OpenSUSEDistribution) detectXDGPortal() deps.Dependency { + return o.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", o.packageInstalled("xdg-desktop-portal-gtk")) +} + +func (o *OpenSUSEDistribution) packageInstalled(pkg string) bool { + cmd := exec.Command("rpm", "-q", pkg) + err := cmd.Run() + return err == nil +} + +func (o *OpenSUSEDistribution) detectDMSGreeter() deps.Dependency { + return o.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", o.packageInstalled("dms-greeter")) +} + +func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { + return o.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) +} + +func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping { + packages := map[string]PackageMapping{ + // Standard zypper packages + "git": {Name: "git", Repository: RepoTypeSystem}, + "kitty": {Name: "kitty", Repository: RepoTypeSystem}, + "alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, + "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, + "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, + + // DMS packages from OBS + "dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]), + "quickshell": o.getQuickshellMapping(variants["quickshell"]), + "dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, + "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, + "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, + "dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, + } + + switch wm { + case deps.WindowManagerHyprland: + packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem} + packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypeSystem} + packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} + case deps.WindowManagerNiri: + // Niri stable has native package support on openSUSE + niriVariant := variants["niri"] + packages["niri"] = o.getNiriMapping(niriVariant) + packages["xwayland-satellite"] = o.getXwaylandSatelliteMapping(niriVariant) + } + + return packages +} + +func (o *OpenSUSEDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "dms-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms-git"} + } + return PackageMapping{Name: "dms", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:dms"} +} + +func (o *OpenSUSEDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { + if forceQuickshellGit || variant == deps.VariantGit { + return PackageMapping{Name: "quickshell-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + } + return PackageMapping{Name: "quickshell", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} +} + +func (o *OpenSUSEDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "niri-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + } + return PackageMapping{Name: "niri", Repository: RepoTypeSystem} +} + +func (o *OpenSUSEDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"} + } + return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypeSystem} +} + +func (o *OpenSUSEDistribution) detectXwaylandSatellite() deps.Dependency { + status := deps.StatusMissing + if o.commandExists("xwayland-satellite") { + status = deps.StatusInstalled + } + + return deps.Dependency{ + Name: "xwayland-satellite", + Status: status, + Description: "Xwayland support", + Required: true, + } +} + +func (o *OpenSUSEDistribution) detectAccountsService() deps.Dependency { + status := deps.StatusMissing + if o.packageInstalled("accountsservice") { + status = deps.StatusInstalled + } + + return deps.Dependency{ + Name: "accountsservice", + Status: status, + Description: "D-Bus interface for user account query and manipulation", + Required: true, + } +} + +func (o *OpenSUSEDistribution) getPrerequisites() []string { + return []string{} +} + +func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + prerequisites := o.getPrerequisites() + var missingPkgs []string + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.06, + Step: "Checking prerequisites...", + IsComplete: false, + LogOutput: "Checking prerequisite packages", + } + + for _, pkg := range prerequisites { + checkCmd := exec.CommandContext(ctx, "rpm", "-q", pkg) + if err := checkCmd.Run(); err != nil { + missingPkgs = append(missingPkgs, pkg) + } + } + + _, err := exec.LookPath("go") + if err != nil { + o.log("go not found in PATH, will install go") + missingPkgs = append(missingPkgs, "go") + } else { + o.log("go already available in PATH") + } + + if len(missingPkgs) == 0 { + o.log("All prerequisites already installed") + return nil + } + + o.log(fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", "))) + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.08, + Step: fmt.Sprintf("Installing %d prerequisites...", len(missingPkgs)), + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo zypper install -y %s", strings.Join(missingPkgs, " ")), + LogOutput: fmt.Sprintf("Installing prerequisites: %s", strings.Join(missingPkgs, ", ")), + } + + args := []string{"zypper", "install", "-y"} + args = append(args, missingPkgs...) + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + output, err := cmd.CombinedOutput() + if err != nil { + o.logError("failed to install prerequisites", err) + o.log(fmt.Sprintf("Prerequisites command output: %s", string(output))) + return fmt.Errorf("failed to install prerequisites: %w", err) + } + o.log(fmt.Sprintf("Prerequisites install output: %s", string(output))) + + return nil +} + +func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error { + // Phase 1: Check Prerequisites + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.05, + Step: "Checking system prerequisites...", + IsComplete: false, + LogOutput: "Starting prerequisite check...", + } + + if err := o.disableInstallMediaRepos(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to disable install media repositories: %w", err) + } + + if err := o.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install prerequisites: %w", err) + } + + systemPkgs, obsPkgs, manualPkgs, variantMap := o.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags) + + // Enable OBS repositories + if len(obsPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.15, + Step: "Enabling OBS repositories...", + IsComplete: false, + LogOutput: "Setting up OBS repositories for additional packages", + } + if err := o.enableOBSRepos(ctx, obsPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to enable OBS repositories: %w", err) + } + } + + // Phase 3: System Packages (Zypper) + if len(systemPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.35, + Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)), + IsComplete: false, + NeedsSudo: true, + LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")), + } + if err := o.installZypperPackages(ctx, systemPkgs, sudoPassword, progressChan, PhaseSystemPackages, "Installing system packages...", 0.40, 0.60); err != nil { + return fmt.Errorf("failed to install zypper packages: %w", err) + } + } + + // OBS Packages + obsPkgNames := o.extractPackageNames(obsPkgs) + if len(obsPkgNames) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, + Progress: 0.65, + Step: fmt.Sprintf("Installing %d OBS packages...", len(obsPkgNames)), + IsComplete: false, + LogOutput: fmt.Sprintf("Installing OBS packages: %s", strings.Join(obsPkgNames, ", ")), + } + if err := o.installZypperPackages(ctx, obsPkgNames, sudoPassword, progressChan, PhaseAURPackages, "Installing OBS packages...", 0.70, 0.85); err != nil { + return fmt.Errorf("failed to install OBS packages: %w", err) + } + } + + // Manual Builds + if len(manualPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.85, + Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)), + IsComplete: false, + LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")), + } + if err := o.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install manual packages: %w", err) + } + } + + // Configuration + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.90, + Step: "Configuring system...", + IsComplete: false, + LogOutput: "Starting post-installation configuration...", + } + + terminal := o.DetectTerminalFromDeps(dependencies) + if err := o.WriteEnvironmentConfig(terminal); err != nil { + o.log(fmt.Sprintf("Warning: failed to write environment config: %v", err)) + } + + if err := o.WriteWindowManagerConfig(wm); err != nil { + o.log(fmt.Sprintf("Warning: failed to write window manager config: %v", err)) + } + + if err := o.EnableDMSService(ctx, wm); err != nil { + o.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err)) + } + + // Complete + progressChan <- InstallProgressMsg{ + Phase: PhaseComplete, + Progress: 1.0, + Step: "Installation complete!", + IsComplete: true, + LogOutput: "All packages installed and configured successfully", + } + + return nil +} + +func (o *OpenSUSEDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping, []string, map[string]deps.PackageVariant) { + systemPkgs := []string{} + obsPkgs := []PackageMapping{} + manualPkgs := []string{} + + variantMap := make(map[string]deps.PackageVariant) + for _, dep := range dependencies { + variantMap[dep.Name] = dep.Variant + } + + packageMap := o.GetPackageMappingWithVariants(wm, variantMap) + + for _, dep := range dependencies { + if disabledFlags[dep.Name] { + continue + } + + if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] { + continue + } + + pkgInfo, exists := packageMap[dep.Name] + if !exists { + o.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name)) + continue + } + + switch pkgInfo.Repository { + case RepoTypeSystem: + systemPkgs = append(systemPkgs, pkgInfo.Name) + case RepoTypeOBS: + obsPkgs = append(obsPkgs, pkgInfo) + case RepoTypeManual: + manualPkgs = append(manualPkgs, dep.Name) + } + } + + systemPkgs = o.appendMissingSystemPackages(systemPkgs, openSUSENiriRuntimePackages(wm, disabledFlags)) + + return systemPkgs, obsPkgs, manualPkgs, variantMap +} + +func openSUSENiriRuntimePackages(wm deps.WindowManager, disabledFlags map[string]bool) []string { + if wm != deps.WindowManagerNiri || disabledFlags["niri"] { + return nil + } + + return []string{openSUSENiriWaylandServerPackage} +} + +func (o *OpenSUSEDistribution) appendMissingSystemPackages(systemPkgs []string, extraPkgs []string) []string { + for _, pkg := range extraPkgs { + if slices.Contains(systemPkgs, pkg) || o.packageInstalled(pkg) { + continue + } + + o.log(fmt.Sprintf("Adding openSUSE runtime package: %s", pkg)) + systemPkgs = append(systemPkgs, pkg) + } + + return systemPkgs +} + +func (o *OpenSUSEDistribution) extractPackageNames(packages []PackageMapping) []string { + names := make([]string, len(packages)) + for i, pkg := range packages { + names[i] = pkg.Name + } + return names +} + +func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + enabledRepos := make(map[string]bool) + + osInfo, err := GetOSInfo() + if err != nil { + return fmt.Errorf("failed to get OS info: %w", err) + } + + obsDistroVersion := "openSUSE_Tumbleweed" + switch osInfo.Distribution.ID { + case "opensuse-leap": + obsDistroVersion = fmt.Sprintf("openSUSE_Leap_%s", osInfo.VersionID) + case "opensuse-slowroll": + obsDistroVersion = "openSUSE_Slowroll" + } + + for _, pkg := range obsPkgs { + if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] { + o.log(fmt.Sprintf("Enabling OBS repository: %s", pkg.RepoURL)) + + // RepoURL format: "home:AvengeMedia:danklinux" + repoPath := strings.ReplaceAll(pkg.RepoURL, ":", ":/") + repoName := strings.ReplaceAll(pkg.RepoURL, ":", "-") + repoURL := fmt.Sprintf("https://download.opensuse.org/repositories/%s/%s/%s.repo", + repoPath, obsDistroVersion, pkg.RepoURL) + + checkCmd := exec.CommandContext(ctx, "zypper", "repos", repoName) + if checkCmd.Run() == nil { + o.log(fmt.Sprintf("OBS repo %s already exists, skipping", pkg.RepoURL)) + enabledRepos[pkg.RepoURL] = true + continue + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.20, + Step: fmt.Sprintf("Enabling OBS repo %s...", pkg.RepoURL), + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("zypper addrepo -f %s", repoURL)) + if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { + o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err)) + } + + enabledRepos[pkg.RepoURL] = true + o.log(fmt.Sprintf("OBS repo %s enabled successfully", pkg.RepoURL)) + } + } + + // Refresh repositories with GPG auto-import + if len(enabledRepos) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.25, + Step: "Refreshing repositories...", + NeedsSudo: true, + CommandInfo: "sudo zypper --gpg-auto-import-keys refresh", + } + + refreshCmd := ExecSudoCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh") + if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil { + return fmt.Errorf("failed to refresh repositories: %w", err) + } + } + + return nil +} + +func isOpenSUSEInstallMediaURI(uri string) bool { + normalizedURI := strings.ToLower(strings.TrimSpace(uri)) + + return strings.HasPrefix(normalizedURI, "cd:/") || + strings.HasPrefix(normalizedURI, "dvd:/") || + strings.HasPrefix(normalizedURI, "hd:/") || + strings.HasPrefix(normalizedURI, "iso:/") +} + +func parseZypperInstallMediaAliases(output string) []string { + var aliases []string + + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, "|") { + continue + } + + parts := strings.Split(line, "|") + if len(parts) < 7 { + continue + } + + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + + alias := parts[1] + enabled := strings.ToLower(parts[3]) + uri := parts[len(parts)-1] + + if alias == "" || strings.EqualFold(alias, "alias") { + continue + } + if enabled != "" && enabled != "yes" { + continue + } + if !isOpenSUSEInstallMediaURI(uri) { + continue + } + + aliases = append(aliases, alias) + } + + return aliases +} + +func (o *OpenSUSEDistribution) disableInstallMediaRepos(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + listCmd := exec.CommandContext(ctx, "zypper", "repos", "-u") + output, err := listCmd.CombinedOutput() + if err != nil { + o.log(fmt.Sprintf("Warning: failed to list zypper repositories: %s", strings.TrimSpace(string(output)))) + return fmt.Errorf("failed to list zypper repositories: %w", err) + } + + aliases := parseZypperInstallMediaAliases(string(output)) + if len(aliases) == 0 { + return nil + } + + o.log(fmt.Sprintf("Disabling install media repositories: %s", strings.Join(aliases, ", "))) + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.055, + Step: "Disabling install media repositories...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo zypper modifyrepo -d %s", strings.Join(aliases, " ")), + LogOutput: fmt.Sprintf("Disabling install media repositories: %s", strings.Join(aliases, ", ")), + } + + for _, alias := range aliases { + cmd := ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", escapeSingleQuotes(alias))) + repoOutput, err := cmd.CombinedOutput() + if err != nil { + o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput)))) + return fmt.Errorf("failed to disable install media repo %s: %w", alias, err) + } + o.log(fmt.Sprintf("Disabled install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput)))) + } + + return nil +} + +func (o *OpenSUSEDistribution) zypperInstallArgs(packages []string, minimal bool) []string { + args := []string{"zypper", "install", "-y"} + if minimal { + args = append(args, "--no-recommends") + } + return append(args, packages...) +} + +func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg, phase InstallPhase, step string, startProgress float64, endProgress float64) error { + if len(packages) == 0 { + return nil + } + + o.log(fmt.Sprintf("Installing zypper packages: %s", strings.Join(packages, ", "))) + + groups := orderedMinimalInstallGroups(packages) + totalGroups := len(groups) + + groupIndex := 0 + installGroup := func(groupPackages []string, minimal bool) error { + if len(groupPackages) == 0 { + return nil + } + + groupIndex++ + groupStart := startProgress + groupEnd := endProgress + if totalGroups > 1 { + midpoint := startProgress + ((endProgress - startProgress) / 2) + if groupIndex == 1 { + groupEnd = midpoint + } else { + groupStart = midpoint + } + } + + args := o.zypperInstallArgs(groupPackages, minimal) + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: groupStart, + Step: step, + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) + } + + for _, group := range groups { + if err := installGroup(group.packages, group.minimal); err != nil { + return err + } + } + return nil +} + +func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + o.log("Installing quickshell from source (with openSUSE-specific build flags)...") + + homeDir := os.Getenv("HOME") + if homeDir == "" { + return fmt.Errorf("HOME environment variable not set") + } + + cacheDir := filepath.Join(homeDir, ".cache", "dankinstall") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + tmpDir := filepath.Join(cacheDir, "quickshell-build") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.1, + Step: "Cloning quickshell repository...", + IsComplete: false, + CommandInfo: "git clone https://github.com/quickshell-mirror/quickshell.git", + } + + var cloneCmd *exec.Cmd + if forceQuickshellGit || variant == deps.VariantGit { + cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir) + } else { + latestTag := o.getLatestQuickshellTag(ctx) + if latestTag != "" { + o.log(fmt.Sprintf("Using latest quickshell tag: %s", latestTag)) + cloneCmd = exec.CommandContext(ctx, "git", "clone", "--branch", latestTag, "https://github.com/quickshell-mirror/quickshell.git", tmpDir) + } else { + o.log("Warning: failed to fetch latest tag, using default branch") + cloneCmd = exec.CommandContext(ctx, "git", "clone", "https://github.com/quickshell-mirror/quickshell.git", tmpDir) + } + } + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("failed to clone quickshell: %w", err) + } + + buildDir := tmpDir + "/build" + if err := os.MkdirAll(buildDir, 0o755); err != nil { + return fmt.Errorf("failed to create build directory: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.3, + Step: "Configuring quickshell build (with openSUSE flags)...", + IsComplete: false, + CommandInfo: "cmake -B build -S . -G Ninja", + } + + // Get optflags from rpm + optflagsCmd := exec.CommandContext(ctx, "rpm", "--eval", "%{optflags}") + optflagsOutput, err := optflagsCmd.Output() + optflags := strings.TrimSpace(string(optflagsOutput)) + if err != nil || optflags == "" { + o.log("Warning: Could not get optflags from rpm, using default -O2 -g") + optflags = "-O2 -g" + } + + // Set openSUSE-specific CFLAGS + customCFLAGS := fmt.Sprintf("%s -I/usr/include/wayland", optflags) + + configureCmd := exec.CommandContext(ctx, "cmake", "-GNinja", "-B", "build", + "-DCMAKE_BUILD_TYPE=RelWithDebInfo", + "-DCRASH_REPORTER=off", + "-DCMAKE_CXX_STANDARD=20") + configureCmd.Dir = tmpDir + configureCmd.Env = append(os.Environ(), + "TMPDIR="+cacheDir, + "CFLAGS="+customCFLAGS, + "CXXFLAGS="+customCFLAGS) + + o.log(fmt.Sprintf("Using CFLAGS: %s", customCFLAGS)) + + output, err := configureCmd.CombinedOutput() + if err != nil { + o.log(fmt.Sprintf("cmake configure failed. Output:\n%s", string(output))) + return fmt.Errorf("failed to configure quickshell: %w\nCMake output:\n%s", err, string(output)) + } + + o.log(fmt.Sprintf("cmake configure successful. Output:\n%s", string(output))) + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.4, + Step: "Building quickshell (this may take a while)...", + IsComplete: false, + CommandInfo: "cmake --build build", + } + + buildCmd := exec.CommandContext(ctx, "cmake", "--build", "build") + buildCmd.Dir = tmpDir + buildCmd.Env = append(os.Environ(), + "TMPDIR="+cacheDir, + "CFLAGS="+customCFLAGS, + "CXXFLAGS="+customCFLAGS) + if err := o.runWithProgressStep(buildCmd, progressChan, PhaseSystemPackages, 0.4, 0.8, "Building quickshell..."); err != nil { + return fmt.Errorf("failed to build quickshell: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.8, + Step: "Installing quickshell...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo cmake --install build", + } + + installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build") + installCmd.Dir = tmpDir + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install quickshell: %w", err) + } + + o.log("quickshell installed successfully from source") + return nil +} + +func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if o.commandExists("cargo") { + return nil + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.82, + Step: "Installing rustup...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo zypper install rustup", + } + + rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "zypper install -y rustup") + if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil { + return fmt.Errorf("failed to install rustup: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.83, + Step: "Installing stable Rust toolchain...", + IsComplete: false, + CommandInfo: "rustup install stable", + } + + rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable") + if err := o.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil { + return fmt.Errorf("failed to install Rust toolchain: %w", err) + } + + if !o.commandExists("cargo") { + o.log("Warning: cargo not found in PATH after Rust installation, trying to source environment") + } + + return nil +} + +func (o *OpenSUSEDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + o.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", "))) + + for _, pkg := range packages { + if pkg == "matugen" { + if err := o.installRust(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install Rust: %w", err) + } + break + } + } + + for _, pkg := range packages { + variant := variantMap[pkg] + if pkg == "quickshell" { + if err := o.installQuickshell(ctx, variant, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install quickshell: %w", err) + } + } else { + if err := o.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, variantMap, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install %s: %w", pkg, err) + } + } + } + + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/osinfo.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/osinfo.go new file mode 100644 index 0000000..8fc4b25 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/osinfo.go @@ -0,0 +1,122 @@ +package distros + +import ( + "bufio" + "fmt" + "os" + "runtime" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" +) + +// DistroInfo contains basic information about a distribution +type DistroInfo struct { + ID string + HexColorCode string +} + +// OSInfo contains complete OS information +type OSInfo struct { + Distribution DistroInfo + Version string + VersionID string + VersionCodename string + PrettyName string + Architecture string +} + +// GetOSInfo detects the current OS and returns information about it +func GetOSInfo() (*OSInfo, error) { + if runtime.GOOS != "linux" { + return nil, errdefs.NewCustomError(errdefs.ErrTypeNotLinux, fmt.Sprintf("Only linux is supported, but I found %s", runtime.GOOS)) + } + + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + return nil, errdefs.NewCustomError(errdefs.ErrTypeInvalidArchitecture, fmt.Sprintf("Only amd64 and arm64 are supported, but I found %s", runtime.GOARCH)) + } + + info := &OSInfo{ + Architecture: runtime.GOARCH, + } + + file, err := os.Open("/etc/os-release") + if err != nil { + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := parts[0] + value := strings.Trim(parts[1], "\"") + + switch key { + case "ID": + config, exists := Registry[value] + if !exists { + return nil, errdefs.NewCustomError(errdefs.ErrTypeUnsupportedDistribution, fmt.Sprintf("Unsupported distribution: %s", value)) + } + + info.Distribution = DistroInfo{ + ID: value, // Use the actual ID from os-release + HexColorCode: config.ColorHex, + } + case "VERSION_ID", "BUILD_ID": + info.VersionID = value + case "VERSION": + info.Version = value + case "VERSION_CODENAME": + info.VersionCodename = value + case "PRETTY_NAME": + info.PrettyName = value + } + } + + return info, scanner.Err() +} + +// IsUnsupportedDistro checks if a distribution/version combination is supported +func IsUnsupportedDistro(distroID, versionID string) bool { + if !IsDistroSupported(distroID) { + return true + } + + if distroID == "ubuntu" { + parts := strings.Split(versionID, ".") + if len(parts) >= 2 { + major, err1 := strconv.Atoi(parts[0]) + minor, err2 := strconv.Atoi(parts[1]) + + if err1 == nil && err2 == nil { + return major < 25 || (major == 25 && minor < 4) + } + } + return true + } + + if distroID == "debian" { + // unstable/sid support + if versionID == "sid" { + return false + } + if versionID == "" { + // debian testing/sid have no version ID + return false + } + versionNum, err := strconv.Atoi(versionID) + if err == nil { + return versionNum < 12 + } + return true + } + + return false +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/ubuntu.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/ubuntu.go new file mode 100644 index 0000000..37911f0 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/distros/ubuntu.go @@ -0,0 +1,702 @@ +package distros + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +func init() { + Register("ubuntu", "#E95420", FamilyUbuntu, func(config DistroConfig, logChan chan<- string) Distribution { + return NewUbuntuDistribution(config, logChan) + }) +} + +type UbuntuDistribution struct { + *BaseDistribution + *ManualPackageInstaller + config DistroConfig +} + +func NewUbuntuDistribution(config DistroConfig, logChan chan<- string) *UbuntuDistribution { + base := NewBaseDistribution(logChan) + return &UbuntuDistribution{ + BaseDistribution: base, + ManualPackageInstaller: &ManualPackageInstaller{BaseDistribution: base}, + config: config, + } +} + +func (u *UbuntuDistribution) GetID() string { + return u.config.ID +} + +func (u *UbuntuDistribution) GetColorHex() string { + return u.config.ColorHex +} + +func (u *UbuntuDistribution) GetFamily() DistroFamily { + return u.config.Family +} + +func (u *UbuntuDistribution) GetPackageManager() PackageManagerType { + return PackageManagerAPT +} + +func (u *UbuntuDistribution) DetectDependencies(ctx context.Context, wm deps.WindowManager) ([]deps.Dependency, error) { + return u.DetectDependenciesWithTerminal(ctx, wm, deps.TerminalGhostty) +} + +func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context, wm deps.WindowManager, terminal deps.Terminal) ([]deps.Dependency, error) { + var dependencies []deps.Dependency + + // DMS at the top (shell is prominent) + dependencies = append(dependencies, u.detectDMS()) + + // Terminal with choice support + dependencies = append(dependencies, u.detectSpecificTerminal(terminal)) + + // Common detections using base methods + dependencies = append(dependencies, u.detectGit()) + dependencies = append(dependencies, u.detectWindowManager(wm)) + dependencies = append(dependencies, u.detectQuickshell()) + dependencies = append(dependencies, u.detectDMSGreeter()) + dependencies = append(dependencies, u.detectXDGPortal()) + dependencies = append(dependencies, u.detectAccountsService()) + + // Hyprland-specific tools + if wm == deps.WindowManagerHyprland { + dependencies = append(dependencies, u.detectHyprlandTools()...) + } + + // Niri-specific tools + if wm == deps.WindowManagerNiri { + dependencies = append(dependencies, u.detectXwaylandSatellite()) + } + + dependencies = append(dependencies, u.detectMatugen()) + dependencies = append(dependencies, u.detectDgop()) + + return dependencies, nil +} + +func (u *UbuntuDistribution) detectXDGPortal() deps.Dependency { + return u.detectPackage("xdg-desktop-portal-gtk", "Desktop integration portal for GTK", u.packageInstalled("xdg-desktop-portal-gtk")) +} + +func (u *UbuntuDistribution) detectXwaylandSatellite() deps.Dependency { + return u.detectCommand("xwayland-satellite", "Xwayland support") +} + +func (u *UbuntuDistribution) detectAccountsService() deps.Dependency { + return u.detectPackage("accountsservice", "D-Bus interface for user account query and manipulation", u.packageInstalled("accountsservice")) +} + +func (u *UbuntuDistribution) detectDMSGreeter() deps.Dependency { + return u.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", u.packageInstalled("dms-greeter")) +} + +func (u *UbuntuDistribution) packageInstalled(pkg string) bool { + return debianPackageInstalledPrecisely(pkg) +} + +func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping { + return u.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant)) +} + +func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping { + packages := map[string]PackageMapping{ + // Standard APT packages + "git": {Name: "git", Repository: RepoTypeSystem}, + "kitty": {Name: "kitty", Repository: RepoTypeSystem}, + "alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, + "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, + "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, + + // DMS packages from PPAs + "dms (DankMaterialShell)": u.getDmsMapping(variants["dms (DankMaterialShell)"]), + "quickshell": u.getQuickshellMapping(variants["quickshell"]), + "dms-greeter": {Name: "dms-greeter", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, + "matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, + "dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, + "ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, + } + + switch wm { + case deps.WindowManagerHyprland: + // Use the cppiber PPA for Hyprland + packages["hyprland"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"} + packages["hyprctl"] = PackageMapping{Name: "hyprland", Repository: RepoTypePPA, RepoURL: "ppa:cppiber/hyprland"} + packages["jq"] = PackageMapping{Name: "jq", Repository: RepoTypeSystem} + case deps.WindowManagerNiri: + niriVariant := variants["niri"] + packages["niri"] = u.getNiriMapping(niriVariant) + packages["xwayland-satellite"] = u.getXwaylandSatelliteMapping(niriVariant) + } + + return packages +} + +func (u *UbuntuDistribution) getDmsMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "dms-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/dms-git"} + } + return PackageMapping{Name: "dms", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/dms"} +} + +func (u *UbuntuDistribution) getQuickshellMapping(variant deps.PackageVariant) PackageMapping { + if forceQuickshellGit || variant == deps.VariantGit { + return PackageMapping{Name: "quickshell-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} + } + return PackageMapping{Name: "quickshell", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} +} + +func (u *UbuntuDistribution) getNiriMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "niri-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} + } + return PackageMapping{Name: "niri", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} +} + +func (u *UbuntuDistribution) getXwaylandSatelliteMapping(variant deps.PackageVariant) PackageMapping { + if variant == deps.VariantGit { + return PackageMapping{Name: "xwayland-satellite-git", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} + } + return PackageMapping{Name: "xwayland-satellite", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"} +} + +func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.06, + Step: "Updating package lists...", + IsComplete: false, + LogOutput: "Updating APT package lists", + } + + updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil { + return fmt.Errorf("failed to update package lists: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.08, + Step: "Installing build-essential...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get install -y build-essential", + LogOutput: "Installing build tools", + } + + checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential") + if err := checkCmd.Run(); err != nil { + // Not installed, install it + cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential") + if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil { + return fmt.Errorf("failed to install build-essential: %w", err) + } + } + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.10, + Step: "Installing development dependencies...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev", + LogOutput: "Installing additional development tools", + } + + devToolsCmd := ExecSudoCommand(ctx, sudoPassword, + "apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev") + if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil { + return fmt.Errorf("failed to install development tools: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.12, + Step: "Prerequisites installation complete", + IsComplete: false, + LogOutput: "Prerequisites successfully installed", + } + + return nil +} + +func (u *UbuntuDistribution) InstallPackages(ctx context.Context, dependencies []deps.Dependency, wm deps.WindowManager, sudoPassword string, reinstallFlags map[string]bool, disabledFlags map[string]bool, skipGlobalUseFlags bool, progressChan chan<- InstallProgressMsg) error { + // Phase 1: Check Prerequisites + progressChan <- InstallProgressMsg{ + Phase: PhasePrerequisites, + Progress: 0.05, + Step: "Checking system prerequisites...", + IsComplete: false, + LogOutput: "Starting prerequisite check...", + } + + if err := u.InstallPrerequisites(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install prerequisites: %w", err) + } + + systemPkgs, ppaPkgs, manualPkgs, variantMap := u.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags) + + // Phase 2: Enable PPA repositories + if len(ppaPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.15, + Step: "Enabling PPA repositories...", + IsComplete: false, + LogOutput: "Setting up PPA repositories for additional packages", + } + if err := u.enablePPARepos(ctx, ppaPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to enable PPA repositories: %w", err) + } + } + + // Phase 3: System Packages (APT) + if len(systemPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.35, + Step: fmt.Sprintf("Installing %d system packages...", len(systemPkgs)), + IsComplete: false, + NeedsSudo: true, + LogOutput: fmt.Sprintf("Installing system packages: %s", strings.Join(systemPkgs, ", ")), + } + if err := u.installAPTPackages(ctx, systemPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install APT packages: %w", err) + } + } + + // Phase 4: PPA Packages + ppaPkgNames := u.extractPackageNames(ppaPkgs) + if len(ppaPkgNames) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseAURPackages, // Reusing AUR phase for PPA + Progress: 0.65, + Step: fmt.Sprintf("Installing %d PPA packages...", len(ppaPkgNames)), + IsComplete: false, + LogOutput: fmt.Sprintf("Installing PPA packages: %s", strings.Join(ppaPkgNames, ", ")), + } + if err := u.installPPAPackages(ctx, ppaPkgNames, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install PPA packages: %w", err) + } + } + + // Phase 5: Manual Builds + if len(manualPkgs) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.80, + Step: "Installing build dependencies...", + IsComplete: false, + LogOutput: "Installing build tools for manual compilation", + } + if err := u.installBuildDependencies(ctx, manualPkgs, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install build dependencies: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.85, + Step: fmt.Sprintf("Building %d packages from source...", len(manualPkgs)), + IsComplete: false, + LogOutput: fmt.Sprintf("Building from source: %s", strings.Join(manualPkgs, ", ")), + } + if err := u.InstallManualPackages(ctx, manualPkgs, variantMap, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install manual packages: %w", err) + } + } + + // Phase 6: Configuration + progressChan <- InstallProgressMsg{ + Phase: PhaseConfiguration, + Progress: 0.90, + Step: "Configuring system...", + IsComplete: false, + LogOutput: "Starting post-installation configuration...", + } + + terminal := u.DetectTerminalFromDeps(dependencies) + if err := u.WriteEnvironmentConfig(terminal); err != nil { + u.log(fmt.Sprintf("Warning: failed to write environment config: %v", err)) + } + + if err := u.WriteWindowManagerConfig(wm); err != nil { + u.log(fmt.Sprintf("Warning: failed to write window manager config: %v", err)) + } + + if err := u.EnableDMSService(ctx, wm); err != nil { + u.log(fmt.Sprintf("Warning: failed to enable dms service: %v", err)) + } + + // Phase 7: Complete + progressChan <- InstallProgressMsg{ + Phase: PhaseComplete, + Progress: 1.0, + Step: "Installation complete!", + IsComplete: true, + LogOutput: "All packages installed and configured successfully", + } + + return nil +} + +func (u *UbuntuDistribution) categorizePackages(dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags map[string]bool, disabledFlags map[string]bool) ([]string, []PackageMapping, []string, map[string]deps.PackageVariant) { + systemPkgs := []string{} + ppaPkgs := []PackageMapping{} + manualPkgs := []string{} + + variantMap := make(map[string]deps.PackageVariant) + for _, dep := range dependencies { + variantMap[dep.Name] = dep.Variant + } + + packageMap := u.GetPackageMappingWithVariants(wm, variantMap) + + for _, dep := range dependencies { + if disabledFlags[dep.Name] { + continue + } + + if dep.Status == deps.StatusInstalled && !reinstallFlags[dep.Name] { + continue + } + + pkgInfo, exists := packageMap[dep.Name] + if !exists { + u.log(fmt.Sprintf("Warning: No package mapping for %s", dep.Name)) + continue + } + + switch pkgInfo.Repository { + case RepoTypeSystem: + systemPkgs = append(systemPkgs, pkgInfo.Name) + case RepoTypePPA: + ppaPkgs = append(ppaPkgs, pkgInfo) + case RepoTypeManual: + manualPkgs = append(manualPkgs, dep.Name) + } + } + + return systemPkgs, ppaPkgs, manualPkgs, variantMap +} + +func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []string { + names := make([]string, len(packages)) + for i, pkg := range packages { + names[i] = pkg.Name + } + return names +} + +func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + enabledRepos := make(map[string]bool) + + installPPACmd := ExecSudoCommand(ctx, sudoPassword, + "apt-get install -y software-properties-common") + if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil { + return fmt.Errorf("failed to install software-properties-common: %w", err) + } + + for _, pkg := range ppaPkgs { + if pkg.RepoURL != "" && !enabledRepos[pkg.RepoURL] { + u.log(fmt.Sprintf("Enabling PPA repository: %s", pkg.RepoURL)) + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.20, + Step: fmt.Sprintf("Enabling PPA repo %s...", pkg.RepoURL), + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, + fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL)) + if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { + u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err) + return fmt.Errorf("failed to enable PPA repo %s: %w", pkg.RepoURL, err) + } + u.log(fmt.Sprintf("PPA repo %s enabled successfully", pkg.RepoURL)) + enabledRepos[pkg.RepoURL] = true + } + } + + if len(enabledRepos) > 0 { + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.25, + Step: "Updating package lists...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get update", + } + + updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil { + return fmt.Errorf("failed to update package lists after adding PPAs: %w", err) + } + } + + return nil +} + +func (u *UbuntuDistribution) installAPTPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + u.log(fmt.Sprintf("Installing APT packages: %s", strings.Join(packages, ", "))) + return u.installAPTGroups(ctx, packages, sudoPassword, progressChan, PhaseSystemPackages, "Installing system packages...", 0.40, 0.60) +} + +func (u *UbuntuDistribution) installPPAPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + u.log(fmt.Sprintf("Installing PPA packages: %s", strings.Join(packages, ", "))) + return u.installAPTGroups(ctx, packages, sudoPassword, progressChan, PhaseAURPackages, "Installing PPA packages...", 0.70, 0.85) +} + +func (u *UbuntuDistribution) aptInstallArgs(packages []string, minimal bool) []string { + args := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y"} + if minimal { + args = append(args, "--no-install-recommends") + } + return append(args, packages...) +} + +func (u *UbuntuDistribution) installAPTGroups(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg, phase InstallPhase, step string, startProgress float64, endProgress float64) error { + groups := orderedMinimalInstallGroups(packages) + totalGroups := len(groups) + + groupIndex := 0 + installGroup := func(groupPackages []string, minimal bool) error { + if len(groupPackages) == 0 { + return nil + } + + groupIndex++ + groupStart := startProgress + groupEnd := endProgress + if totalGroups > 1 { + midpoint := startProgress + ((endProgress - startProgress) / 2) + if groupIndex == 1 { + groupEnd = midpoint + } else { + groupStart = midpoint + } + } + + args := u.aptInstallArgs(groupPackages, minimal) + progressChan <- InstallProgressMsg{ + Phase: phase, + Progress: groupStart, + Step: step, + IsComplete: false, + NeedsSudo: true, + CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), + } + + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) + } + + for _, group := range groups { + if err := installGroup(group.packages, group.minimal); err != nil { + return err + } + } + return nil +} + +func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manualPkgs []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + buildDeps := make(map[string]bool) + + for _, pkg := range manualPkgs { + switch pkg { + case "niri": + buildDeps["curl"] = true + buildDeps["libxkbcommon-dev"] = true + buildDeps["libwayland-dev"] = true + buildDeps["libudev-dev"] = true + buildDeps["libinput-dev"] = true + buildDeps["libdisplay-info-dev"] = true + buildDeps["libpango1.0-dev"] = true + buildDeps["libcairo-dev"] = true + buildDeps["libpipewire-0.3-dev"] = true + buildDeps["libc6-dev"] = true + buildDeps["clang"] = true + buildDeps["libseat-dev"] = true + buildDeps["libgbm-dev"] = true + buildDeps["alacritty"] = true + buildDeps["fuzzel"] = true + buildDeps["libxcb-cursor-dev"] = true + case "quickshell": + buildDeps["qt6-base-dev"] = true + buildDeps["qt6-base-private-dev"] = true + buildDeps["qt6-declarative-dev"] = true + buildDeps["qt6-declarative-private-dev"] = true + buildDeps["qt6-wayland-dev"] = true + buildDeps["qt6-wayland-private-dev"] = true + buildDeps["qt6-tools-dev"] = true + buildDeps["libqt6svg6-dev"] = true + buildDeps["qt6-shadertools-dev"] = true + buildDeps["spirv-tools"] = true + buildDeps["libcli11-dev"] = true + buildDeps["libjemalloc-dev"] = true + buildDeps["libwayland-dev"] = true + buildDeps["wayland-protocols"] = true + buildDeps["libdrm-dev"] = true + buildDeps["libgbm-dev"] = true + buildDeps["libegl-dev"] = true + buildDeps["libgles2-mesa-dev"] = true + buildDeps["libgl1-mesa-dev"] = true + buildDeps["libxcb1-dev"] = true + buildDeps["libpipewire-0.3-dev"] = true + buildDeps["libpam0g-dev"] = true + case "matugen": + buildDeps["curl"] = true + } + } + + for _, pkg := range manualPkgs { + switch pkg { + case "niri", "matugen": + if err := u.installRust(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install Rust: %w", err) + } + case "dgop": + if err := u.installGo(ctx, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install Go: %w", err) + } + } + } + + if len(buildDeps) == 0 { + return nil + } + + depList := make([]string, 0, len(buildDeps)) + for dep := range buildDeps { + depList = append(depList, dep) + } + + args := []string{"apt-get", "install", "-y"} + args = append(args, depList...) + + cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82) +} + +func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if u.commandExists("cargo") { + return nil + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.82, + Step: "Installing rustup...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get install rustup", + } + + rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup") + if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil { + return fmt.Errorf("failed to install rustup: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.83, + Step: "Installing stable Rust toolchain...", + IsComplete: false, + CommandInfo: "rustup install stable", + } + + rustInstallCmd := exec.CommandContext(ctx, "bash", "-c", "rustup install stable && rustup default stable") + if err := u.runWithProgress(rustInstallCmd, progressChan, PhaseSystemPackages, 0.83, 0.84); err != nil { + return fmt.Errorf("failed to install Rust toolchain: %w", err) + } + + // Verify cargo is now available + if !u.commandExists("cargo") { + u.log("Warning: cargo not found in PATH after Rust installation, trying to source environment") + } + + return nil +} + +func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if u.commandExists("go") { + return nil + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.87, + Step: "Adding Go PPA repository...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports", + } + + addPPACmd := ExecSudoCommand(ctx, sudoPassword, + "add-apt-repository -y ppa:longsleep/golang-backports") + if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil { + return fmt.Errorf("failed to add Go PPA: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.88, + Step: "Updating package lists...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get update", + } + + updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil { + return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err) + } + + progressChan <- InstallProgressMsg{ + Phase: PhaseSystemPackages, + Progress: 0.89, + Step: "Installing Go...", + IsComplete: false, + NeedsSudo: true, + CommandInfo: "sudo apt-get install golang-go", + } + + installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go") + return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90) +} + +func (u *UbuntuDistribution) InstallManualPackages(ctx context.Context, packages []string, variantMap map[string]deps.PackageVariant, sudoPassword string, progressChan chan<- InstallProgressMsg) error { + if len(packages) == 0 { + return nil + } + + u.log(fmt.Sprintf("Installing manual packages: %s", strings.Join(packages, ", "))) + + for _, pkg := range packages { + switch pkg { + default: + if err := u.ManualPackageInstaller.InstallManualPackages(ctx, []string{pkg}, variantMap, sudoPassword, progressChan); err != nil { + return fmt.Errorf("failed to install %s: %w", pkg, err) + } + } + } + + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/errdefs/errdefs.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/errdefs/errdefs.go new file mode 100644 index 0000000..3b5f159 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/errdefs/errdefs.go @@ -0,0 +1,65 @@ +package errdefs + +type ErrorType int + +const ( + ErrTypeNotLinux ErrorType = iota + ErrTypeInvalidArchitecture + ErrTypeUnsupportedDistribution + ErrTypeUnsupportedVersion + ErrTypeUpdateCancelled + ErrTypeNoUpdateNeeded + ErrTypeInvalidTemperature + ErrTypeInvalidGamma + ErrTypeInvalidLocation + ErrTypeInvalidManualTimes + ErrTypeNoWaylandDisplay + ErrTypeNoGammaControl + ErrTypeNotInitialized + ErrTypeSecretPromptCancelled + ErrTypeSecretPromptTimeout + ErrTypeSecretAgentFailed + ErrTypeGeneric +) + +type CustomError struct { + Type ErrorType + Message string +} + +func (e *CustomError) Error() string { + return e.Message +} + +func NewCustomError(errType ErrorType, message string) error { + return &CustomError{ + Type: errType, + Message: message, + } +} + +const ( + ErrBadCredentials = "bad-credentials" + ErrNoSuchSSID = "no-such-ssid" + ErrAssocTimeout = "assoc-timeout" + ErrDhcpTimeout = "dhcp-timeout" + ErrUserCanceled = "user-canceled" + ErrWifiDisabled = "wifi-disabled" + ErrAlreadyConnected = "already-connected" + ErrConnectionFailed = "connection-failed" +) + +var ( + ErrUpdateCancelled = NewCustomError(ErrTypeUpdateCancelled, "update cancelled by user") + ErrNoUpdateNeeded = NewCustomError(ErrTypeNoUpdateNeeded, "no update needed") + ErrInvalidTemperature = NewCustomError(ErrTypeInvalidTemperature, "temperature must be between 1000 and 10000") + ErrInvalidGamma = NewCustomError(ErrTypeInvalidGamma, "gamma must be between 0 and 10") + ErrInvalidLocation = NewCustomError(ErrTypeInvalidLocation, "invalid latitude/longitude") + ErrInvalidManualTimes = NewCustomError(ErrTypeInvalidManualTimes, "both sunrise and sunset must be set or neither") + ErrNoWaylandDisplay = NewCustomError(ErrTypeNoWaylandDisplay, "no wayland display available") + ErrNoGammaControl = NewCustomError(ErrTypeNoGammaControl, "compositor does not support gamma control") + ErrNotInitialized = NewCustomError(ErrTypeNotInitialized, "manager not initialized") + ErrSecretPromptCancelled = NewCustomError(ErrTypeSecretPromptCancelled, "secret prompt cancelled by user") + ErrSecretPromptTimeout = NewCustomError(ErrTypeSecretPromptTimeout, "secret prompt timed out") + ErrSecretAgentFailed = NewCustomError(ErrTypeSecretAgentFailed, "secret agent operation failed") +) diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client.go new file mode 100644 index 0000000..5b44121 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client.go @@ -0,0 +1,42 @@ +package geolocation + +import "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + +func NewClient() Client { + geoclueClient, err := newGeoClueClient() + if err != nil { + log.Warnf("GeoClue2 unavailable: %v", err) + return newSeededIpClient() + } + + loc, _ := geoclueClient.GetLocation() + if loc.Latitude != 0 || loc.Longitude != 0 { + log.Info("Using GeoClue2 location") + return geoclueClient + } + + log.Info("GeoClue2 has no fix yet, seeding with IP location") + ipLoc, err := fetchIPLocation() + if err != nil { + log.Warnf("IP location seed failed: %v", err) + return geoclueClient + } + + log.Info("Seeded GeoClue2 with IP location") + geoclueClient.SeedLocation(Location{Latitude: ipLoc.Latitude, Longitude: ipLoc.Longitude}) + return geoclueClient +} + +func newSeededIpClient() *IpClient { + client := newIpClient() + ipLoc, err := fetchIPLocation() + if err != nil { + log.Warnf("IP location also failed: %v", err) + return client + } + + log.Info("Using IP location") + client.currLocation.Latitude = ipLoc.Latitude + client.currLocation.Longitude = ipLoc.Longitude + return client +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client_geoclue.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client_geoclue.go new file mode 100644 index 0000000..a06aadf --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client_geoclue.go @@ -0,0 +1,243 @@ +package geolocation + +import ( + "fmt" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +const ( + dbusGeoClueService = "org.freedesktop.GeoClue2" + dbusGeoCluePath = "/org/freedesktop/GeoClue2" + dbusGeoClueInterface = dbusGeoClueService + + dbusGeoClueManagerPath = dbusGeoCluePath + "/Manager" + dbusGeoClueManagerInterface = dbusGeoClueInterface + ".Manager" + dbusGeoClueManagerGetClient = dbusGeoClueManagerInterface + ".GetClient" + + dbusGeoClueClientInterface = dbusGeoClueInterface + ".Client" + dbusGeoClueClientDesktopId = dbusGeoClueClientInterface + ".DesktopId" + dbusGeoClueClientTimeThreshold = dbusGeoClueClientInterface + ".TimeThreshold" + dbusGeoClueClientTimeStart = dbusGeoClueClientInterface + ".Start" + dbusGeoClueClientTimeStop = dbusGeoClueClientInterface + ".Stop" + dbusGeoClueClientLocationUpdated = dbusGeoClueClientInterface + ".LocationUpdated" + + dbusGeoClueLocationInterface = dbusGeoClueInterface + ".Location" + dbusGeoClueLocationLatitude = dbusGeoClueLocationInterface + ".Latitude" + dbusGeoClueLocationLongitude = dbusGeoClueLocationInterface + ".Longitude" +) + +type GeoClueClient struct { + currLocation *Location + locationMutex sync.RWMutex + + dbusConn *dbus.Conn + clientPath dbus.ObjectPath + signals chan *dbus.Signal + + stopChan chan struct{} + sigWG sync.WaitGroup + + subscribers syncmap.Map[string, chan Location] +} + +func newGeoClueClient() (*GeoClueClient, error) { + dbusConn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("system bus connection failed: %w", err) + } + + c := &GeoClueClient{ + dbusConn: dbusConn, + stopChan: make(chan struct{}), + signals: make(chan *dbus.Signal, 256), + + currLocation: &Location{ + Latitude: 0.0, + Longitude: 0.0, + }, + } + + if err := c.setupClient(); err != nil { + dbusConn.Close() + return nil, err + } + + if err := c.startSignalPump(); err != nil { + return nil, err + } + + return c, nil +} + +func (c *GeoClueClient) Close() { + close(c.stopChan) + + c.sigWG.Wait() + + if c.signals != nil { + c.dbusConn.RemoveSignal(c.signals) + close(c.signals) + } + + c.subscribers.Range(func(key string, ch chan Location) bool { + close(ch) + c.subscribers.Delete(key) + return true + }) + + if c.dbusConn != nil { + c.dbusConn.Close() + } +} + +func (c *GeoClueClient) Subscribe(id string) chan Location { + ch := make(chan Location, 64) + c.subscribers.Store(id, ch) + return ch +} + +func (c *GeoClueClient) Unsubscribe(id string) { + if ch, ok := c.subscribers.LoadAndDelete(id); ok { + close(ch) + } +} + +func (c *GeoClueClient) setupClient() error { + managerObj := c.dbusConn.Object(dbusGeoClueService, dbusGeoClueManagerPath) + + if err := managerObj.Call(dbusGeoClueManagerGetClient, 0).Store(&c.clientPath); err != nil { + return fmt.Errorf("failed to create GeoClue2 client: %w", err) + } + + clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath) + if err := clientObj.SetProperty(dbusGeoClueClientDesktopId, "dms"); err != nil { + return fmt.Errorf("failed to set desktop ID: %w", err) + } + + if err := clientObj.SetProperty(dbusGeoClueClientTimeThreshold, uint(10)); err != nil { + return fmt.Errorf("failed to set time threshold: %w", err) + } + + return nil +} + +func (c *GeoClueClient) startSignalPump() error { + c.dbusConn.Signal(c.signals) + + if err := c.dbusConn.AddMatchSignal( + dbus.WithMatchObjectPath(c.clientPath), + dbus.WithMatchInterface(dbusGeoClueClientInterface), + dbus.WithMatchSender(dbusGeoClueClientLocationUpdated), + ); err != nil { + return err + } + + c.sigWG.Add(1) + go func() { + defer c.sigWG.Done() + + clientObj := c.dbusConn.Object(dbusGeoClueService, c.clientPath) + clientObj.Call(dbusGeoClueClientTimeStart, 0) + defer clientObj.Call(dbusGeoClueClientTimeStop, 0) + + for { + select { + case <-c.stopChan: + return + case sig, ok := <-c.signals: + if !ok { + return + } + if sig == nil { + continue + } + + c.handleSignal(sig) + } + } + }() + + return nil +} + +func (c *GeoClueClient) handleSignal(sig *dbus.Signal) { + switch sig.Name { + case dbusGeoClueClientLocationUpdated: + if len(sig.Body) != 2 { + return + } + + newLocationPath, ok := sig.Body[1].(dbus.ObjectPath) + if !ok { + return + } + + if err := c.handleLocationUpdated(newLocationPath); err != nil { + log.Warn("GeoClue: Failed to handle location update: %v", err) + return + } + } +} + +func (c *GeoClueClient) handleLocationUpdated(path dbus.ObjectPath) error { + locationObj := c.dbusConn.Object(dbusGeoClueService, path) + + lat, err := locationObj.GetProperty(dbusGeoClueLocationLatitude) + if err != nil { + return err + } + + long, err := locationObj.GetProperty(dbusGeoClueLocationLongitude) + if err != nil { + return err + } + + c.locationMutex.Lock() + c.currLocation.Latitude = dbusutil.AsOr(lat, 0.0) + c.currLocation.Longitude = dbusutil.AsOr(long, 0.0) + c.locationMutex.Unlock() + + c.notifySubscribers() + return nil +} + +func (c *GeoClueClient) notifySubscribers() { + currentLocation, err := c.GetLocation() + if err != nil { + return + } + + c.subscribers.Range(func(key string, ch chan Location) bool { + select { + case ch <- currentLocation: + default: + log.Warn("GeoClue: subscriber channel full, dropping update") + } + return true + }) +} + +func (c *GeoClueClient) SeedLocation(loc Location) { + c.locationMutex.Lock() + defer c.locationMutex.Unlock() + c.currLocation.Latitude = loc.Latitude + c.currLocation.Longitude = loc.Longitude +} + +func (c *GeoClueClient) GetLocation() (Location, error) { + c.locationMutex.RLock() + defer c.locationMutex.RUnlock() + if c.currLocation == nil { + return Location{ + Latitude: 0.0, + Longitude: 0.0, + }, nil + } + stateCopy := *c.currLocation + return stateCopy, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client_ip.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client_ip.go new file mode 100644 index 0000000..0e68cae --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/client_ip.go @@ -0,0 +1,91 @@ +package geolocation + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type IpClient struct { + currLocation *Location +} + +type ipLocationResult struct { + Location + City string +} + +type ipAPIResponse struct { + Status string `json:"status"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + City string `json:"city"` +} + +func newIpClient() *IpClient { + return &IpClient{ + currLocation: &Location{}, + } +} + +func (c *IpClient) Subscribe(id string) chan Location { + ch := make(chan Location, 1) + if location, err := c.GetLocation(); err == nil { + ch <- location + } + return ch +} + +func (c *IpClient) Unsubscribe(id string) {} + +func (c *IpClient) Close() {} + +func (c *IpClient) GetLocation() (Location, error) { + if c.currLocation.Latitude != 0 || c.currLocation.Longitude != 0 { + return *c.currLocation, nil + } + + result, err := fetchIPLocation() + if err != nil { + return Location{}, err + } + + c.currLocation.Latitude = result.Latitude + c.currLocation.Longitude = result.Longitude + return *c.currLocation, nil +} + +func fetchIPLocation() (ipLocationResult, error) { + client := &http.Client{Timeout: 10 * time.Second} + + resp, err := client.Get("http://ip-api.com/json/") + if err != nil { + return ipLocationResult{}, fmt.Errorf("failed to fetch IP location: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return ipLocationResult{}, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return ipLocationResult{}, fmt.Errorf("failed to read response: %w", err) + } + + var data ipAPIResponse + if err := json.Unmarshal(body, &data); err != nil { + return ipLocationResult{}, fmt.Errorf("failed to parse response: %w", err) + } + + if data.Status == "fail" || (data.Lat == 0 && data.Lon == 0) { + return ipLocationResult{}, fmt.Errorf("ip-api.com returned no location data") + } + + return ipLocationResult{ + Location: Location{Latitude: data.Lat, Longitude: data.Lon}, + City: data.City, + }, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/types.go new file mode 100644 index 0000000..bf71041 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/geolocation/types.go @@ -0,0 +1,15 @@ +package geolocation + +type Location struct { + Latitude float64 + Longitude float64 +} + +type Client interface { + GetLocation() (Location, error) + + Subscribe(id string) chan Location + Unsubscribe(id string) + + Close() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/assets/apparmor/usr.bin.dms-greeter b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/assets/apparmor/usr.bin.dms-greeter new file mode 100644 index 0000000..7c42f9e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/assets/apparmor/usr.bin.dms-greeter @@ -0,0 +1,91 @@ +# AppArmor profile for dms-greeter +# +# Managed by DMS — regenerated on every `dms greeter install` / `dms greeter sync`. +# Manual edits will be overwritten on next sync. +# +# Mode: complain (denials are logged, nothing is blocked) +# To switch to enforce after validating with `aa-logprof`: +# sudo aa-enforce /etc/apparmor.d/usr.bin.dms-greeter +# +#include + +profile dms-greeter /usr/bin/dms-greeter flags=(complain) { + #include + #include + + # The launcher script itself + /usr/bin/dms-greeter r, + + # Cache directory — created by dms greeter sync/enable with greeter:greeter ownership + /var/cache/dms-greeter/ rw, + /var/cache/dms-greeter/** rwlk, + + # DMS config — packaged path + /usr/share/quickshell/dms-greeter/ r, + /usr/share/quickshell/dms-greeter/** r, + /usr/share/quickshell/ r, + /usr/share/quickshell/** r, + + # DMS config — system and user overrides + /etc/dms/ r, + /etc/dms/** r, + /usr/share/dms/ r, + /usr/share/dms/** r, + /home/*/.config/quickshell/ r, + /home/*/.config/quickshell/** r, + /root/.config/quickshell/ r, + /root/.config/quickshell/** r, + + # greetd / PAM — read-only for session setup + /etc/greetd/ r, + /etc/greetd/** r, + /etc/pam.d/ r, + /etc/pam.d/** r, + /usr/lib/pam.d/ r, + /usr/lib/pam.d/** r, + + # Compositor binaries — run unconfined so each compositor uses its own profile + /usr/bin/niri Ux, + /usr/bin/hyprland Ux, + /usr/bin/Hyprland Ux, + /usr/bin/sway Ux, + /usr/bin/labwc Ux, + /usr/bin/scroll Ux, + /usr/bin/miracle-wm Ux, + /usr/bin/mango Ux, + + # Quickshell — run unconfined (has its own compositor profile on some distros) + /usr/bin/qs Ux, + /usr/bin/quickshell Ux, + + # Wayland / XDG runtime (pipewire, wireplumber, wayland socket) + /run/user/[0-9]*/ rw, + /run/user/[0-9]*/** rw, + + # DRM / GPU devices (required for Wayland compositor startup) + /dev/dri/ r, + /dev/dri/* rw, + /dev/udmabuf rw, + + # Input devices + /dev/input/ r, + /dev/input/* r, + + # Systemd journal / logging + /run/systemd/journal/socket rw, + /dev/log rw, + + # Shell helper binaries invoked by the launcher script + /usr/bin/env ix, + /usr/bin/mkdir ix, + /usr/bin/cat ix, + /usr/bin/grep ix, + /usr/bin/dirname ix, + /usr/bin/basename ix, + /usr/bin/command ix, + /bin/env ix, + /bin/mkdir ix, + + # Signal management (compositor lifecycle) + signal (send, receive) set=("term", "int", "hup", "kill"), +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/installer.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/installer.go new file mode 100644 index 0000000..ace72ce --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/installer.go @@ -0,0 +1,2076 @@ +package greeter + +import ( + "bufio" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/sblinch/kdl-go" + "github.com/sblinch/kdl-go/document" +) + +var appArmorProfileData []byte + +const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter" + +const GreeterCacheDir = "/var/cache/dms-greeter" + +func DetectDMSPath() (string, error) { + return config.LocateDMSConfig() +} + +// IsNixOS returns true when running on NixOS, which manages PAM configs through +// its module system. The DMS PAM managed block won't be written on NixOS. +func IsNixOS() bool { + _, err := os.Stat("/etc/NIXOS") + return err == nil +} + +func DetectGreeterGroup() string { + data, err := os.ReadFile("/etc/group") + if err != nil { + fmt.Fprintln(os.Stderr, "⚠ Warning: could not read /etc/group, defaulting to greeter") + return "greeter" + } + + if group, found := utils.FindGroupData(string(data), "greeter", "greetd", "_greeter"); found { + return group + } + + fmt.Fprintln(os.Stderr, "⚠ Warning: no greeter group found in /etc/group, defaulting to greeter") + return "greeter" +} + +func hasPasswdUser(passwdData, user string) bool { + prefix := user + ":" + for line := range strings.SplitSeq(passwdData, "\n") { + if strings.HasPrefix(line, prefix) { + return true + } + } + return false +} + +func findPasswdUser(passwdData string, candidates ...string) (string, bool) { + for _, candidate := range candidates { + if hasPasswdUser(passwdData, candidate) { + return candidate, true + } + } + return "", false +} + +func stripTomlComment(line string) string { + trimmed := strings.TrimSpace(line) + if idx := strings.Index(trimmed, "#"); idx >= 0 { + return strings.TrimSpace(trimmed[:idx]) + } + return trimmed +} + +func parseTomlSection(line string) (string, bool) { + trimmed := stripTomlComment(line) + if len(trimmed) < 3 || !strings.HasPrefix(trimmed, "[") || !strings.HasSuffix(trimmed, "]") { + return "", false + } + return strings.TrimSpace(trimmed[1 : len(trimmed)-1]), true +} + +func extractDefaultSessionUser(configContent string) string { + inDefaultSession := false + for line := range strings.SplitSeq(configContent, "\n") { + if section, ok := parseTomlSection(line); ok { + inDefaultSession = section == "default_session" + continue + } + + if !inDefaultSession { + continue + } + + trimmed := stripTomlComment(line) + if !strings.HasPrefix(trimmed, "user =") && !strings.HasPrefix(trimmed, "user=") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + continue + } + user := strings.Trim(strings.TrimSpace(parts[1]), `"`) + if user != "" { + return user + } + } + + return "" +} + +func upsertDefaultSession(configContent, greeterUser, command string) string { + lines := strings.Split(configContent, "\n") + var out []string + + inDefaultSession := false + foundDefaultSession := false + defaultSessionUserSet := false + defaultSessionCommandSet := false + + appendDefaultSessionFields := func() { + if !defaultSessionUserSet { + out = append(out, fmt.Sprintf(`user = "%s"`, greeterUser)) + } + if !defaultSessionCommandSet { + out = append(out, command) + } + } + + for _, line := range lines { + if section, ok := parseTomlSection(line); ok { + if inDefaultSession { + appendDefaultSessionFields() + } + + inDefaultSession = section == "default_session" + if inDefaultSession { + foundDefaultSession = true + defaultSessionUserSet = false + defaultSessionCommandSet = false + } + + out = append(out, line) + continue + } + + if inDefaultSession { + trimmed := stripTomlComment(line) + if strings.HasPrefix(trimmed, "user =") || strings.HasPrefix(trimmed, "user=") { + out = append(out, fmt.Sprintf(`user = "%s"`, greeterUser)) + defaultSessionUserSet = true + continue + } + + if strings.HasPrefix(trimmed, "command =") || strings.HasPrefix(trimmed, "command=") { + if !defaultSessionCommandSet { + out = append(out, command) + defaultSessionCommandSet = true + } + continue + } + } + + out = append(out, line) + } + + if inDefaultSession { + appendDefaultSessionFields() + } + + if !foundDefaultSession { + if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { + out = append(out, "") + } + out = append(out, "[default_session]") + out = append(out, fmt.Sprintf(`user = "%s"`, greeterUser)) + out = append(out, command) + } + + return strings.Join(out, "\n") +} + +func DetectGreeterUser() string { + passwdData, err := os.ReadFile("/etc/passwd") + if err == nil { + passwdContent := string(passwdData) + + if configData, cfgErr := os.ReadFile("/etc/greetd/config.toml"); cfgErr == nil { + if configured := extractDefaultSessionUser(string(configData)); configured != "" && hasPasswdUser(passwdContent, configured) { + return configured + } + } + + if user, found := findPasswdUser(passwdContent, "greeter", "greetd", "_greeter"); found { + return user + } + } else { + fmt.Fprintln(os.Stderr, "⚠ Warning: could not read /etc/passwd, defaulting greeter user to 'greeter'") + } + + if configData, cfgErr := os.ReadFile("/etc/greetd/config.toml"); cfgErr == nil { + if configured := extractDefaultSessionUser(string(configData)); configured != "" { + return configured + } + } + + fmt.Fprintln(os.Stderr, "⚠ Warning: no greeter user found, defaulting to 'greeter'") + return "greeter" +} + +func resolveGreeterWrapperPath() string { + if override := strings.TrimSpace(os.Getenv("DMS_GREETER_WRAPPER_CMD")); override != "" { + return override + } + + // Packaged installs only use the official wrapper; never fall back to /usr/local/bin. + if IsGreeterPackaged() { + packagedWrapper := "/usr/bin/dms-greeter" + if info, err := os.Stat(packagedWrapper); err == nil && !info.IsDir() && (info.Mode()&0o111) != 0 { + return packagedWrapper + } + fmt.Fprintln(os.Stderr, "⚠ Warning: packaged dms-greeter detected, but /usr/bin/dms-greeter is missing or not executable") + return packagedWrapper + } + + for _, candidate := range []string{"/usr/bin/dms-greeter", "/usr/local/bin/dms-greeter"} { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() && (info.Mode()&0o111) != 0 { + return candidate + } + } + + if path, err := exec.LookPath("dms-greeter"); err == nil { + resolved := path + if realPath, realErr := filepath.EvalSymlinks(path); realErr == nil { + resolved = realPath + } + if strings.HasPrefix(resolved, "/home/") || strings.HasPrefix(resolved, "/tmp/") { + fmt.Fprintf(os.Stderr, "⚠ Warning: ignoring non-system dms-greeter on PATH: %s\n", path) + } else { + return path + } + } + + return "/usr/bin/dms-greeter" +} + +func DetectCompositors() []string { + var compositors []string + + if utils.CommandExists("niri") { + compositors = append(compositors, "niri") + } + if utils.CommandExists("Hyprland") { + compositors = append(compositors, "Hyprland") + } + + return compositors +} + +func PromptCompositorChoice(compositors []string) (string, error) { + fmt.Println("\nMultiple compositors detected:") + for i, comp := range compositors { + fmt.Printf("%d) %s\n", i+1, comp) + } + + reader := bufio.NewReader(os.Stdin) + fmt.Print("Choose compositor for greeter (1-2): ") + response, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("error reading input: %w", err) + } + + response = strings.TrimSpace(response) + switch response { + case "1": + return compositors[0], nil + case "2": + if len(compositors) > 1 { + return compositors[1], nil + } + return "", fmt.Errorf("invalid choice") + default: + return "", fmt.Errorf("invalid choice") + } +} + +// EnsureGreetdInstalled checks if greetd is installed - greetd is a daemon in /usr/sbin on Debian/Ubuntu +func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error { + greetdFound := utils.CommandExists("greetd") + if !greetdFound { + for _, p := range []string{"/usr/sbin/greetd", "/sbin/greetd"} { + if _, err := os.Stat(p); err == nil { + greetdFound = true + break + } + } + } + if greetdFound { + logFunc("✓ greetd is already installed") + return nil + } + + logFunc("greetd is not installed. Installing...") + + osInfo, err := distros.GetOSInfo() + if err != nil { + return fmt.Errorf("failed to detect OS: %w", err) + } + + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return fmt.Errorf("unsupported distribution for automatic greetd installation: %s", osInfo.Distribution.ID) + } + + ctx := context.Background() + var installCmd *exec.Cmd + + switch config.Family { + case distros.FamilyArch: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "pacman -S --needed --noconfirm greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd") + } + + case distros.FamilyFedora: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "dnf install -y greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd") + } + + case distros.FamilySUSE: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "zypper install -y greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "greetd") + } + + case distros.FamilyUbuntu: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "apt-get install -y greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd") + } + + case distros.FamilyDebian: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "apt-get install -y greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd") + } + + case distros.FamilyGentoo: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, + "emerge --ask n sys-apps/greetd") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-apps/greetd") + } + + case distros.FamilyNix: + return fmt.Errorf("on NixOS, please add greetd to your configuration.nix") + + default: + return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family) + } + + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install greetd: %w", err) + } + + logFunc("✓ greetd installed successfully") + return nil +} + +// IsGreeterPackaged returns true if dms-greeter was installed from a system package. +func IsGreeterPackaged() bool { + if !utils.CommandExists("dms-greeter") { + return false + } + packagedPath := "/usr/share/quickshell/dms-greeter" + info, err := os.Stat(packagedPath) + return err == nil && info.IsDir() +} + +// HasLegacyLocalGreeterWrapper returns true when a manually installed wrapper exists. +func HasLegacyLocalGreeterWrapper() bool { + info, err := os.Stat("/usr/local/bin/dms-greeter") + return err == nil && !info.IsDir() +} + +// TryInstallGreeterPackage attempts to install dms-greeter from the distro's official repo. +func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool { + osInfo, err := distros.GetOSInfo() + if err != nil { + return false + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return false + } + + if IsGreeterPackaged() { + logFunc("✓ dms-greeter package already installed") + return true + } + + ctx := context.Background() + var installCmd *exec.Cmd + var failHint string + + switch config.Family { + case distros.FamilyDebian: + obsSlug := getDebianOBSSlug(osInfo) + keyURL := fmt.Sprintf("https://download.opensuse.org/repositories/home:AvengeMedia:danklinux/%s/Release.key", obsSlug) + repoLine := fmt.Sprintf("deb [signed-by=/etc/apt/keyrings/danklinux.gpg] https://download.opensuse.org/repositories/home:/AvengeMedia:/danklinux/%s/ /", obsSlug) + failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo apt-get install -y gnupg\nsudo mkdir -p /etc/apt/keyrings\ncurl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg\necho '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list\nsudo apt update && sudo apt-get install -y dms-greeter", keyURL, repoLine) + logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug)) + if _, err := exec.LookPath("gpg"); err != nil { + logFunc("Installing gnupg for OBS repository key import...") + installGPGCmd := exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "gnupg") + installGPGCmd.Stdout = os.Stdout + installGPGCmd.Stderr = os.Stderr + if err := installGPGCmd.Run(); err != nil { + logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err)) + } + } + mkdirCmd := exec.CommandContext(ctx, "sudo", "mkdir", "-p", "/etc/apt/keyrings") + mkdirCmd.Stdout = os.Stdout + mkdirCmd.Stderr = os.Stderr + mkdirCmd.Run() + addKeyCmd := exec.CommandContext(ctx, "bash", "-c", + fmt.Sprintf(`curl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg`, keyURL)) + addKeyCmd.Stdout = os.Stdout + addKeyCmd.Stderr = os.Stderr + addKeyCmd.Run() + addRepoCmd := exec.CommandContext(ctx, "bash", "-c", + fmt.Sprintf(`echo '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list`, repoLine)) + addRepoCmd.Stdout = os.Stdout + addRepoCmd.Stderr = os.Stderr + addRepoCmd.Run() + exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") + case distros.FamilySUSE: + repoURL := getOpenSUSEOBSRepoURL(osInfo) + failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL) + logFunc("Adding DankLinux OBS repository...") + addRepoCmd := exec.CommandContext(ctx, "sudo", "zypper", "addrepo", repoURL) + addRepoCmd.Stdout = os.Stdout + addRepoCmd.Stderr = os.Stderr + addRepoCmd.Run() + exec.CommandContext(ctx, "sudo", "zypper", "refresh").Run() + installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "dms-greeter") + case distros.FamilyUbuntu: + failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter" + logFunc("Enabling PPA ppa:avengemedia/danklinux...") + ppacmd := exec.CommandContext(ctx, "sudo", "add-apt-repository", "-y", "ppa:avengemedia/danklinux") + ppacmd.Stdout = os.Stdout + ppacmd.Stderr = os.Stderr + ppacmd.Run() + exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") + case distros.FamilyFedora: + failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter" + logFunc("Enabling COPR avengemedia/danklinux...") + coprcmd := exec.CommandContext(ctx, "sudo", "dnf", "copr", "enable", "-y", "avengemedia/danklinux") + coprcmd.Stdout = os.Stdout + coprcmd.Stderr = os.Stderr + coprcmd.Run() + installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "dms-greeter") + case distros.FamilyArch: + aurHelper := "" + for _, helper := range []string{"paru", "yay"} { + if _, err := exec.LookPath(helper); err == nil { + aurHelper = helper + break + } + } + if aurHelper == "" { + logFunc("⚠ No AUR helper found (paru/yay). Install greetd-dms-greeter-git from AUR: https://aur.archlinux.org/packages/greetd-dms-greeter-git") + return false + } + failHint = fmt.Sprintf("⚠ dms-greeter install failed. Install from AUR: %s -S greetd-dms-greeter-git", aurHelper) + installCmd = exec.CommandContext(ctx, aurHelper, "-S", "--noconfirm", "greetd-dms-greeter-git") + default: + return false + } + + logFunc("Installing dms-greeter from official repository...") + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + + if err := installCmd.Run(); err != nil { + logFunc(failHint) + return false + } + + logFunc("✓ dms-greeter package installed") + return true +} + +// CopyGreeterFiles installs the dms-greeter wrapper and sets up cache directory +func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { + if IsGreeterPackaged() { + logFunc("✓ dms-greeter package already installed") + } else { + if dmsPath == "" { + return fmt.Errorf("dms path is required for manual dms-greeter wrapper installs") + } + + assetsDir := filepath.Join(dmsPath, "Modules", "Greetd", "assets") + wrapperSrc := filepath.Join(assetsDir, "dms-greeter") + + if _, err := os.Stat(wrapperSrc); os.IsNotExist(err) { + return fmt.Errorf("dms-greeter wrapper not found at %s", wrapperSrc) + } + + wrapperDst := "/usr/local/bin/dms-greeter" + action := "Installed" + if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() { + action = "Updated" + } + if err := runSudoCmd(sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil { + return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err) + } + logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst)) + + if err := runSudoCmd(sudoPassword, "chmod", "+x", wrapperDst); err != nil { + return fmt.Errorf("failed to make wrapper executable: %w", err) + } + + osInfo, err := distros.GetOSInfo() + if err == nil { + if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) { + if err := runSudoCmd(sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err)) + } else { + logFunc("✓ Set SELinux fcontext for dms-greeter") + } + + if err := runSudoCmd(sudoPassword, "restorecon", "-v", wrapperDst); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err)) + } else { + logFunc("✓ Restored SELinux context for dms-greeter") + } + } + } + } + + if err := EnsureGreeterCacheDir(logFunc, sudoPassword); err != nil { + return err + } + + return nil +} + +// EnsureGreeterCacheDir creates /var/cache/dms-greeter with correct ownership if it does not exist. +// It is safe to call multiple times (idempotent) and will repair ownership/mode +// when the directory already exists with stale permissions. +func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error { + cacheDir := GreeterCacheDir + created := false + if info, err := os.Stat(cacheDir); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat cache directory: %w", err) + } + if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + created = true + } else if !info.IsDir() { + return fmt.Errorf("cache path exists but is not a directory: %s", cacheDir) + } + + group := DetectGreeterGroup() + daemonUser := DetectGreeterUser() + preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group) + owner := preferredOwner + if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil { + // Some setups may not have a matching daemon user at this moment; fall back + // to root: while still allowing group-writable greeter runtime access. + fallbackOwner := fmt.Sprintf("root:%s", group) + if fallbackErr := runSudoCmd(sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil { + return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr) + } + owner = fallbackOwner + } + + if err := runSudoCmd(sudoPassword, "chmod", "2770", cacheDir); err != nil { + return fmt.Errorf("failed to set cache directory permissions: %w", err) + } + + runtimeDirs := []string{ + filepath.Join(cacheDir, ".local"), + filepath.Join(cacheDir, ".local", "state"), + filepath.Join(cacheDir, ".local", "share"), + filepath.Join(cacheDir, ".cache"), + } + for _, dir := range runtimeDirs { + if err := runSudoCmd(sudoPassword, "mkdir", "-p", dir); err != nil { + return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err) + } + if err := runSudoCmd(sudoPassword, "chown", owner, dir); err != nil { + return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err) + } + if err := runSudoCmd(sudoPassword, "chmod", "2770", dir); err != nil { + return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err) + } + } + + legacyMemoryPath := filepath.Join(cacheDir, "memory.json") + stateMemoryPath := filepath.Join(cacheDir, ".local", "state", "memory.json") + if err := ensureGreeterMemoryCompatLink(logFunc, sudoPassword, legacyMemoryPath, stateMemoryPath); err != nil { + return err + } + + if isSELinuxEnforcing() && utils.CommandExists("restorecon") { + if err := runSudoCmd(sudoPassword, "restorecon", "-Rv", cacheDir); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err)) + } + } + + if created { + logFunc(fmt.Sprintf("✓ Created cache directory %s (owner: %s, mode: 2770)", cacheDir, owner)) + } else { + logFunc(fmt.Sprintf("✓ Ensured cache directory %s permissions (owner: %s, mode: 2770)", cacheDir, owner)) + } + return nil +} + +func isSELinuxEnforcing() bool { + data, err := os.ReadFile("/sys/fs/selinux/enforce") + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == "1" +} + +func ensureGreeterMemoryCompatLink(logFunc func(string), sudoPassword, legacyPath, statePath string) error { + info, err := os.Lstat(legacyPath) + if err == nil && info.Mode().IsRegular() { + if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) { + if copyErr := runSudoCmd(sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr)) + } + } + } + + if err := runSudoCmd(sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil { + return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err) + } + + return nil +} + +// IsAppArmorEnabled reports whether AppArmor is active on the running kernel. +func IsAppArmorEnabled() bool { + data, err := os.ReadFile("/sys/module/apparmor/parameters/enabled") + if err != nil { + return false + } + return strings.HasPrefix(strings.TrimSpace(strings.ToLower(string(data))), "y") +} + +// InstallAppArmorProfile installs the bundled AppArmor profile and reloads it. No-op on NixOS or non-AppArmor systems. +func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error { + if IsNixOS() { + logFunc(" ℹ Skipping AppArmor profile on NixOS (manage via security.apparmor.policies)") + return nil + } + + if !IsAppArmorEnabled() { + return nil + } + + if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil { + return fmt.Errorf("failed to create /etc/apparmor.d: %w", err) + } + + tmp, err := os.CreateTemp("", "dms-apparmor-*") + if err != nil { + return fmt.Errorf("failed to create temp file for AppArmor profile: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(appArmorProfileData); err != nil { + tmp.Close() + return fmt.Errorf("failed to write AppArmor profile: %w", err) + } + tmp.Close() + + if err := runSudoCmd(sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil { + return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err) + } + if err := runSudoCmd(sudoPassword, "chmod", "644", appArmorProfileDest); err != nil { + return fmt.Errorf("failed to set AppArmor profile permissions: %w", err) + } + + if utils.CommandExists("apparmor_parser") { + if err := runSudoCmd(sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil { + logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err)) + logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest) + } else { + logFunc(" ✓ AppArmor profile installed and loaded (complain mode)") + } + } else { + logFunc(" ✓ AppArmor profile installed at " + appArmorProfileDest) + logFunc(" apparmor_parser not found — profile will be loaded on next boot") + } + + return nil +} + +// UninstallAppArmorProfile removes the DMS AppArmor profile and reloads AppArmor. +// It is a no-op when AppArmor is not active or the profile does not exist. +func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error { + if IsNixOS() { + return nil + } + if _, err := os.Stat("/sys/module/apparmor"); os.IsNotExist(err) { + return nil + } + if _, err := os.Stat(appArmorProfileDest); os.IsNotExist(err) { + return nil + } + + if utils.CommandExists("apparmor_parser") { + _ = runSudoCmd(sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest) + } + if err := runSudoCmd(sudoPassword, "rm", "-f", appArmorProfileDest); err != nil { + return fmt.Errorf("failed to remove AppArmor profile: %w", err) + } + logFunc(" ✓ Removed DMS AppArmor profile") + return nil +} + +// EnsureACLInstalled installs the acl package (setfacl/getfacl) if not already present +func EnsureACLInstalled(logFunc func(string), sudoPassword string) error { + if utils.CommandExists("setfacl") { + return nil + } + + logFunc("setfacl not found – installing acl package...") + + osInfo, err := distros.GetOSInfo() + if err != nil { + return fmt.Errorf("failed to detect OS: %w", err) + } + + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return fmt.Errorf("unsupported distribution for automatic acl installation: %s", osInfo.Distribution.ID) + } + + ctx := context.Background() + var installCmd *exec.Cmd + + switch config.Family { + case distros.FamilyArch: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "acl") + } + + case distros.FamilyFedora: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "dnf install -y acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "acl") + } + + case distros.FamilySUSE: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "zypper install -y acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "acl") + } + + case distros.FamilyUbuntu: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl") + } + + case distros.FamilyDebian: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl") + } + + case distros.FamilyGentoo: + if sudoPassword != "" { + installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl") + } else { + installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-fs/acl") + } + + case distros.FamilyNix: + return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix") + + default: + return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family) + } + + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install acl: %w", err) + } + + logFunc("✓ acl package installed") + return nil +} + +// SetupParentDirectoryACLs sets ACLs on parent directories to allow traversal +func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error { + if err := EnsureACLInstalled(logFunc, sudoPassword); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: could not install acl package: %v", err)) + logFunc(" ACL permissions will be skipped; theme sync may not work correctly.") + return nil + } + if !utils.CommandExists("setfacl") { + logFunc("⚠ Warning: setfacl still not available after install attempt; skipping ACL setup.") + return nil + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + parentDirs := []struct { + path string + desc string + }{ + {homeDir, "home directory"}, + {filepath.Join(homeDir, ".config"), ".config directory"}, + {filepath.Join(homeDir, ".local"), ".local directory"}, + {filepath.Join(homeDir, ".cache"), ".cache directory"}, + {filepath.Join(homeDir, ".local", "state"), ".local/state directory"}, + {filepath.Join(homeDir, ".local", "share"), ".local/share directory"}, + } + + group := DetectGreeterGroup() + + logFunc("\nSetting up parent directory ACLs for greeter user access...") + + for _, dir := range parentDirs { + if _, err := os.Stat(dir.path); os.IsNotExist(err) { + if err := os.MkdirAll(dir.path, 0o755); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.desc, err)) + continue + } + } + + // Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora). + if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err)) + logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path)) + continue + } + + logFunc(fmt.Sprintf("✓ Set ACL on %s", dir.desc)) + } + + return nil +} + +// RemediateStaleACLs removes user-based ACLs left by older binary versions. Best-effort. +func RemediateStaleACLs(logFunc func(string), sudoPassword string) { + if !utils.CommandExists("setfacl") { + return + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return + } + + passwdData, err := os.ReadFile("/etc/passwd") + if err != nil { + return + } + + dirs := []string{ + homeDir, + filepath.Join(homeDir, ".config"), + filepath.Join(homeDir, ".config", "DankMaterialShell"), + filepath.Join(homeDir, ".cache"), + filepath.Join(homeDir, ".cache", "DankMaterialShell"), + filepath.Join(homeDir, ".local"), + filepath.Join(homeDir, ".local", "state"), + filepath.Join(homeDir, ".local", "share"), + } + + passwdContent := string(passwdData) + staleUsers := []string{"greeter", "greetd", "_greeter"} + existingUsers := make([]string, 0, len(staleUsers)) + for _, user := range staleUsers { + if hasPasswdUser(passwdContent, user) { + existingUsers = append(existingUsers, user) + } + } + if len(existingUsers) == 0 { + return + } + + cleaned := false + for _, dir := range dirs { + if _, err := os.Stat(dir); err != nil { + continue + } + for _, user := range existingUsers { + _ = runSudoCmd(sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir) + cleaned = true + } + } + if cleaned { + logFunc("✓ Cleaned up stale user-based ACLs from previous versions") + } +} + +// RemediateStaleAppArmor removes any AppArmor profile installed by an older binary on +// systems where AppArmor is not active. +func RemediateStaleAppArmor(logFunc func(string), sudoPassword string) { + if IsAppArmorEnabled() { + return + } + if _, err := os.Stat(appArmorProfileDest); os.IsNotExist(err) { + return + } + logFunc("ℹ Removing stale AppArmor profile (AppArmor is not active on this system)") + _ = UninstallAppArmorProfile(logFunc, sudoPassword) +} + +func SetupDMSGroup(logFunc func(string), sudoPassword string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + currentUser := os.Getenv("USER") + if currentUser == "" { + currentUser = os.Getenv("LOGNAME") + } + if currentUser == "" { + return fmt.Errorf("failed to determine current user") + } + + group := DetectGreeterGroup() + + // Create the group if it doesn't exist yet (e.g. before greetd package is installed). + if !utils.HasGroup(group) { + if err := runSudoCmd(sudoPassword, "groupadd", "-r", group); err != nil { + return fmt.Errorf("failed to create %s group: %w", group, err) + } + logFunc(fmt.Sprintf("✓ Created system group %s", group)) + } + + groupsCmd := exec.Command("groups", currentUser) + groupsOutput, err := groupsCmd.Output() + if err == nil && strings.Contains(string(groupsOutput), group) { + logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group)) + } else { + if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, currentUser); err != nil { + return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err) + } + logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group)) + } + + // Also add the daemon user (e.g. greetd on Fedora) so group ACLs apply to the running process. + daemonUser := DetectGreeterUser() + if daemonUser != currentUser { + daemonGroupsCmd := exec.Command("groups", daemonUser) + daemonGroupsOutput, daemonGroupsErr := daemonGroupsCmd.Output() + if daemonGroupsErr == nil { + if strings.Contains(string(daemonGroupsOutput), group) { + logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group)) + } else { + if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, daemonUser); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err)) + } else { + logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group)) + } + } + } + } + + configDirs := []struct { + path string + desc string + }{ + {filepath.Join(homeDir, ".config", "DankMaterialShell"), "DankMaterialShell config"}, + {filepath.Join(homeDir, ".local", "state", "DankMaterialShell"), "DankMaterialShell state"}, + {filepath.Join(homeDir, ".cache", "DankMaterialShell"), "DankMaterialShell cache"}, + {filepath.Join(homeDir, ".cache", "quickshell"), "quickshell cache"}, + {filepath.Join(homeDir, ".config", "quickshell"), "quickshell config"}, + {filepath.Join(homeDir, ".local", "share", "wayland-sessions"), "wayland sessions"}, + {filepath.Join(homeDir, ".local", "share", "xsessions"), "xsessions"}, + } + + for _, dir := range configDirs { + if _, err := os.Stat(dir.path); os.IsNotExist(err) { + if err := os.MkdirAll(dir.path, 0o755); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Could not create %s: %v", dir.path, err)) + continue + } + } + + if err := runSudoCmd(sudoPassword, "chgrp", "-R", group, dir.path); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err)) + continue + } + + if err := runSudoCmd(sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err)) + continue + } + + logFunc(fmt.Sprintf("✓ Set group permissions for %s", dir.desc)) + } + + if err := SetupParentDirectoryACLs(logFunc, sudoPassword); err != nil { + return fmt.Errorf("failed to setup parent directory ACLs: %w", err) + } + + return nil +} + +type GreeterColorSyncInfo struct { + SourcePath string + ThemeName string + UsesDynamicWallpaperOverride bool +} + +type greeterThemeSyncSettings struct { + CurrentThemeName string `json:"currentThemeName"` + GreeterWallpaperPath string `json:"greeterWallpaperPath"` + MatugenScheme string `json:"matugenScheme"` + IconTheme string `json:"iconTheme"` +} + +type greeterThemeSyncSession struct { + IsLightMode bool `json:"isLightMode"` +} + +type greeterThemeSyncState struct { + ThemeName string + GreeterWallpaperPath string + ResolvedGreeterWallpaperPath string + MatugenScheme string + IconTheme string + IsLightMode bool + UsesDynamicWallpaperOverride bool +} + +func defaultGreeterColorsSource(homeDir string) string { + return filepath.Join(homeDir, ".cache", "DankMaterialShell", "dms-colors.json") +} + +func greeterOverrideColorsStateDir(homeDir string) string { + return filepath.Join(homeDir, ".cache", "DankMaterialShell", "greeter-colors") +} + +func greeterOverrideColorsSource(homeDir string) string { + return filepath.Join(greeterOverrideColorsStateDir(homeDir), "dms-colors.json") +} + +func readOptionalJSONFile(path string, dst any) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if strings.TrimSpace(string(data)) == "" { + return nil + } + return json.Unmarshal(data, dst) +} + +func readGreeterThemeSyncSettings(homeDir string) (greeterThemeSyncSettings, error) { + settings := greeterThemeSyncSettings{ + CurrentThemeName: "purple", + MatugenScheme: "scheme-tonal-spot", + IconTheme: "System Default", + } + settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") + if err := readOptionalJSONFile(settingsPath, &settings); err != nil { + return greeterThemeSyncSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err) + } + return settings, nil +} + +func readGreeterThemeSyncSession(homeDir string) (greeterThemeSyncSession, error) { + session := greeterThemeSyncSession{} + sessionPath := filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json") + if err := readOptionalJSONFile(sessionPath, &session); err != nil { + return greeterThemeSyncSession{}, fmt.Errorf("failed to parse session at %s: %w", sessionPath, err) + } + return session, nil +} + +func resolveGreeterThemeSyncState(homeDir string) (greeterThemeSyncState, error) { + settings, err := readGreeterThemeSyncSettings(homeDir) + if err != nil { + return greeterThemeSyncState{}, err + } + session, err := readGreeterThemeSyncSession(homeDir) + if err != nil { + return greeterThemeSyncState{}, err + } + + resolvedWallpaperPath := "" + if settings.GreeterWallpaperPath != "" { + resolvedWallpaperPath = settings.GreeterWallpaperPath + if !filepath.IsAbs(resolvedWallpaperPath) { + resolvedWallpaperPath = filepath.Join(homeDir, resolvedWallpaperPath) + } + } + + usesDynamicWallpaperOverride := strings.EqualFold(strings.TrimSpace(settings.CurrentThemeName), "dynamic") && resolvedWallpaperPath != "" + + return greeterThemeSyncState{ + ThemeName: settings.CurrentThemeName, + GreeterWallpaperPath: settings.GreeterWallpaperPath, + ResolvedGreeterWallpaperPath: resolvedWallpaperPath, + MatugenScheme: settings.MatugenScheme, + IconTheme: settings.IconTheme, + IsLightMode: session.IsLightMode, + UsesDynamicWallpaperOverride: usesDynamicWallpaperOverride, + }, nil +} + +func (s greeterThemeSyncState) effectiveColorsSource(homeDir string) string { + if s.UsesDynamicWallpaperOverride { + return greeterOverrideColorsSource(homeDir) + } + return defaultGreeterColorsSource(homeDir) +} + +func ResolveGreeterColorSyncInfo(homeDir string) (GreeterColorSyncInfo, error) { + state, err := resolveGreeterThemeSyncState(homeDir) + if err != nil { + return GreeterColorSyncInfo{}, err + } + return GreeterColorSyncInfo{ + SourcePath: state.effectiveColorsSource(homeDir), + ThemeName: state.ThemeName, + UsesDynamicWallpaperOverride: state.UsesDynamicWallpaperOverride, + }, nil +} + +func ensureGreeterSyncSourceFile(path string) error { + sourceDir := filepath.Dir(path) + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + return fmt.Errorf("failed to create source directory %s: %w", sourceDir, err) + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { + return fmt.Errorf("failed to create source file %s: %w", path, err) + } + } else if err != nil { + return fmt.Errorf("failed to inspect source file %s: %w", path, err) + } + + return nil +} + +func syncGreeterDynamicOverrideColors(dmsPath, homeDir string, state greeterThemeSyncState, logFunc func(string)) error { + if !state.UsesDynamicWallpaperOverride { + return nil + } + + st, err := os.Stat(state.ResolvedGreeterWallpaperPath) + if err != nil { + return fmt.Errorf("configured greeter wallpaper not found at %s: %w", state.ResolvedGreeterWallpaperPath, err) + } + if st.IsDir() { + return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", state.ResolvedGreeterWallpaperPath) + } + + mode := matugen.ColorModeDark + if state.IsLightMode { + mode = matugen.ColorModeLight + } + + opts := matugen.Options{ + StateDir: greeterOverrideColorsStateDir(homeDir), + ShellDir: dmsPath, + ConfigDir: filepath.Join(homeDir, ".config"), + Kind: "image", + Value: state.ResolvedGreeterWallpaperPath, + Mode: mode, + IconTheme: state.IconTheme, + MatugenType: state.MatugenScheme, + RunUserTemplates: false, + ColorsOnly: true, + } + + err = matugen.Run(opts) + switch { + case errors.Is(err, matugen.ErrNoChanges): + logFunc("✓ Greeter dynamic override colors already up to date") + return nil + case err != nil: + return fmt.Errorf("failed to generate greeter dynamic colors from wallpaper override: %w", err) + default: + logFunc("✓ Generated greeter dynamic colors from wallpaper override") + return nil + } +} + +func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncState, logFunc func(string), sudoPassword string) error { + source := state.effectiveColorsSource(homeDir) + if !state.UsesDynamicWallpaperOverride { + if err := ensureGreeterSyncSourceFile(source); err != nil { + return err + } + } else if _, err := os.Stat(source); err != nil { + return fmt.Errorf("expected generated greeter colors at %s: %w", source, err) + } + + target := filepath.Join(cacheDir, "colors.json") + _ = runSudoCmd(sudoPassword, "rm", "-f", target) + if err := runSudoCmd(sudoPassword, "ln", "-sf", source, target); err != nil { + return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err) + } + + if state.UsesDynamicWallpaperOverride { + logFunc("✓ Synced wallpaper based theming (greeter wallpaper override)") + } else { + logFunc("✓ Synced wallpaper based theming") + } + + return nil +} + +func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + + cacheDir := GreeterCacheDir + + symlinks := []struct { + source string + target string + desc string + }{ + { + source: filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), + target: filepath.Join(cacheDir, "settings.json"), + desc: "core settings (theme, clock formats, etc)", + }, + { + source: filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), + target: filepath.Join(cacheDir, "session.json"), + desc: "state (wallpaper configuration)", + }, + } + + for _, link := range symlinks { + sourceDir := filepath.Dir(link.source) + if _, err := os.Stat(sourceDir); os.IsNotExist(err) { + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + return fmt.Errorf("failed to create source directory %s for %s: %w", sourceDir, link.desc, err) + } + } + + if _, err := os.Stat(link.source); os.IsNotExist(err) { + if err := os.WriteFile(link.source, []byte("{}"), 0o644); err != nil { + return fmt.Errorf("failed to create source file %s for %s: %w", link.source, link.desc, err) + } + } + + _ = runSudoCmd(sudoPassword, "rm", "-f", link.target) + + if err := runSudoCmd(sudoPassword, "ln", "-sf", link.source, link.target); err != nil { + return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err) + } + + logFunc(fmt.Sprintf("✓ Synced %s", link.desc)) + } + + state, err := resolveGreeterThemeSyncState(homeDir) + if err != nil { + return fmt.Errorf("failed to resolve greeter color source: %w", err) + } + + if err := syncGreeterDynamicOverrideColors(dmsPath, homeDir, state, logFunc); err != nil { + return err + } + + if err := syncGreeterColorSource(homeDir, cacheDir, state, logFunc, sudoPassword); err != nil { + return err + } + + if err := syncGreeterWallpaperOverride(cacheDir, logFunc, sudoPassword, state); err != nil { + return fmt.Errorf("greeter wallpaper override sync failed: %w", err) + } + + if strings.ToLower(compositor) != "niri" { + return nil + } + + if err := syncNiriGreeterConfig(logFunc, sudoPassword); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to sync niri greeter config: %v", err)) + } + + return nil +} + +func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error { + destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg") + if state.ResolvedGreeterWallpaperPath == "" { + if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { + return fmt.Errorf("failed to clear override file %s: %w", destPath, err) + } + logFunc("✓ Cleared greeter wallpaper override") + return nil + } + if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { + return fmt.Errorf("failed to remove old override file %s: %w", destPath, err) + } + src := state.ResolvedGreeterWallpaperPath + st, err := os.Stat(src) + if err != nil { + return fmt.Errorf("configured greeter wallpaper not found at %s: %w", src, err) + } + if st.IsDir() { + return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src) + } + if err := runSudoCmd(sudoPassword, "cp", src, destPath); err != nil { + return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err) + } + greeterGroup := DetectGreeterGroup() + daemonUser := DetectGreeterUser() + if err := runSudoCmd(sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil { + if fallbackErr := runSudoCmd(sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil { + return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err) + } + } + if err := runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil { + return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err) + } + logFunc("✓ Synced greeter wallpaper override") + return nil +} + +type niriGreeterSync struct { + processed map[string]bool + nodes []*document.Node + inputCount int + outputCount int + cursorCount int + debugCount int + cursorNode *document.Node + inputNode *document.Node + outputNodes map[string]*document.Node +} + +func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error { + configDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("failed to resolve user config directory: %w", err) + } + + configPath := filepath.Join(configDir, "niri", "config.kdl") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + logFunc("ℹ Niri config not found; skipping greeter niri sync") + return nil + } else if err != nil { + return fmt.Errorf("failed to stat niri config: %w", err) + } + + extractor := &niriGreeterSync{ + processed: make(map[string]bool), + outputNodes: make(map[string]*document.Node), + } + + if err := extractor.processFile(configPath); err != nil { + return err + } + + if len(extractor.nodes) == 0 { + logFunc("ℹ No niri input/output sections found; skipping greeter niri sync") + return nil + } + + content := extractor.render() + if strings.TrimSpace(content) == "" { + logFunc("ℹ No niri input/output content to sync; skipping greeter niri sync") + return nil + } + + greeterDir := "/etc/greetd/niri" + greeterGroup := DetectGreeterGroup() + if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil { + return fmt.Errorf("failed to create greetd niri directory: %w", err) + } + if err := runSudoCmd(sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil { + return fmt.Errorf("failed to set greetd niri directory ownership: %w", err) + } + if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil { + return fmt.Errorf("failed to set greetd niri directory permissions: %w", err) + } + + dmsTemp, err := os.CreateTemp("", "dms-greeter-niri-dms-*.kdl") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(dmsTemp.Name()) + + if _, err := dmsTemp.WriteString(content); err != nil { + _ = dmsTemp.Close() + return fmt.Errorf("failed to write temp niri config: %w", err) + } + if err := dmsTemp.Close(); err != nil { + return fmt.Errorf("failed to close temp niri config: %w", err) + } + + dmsPath := filepath.Join(greeterDir, "dms.kdl") + if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil { + return fmt.Errorf("failed to backup %s: %w", dmsPath, err) + } + if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil { + return fmt.Errorf("failed to install greetd niri dms config: %w", err) + } + + mainContent := fmt.Sprintf("%s\ninclude \"%s\"\n", config.NiriGreeterConfig, dmsPath) + mainTemp, err := os.CreateTemp("", "dms-greeter-niri-main-*.kdl") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(mainTemp.Name()) + + if _, err := mainTemp.WriteString(mainContent); err != nil { + _ = mainTemp.Close() + return fmt.Errorf("failed to write temp niri main config: %w", err) + } + if err := mainTemp.Close(); err != nil { + return fmt.Errorf("failed to close temp niri main config: %w", err) + } + + mainPath := filepath.Join(greeterDir, "config.kdl") + if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil { + return fmt.Errorf("failed to backup %s: %w", mainPath, err) + } + if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil { + return fmt.Errorf("failed to install greetd niri main config: %w", err) + } + + if err := ensureGreetdNiriConfig(logFunc, sudoPassword, mainPath); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to update greetd config for niri: %v", err)) + } + + logFunc(fmt.Sprintf("✓ Synced niri greeter config (%d input, %d output, %d cursor, %d debug) to %s", extractor.inputCount, extractor.outputCount, extractor.cursorCount, extractor.debugCount, dmsPath)) + return nil +} + +func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfigPath string) error { + configPath := "/etc/greetd/config.toml" + data, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + logFunc("ℹ greetd config not found; skipping niri config wiring") + return nil + } + if err != nil { + return fmt.Errorf("failed to read greetd config: %w", err) + } + + lines := strings.Split(string(data), "\n") + updated := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "command") { + continue + } + + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) != 2 { + continue + } + + command := strings.Trim(strings.TrimSpace(parts[1]), "\"") + if !strings.Contains(command, "dms-greeter") { + continue + } + if !strings.Contains(command, "--command niri") { + continue + } + command = stripConfigFlag(command) + command = stripCacheDirFlag(command) + command = strings.TrimSpace(command + " --cache-dir " + GreeterCacheDir) + + newCommand := fmt.Sprintf("%s -C %s", command, niriConfigPath) + idx := strings.Index(line, "command") + leading := "" + if idx > 0 { + leading = line[:idx] + } + lines[i] = fmt.Sprintf("%scommand = \"%s\"", leading, newCommand) + updated = true + break + } + + if !updated { + return nil + } + + tmpFile, err := os.CreateTemp("", "greetd-config-*.toml") + if err != nil { + return fmt.Errorf("failed to create temp greetd config: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(strings.Join(lines, "\n")); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write temp greetd config: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp greetd config: %w", err) + } + + if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil { + return fmt.Errorf("failed to update greetd config: %w", err) + } + + logFunc(fmt.Sprintf("✓ Updated greetd config to use niri config %s", niriConfigPath)) + return nil +} + +func backupFileIfExists(sudoPassword string, path string, suffix string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + + backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405")) + if err := runSudoCmd(sudoPassword, "cp", path, backupPath); err != nil { + return err + } + return runSudoCmd(sudoPassword, "chmod", "644", backupPath) +} + +func (s *niriGreeterSync) processFile(filePath string) error { + absPath, err := filepath.Abs(filePath) + if err != nil { + return fmt.Errorf("failed to resolve path %s: %w", filePath, err) + } + + if s.processed[absPath] { + return nil + } + s.processed[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", absPath, err) + } + + doc, err := kdl.Parse(strings.NewReader(string(data))) + if err != nil { + return fmt.Errorf("failed to parse KDL in %s: %w", absPath, err) + } + + baseDir := filepath.Dir(absPath) + for _, node := range doc.Nodes { + name := node.Name.String() + switch name { + case "include": + if err := s.handleInclude(node, baseDir); err != nil { + return err + } + case "input": + if s.inputNode == nil { + s.inputNode = node + s.inputNode.Children = dedupeCursorChildren(s.inputNode.Children) + s.nodes = append(s.nodes, node) + } else if len(node.Children) > 0 { + s.inputNode.Children = mergeInputChildren(s.inputNode.Children, node.Children) + } + s.inputCount++ + case "output": + key := outputNodeKey(node) + if existing, ok := s.outputNodes[key]; ok { + *existing = *node + } else { + s.outputNodes[key] = node + s.nodes = append(s.nodes, node) + } + s.outputCount++ + case "cursor": + if s.cursorNode == nil { + s.cursorNode = node + s.cursorNode.Children = dedupeCursorChildren(s.cursorNode.Children) + s.nodes = append(s.nodes, node) + s.cursorCount++ + } else if len(node.Children) > 0 { + s.cursorNode.Children = mergeCursorChildren(s.cursorNode.Children, node.Children) + } + case "debug": + s.nodes = append(s.nodes, node) + s.debugCount++ + } + } + + return nil +} + +func mergeCursorChildren(existing []*document.Node, incoming []*document.Node) []*document.Node { + if len(incoming) == 0 { + return existing + } + + indexByName := make(map[string]int, len(existing)) + for i, child := range existing { + indexByName[child.Name.String()] = i + } + + for _, child := range incoming { + name := child.Name.String() + if idx, ok := indexByName[name]; ok { + existing[idx] = child + continue + } + indexByName[name] = len(existing) + existing = append(existing, child) + } + + return existing +} + +func dedupeCursorChildren(children []*document.Node) []*document.Node { + if len(children) == 0 { + return children + } + + var result []*document.Node + indexByName := make(map[string]int, len(children)) + for _, child := range children { + name := child.Name.String() + if idx, ok := indexByName[name]; ok { + result[idx] = child + continue + } + indexByName[name] = len(result) + result = append(result, child) + } + + return result +} + +func mergeInputChildren(existing []*document.Node, incoming []*document.Node) []*document.Node { + if len(incoming) == 0 { + return existing + } + + indexByName := make(map[string]int, len(existing)) + for i, child := range existing { + indexByName[child.Name.String()] = i + } + + for _, child := range incoming { + name := child.Name.String() + if idx, ok := indexByName[name]; ok { + existing[idx] = child + continue + } + indexByName[name] = len(existing) + existing = append(existing, child) + } + + return existing +} + +func outputNodeKey(node *document.Node) string { + if len(node.Arguments) > 0 { + return strings.Trim(node.Arguments[0].String(), "\"") + } + return "" +} + +func (s *niriGreeterSync) handleInclude(node *document.Node, baseDir string) error { + if len(node.Arguments) == 0 { + return nil + } + + includePath := strings.Trim(node.Arguments[0].String(), "\"") + if includePath == "" { + return nil + } + + fullPath := includePath + if !filepath.IsAbs(includePath) { + fullPath = filepath.Join(baseDir, includePath) + } + + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + return nil + } else if err != nil { + return fmt.Errorf("failed to stat include %s: %w", fullPath, err) + } + + return s.processFile(fullPath) +} + +func (s *niriGreeterSync) render() string { + if len(s.nodes) == 0 { + return "" + } + + var builder strings.Builder + for _, node := range s.nodes { + _, _ = node.WriteToOptions(&builder, document.NodeWriteOptions{ + LeadingTrailingSpace: true, + NameAndType: true, + Depth: 0, + Indent: []byte(" "), + IgnoreFlags: false, + }) + builder.WriteString("\n") + } + + return builder.String() +} + +func ConfigureGreetd(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { + configPath := "/etc/greetd/config.toml" + + backupPath := fmt.Sprintf("%s.backup-%s", configPath, time.Now().Format("20060102-150405")) + if err := backupFileIfExists(sudoPassword, configPath, ".backup"); err != nil { + return fmt.Errorf("failed to backup greetd config: %w", err) + } + if _, err := os.Stat(configPath); err == nil { + logFunc(fmt.Sprintf("✓ Backed up existing config to %s", backupPath)) + } + + greeterUser := DetectGreeterUser() + + var configContent string + if data, err := os.ReadFile(configPath); err == nil { + configContent = string(data) + } else if os.IsNotExist(err) { + configContent = `[terminal] +vt = 1 + +[default_session] +` + } else { + return fmt.Errorf("failed to read greetd config: %w", err) + } + + wrapperCmd := resolveGreeterWrapperPath() + + compositorLower := strings.ToLower(compositor) + commandValue := fmt.Sprintf("%s --command %s --cache-dir %s", wrapperCmd, compositorLower, GreeterCacheDir) + if dmsPath != "" { + commandValue = fmt.Sprintf("%s -p %s", commandValue, dmsPath) + } + + commandLine := fmt.Sprintf(`command = "%s"`, commandValue) + newConfig := upsertDefaultSession(configContent, greeterUser, commandLine) + + tmpFile, err := os.CreateTemp("", "greetd-config-*.toml") + if err != nil { + return fmt.Errorf("failed to create temp greetd config: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(newConfig); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("failed to write temp greetd config: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp greetd config: %w", err) + } + + if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil { + return fmt.Errorf("failed to create /etc/greetd: %w", err) + } + + if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil { + return fmt.Errorf("failed to install greetd config: %w", err) + } + + logFunc(fmt.Sprintf("✓ Updated greetd configuration (user: %s, command: %s)", greeterUser, commandValue)) + return nil +} + +func stripConfigFlag(command string) string { + for _, flag := range []string{" -C ", " --config "} { + idx := strings.Index(command, flag) + if idx == -1 { + continue + } + + before := command[:idx] + after := command[idx+len(flag):] + + switch { + case strings.HasPrefix(after, `"`): + if end := strings.Index(after[1:], `"`); end != -1 { + after = after[end+2:] + } else { + after = "" + } + default: + if space := strings.Index(after, " "); space != -1 { + after = after[space:] + } else { + after = "" + } + } + + command = strings.TrimSpace(before + after) + } + + return command +} + +func stripCacheDirFlag(command string) string { + fields := strings.Fields(command) + if len(fields) == 0 { + return strings.TrimSpace(command) + } + + filtered := make([]string, 0, len(fields)) + for i := 0; i < len(fields); i++ { + token := fields[i] + if token == "--cache-dir" { + if i+1 < len(fields) { + i++ + } + continue + } + if strings.HasPrefix(token, "--cache-dir=") { + continue + } + filtered = append(filtered, token) + } + + return strings.Join(filtered, " ") +} + +// getDebianOBSSlug returns the OBS repository slug for the running Debian version. +func getDebianOBSSlug(osInfo *distros.OSInfo) string { + versionID := strings.ToLower(osInfo.VersionID) + codename := strings.ToLower(osInfo.VersionCodename) + prettyName := strings.ToLower(osInfo.PrettyName) + + if strings.Contains(prettyName, "sid") || strings.Contains(prettyName, "unstable") || + codename == "sid" || versionID == "sid" { + return "Debian_Unstable" + } + if versionID == "testing" || codename == "testing" { + return "Debian_Testing" + } + if versionID != "" { + return "Debian_" + versionID // "Debian_13" + } + return "Debian_Unstable" +} + +// getOpenSUSEOBSRepoURL returns the OBS .repo file URL for the running openSUSE variant. +func getOpenSUSEOBSRepoURL(osInfo *distros.OSInfo) string { + const base = "https://download.opensuse.org/repositories/home:AvengeMedia:danklinux" + var slug string + switch osInfo.Distribution.ID { + case "opensuse-leap": + v := osInfo.VersionID + if v != "" && !strings.Contains(v, ".") { + v += ".0" // "16" → "16.0" + } + if v == "" { + v = "16.0" + } + slug = v + case "opensuse-slowroll": + slug = "openSUSE_Slowroll" + default: // opensuse-tumbleweed || unknown version + slug = "openSUSE_Tumbleweed" + } + return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug) +} + +func runSudoCmd(sudoPassword string, command string, args ...string) error { + var cmd *exec.Cmd + + if sudoPassword != "" { + fullArgs := append([]string{command}, args...) + quotedArgs := make([]string, len(fullArgs)) + for i, arg := range fullArgs { + quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" + } + cmdStr := strings.Join(quotedArgs, " ") + + cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr) + } else { + cmd = exec.Command("sudo", append([]string{command}, args...)...) + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func checkSystemdEnabled(service string) (string, error) { + cmd := exec.Command("systemctl", "is-enabled", service) + output, _ := cmd.Output() + return strings.TrimSpace(string(output)), nil +} + +func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string)) error { + conflictingDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} + for _, dm := range conflictingDMs { + state, err := checkSystemdEnabled(dm) + if err != nil || state == "" || state == "not-found" { + continue + } + switch state { + case "enabled", "enabled-runtime", "static", "indirect", "alias": + logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm)) + if err := runSudoCmd(sudoPassword, "systemctl", "disable", dm); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err)) + } else { + logFunc(fmt.Sprintf("✓ Disabled %s", dm)) + } + } + } + return nil +} + +// EnableGreetd unmasks and enables greetd, forcing it over any other DM. +func EnableGreetd(sudoPassword string, logFunc func(string)) error { + state, err := checkSystemdEnabled("greetd") + if err != nil { + return fmt.Errorf("failed to check greetd state: %w", err) + } + if state == "not-found" { + return fmt.Errorf("greetd service not found; ensure greetd is installed") + } + if state == "masked" || state == "masked-runtime" { + logFunc(" Unmasking greetd...") + if err := runSudoCmd(sudoPassword, "systemctl", "unmask", "greetd"); err != nil { + return fmt.Errorf("failed to unmask greetd: %w", err) + } + logFunc(" ✓ Unmasked greetd") + } + logFunc(" Enabling greetd service (--force)...") + if err := runSudoCmd(sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil { + return fmt.Errorf("failed to enable greetd: %w", err) + } + logFunc("✓ greetd enabled") + return nil +} + +func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error { + cmd := exec.Command("systemctl", "get-default") + output, err := cmd.Output() + if err != nil { + logFunc(fmt.Sprintf("⚠ Warning: could not get default systemd target: %v", err)) + return nil + } + current := strings.TrimSpace(string(output)) + if current == "graphical.target" { + logFunc("✓ Default target is already graphical.target") + return nil + } + logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current)) + if err := runSudoCmd(sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil { + return fmt.Errorf("failed to set graphical target: %w", err) + } + logFunc("✓ Default target set to graphical.target") + return nil +} + +// AutoSetupGreeter performs the full non-interactive greeter setup +func AutoSetupGreeter(compositor, sudoPassword string, logFunc func(string)) error { + if IsGreeterPackaged() && HasLegacyLocalGreeterWrapper() { + return fmt.Errorf("legacy manual wrapper detected at /usr/local/bin/dms-greeter; " + + "remove it before using packaged dms-greeter: sudo rm -f /usr/local/bin/dms-greeter") + } + + logFunc("Ensuring greetd is installed...") + if err := EnsureGreetdInstalled(logFunc, sudoPassword); err != nil { + return fmt.Errorf("greetd install failed: %w", err) + } + + dmsPath := "" + if !IsGreeterPackaged() { + detected, err := DetectDMSPath() + if err != nil { + return fmt.Errorf("DMS installation not found: %w", err) + } + dmsPath = detected + logFunc(fmt.Sprintf("✓ Found DMS at: %s", dmsPath)) + } else { + logFunc("✓ Using packaged dms-greeter (/usr/share/quickshell/dms-greeter)") + } + + logFunc("Setting up dms-greeter group and permissions...") + if err := SetupDMSGroup(logFunc, sudoPassword); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: group/permissions setup error: %v", err)) + } + + logFunc("Copying greeter files...") + if err := CopyGreeterFiles(dmsPath, compositor, logFunc, sudoPassword); err != nil { + return fmt.Errorf("failed to copy greeter files: %w", err) + } + + logFunc("Configuring greetd...") + greeterPathForConfig := "" + if !IsGreeterPackaged() { + greeterPathForConfig = dmsPath + } + if err := ConfigureGreetd(greeterPathForConfig, compositor, logFunc, sudoPassword); err != nil { + return fmt.Errorf("failed to configure greetd: %w", err) + } + + logFunc("Synchronizing DMS configurations...") + if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: config sync error: %v", err)) + } + + logFunc("Configuring authentication...") + if err := sharedpam.SyncAuthConfig(logFunc, sudoPassword, sharedpam.SyncAuthOptions{}); err != nil { + return fmt.Errorf("failed to sync authentication: %w", err) + } + + logFunc("Checking for conflicting display managers...") + if err := DisableConflictingDisplayManagers(sudoPassword, logFunc); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: %v", err)) + } + + logFunc("Enabling greetd service...") + if err := EnableGreetd(sudoPassword, logFunc); err != nil { + return fmt.Errorf("failed to enable greetd: %w", err) + } + + logFunc("Ensuring graphical.target as default...") + if err := EnsureGraphicalTarget(sudoPassword, logFunc); err != nil { + logFunc(fmt.Sprintf("⚠ Warning: %v", err)) + } + + logFunc("✓ DMS greeter setup complete") + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/installer_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/installer_test.go new file mode 100644 index 0000000..9acee57 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/greeter/installer_test.go @@ -0,0 +1,98 @@ +package greeter + +import ( + "os" + "path/filepath" + "testing" +) + +func writeTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("failed to create parent dir for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} + +func TestResolveGreeterThemeSyncState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + settingsJSON string + sessionJSON string + wantSourcePath string + wantResolvedWallpaper string + wantDynamicOverrideUsed bool + }{ + { + name: "dynamic theme with greeter wallpaper override uses generated greeter colors", + settingsJSON: `{ + "currentThemeName": "dynamic", + "greeterWallpaperPath": "Pictures/blue.jpg", + "matugenScheme": "scheme-tonal-spot", + "iconTheme": "Papirus" +}`, + sessionJSON: `{"isLightMode":true}`, + wantSourcePath: filepath.Join(".cache", "DankMaterialShell", "greeter-colors", "dms-colors.json"), + wantResolvedWallpaper: filepath.Join("Pictures", "blue.jpg"), + wantDynamicOverrideUsed: true, + }, + { + name: "dynamic theme without override uses desktop colors", + settingsJSON: `{ + "currentThemeName": "dynamic", + "greeterWallpaperPath": "" +}`, + sessionJSON: `{"isLightMode":false}`, + wantSourcePath: filepath.Join(".cache", "DankMaterialShell", "dms-colors.json"), + wantResolvedWallpaper: "", + wantDynamicOverrideUsed: false, + }, + { + name: "non-dynamic theme keeps desktop colors even with override wallpaper", + settingsJSON: `{ + "currentThemeName": "purple", + "greeterWallpaperPath": "/tmp/blue.jpg" +}`, + sessionJSON: `{"isLightMode":false}`, + wantSourcePath: filepath.Join(".cache", "DankMaterialShell", "dms-colors.json"), + wantResolvedWallpaper: "/tmp/blue.jpg", + wantDynamicOverrideUsed: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + homeDir := t.TempDir() + writeTestFile(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON) + writeTestFile(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON) + + state, err := resolveGreeterThemeSyncState(homeDir) + if err != nil { + t.Fatalf("resolveGreeterThemeSyncState returned error: %v", err) + } + + if got := state.effectiveColorsSource(homeDir); got != filepath.Join(homeDir, tt.wantSourcePath) { + t.Fatalf("effectiveColorsSource = %q, want %q", got, filepath.Join(homeDir, tt.wantSourcePath)) + } + + wantResolvedWallpaper := tt.wantResolvedWallpaper + if wantResolvedWallpaper != "" && !filepath.IsAbs(wantResolvedWallpaper) { + wantResolvedWallpaper = filepath.Join(homeDir, wantResolvedWallpaper) + } + if state.ResolvedGreeterWallpaperPath != wantResolvedWallpaper { + t.Fatalf("ResolvedGreeterWallpaperPath = %q, want %q", state.ResolvedGreeterWallpaperPath, wantResolvedWallpaper) + } + + if state.UsesDynamicWallpaperOverride != tt.wantDynamicOverrideUsed { + t.Fatalf("UsesDynamicWallpaperOverride = %v, want %v", state.UsesDynamicWallpaperOverride, tt.wantDynamicOverrideUsed) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/headless/runner.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/headless/runner.go new file mode 100644 index 0000000..7dae9fc --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/headless/runner.go @@ -0,0 +1,418 @@ +package headless + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" +) + +// ErrConfirmationRequired is returned when --yes is not set and the user +// must explicitly confirm the operation. +var ErrConfirmationRequired = fmt.Errorf("confirmation required: pass --yes to proceed") + +// validConfigNames maps lowercase CLI input to the deployer key used in +// replaceConfigs. Keep in sync with the config types checked by +// shouldReplaceConfig in deployer.go. +var validConfigNames = map[string]string{ + "niri": "Niri", + "hyprland": "Hyprland", + "ghostty": "Ghostty", + "kitty": "Kitty", + "alacritty": "Alacritty", +} + +// orderedConfigNames defines the canonical order for config names in output. +// Must be kept in sync with validConfigNames. +var orderedConfigNames = []string{"niri", "hyprland", "ghostty", "kitty", "alacritty"} + +// Config holds all CLI parameters for unattended installation. +type Config struct { + Compositor string // "niri" or "hyprland" + Terminal string // "ghostty", "kitty", or "alacritty" + IncludeDeps []string + ExcludeDeps []string + ReplaceConfigs []string // specific configs to deploy (e.g. "niri", "ghostty") + ReplaceConfigsAll bool // deploy/replace all configurations + Yes bool +} + +// Runner orchestrates unattended (headless) installation. +type Runner struct { + cfg Config + logChan chan string +} + +// NewRunner creates a new headless runner. +func NewRunner(cfg Config) *Runner { + return &Runner{ + cfg: cfg, + logChan: make(chan string, 1000), + } +} + +// GetLogChan returns the log channel for file logging. +func (r *Runner) GetLogChan() <-chan string { + return r.logChan +} + +// Run executes the full unattended installation flow. +func (r *Runner) Run() error { + r.log("Starting headless installation") + + // 1. Parse compositor and terminal selections + wm, err := r.parseWindowManager() + if err != nil { + return err + } + + terminal, err := r.parseTerminal() + if err != nil { + return err + } + + // 2. Build replace-configs map + replaceConfigs, err := r.buildReplaceConfigs() + if err != nil { + return err + } + + // 3. Detect OS + r.log("Detecting operating system...") + osInfo, err := distros.GetOSInfo() + if err != nil { + return fmt.Errorf("OS detection failed: %w", err) + } + + if distros.IsUnsupportedDistro(osInfo.Distribution.ID, osInfo.VersionID) { + return fmt.Errorf("unsupported distribution: %s %s", osInfo.PrettyName, osInfo.VersionID) + } + + fmt.Fprintf(os.Stdout, "Detected: %s (%s)\n", osInfo.PrettyName, osInfo.Architecture) + + // 4. Create distribution instance + distro, err := distros.NewDistribution(osInfo.Distribution.ID, r.logChan) + if err != nil { + return fmt.Errorf("failed to initialize distribution: %w", err) + } + + // 5. Detect dependencies + r.log("Detecting dependencies...") + fmt.Fprintln(os.Stdout, "Detecting dependencies...") + dependencies, err := distro.DetectDependenciesWithTerminal(context.Background(), wm, terminal) + if err != nil { + return fmt.Errorf("dependency detection failed: %w", err) + } + + // 5. Apply include/exclude filters and build the disabled-items map. + // Headless mode does not currently collect any explicit reinstall selections, + // so keep reinstallItems nil instead of constructing an always-empty map. + disabledItems, err := r.buildDisabledItems(dependencies) + if err != nil { + return err + } + var reinstallItems map[string]bool + + // Print dependency summary + fmt.Fprintln(os.Stdout, "\nDependencies:") + for _, dep := range dependencies { + marker := " " + status := "" + if disabledItems[dep.Name] { + marker = " SKIP " + status = "(disabled)" + } else { + switch dep.Status { + case deps.StatusInstalled: + marker = " OK " + status = "(installed)" + case deps.StatusMissing: + marker = " NEW " + status = "(will install)" + case deps.StatusNeedsUpdate: + marker = " UPD " + status = "(will update)" + case deps.StatusNeedsReinstall: + marker = " RE " + status = "(will reinstall)" + } + } + fmt.Fprintf(os.Stdout, "%s%-30s %s\n", marker, dep.Name, status) + } + fmt.Fprintln(os.Stdout) + + // 6b. Require explicit confirmation unless --yes is set + if !r.cfg.Yes { + if replaceConfigs == nil { + // --replace-configs-all + fmt.Fprintln(os.Stdout, "Packages will be installed and all configurations will be replaced.") + fmt.Fprintln(os.Stdout, "Existing config files will be backed up before replacement.") + } else if r.anyConfigEnabled(replaceConfigs) { + var names []string + for _, cliName := range orderedConfigNames { + deployerKey := validConfigNames[cliName] + if replaceConfigs[deployerKey] { + names = append(names, deployerKey) + } + } + fmt.Fprintf(os.Stdout, "Packages will be installed. The following configurations will be replaced (with backups): %s\n", strings.Join(names, ", ")) + } else { + fmt.Fprintln(os.Stdout, "Packages will be installed. No configurations will be deployed.") + } + fmt.Fprintln(os.Stdout, "Re-run with --yes (-y) to proceed.") + r.log("Aborted: --yes not set") + return ErrConfirmationRequired + } + + // 7. Authenticate sudo + sudoPassword, err := r.resolveSudoPassword() + if err != nil { + return err + } + + // 8. Install packages + fmt.Fprintln(os.Stdout, "Installing packages...") + r.log("Starting package installation") + + progressChan := make(chan distros.InstallProgressMsg, 100) + + installErr := make(chan error, 1) + go func() { + defer close(progressChan) + installErr <- distro.InstallPackages( + context.Background(), + dependencies, + wm, + sudoPassword, + reinstallItems, + disabledItems, + false, // skipGlobalUseFlags + progressChan, + ) + }() + + // Consume progress messages and print them + for msg := range progressChan { + if msg.Error != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error) + } else if msg.Step != "" { + fmt.Fprintf(os.Stdout, " [%3.0f%%] %s\n", msg.Progress*100, msg.Step) + } + if msg.LogOutput != "" { + r.log(msg.LogOutput) + fmt.Fprintf(os.Stdout, " %s\n", msg.LogOutput) + } + } + + if err := <-installErr; err != nil { + return fmt.Errorf("package installation failed: %w", err) + } + + // 9. Greeter setup (if dms-greeter was included) + if !disabledItems["dms-greeter"] && r.depExists(dependencies, "dms-greeter") { + compositorName := "niri" + if wm == deps.WindowManagerHyprland { + compositorName = "Hyprland" + } + fmt.Fprintln(os.Stdout, "Configuring DMS greeter...") + logFunc := func(line string) { + r.log(line) + fmt.Fprintf(os.Stdout, " greeter: %s\n", line) + } + if err := greeter.AutoSetupGreeter(compositorName, sudoPassword, logFunc); err != nil { + // Non-fatal, matching TUI behavior + fmt.Fprintf(os.Stderr, "Warning: greeter setup issue (non-fatal): %v\n", err) + } + } + + // 10. Deploy configurations + fmt.Fprintln(os.Stdout, "Deploying configurations...") + r.log("Starting configuration deployment") + + deployer := config.NewConfigDeployer(r.logChan) + results, err := deployer.DeployConfigurationsSelectiveWithReinstalls( + context.Background(), + wm, + terminal, + dependencies, + replaceConfigs, + reinstallItems, + ) + if err != nil { + return fmt.Errorf("configuration deployment failed: %w", err) + } + + for _, result := range results { + if result.Deployed { + msg := fmt.Sprintf(" Deployed: %s", result.ConfigType) + if result.BackupPath != "" { + msg += fmt.Sprintf(" (backup: %s)", result.BackupPath) + } + fmt.Fprintln(os.Stdout, msg) + } + if result.Error != nil { + fmt.Fprintf(os.Stderr, " Error deploying %s: %v\n", result.ConfigType, result.Error) + } + } + + fmt.Fprintln(os.Stdout, "\nInstallation complete!") + r.log("Headless installation completed successfully") + return nil +} + +// buildDisabledItems computes the set of dependencies that should be skipped +// during installation, applying the --include-deps and --exclude-deps filters. +// dms-greeter is disabled by default (opt-in), matching TUI behavior. +func (r *Runner) buildDisabledItems(dependencies []deps.Dependency) (map[string]bool, error) { + disabledItems := make(map[string]bool) + + // dms-greeter is opt-in (disabled by default), matching TUI behavior + for i := range dependencies { + if dependencies[i].Name == "dms-greeter" { + disabledItems["dms-greeter"] = true + break + } + } + + // Process --include-deps (enable items that are disabled by default) + for _, name := range r.cfg.IncludeDeps { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if !r.depExists(dependencies, name) { + return nil, fmt.Errorf("--include-deps: unknown dependency %q", name) + } + delete(disabledItems, name) + } + + // Process --exclude-deps (disable items) + for _, name := range r.cfg.ExcludeDeps { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if !r.depExists(dependencies, name) { + return nil, fmt.Errorf("--exclude-deps: unknown dependency %q", name) + } + // Don't allow excluding DMS itself + if name == "dms (DankMaterialShell)" { + return nil, fmt.Errorf("--exclude-deps: cannot exclude required package %q", name) + } + disabledItems[name] = true + } + + return disabledItems, nil +} + +// buildReplaceConfigs converts the --replace-configs / --replace-configs-all +// flags into the map[string]bool consumed by the config deployer. +// +// Returns: +// - nil when --replace-configs-all is set (deployer treats nil as "replace all") +// - a map with all known configs set to false when neither flag is set (deploy only if config file is missing on disk) +// - a map with requested configs true, all others false for --replace-configs +// - an error when both flags are set or an invalid config name is given +func (r *Runner) buildReplaceConfigs() (map[string]bool, error) { + hasSpecific := len(r.cfg.ReplaceConfigs) > 0 + if hasSpecific && r.cfg.ReplaceConfigsAll { + return nil, fmt.Errorf("--replace-configs and --replace-configs-all are mutually exclusive") + } + + if r.cfg.ReplaceConfigsAll { + return nil, nil + } + + // Build a map with all known configs explicitly set to false + result := make(map[string]bool, len(validConfigNames)) + for _, cliName := range orderedConfigNames { + result[validConfigNames[cliName]] = false + } + + // Enable only the requested configs + for _, name := range r.cfg.ReplaceConfigs { + name = strings.TrimSpace(name) + if name == "" { + continue + } + deployerKey, ok := validConfigNames[strings.ToLower(name)] + if !ok { + return nil, fmt.Errorf("--replace-configs: unknown config %q; valid values: niri, hyprland, ghostty, kitty, alacritty", name) + } + result[deployerKey] = true + } + + return result, nil +} + +func (r *Runner) log(message string) { + select { + case r.logChan <- message: + default: + } +} + +func (r *Runner) parseWindowManager() (deps.WindowManager, error) { + switch strings.ToLower(r.cfg.Compositor) { + case "niri": + return deps.WindowManagerNiri, nil + case "hyprland": + return deps.WindowManagerHyprland, nil + default: + return 0, fmt.Errorf("invalid --compositor value %q: must be 'niri' or 'hyprland'", r.cfg.Compositor) + } +} + +func (r *Runner) parseTerminal() (deps.Terminal, error) { + switch strings.ToLower(r.cfg.Terminal) { + case "ghostty": + return deps.TerminalGhostty, nil + case "kitty": + return deps.TerminalKitty, nil + case "alacritty": + return deps.TerminalAlacritty, nil + default: + return 0, fmt.Errorf("invalid --term value %q: must be 'ghostty', 'kitty', or 'alacritty'", r.cfg.Terminal) + } +} + +func (r *Runner) resolveSudoPassword() (string, error) { + // Check if sudo credentials are cached (via sudo -v or NOPASSWD) + cmd := exec.Command("sudo", "-n", "true") + if err := cmd.Run(); err == nil { + r.log("sudo cache is valid, no password needed") + fmt.Fprintln(os.Stdout, "sudo: using cached credentials") + return "", nil + } + + return "", fmt.Errorf( + "sudo authentication required but no cached credentials found\n" + + "Options:\n" + + " 1. Run 'sudo -v' before dankinstall to cache credentials\n" + + " 2. Configure passwordless sudo for your user", + ) +} + +func (r *Runner) anyConfigEnabled(m map[string]bool) bool { + for _, v := range m { + if v { + return true + } + } + return false +} + +func (r *Runner) depExists(dependencies []deps.Dependency, name string) bool { + for _, dep := range dependencies { + if dep.Name == name { + return true + } + } + return false +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/headless/runner_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/headless/runner_test.go new file mode 100644 index 0000000..79260ae --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/headless/runner_test.go @@ -0,0 +1,459 @@ +package headless + +import ( + "strings" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" +) + +func TestParseWindowManager(t *testing.T) { + tests := []struct { + name string + input string + want deps.WindowManager + wantErr bool + }{ + {"niri lowercase", "niri", deps.WindowManagerNiri, false}, + {"niri mixed case", "Niri", deps.WindowManagerNiri, false}, + {"hyprland lowercase", "hyprland", deps.WindowManagerHyprland, false}, + {"hyprland mixed case", "Hyprland", deps.WindowManagerHyprland, false}, + {"invalid", "sway", 0, true}, + {"empty", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(Config{Compositor: tt.input}) + got, err := r.parseWindowManager() + if (err != nil) != tt.wantErr { + t.Errorf("parseWindowManager() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("parseWindowManager() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseTerminal(t *testing.T) { + tests := []struct { + name string + input string + want deps.Terminal + wantErr bool + }{ + {"ghostty lowercase", "ghostty", deps.TerminalGhostty, false}, + {"ghostty mixed case", "Ghostty", deps.TerminalGhostty, false}, + {"kitty lowercase", "kitty", deps.TerminalKitty, false}, + {"alacritty lowercase", "alacritty", deps.TerminalAlacritty, false}, + {"alacritty uppercase", "ALACRITTY", deps.TerminalAlacritty, false}, + {"invalid", "wezterm", 0, true}, + {"empty", "", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(Config{Terminal: tt.input}) + got, err := r.parseTerminal() + if (err != nil) != tt.wantErr { + t.Errorf("parseTerminal() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("parseTerminal() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDepExists(t *testing.T) { + dependencies := []deps.Dependency{ + {Name: "niri", Status: deps.StatusInstalled}, + {Name: "ghostty", Status: deps.StatusMissing}, + {Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled}, + {Name: "dms-greeter", Status: deps.StatusMissing}, + } + + tests := []struct { + name string + dep string + want bool + }{ + {"existing dep", "niri", true}, + {"existing dep with special chars", "dms (DankMaterialShell)", true}, + {"existing optional dep", "dms-greeter", true}, + {"non-existing dep", "firefox", false}, + {"empty name", "", false}, + } + + r := NewRunner(Config{}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := r.depExists(dependencies, tt.dep); got != tt.want { + t.Errorf("depExists(%q) = %v, want %v", tt.dep, got, tt.want) + } + }) + } +} + +func TestNewRunner(t *testing.T) { + cfg := Config{ + Compositor: "niri", + Terminal: "ghostty", + IncludeDeps: []string{"dms-greeter"}, + ExcludeDeps: []string{"some-pkg"}, + Yes: true, + } + r := NewRunner(cfg) + + if r == nil { + t.Fatal("NewRunner returned nil") + } + if r.cfg.Compositor != "niri" { + t.Errorf("cfg.Compositor = %q, want %q", r.cfg.Compositor, "niri") + } + if r.cfg.Terminal != "ghostty" { + t.Errorf("cfg.Terminal = %q, want %q", r.cfg.Terminal, "ghostty") + } + if !r.cfg.Yes { + t.Error("cfg.Yes = false, want true") + } + if r.logChan == nil { + t.Error("logChan is nil") + } +} + +func TestGetLogChan(t *testing.T) { + r := NewRunner(Config{}) + ch := r.GetLogChan() + if ch == nil { + t.Fatal("GetLogChan returned nil") + } + + // Verify the channel is readable by sending a message + go func() { + r.logChan <- "test message" + }() + msg := <-ch + if msg != "test message" { + t.Errorf("received %q, want %q", msg, "test message") + } +} + +func TestLog(t *testing.T) { + r := NewRunner(Config{}) + + // log should not block even if channel is full + for i := 0; i < 1100; i++ { + r.log("message") + } + // If we reach here without hanging, the non-blocking send works +} + +func TestRunRequiresYes(t *testing.T) { + // Verify that ErrConfirmationRequired is a distinct sentinel error + if ErrConfirmationRequired == nil { + t.Fatal("ErrConfirmationRequired should not be nil") + } + expected := "confirmation required: pass --yes to proceed" + if ErrConfirmationRequired.Error() != expected { + t.Errorf("ErrConfirmationRequired = %q, want %q", ErrConfirmationRequired.Error(), expected) + } +} + +func TestConfigYesStoredCorrectly(t *testing.T) { + // Yes=false (default) should be stored + rNo := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: false}) + if rNo.cfg.Yes { + t.Error("cfg.Yes = true, want false") + } + + // Yes=true should be stored + rYes := NewRunner(Config{Compositor: "niri", Terminal: "ghostty", Yes: true}) + if !rYes.cfg.Yes { + t.Error("cfg.Yes = false, want true") + } +} + +func TestValidConfigNamesCompleteness(t *testing.T) { + // orderedConfigNames and validConfigNames must stay in sync. + if len(orderedConfigNames) != len(validConfigNames) { + t.Fatalf("orderedConfigNames has %d entries but validConfigNames has %d", + len(orderedConfigNames), len(validConfigNames)) + } + + // Every entry in orderedConfigNames must exist in validConfigNames. + for _, name := range orderedConfigNames { + if _, ok := validConfigNames[name]; !ok { + t.Errorf("orderedConfigNames contains %q which is missing from validConfigNames", name) + } + } + + // validConfigNames must have no extra keys not in orderedConfigNames. + ordered := make(map[string]bool, len(orderedConfigNames)) + for _, name := range orderedConfigNames { + ordered[name] = true + } + for key := range validConfigNames { + if !ordered[key] { + t.Errorf("validConfigNames contains %q which is missing from orderedConfigNames", key) + } + } +} + +func TestBuildReplaceConfigs(t *testing.T) { + allDeployerKeys := []string{"Niri", "Hyprland", "Ghostty", "Kitty", "Alacritty"} + + tests := []struct { + name string + replaceConfigs []string + replaceAll bool + wantNil bool // expect nil (replace all) + wantEnabled []string // deployer keys that should be true + wantErr bool + }{ + { + name: "neither flag set", + wantNil: false, + wantEnabled: nil, // all should be false + }, + { + name: "replace-configs-all", + replaceAll: true, + wantNil: true, + }, + { + name: "specific configs", + replaceConfigs: []string{"niri", "ghostty"}, + wantNil: false, + wantEnabled: []string{"Niri", "Ghostty"}, + }, + { + name: "both flags set", + replaceConfigs: []string{"niri"}, + replaceAll: true, + wantErr: true, + }, + { + name: "invalid config name", + replaceConfigs: []string{"foo"}, + wantErr: true, + }, + { + name: "case insensitive", + replaceConfigs: []string{"NIRI", "Ghostty"}, + wantNil: false, + wantEnabled: []string{"Niri", "Ghostty"}, + }, + { + name: "single config", + replaceConfigs: []string{"kitty"}, + wantNil: false, + wantEnabled: []string{"Kitty"}, + }, + { + name: "whitespace entry", + replaceConfigs: []string{" ", "niri"}, + wantNil: false, + wantEnabled: []string{"Niri"}, + }, + { + name: "duplicate entry", + replaceConfigs: []string{"niri", "niri"}, + wantNil: false, + wantEnabled: []string{"Niri"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(Config{ + ReplaceConfigs: tt.replaceConfigs, + ReplaceConfigsAll: tt.replaceAll, + }) + got, err := r.buildReplaceConfigs() + if (err != nil) != tt.wantErr { + t.Fatalf("buildReplaceConfigs() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if tt.wantNil { + if got != nil { + t.Fatalf("buildReplaceConfigs() = %v, want nil", got) + } + return + } + if got == nil { + t.Fatal("buildReplaceConfigs() = nil, want non-nil map") + } + + // All known deployer keys must be present + for _, key := range allDeployerKeys { + if _, exists := got[key]; !exists { + t.Errorf("missing deployer key %q in result map", key) + } + } + + // Build enabled set for easy lookup + enabledSet := make(map[string]bool) + for _, k := range tt.wantEnabled { + enabledSet[k] = true + } + + for _, key := range allDeployerKeys { + want := enabledSet[key] + if got[key] != want { + t.Errorf("replaceConfigs[%q] = %v, want %v", key, got[key], want) + } + } + }) + } +} + +func TestConfigReplaceConfigsStoredCorrectly(t *testing.T) { + r := NewRunner(Config{ + Compositor: "niri", + Terminal: "ghostty", + ReplaceConfigs: []string{"niri", "ghostty"}, + ReplaceConfigsAll: false, + }) + if len(r.cfg.ReplaceConfigs) != 2 { + t.Errorf("len(ReplaceConfigs) = %d, want 2", len(r.cfg.ReplaceConfigs)) + } + if r.cfg.ReplaceConfigsAll { + t.Error("ReplaceConfigsAll = true, want false") + } + + r2 := NewRunner(Config{ + Compositor: "niri", + Terminal: "ghostty", + ReplaceConfigsAll: true, + }) + if !r2.cfg.ReplaceConfigsAll { + t.Error("ReplaceConfigsAll = false, want true") + } + if len(r2.cfg.ReplaceConfigs) != 0 { + t.Errorf("len(ReplaceConfigs) = %d, want 0", len(r2.cfg.ReplaceConfigs)) + } +} + +func TestBuildDisabledItems(t *testing.T) { + dependencies := []deps.Dependency{ + {Name: "niri", Status: deps.StatusInstalled}, + {Name: "ghostty", Status: deps.StatusMissing}, + {Name: "dms (DankMaterialShell)", Status: deps.StatusInstalled}, + {Name: "dms-greeter", Status: deps.StatusMissing}, + {Name: "waybar", Status: deps.StatusMissing}, + } + + tests := []struct { + name string + includeDeps []string + excludeDeps []string + deps []deps.Dependency // nil means use the shared fixture + wantErr bool + errContains string // substring expected in error message + wantDisabled []string // dep names that should be in disabledItems + wantEnabled []string // dep names that should NOT be in disabledItems (extra check) + }{ + { + name: "no flags set, dms-greeter disabled by default", + wantDisabled: []string{"dms-greeter"}, + wantEnabled: []string{"niri", "ghostty", "waybar"}, + }, + { + name: "include dms-greeter enables it", + includeDeps: []string{"dms-greeter"}, + wantEnabled: []string{"dms-greeter"}, + }, + { + name: "exclude a regular dep", + excludeDeps: []string{"waybar"}, + wantDisabled: []string{"dms-greeter", "waybar"}, + }, + { + name: "include unknown dep returns error", + includeDeps: []string{"nonexistent"}, + wantErr: true, + errContains: "--include-deps", + }, + { + name: "exclude unknown dep returns error", + excludeDeps: []string{"nonexistent"}, + wantErr: true, + errContains: "--exclude-deps", + }, + { + name: "exclude DMS itself is forbidden", + excludeDeps: []string{"dms (DankMaterialShell)"}, + wantErr: true, + errContains: "cannot exclude required package", + }, + { + name: "include and exclude same dep", + includeDeps: []string{"dms-greeter"}, + excludeDeps: []string{"dms-greeter"}, + wantDisabled: []string{"dms-greeter"}, + }, + { + name: "whitespace entries are skipped", + includeDeps: []string{" ", "dms-greeter"}, + wantEnabled: []string{"dms-greeter"}, + }, + { + name: "no dms-greeter in deps, nothing disabled by default", + deps: []deps.Dependency{ + {Name: "niri", Status: deps.StatusInstalled}, + }, + wantEnabled: []string{"niri"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRunner(Config{ + IncludeDeps: tt.includeDeps, + ExcludeDeps: tt.excludeDeps, + }) + d := tt.deps + if d == nil { + d = dependencies + } + got, err := r.buildDisabledItems(d) + if (err != nil) != tt.wantErr { + t.Fatalf("buildDisabledItems() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) + } + return + } + if got == nil { + t.Fatal("buildDisabledItems() returned nil map, want non-nil") + } + + // Check expected disabled items + for _, name := range tt.wantDisabled { + if !got[name] { + t.Errorf("expected %q to be disabled, but it is not", name) + } + } + + // Check expected enabled items (should not be in the map or be false) + for _, name := range tt.wantEnabled { + if got[name] { + t.Errorf("expected %q to NOT be disabled, but it is", name) + } + } + + // If wantDisabled is empty, the map should have length 0 + if len(tt.wantDisabled) == 0 && len(got) != 0 { + t.Errorf("expected empty disabledItems map, got %v", got) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery.go new file mode 100644 index 0000000..4990975 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery.go @@ -0,0 +1,107 @@ +package keybinds + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type DiscoveryConfig struct { + SearchPaths []string +} + +func DefaultDiscoveryConfig() *DiscoveryConfig { + var searchPaths []string + + configDir, err := os.UserConfigDir() + if err == nil && configDir != "" { + searchPaths = append(searchPaths, filepath.Join(configDir, "DankMaterialShell", "cheatsheets")) + } + + configDirs := os.Getenv("XDG_CONFIG_DIRS") + if configDirs != "" { + for dir := range strings.SplitSeq(configDirs, ":") { + if dir != "" { + searchPaths = append(searchPaths, filepath.Join(dir, "DankMaterialShell", "cheatsheets")) + } + } + } + + return &DiscoveryConfig{ + SearchPaths: searchPaths, + } +} + +func (d *DiscoveryConfig) FindJSONFiles() ([]string, error) { + var files []string + + for _, searchPath := range d.SearchPaths { + expandedPath, err := utils.ExpandPath(searchPath) + if err != nil { + continue + } + + if _, err := os.Stat(expandedPath); os.IsNotExist(err) { + continue + } + + entries, err := os.ReadDir(expandedPath) + if err != nil { + continue + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + fullPath := filepath.Join(expandedPath, entry.Name()) + files = append(files, fullPath) + } + } + + return files, nil +} + +type JSONProviderFactory func(filePath string) (Provider, error) + +var jsonProviderFactory JSONProviderFactory + +func SetJSONProviderFactory(factory JSONProviderFactory) { + jsonProviderFactory = factory +} + +func AutoDiscoverProviders(registry *Registry, config *DiscoveryConfig) error { + if config == nil { + config = DefaultDiscoveryConfig() + } + + if jsonProviderFactory == nil { + return nil + } + + files, err := config.FindJSONFiles() + if err != nil { + return fmt.Errorf("failed to discover JSON files: %w", err) + } + + for _, file := range files { + provider, err := jsonProviderFactory(file) + if err != nil { + continue + } + + if err := registry.Register(provider); err != nil { + continue + } + } + + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery_test.go new file mode 100644 index 0000000..a78724e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/discovery_test.go @@ -0,0 +1,287 @@ +package keybinds + +import ( + "os" + "path/filepath" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func TestDefaultDiscoveryConfig(t *testing.T) { + oldConfigHome := os.Getenv("XDG_CONFIG_HOME") + oldConfigDirs := os.Getenv("XDG_CONFIG_DIRS") + defer func() { + os.Setenv("XDG_CONFIG_HOME", oldConfigHome) + os.Setenv("XDG_CONFIG_DIRS", oldConfigDirs) + }() + + tests := []struct { + name string + configHome string + configDirs string + expectedCount int + checkFirstPath bool + firstPath string + }{ + { + name: "default with no XDG vars", + configHome: "", + configDirs: "", + expectedCount: 1, + checkFirstPath: true, + }, + { + name: "with XDG_CONFIG_HOME set", + configHome: "/custom/config", + configDirs: "", + expectedCount: 1, + checkFirstPath: true, + firstPath: "/custom/config/DankMaterialShell/cheatsheets", + }, + { + name: "with XDG_CONFIG_DIRS set", + configHome: "/home/user/.config", + configDirs: "/etc/xdg:/opt/config", + expectedCount: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("XDG_CONFIG_HOME", tt.configHome) + os.Setenv("XDG_CONFIG_DIRS", tt.configDirs) + + config := DefaultDiscoveryConfig() + + if config == nil { + t.Fatal("DefaultDiscoveryConfig returned nil") + } + + if len(config.SearchPaths) != tt.expectedCount { + t.Errorf("SearchPaths count = %d, want %d", len(config.SearchPaths), tt.expectedCount) + } + + if tt.checkFirstPath && len(config.SearchPaths) > 0 { + if tt.firstPath != "" && config.SearchPaths[0] != tt.firstPath { + t.Errorf("SearchPaths[0] = %q, want %q", config.SearchPaths[0], tt.firstPath) + } + } + }) + } +} + +func TestFindJSONFiles(t *testing.T) { + tmpDir := t.TempDir() + + file1 := filepath.Join(tmpDir, "tmux.json") + file2 := filepath.Join(tmpDir, "vim.json") + txtFile := filepath.Join(tmpDir, "readme.txt") + subdir := filepath.Join(tmpDir, "subdir") + + if err := os.WriteFile(file1, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create file1: %v", err) + } + if err := os.WriteFile(file2, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create file2: %v", err) + } + if err := os.WriteFile(txtFile, []byte("text"), 0o644); err != nil { + t.Fatalf("Failed to create txt file: %v", err) + } + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + config := &DiscoveryConfig{ + SearchPaths: []string{tmpDir}, + } + + files, err := config.FindJSONFiles() + if err != nil { + t.Fatalf("FindJSONFiles failed: %v", err) + } + + if len(files) != 2 { + t.Errorf("expected 2 JSON files, got %d", len(files)) + } + + found := make(map[string]bool) + for _, f := range files { + found[filepath.Base(f)] = true + } + + if !found["tmux.json"] { + t.Error("tmux.json not found") + } + if !found["vim.json"] { + t.Error("vim.json not found") + } + if found["readme.txt"] { + t.Error("readme.txt should not be included") + } +} + +func TestFindJSONFilesNonexistentPath(t *testing.T) { + config := &DiscoveryConfig{ + SearchPaths: []string{"/nonexistent/path"}, + } + + files, err := config.FindJSONFiles() + if err != nil { + t.Fatalf("FindJSONFiles failed: %v", err) + } + + if len(files) != 0 { + t.Errorf("expected 0 files for nonexistent path, got %d", len(files)) + } +} + +func TestFindJSONFilesMultiplePaths(t *testing.T) { + tmpDir1 := t.TempDir() + tmpDir2 := t.TempDir() + + file1 := filepath.Join(tmpDir1, "app1.json") + file2 := filepath.Join(tmpDir2, "app2.json") + + if err := os.WriteFile(file1, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create file1: %v", err) + } + if err := os.WriteFile(file2, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create file2: %v", err) + } + + config := &DiscoveryConfig{ + SearchPaths: []string{tmpDir1, tmpDir2}, + } + + files, err := config.FindJSONFiles() + if err != nil { + t.Fatalf("FindJSONFiles failed: %v", err) + } + + if len(files) != 2 { + t.Errorf("expected 2 JSON files from multiple paths, got %d", len(files)) + } +} + +func TestAutoDiscoverProviders(t *testing.T) { + tmpDir := t.TempDir() + + jsonContent := `{ + "title": "Test App", + "provider": "testapp", + "binds": {} +}` + + file := filepath.Join(tmpDir, "testapp.json") + if err := os.WriteFile(file, []byte(jsonContent), 0o644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + config := &DiscoveryConfig{ + SearchPaths: []string{tmpDir}, + } + + registry := NewRegistry() + + factoryCalled := false + SetJSONProviderFactory(func(filePath string) (Provider, error) { + factoryCalled = true + return &mockProvider{name: "testapp"}, nil + }) + + err := AutoDiscoverProviders(registry, config) + if err != nil { + t.Fatalf("AutoDiscoverProviders failed: %v", err) + } + + if !factoryCalled { + t.Error("factory was not called") + } + + provider, err := registry.Get("testapp") + if err != nil { + t.Fatalf("provider not registered: %v", err) + } + + if provider.Name() != "testapp" { + t.Errorf("provider name = %q, want %q", provider.Name(), "testapp") + } +} + +func TestAutoDiscoverProvidersNilConfig(t *testing.T) { + registry := NewRegistry() + + SetJSONProviderFactory(func(filePath string) (Provider, error) { + return &mockProvider{name: "test"}, nil + }) + + err := AutoDiscoverProviders(registry, nil) + if err != nil { + t.Fatalf("AutoDiscoverProviders with nil config failed: %v", err) + } +} + +func TestAutoDiscoverProvidersNoFactory(t *testing.T) { + tmpDir := t.TempDir() + + file := filepath.Join(tmpDir, "test.json") + if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + config := &DiscoveryConfig{ + SearchPaths: []string{tmpDir}, + } + + registry := NewRegistry() + + SetJSONProviderFactory(nil) + + err := AutoDiscoverProviders(registry, config) + if err != nil { + t.Fatalf("AutoDiscoverProviders should not fail without factory: %v", err) + } + + providers := registry.List() + if len(providers) != 0 { + t.Errorf("expected 0 providers without factory, got %d", len(providers)) + } +} + +func TestExpandPathInDiscovery(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "tilde expansion", + input: "~/test", + expected: filepath.Join(home, "test"), + }, + { + name: "absolute path", + input: "/tmp/test", + expected: "/tmp/test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := utils.ExpandPath(tt.input) + if err != nil { + t.Fatalf("expandPath failed: %v", err) + } + + if result != tt.expected { + t.Errorf("utils.ExpandPath(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland.go new file mode 100644 index 0000000..e43a9eb --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland.go @@ -0,0 +1,497 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type HyprlandProvider struct { + configPath string + dmsBindsIncluded bool + parsed bool +} + +func NewHyprlandProvider(configPath string) *HyprlandProvider { + if configPath == "" { + configPath = defaultHyprlandConfigDir() + } + return &HyprlandProvider{ + configPath: configPath, + } +} + +func defaultHyprlandConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "hypr") +} + +func (h *HyprlandProvider) Name() string { + return "hyprland" +} + +func (h *HyprlandProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + result, err := ParseHyprlandKeysWithDMS(h.configPath) + if err != nil { + return nil, fmt.Errorf("failed to parse hyprland config: %w", err) + } + + h.dmsBindsIncluded = result.DMSBindsIncluded + h.parsed = true + + categorizedBinds := make(map[string][]keybinds.Keybind) + h.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs) + + sheet := &keybinds.CheatSheet{ + Title: "Hyprland Keybinds", + Provider: h.Name(), + Binds: categorizedBinds, + DMSBindsIncluded: result.DMSBindsIncluded, + } + + if result.DMSStatus != nil { + sheet.DMSStatus = &keybinds.DMSBindsStatus{ + Exists: result.DMSStatus.Exists, + Included: result.DMSStatus.Included, + IncludePosition: result.DMSStatus.IncludePosition, + TotalIncludes: result.DMSStatus.TotalIncludes, + BindsAfterDMS: result.DMSStatus.BindsAfterDMS, + Effective: result.DMSStatus.Effective, + OverriddenBy: result.DMSStatus.OverriddenBy, + StatusMessage: result.DMSStatus.StatusMessage, + } + } + + return sheet, nil +} + +func (h *HyprlandProvider) HasDMSBindsIncluded() bool { + if h.parsed { + return h.dmsBindsIncluded + } + + result, err := ParseHyprlandKeysWithDMS(h.configPath) + if err != nil { + return false + } + + h.dmsBindsIncluded = result.DMSBindsIncluded + h.parsed = true + return h.dmsBindsIncluded +} + +func (h *HyprlandProvider) convertSection(section *HyprlandSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*HyprlandKeyBinding) { + currentSubcat := subcategory + if section.Name != "" { + currentSubcat = section.Name + } + + for _, kb := range section.Keybinds { + category := h.categorizeByDispatcher(kb.Dispatcher) + bind := h.convertKeybind(&kb, currentSubcat, conflicts) + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + for _, child := range section.Children { + h.convertSection(&child, currentSubcat, categorizedBinds, conflicts) + } +} + +func (h *HyprlandProvider) categorizeByDispatcher(dispatcher string) string { + switch { + case strings.Contains(dispatcher, "workspace"): + return "Workspace" + case strings.Contains(dispatcher, "monitor"): + return "Monitor" + case strings.Contains(dispatcher, "window") || + strings.Contains(dispatcher, "focus") || + strings.Contains(dispatcher, "move") || + strings.Contains(dispatcher, "swap") || + strings.Contains(dispatcher, "resize") || + dispatcher == "killactive" || + dispatcher == "fullscreen" || + dispatcher == "togglefloating" || + dispatcher == "pin" || + dispatcher == "fakefullscreen" || + dispatcher == "splitratio" || + dispatcher == "resizeactive": + return "Window" + case dispatcher == "exec": + return "Execute" + case dispatcher == "exit" || strings.Contains(dispatcher, "dpms"): + return "System" + default: + return "Other" + } +} + +func (h *HyprlandProvider) convertKeybind(kb *HyprlandKeyBinding, subcategory string, conflicts map[string]*HyprlandKeyBinding) keybinds.Keybind { + keyStr := h.formatKey(kb) + rawAction := h.formatRawAction(kb.Dispatcher, kb.Params) + desc := kb.Comment + + if desc == "" { + desc = rawAction + } + + source := "config" + if strings.Contains(kb.Source, "dms/binds.conf") { + source = "dms" + } + + bind := keybinds.Keybind{ + Key: keyStr, + Description: desc, + Action: rawAction, + Subcategory: subcategory, + Source: source, + Flags: kb.Flags, + } + + if source == "dms" && conflicts != nil { + normalizedKey := strings.ToLower(keyStr) + if conflictKb, ok := conflicts[normalizedKey]; ok { + bind.Conflict = &keybinds.Keybind{ + Key: keyStr, + Description: conflictKb.Comment, + Action: h.formatRawAction(conflictKb.Dispatcher, conflictKb.Params), + Source: "config", + } + } + } + + return bind +} + +func (h *HyprlandProvider) formatRawAction(dispatcher, params string) string { + if params != "" { + return dispatcher + " " + params + } + return dispatcher +} + +func (h *HyprlandProvider) formatKey(kb *HyprlandKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (h *HyprlandProvider) GetOverridePath() string { + expanded, err := utils.ExpandPath(h.configPath) + if err != nil { + return filepath.Join(h.configPath, "dms", "binds.conf") + } + return filepath.Join(expanded, "dms", "binds.conf") +} + +func (h *HyprlandProvider) validateAction(action string) error { + action = strings.TrimSpace(action) + switch { + case action == "": + return fmt.Errorf("action cannot be empty") + case action == "exec" || action == "exec ": + return fmt.Errorf("exec dispatcher requires arguments") + case strings.HasPrefix(action, "exec "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "exec ")) + if rest == "" { + return fmt.Errorf("exec dispatcher requires arguments") + } + } + return nil +} + +func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error { + if err := h.validateAction(action); err != nil { + return err + } + + overridePath := h.GetOverridePath() + + if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil { + return fmt.Errorf("failed to create dms directory: %w", err) + } + + existingBinds, err := h.loadOverrideBinds() + if err != nil { + existingBinds = make(map[string]*hyprlandOverrideBind) + } + + // Extract flags from options + var flags string + if options != nil { + if f, ok := options["flags"].(string); ok { + flags = f + } + } + + normalizedKey := strings.ToLower(key) + existingBinds[normalizedKey] = &hyprlandOverrideBind{ + Key: key, + Action: action, + Description: description, + Flags: flags, + Options: options, + } + + return h.writeOverrideBinds(existingBinds) +} + +func (h *HyprlandProvider) RemoveBind(key string) error { + existingBinds, err := h.loadOverrideBinds() + if err != nil { + return nil + } + + normalizedKey := strings.ToLower(key) + delete(existingBinds, normalizedKey) + return h.writeOverrideBinds(existingBinds) +} + +type hyprlandOverrideBind struct { + Key string + Action string + Description string + Flags string // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press + Options map[string]any +} + +func (h *HyprlandProvider) loadOverrideBinds() (map[string]*hyprlandOverrideBind, error) { + overridePath := h.GetOverridePath() + binds := make(map[string]*hyprlandOverrideBind) + + data, err := os.ReadFile(overridePath) + if os.IsNotExist(err) { + return binds, nil + } + if err != nil { + return nil, err + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if !strings.HasPrefix(line, "bind") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + continue + } + + // Extract flags from bind type + bindType := strings.TrimSpace(parts[0]) + flags := extractBindFlags(bindType) + hasDescFlag := strings.Contains(flags, "d") + + content := strings.TrimSpace(parts[1]) + commentParts := strings.SplitN(content, "#", 2) + bindContent := strings.TrimSpace(commentParts[0]) + + var comment string + if len(commentParts) > 1 { + comment = strings.TrimSpace(commentParts[1]) + } + + // For bindd, format is: mods, key, description, dispatcher, params + var minFields, descIndex, dispatcherIndex int + if hasDescFlag { + minFields = 4 + descIndex = 2 + dispatcherIndex = 3 + } else { + minFields = 3 + dispatcherIndex = 2 + } + + fields := strings.SplitN(bindContent, ",", minFields+2) + if len(fields) < minFields { + continue + } + + mods := strings.TrimSpace(fields[0]) + keyName := strings.TrimSpace(fields[1]) + + var dispatcher, params string + if hasDescFlag { + if comment == "" { + comment = strings.TrimSpace(fields[descIndex]) + } + dispatcher = strings.TrimSpace(fields[dispatcherIndex]) + if len(fields) > dispatcherIndex+1 { + paramParts := fields[dispatcherIndex+1:] + params = strings.TrimSpace(strings.Join(paramParts, ",")) + } + } else { + dispatcher = strings.TrimSpace(fields[dispatcherIndex]) + if len(fields) > dispatcherIndex+1 { + paramParts := fields[dispatcherIndex+1:] + params = strings.TrimSpace(strings.Join(paramParts, ",")) + } + } + + keyStr := h.buildKeyString(mods, keyName) + normalizedKey := strings.ToLower(keyStr) + action := dispatcher + if params != "" { + action = dispatcher + " " + params + } + + binds[normalizedKey] = &hyprlandOverrideBind{ + Key: keyStr, + Action: action, + Description: comment, + Flags: flags, + } + } + + return binds, nil +} + +func (h *HyprlandProvider) buildKeyString(mods, key string) string { + if mods == "" { + return key + } + + modList := strings.FieldsFunc(mods, func(r rune) bool { + return r == '+' || r == ' ' + }) + + parts := append(modList, key) + return strings.Join(parts, "+") +} + +func (h *HyprlandProvider) getBindSortPriority(action string) int { + switch { + case strings.HasPrefix(action, "exec") && strings.Contains(action, "dms"): + return 0 + case strings.Contains(action, "workspace"): + return 1 + case strings.Contains(action, "window") || strings.Contains(action, "focus") || + strings.Contains(action, "move") || strings.Contains(action, "swap") || + strings.Contains(action, "resize"): + return 2 + case strings.Contains(action, "monitor"): + return 3 + case strings.HasPrefix(action, "exec"): + return 4 + case action == "exit" || strings.Contains(action, "dpms"): + return 5 + default: + return 6 + } +} + +func (h *HyprlandProvider) writeOverrideBinds(binds map[string]*hyprlandOverrideBind) error { + overridePath := h.GetOverridePath() + content := h.generateBindsContent(binds) + return os.WriteFile(overridePath, []byte(content), 0o644) +} + +func (h *HyprlandProvider) generateBindsContent(binds map[string]*hyprlandOverrideBind) string { + if len(binds) == 0 { + return "" + } + + bindList := make([]*hyprlandOverrideBind, 0, len(binds)) + for _, bind := range binds { + bindList = append(bindList, bind) + } + + sort.Slice(bindList, func(i, j int) bool { + pi, pj := h.getBindSortPriority(bindList[i].Action), h.getBindSortPriority(bindList[j].Action) + if pi != pj { + return pi < pj + } + return bindList[i].Key < bindList[j].Key + }) + + var sb strings.Builder + for _, bind := range bindList { + h.writeBindLine(&sb, bind) + } + + return sb.String() +} + +func (h *HyprlandProvider) writeBindLine(sb *strings.Builder, bind *hyprlandOverrideBind) { + mods, key := h.parseKeyString(bind.Key) + dispatcher, params := h.parseAction(bind.Action) + + // Write bind type with flags (e.g., "bind", "binde", "bindel") + sb.WriteString("bind") + if bind.Flags != "" { + sb.WriteString(bind.Flags) + } + sb.WriteString(" = ") + sb.WriteString(mods) + sb.WriteString(", ") + sb.WriteString(key) + sb.WriteString(", ") + + // For bindd (description flag), include description before dispatcher + if strings.Contains(bind.Flags, "d") && bind.Description != "" { + sb.WriteString(bind.Description) + sb.WriteString(", ") + } + + sb.WriteString(dispatcher) + + if params != "" { + sb.WriteString(", ") + sb.WriteString(params) + } + + // Only add comment if not using bindd (which has inline description) + if bind.Description != "" && !strings.Contains(bind.Flags, "d") { + sb.WriteString(" # ") + sb.WriteString(bind.Description) + } + + sb.WriteString("\n") +} + +func (h *HyprlandProvider) parseKeyString(keyStr string) (mods, key string) { + parts := strings.Split(keyStr, "+") + switch len(parts) { + case 0: + return "", keyStr + case 1: + return "", parts[0] + default: + return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1] + } +} + +func (h *HyprlandProvider) parseAction(action string) (dispatcher, params string) { + parts := strings.SplitN(action, " ", 2) + switch len(parts) { + case 0: + return action, "" + case 1: + dispatcher = parts[0] + default: + dispatcher = parts[0] + params = parts[1] + } + + // Convert internal spawn format to Hyprland's exec + if dispatcher == "spawn" { + dispatcher = "exec" + } + + return dispatcher, params +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser.go new file mode 100644 index 0000000..04b5ada --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser.go @@ -0,0 +1,627 @@ +package providers + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +const ( + TitleRegex = "#+!" + HideComment = "[hidden]" + CommentBindPattern = "#/#" +) + +var ModSeparators = []rune{'+', ' '} + +type HyprlandKeyBinding struct { + Mods []string `json:"mods"` + Key string `json:"key"` + Dispatcher string `json:"dispatcher"` + Params string `json:"params"` + Comment string `json:"comment"` + Source string `json:"source"` + Flags string `json:"flags"` // Bind flags: l=locked, r=release, e=repeat, n=non-consuming, m=mouse, t=transparent, i=ignore-mods, s=separate, d=description, o=long-press +} + +type HyprlandSection struct { + Children []HyprlandSection `json:"children"` + Keybinds []HyprlandKeyBinding `json:"keybinds"` + Name string `json:"name"` +} + +type HyprlandParser struct { + contentLines []string + readingLine int + configDir string + currentSource string + dmsBindsExists bool + dmsBindsIncluded bool + includeCount int + dmsIncludePos int + bindsAfterDMS int + dmsBindKeys map[string]bool + configBindKeys map[string]bool + conflictingConfigs map[string]*HyprlandKeyBinding + bindMap map[string]*HyprlandKeyBinding + bindOrder []string + processedFiles map[string]bool + dmsProcessed bool +} + +func NewHyprlandParser(configDir string) *HyprlandParser { + return &HyprlandParser{ + contentLines: []string{}, + readingLine: 0, + configDir: configDir, + dmsIncludePos: -1, + dmsBindKeys: make(map[string]bool), + configBindKeys: make(map[string]bool), + conflictingConfigs: make(map[string]*HyprlandKeyBinding), + bindMap: make(map[string]*HyprlandKeyBinding), + bindOrder: []string{}, + processedFiles: make(map[string]bool), + } +} + +func (p *HyprlandParser) ReadContent(directory string) error { + expandedDir, err := utils.ExpandPath(directory) + if err != nil { + return err + } + + info, err := os.Stat(expandedDir) + if err != nil { + return err + } + if !info.IsDir() { + return os.ErrNotExist + } + + confFiles, err := filepath.Glob(filepath.Join(expandedDir, "*.conf")) + if err != nil { + return err + } + if len(confFiles) == 0 { + return os.ErrNotExist + } + + var combinedContent []string + for _, confFile := range confFiles { + if fileInfo, err := os.Stat(confFile); err == nil && fileInfo.Mode().IsRegular() { + data, err := os.ReadFile(confFile) + if err == nil { + combinedContent = append(combinedContent, string(data)) + } + } + } + + if len(combinedContent) == 0 { + return os.ErrNotExist + } + + fullContent := strings.Join(combinedContent, "\n") + p.contentLines = strings.Split(fullContent, "\n") + return nil +} + +func hyprlandAutogenerateComment(dispatcher, params string) string { + switch dispatcher { + case "resizewindow": + return "Resize window" + + case "movewindow": + if params == "" { + return "Move window" + } + dirMap := map[string]string{ + "l": "left", + "r": "right", + "u": "up", + "d": "down", + } + if dir, ok := dirMap[params]; ok { + return "move in " + dir + " direction" + } + return "move in null direction" + + case "pin": + return "pin (show on all workspaces)" + + case "splitratio": + return "Window split ratio " + params + + case "togglefloating": + return "Float/unfloat window" + + case "resizeactive": + return "Resize window by " + params + + case "killactive": + return "Close window" + + case "fullscreen": + fsMap := map[string]string{ + "0": "fullscreen", + "1": "maximization", + "2": "fullscreen on Hyprland's side", + } + if fs, ok := fsMap[params]; ok { + return "Toggle " + fs + } + return "Toggle null" + + case "fakefullscreen": + return "Toggle fake fullscreen" + + case "workspace": + switch params { + case "+1": + return "focus right" + case "-1": + return "focus left" + } + return "focus workspace " + params + case "movefocus": + dirMap := map[string]string{ + "l": "left", + "r": "right", + "u": "up", + "d": "down", + } + if dir, ok := dirMap[params]; ok { + return "move focus " + dir + } + return "move focus null" + + case "swapwindow": + dirMap := map[string]string{ + "l": "left", + "r": "right", + "u": "up", + "d": "down", + } + if dir, ok := dirMap[params]; ok { + return "swap in " + dir + " direction" + } + return "swap in null direction" + + case "movetoworkspace": + switch params { + case "+1": + return "move to right workspace (non-silent)" + case "-1": + return "move to left workspace (non-silent)" + } + return "move to workspace " + params + " (non-silent)" + case "movetoworkspacesilent": + switch params { + case "+1": + return "move to right workspace" + case "-1": + return "move to right workspace" + } + return "move to workspace " + params + + case "togglespecialworkspace": + return "toggle special" + + case "exec": + return params + + default: + return "" + } +} + +func (p *HyprlandParser) getKeybindAtLine(lineNumber int) *HyprlandKeyBinding { + line := p.contentLines[lineNumber] + return p.parseBindLine(line) +} + +func (p *HyprlandParser) getBindsRecursive(currentContent *HyprlandSection, scope int) *HyprlandSection { + titleRegex := regexp.MustCompile(TitleRegex) + + for p.readingLine < len(p.contentLines) { + line := p.contentLines[p.readingLine] + + loc := titleRegex.FindStringIndex(line) + if loc != nil && loc[0] == 0 { + headingScope := strings.Index(line, "!") + + if headingScope <= scope { + p.readingLine-- + return currentContent + } + + sectionName := strings.TrimSpace(line[headingScope+1:]) + p.readingLine++ + + childSection := &HyprlandSection{ + Children: []HyprlandSection{}, + Keybinds: []HyprlandKeyBinding{}, + Name: sectionName, + } + result := p.getBindsRecursive(childSection, headingScope) + currentContent.Children = append(currentContent.Children, *result) + + } else if strings.HasPrefix(line, CommentBindPattern) { + keybind := p.getKeybindAtLine(p.readingLine) + if keybind != nil { + currentContent.Keybinds = append(currentContent.Keybinds, *keybind) + } + + } else if line == "" || !strings.HasPrefix(strings.TrimSpace(line), "bind") { + + } else { + keybind := p.getKeybindAtLine(p.readingLine) + if keybind != nil { + currentContent.Keybinds = append(currentContent.Keybinds, *keybind) + } + } + + p.readingLine++ + } + + return currentContent +} + +func (p *HyprlandParser) ParseKeys() *HyprlandSection { + p.readingLine = 0 + rootSection := &HyprlandSection{ + Children: []HyprlandSection{}, + Keybinds: []HyprlandKeyBinding{}, + Name: "", + } + return p.getBindsRecursive(rootSection, 0) +} + +func ParseHyprlandKeys(path string) (*HyprlandSection, error) { + parser := NewHyprlandParser(path) + if err := parser.ReadContent(path); err != nil { + return nil, err + } + return parser.ParseKeys(), nil +} + +type HyprlandParseResult struct { + Section *HyprlandSection + DMSBindsIncluded bool + DMSStatus *HyprlandDMSStatus + ConflictingConfigs map[string]*HyprlandKeyBinding +} + +type HyprlandDMSStatus struct { + Exists bool + Included bool + IncludePosition int + TotalIncludes int + BindsAfterDMS int + Effective bool + OverriddenBy int + StatusMessage string +} + +func (p *HyprlandParser) buildDMSStatus() *HyprlandDMSStatus { + status := &HyprlandDMSStatus{ + Exists: p.dmsBindsExists, + Included: p.dmsBindsIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + BindsAfterDMS: p.bindsAfterDMS, + } + + switch { + case !p.dmsBindsExists: + status.Effective = false + status.StatusMessage = "dms/binds.conf does not exist" + case !p.dmsBindsIncluded: + status.Effective = false + status.StatusMessage = "dms/binds.conf is not sourced in config" + case p.bindsAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.bindsAfterDMS + status.StatusMessage = "Some DMS binds may be overridden by config binds" + default: + status.Effective = true + status.StatusMessage = "DMS binds are active" + } + + return status +} + +func (p *HyprlandParser) formatBindKey(kb *HyprlandKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (p *HyprlandParser) normalizeKey(key string) string { + return strings.ToLower(key) +} + +func (p *HyprlandParser) addBind(kb *HyprlandKeyBinding) bool { + key := p.formatBindKey(kb) + normalizedKey := p.normalizeKey(key) + isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") + + if isDMSBind { + p.dmsBindKeys[normalizedKey] = true + } else if p.dmsBindKeys[normalizedKey] { + p.bindsAfterDMS++ + p.conflictingConfigs[normalizedKey] = kb + p.configBindKeys[normalizedKey] = true + return false + } else { + p.configBindKeys[normalizedKey] = true + } + + if _, exists := p.bindMap[normalizedKey]; !exists { + p.bindOrder = append(p.bindOrder, key) + } + p.bindMap[normalizedKey] = kb + return true +} + +func (p *HyprlandParser) ParseWithDMS() (*HyprlandSection, error) { + expandedDir, err := utils.ExpandPath(p.configDir) + if err != nil { + return nil, err + } + + dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf") + if _, err := os.Stat(dmsBindsPath); err == nil { + p.dmsBindsExists = true + } + + mainConfig := filepath.Join(expandedDir, "hyprland.conf") + section, err := p.parseFileWithSource(mainConfig, "") + if err != nil { + return nil, err + } + + if p.dmsBindsExists && !p.dmsProcessed { + p.parseDMSBindsDirectly(dmsBindsPath, section) + } + + return section, nil +} + +func (p *HyprlandParser) parseFileWithSource(filePath, sectionName string) (*HyprlandSection, error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + + if p.processedFiles[absPath] { + return &HyprlandSection{Name: sectionName}, nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + prevSource := p.currentSource + p.currentSource = absPath + + section := &HyprlandSection{Name: sectionName} + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "source") { + p.handleSource(trimmed, section, filepath.Dir(absPath)) + continue + } + + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.parseBindLine(line) + if kb == nil { + continue + } + kb.Source = p.currentSource + if p.addBind(kb) { + section.Keybinds = append(section.Keybinds, *kb) + } + } + + p.currentSource = prevSource + return section, nil +} + +func (p *HyprlandParser) handleSource(line string, section *HyprlandSection, baseDir string) { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return + } + + sourcePath := strings.TrimSpace(parts[1]) + isDMSSource := sourcePath == "dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf") + + p.includeCount++ + if isDMSSource { + p.dmsBindsIncluded = true + p.dmsIncludePos = p.includeCount + p.dmsProcessed = true + } + + fullPath := sourcePath + if !filepath.IsAbs(sourcePath) { + fullPath = filepath.Join(baseDir, sourcePath) + } + + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + return + } + + includedSection, err := p.parseFileWithSource(expanded, "") + if err != nil { + return + } + + section.Children = append(section.Children, *includedSection) +} + +func (p *HyprlandParser) parseDMSBindsDirectly(dmsBindsPath string, section *HyprlandSection) { + data, err := os.ReadFile(dmsBindsPath) + if err != nil { + return + } + + prevSource := p.currentSource + p.currentSource = dmsBindsPath + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.parseBindLine(line) + if kb == nil { + continue + } + kb.Source = dmsBindsPath + if p.addBind(kb) { + section.Keybinds = append(section.Keybinds, *kb) + } + } + + p.currentSource = prevSource + p.dmsProcessed = true +} + +func (p *HyprlandParser) parseBindLine(line string) *HyprlandKeyBinding { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return nil + } + + // Extract bind type and flags from the left side of "=" + bindType := strings.TrimSpace(parts[0]) + flags := extractBindFlags(bindType) + hasDescFlag := strings.Contains(flags, "d") + + keys := parts[1] + keyParts := strings.SplitN(keys, "#", 2) + keys = keyParts[0] + + var comment string + if len(keyParts) > 1 { + comment = strings.TrimSpace(keyParts[1]) + } + + // For bindd, the format is: bindd = MODS, key, description, dispatcher, params + // For regular binds: bind = MODS, key, dispatcher, params + var minFields, descIndex, dispatcherIndex int + if hasDescFlag { + minFields = 4 // mods, key, description, dispatcher + descIndex = 2 + dispatcherIndex = 3 + } else { + minFields = 3 // mods, key, dispatcher + dispatcherIndex = 2 + } + + keyFields := strings.SplitN(keys, ",", minFields+2) // Allow for params + if len(keyFields) < minFields { + return nil + } + + mods := strings.TrimSpace(keyFields[0]) + key := strings.TrimSpace(keyFields[1]) + + var dispatcher, params string + if hasDescFlag { + // bindd format: description is in the bind itself + if comment == "" { + comment = strings.TrimSpace(keyFields[descIndex]) + } + dispatcher = strings.TrimSpace(keyFields[dispatcherIndex]) + if len(keyFields) > dispatcherIndex+1 { + paramParts := keyFields[dispatcherIndex+1:] + params = strings.TrimSpace(strings.Join(paramParts, ",")) + } + } else { + dispatcher = strings.TrimSpace(keyFields[dispatcherIndex]) + if len(keyFields) > dispatcherIndex+1 { + paramParts := keyFields[dispatcherIndex+1:] + params = strings.TrimSpace(strings.Join(paramParts, ",")) + } + } + + if comment != "" && strings.HasPrefix(comment, HideComment) { + return nil + } + + if comment == "" { + comment = hyprlandAutogenerateComment(dispatcher, params) + } + + var modList []string + if mods != "" { + modstring := mods + string(ModSeparators[0]) + idx := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range ModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-idx > 1 { + modList = append(modList, modstring[idx:index]) + } + idx = index + 1 + } + } + } + + return &HyprlandKeyBinding{ + Mods: modList, + Key: key, + Dispatcher: dispatcher, + Params: params, + Comment: comment, + Flags: flags, + } +} + +// extractBindFlags extracts the flags from a bind type string +// e.g., "binde" -> "e", "bindel" -> "el", "bindd" -> "d" +func extractBindFlags(bindType string) string { + bindType = strings.TrimSpace(bindType) + if !strings.HasPrefix(bindType, "bind") { + return "" + } + return bindType[4:] // Everything after "bind" +} + +func ParseHyprlandKeysWithDMS(path string) (*HyprlandParseResult, error) { + parser := NewHyprlandParser(path) + section, err := parser.ParseWithDMS() + if err != nil { + return nil, err + } + + return &HyprlandParseResult{ + Section: section, + DMSBindsIncluded: parser.dmsBindsIncluded, + DMSStatus: parser.buildDMSStatus(), + ConflictingConfigs: parser.conflictingConfigs, + }, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser_test.go new file mode 100644 index 0000000..c2872e5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_parser_test.go @@ -0,0 +1,519 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHyprlandAutogenerateComment(t *testing.T) { + tests := []struct { + dispatcher string + params string + expected string + }{ + {"resizewindow", "", "Resize window"}, + {"movewindow", "", "Move window"}, + {"movewindow", "l", "move in left direction"}, + {"movewindow", "r", "move in right direction"}, + {"movewindow", "u", "move in up direction"}, + {"movewindow", "d", "move in down direction"}, + {"pin", "", "pin (show on all workspaces)"}, + {"splitratio", "0.5", "Window split ratio 0.5"}, + {"togglefloating", "", "Float/unfloat window"}, + {"resizeactive", "10 20", "Resize window by 10 20"}, + {"killactive", "", "Close window"}, + {"fullscreen", "0", "Toggle fullscreen"}, + {"fullscreen", "1", "Toggle maximization"}, + {"fullscreen", "2", "Toggle fullscreen on Hyprland's side"}, + {"fakefullscreen", "", "Toggle fake fullscreen"}, + {"workspace", "+1", "focus right"}, + {"workspace", "-1", "focus left"}, + {"workspace", "5", "focus workspace 5"}, + {"movefocus", "l", "move focus left"}, + {"movefocus", "r", "move focus right"}, + {"movefocus", "u", "move focus up"}, + {"movefocus", "d", "move focus down"}, + {"swapwindow", "l", "swap in left direction"}, + {"swapwindow", "r", "swap in right direction"}, + {"swapwindow", "u", "swap in up direction"}, + {"swapwindow", "d", "swap in down direction"}, + {"movetoworkspace", "+1", "move to right workspace (non-silent)"}, + {"movetoworkspace", "-1", "move to left workspace (non-silent)"}, + {"movetoworkspace", "3", "move to workspace 3 (non-silent)"}, + {"movetoworkspacesilent", "+1", "move to right workspace"}, + {"movetoworkspacesilent", "-1", "move to right workspace"}, + {"movetoworkspacesilent", "2", "move to workspace 2"}, + {"togglespecialworkspace", "", "toggle special"}, + {"exec", "firefox", "firefox"}, + {"unknown", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.dispatcher+"_"+tt.params, func(t *testing.T) { + result := hyprlandAutogenerateComment(tt.dispatcher, tt.params) + if result != tt.expected { + t.Errorf("hyprlandAutogenerateComment(%q, %q) = %q, want %q", + tt.dispatcher, tt.params, result, tt.expected) + } + }) + } +} + +func TestHyprlandGetKeybindAtLine(t *testing.T) { + tests := []struct { + name string + line string + expected *HyprlandKeyBinding + }{ + { + name: "basic_keybind", + line: "bind = SUPER, Q, killactive", + expected: &HyprlandKeyBinding{ + Mods: []string{"SUPER"}, + Key: "Q", + Dispatcher: "killactive", + Params: "", + Comment: "Close window", + }, + }, + { + name: "keybind_with_params", + line: "bind = SUPER, left, movefocus, l", + expected: &HyprlandKeyBinding{ + Mods: []string{"SUPER"}, + Key: "left", + Dispatcher: "movefocus", + Params: "l", + Comment: "move focus left", + }, + }, + { + name: "keybind_with_comment", + line: "bind = SUPER, T, exec, kitty # Open terminal", + expected: &HyprlandKeyBinding{ + Mods: []string{"SUPER"}, + Key: "T", + Dispatcher: "exec", + Params: "kitty", + Comment: "Open terminal", + }, + }, + { + name: "keybind_hidden", + line: "bind = SUPER, H, exec, secret # [hidden]", + expected: nil, + }, + { + name: "keybind_multiple_mods", + line: "bind = SUPER+SHIFT, F, fullscreen, 0", + expected: &HyprlandKeyBinding{ + Mods: []string{"SUPER", "SHIFT"}, + Key: "F", + Dispatcher: "fullscreen", + Params: "0", + Comment: "Toggle fullscreen", + }, + }, + { + name: "keybind_no_mods", + line: "bind = , Print, exec, screenshot", + expected: &HyprlandKeyBinding{ + Mods: []string{}, + Key: "Print", + Dispatcher: "exec", + Params: "screenshot", + Comment: "screenshot", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewHyprlandParser("") + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if tt.expected == nil { + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + return + } + + if result == nil { + t.Errorf("expected %+v, got nil", tt.expected) + return + } + + if result.Key != tt.expected.Key { + t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key) + } + if result.Dispatcher != tt.expected.Dispatcher { + t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expected.Dispatcher) + } + if result.Params != tt.expected.Params { + t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params) + } + if result.Comment != tt.expected.Comment { + t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment) + } + if len(result.Mods) != len(tt.expected.Mods) { + t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods)) + } else { + for i := range result.Mods { + if result.Mods[i] != tt.expected.Mods[i] { + t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i]) + } + } + } + }) + } +} + +func TestHyprlandParseKeysWithSections(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "hyprland.conf") + + content := `##! Window Management +bind = SUPER, Q, killactive +bind = SUPER, F, fullscreen, 0 + +###! Movement +bind = SUPER, left, movefocus, l +bind = SUPER, right, movefocus, r + +##! Applications +bind = SUPER, T, exec, kitty # Terminal +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseHyprlandKeys(tmpDir) + if err != nil { + t.Fatalf("ParseHyprlandKeys failed: %v", err) + } + + if len(section.Children) != 2 { + t.Errorf("Expected 2 top-level sections, got %d", len(section.Children)) + } + + if len(section.Children) >= 1 { + windowMgmt := section.Children[0] + if windowMgmt.Name != "Window Management" { + t.Errorf("First section name = %q, want %q", windowMgmt.Name, "Window Management") + } + if len(windowMgmt.Keybinds) != 2 { + t.Errorf("Window Management keybinds = %d, want 2", len(windowMgmt.Keybinds)) + } + + if len(windowMgmt.Children) != 1 { + t.Errorf("Window Management children = %d, want 1", len(windowMgmt.Children)) + } else { + movement := windowMgmt.Children[0] + if movement.Name != "Movement" { + t.Errorf("Movement section name = %q, want %q", movement.Name, "Movement") + } + if len(movement.Keybinds) != 2 { + t.Errorf("Movement keybinds = %d, want 2", len(movement.Keybinds)) + } + } + } + + if len(section.Children) >= 2 { + apps := section.Children[1] + if apps.Name != "Applications" { + t.Errorf("Second section name = %q, want %q", apps.Name, "Applications") + } + if len(apps.Keybinds) != 1 { + t.Errorf("Applications keybinds = %d, want 1", len(apps.Keybinds)) + } + if len(apps.Keybinds) > 0 && apps.Keybinds[0].Comment != "Terminal" { + t.Errorf("Applications keybind comment = %q, want %q", apps.Keybinds[0].Comment, "Terminal") + } + } +} + +func TestHyprlandParseKeysWithCommentBinds(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "test.conf") + + content := `#/# = SUPER, A, exec, app1 +bind = SUPER, B, exec, app2 +#/# = SUPER, C, exec, app3 # Custom comment +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseHyprlandKeys(tmpDir) + if err != nil { + t.Fatalf("ParseHyprlandKeys failed: %v", err) + } + + if len(section.Keybinds) != 3 { + t.Errorf("Expected 3 keybinds, got %d", len(section.Keybinds)) + } + + if len(section.Keybinds) > 0 && section.Keybinds[0].Key != "A" { + t.Errorf("First keybind key = %q, want %q", section.Keybinds[0].Key, "A") + } + if len(section.Keybinds) > 1 && section.Keybinds[1].Key != "B" { + t.Errorf("Second keybind key = %q, want %q", section.Keybinds[1].Key, "B") + } + if len(section.Keybinds) > 2 && section.Keybinds[2].Comment != "Custom comment" { + t.Errorf("Third keybind comment = %q, want %q", section.Keybinds[2].Comment, "Custom comment") + } +} + +func TestHyprlandReadContentMultipleFiles(t *testing.T) { + tmpDir := t.TempDir() + + file1 := filepath.Join(tmpDir, "a.conf") + file2 := filepath.Join(tmpDir, "b.conf") + + content1 := "bind = SUPER, Q, killactive\n" + content2 := "bind = SUPER, T, exec, kitty\n" + + if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil { + t.Fatalf("Failed to write file1: %v", err) + } + if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil { + t.Fatalf("Failed to write file2: %v", err) + } + + parser := NewHyprlandParser("") + if err := parser.ReadContent(tmpDir); err != nil { + t.Fatalf("ReadContent failed: %v", err) + } + + section := parser.ParseKeys() + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds from multiple files, got %d", len(section.Keybinds)) + } +} + +func TestHyprlandReadContentErrors(t *testing.T) { + tests := []struct { + name string + path string + }{ + { + name: "nonexistent_directory", + path: "/nonexistent/path/that/does/not/exist", + }, + { + name: "empty_directory", + path: t.TempDir(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseHyprlandKeys(tt.path) + if err == nil { + t.Error("Expected error, got nil") + } + }) + } +} + +func TestHyprlandReadContentWithTildeExpansion(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tmpSubdir := filepath.Join(homeDir, ".config", "test-hypr-"+t.Name()) + if err := os.MkdirAll(tmpSubdir, 0o755); err != nil { + t.Skip("Cannot create test directory in home") + } + defer os.RemoveAll(tmpSubdir) + + configFile := filepath.Join(tmpSubdir, "test.conf") + if err := os.WriteFile(configFile, []byte("bind = SUPER, Q, killactive\n"), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + relPath, err := filepath.Rel(homeDir, tmpSubdir) + if err != nil { + t.Skip("Cannot create relative path") + } + + parser := NewHyprlandParser("") + tildePathMatch := "~/" + relPath + err = parser.ReadContent(tildePathMatch) + + if err != nil { + t.Errorf("ReadContent with tilde path failed: %v", err) + } +} + +func TestHyprlandKeybindWithParamsContainingCommas(t *testing.T) { + parser := NewHyprlandParser("") + parser.contentLines = []string{"bind = SUPER, R, exec, notify-send 'Title' 'Message, with comma'"} + + result := parser.getKeybindAtLine(0) + + if result == nil { + t.Fatal("Expected keybind, got nil") + } + + expected := "notify-send 'Title' 'Message, with comma'" + if result.Params != expected { + t.Errorf("Params = %q, want %q", result.Params, expected) + } +} + +func TestHyprlandEmptyAndCommentLines(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "test.conf") + + content := ` +# This is a comment +bind = SUPER, Q, killactive + +# Another comment + +bind = SUPER, T, exec, kitty +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseHyprlandKeys(tmpDir) + if err != nil { + t.Fatalf("ParseHyprlandKeys failed: %v", err) + } + + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds)) + } +} + +func TestExtractBindFlags(t *testing.T) { + tests := []struct { + bindType string + expected string + }{ + {"bind", ""}, + {"binde", "e"}, + {"bindl", "l"}, + {"bindr", "r"}, + {"bindd", "d"}, + {"bindo", "o"}, + {"bindel", "el"}, + {"bindler", "ler"}, + {"bindem", "em"}, + {" bind ", ""}, + {" binde ", "e"}, + {"notbind", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.bindType, func(t *testing.T) { + result := extractBindFlags(tt.bindType) + if result != tt.expected { + t.Errorf("extractBindFlags(%q) = %q, want %q", tt.bindType, result, tt.expected) + } + }) + } +} + +func TestHyprlandBindFlags(t *testing.T) { + tests := []struct { + name string + line string + expectedFlags string + expectedKey string + expectedDisp string + expectedDesc string + }{ + { + name: "regular bind", + line: "bind = SUPER, Q, killactive", + expectedFlags: "", + expectedKey: "Q", + expectedDisp: "killactive", + expectedDesc: "Close window", + }, + { + name: "binde (repeat on hold)", + line: "binde = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+", + expectedFlags: "e", + expectedKey: "XF86AudioRaiseVolume", + expectedDisp: "exec", + expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+", + }, + { + name: "bindl (locked/inhibitor bypass)", + line: "bindl = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", + expectedFlags: "l", + expectedKey: "XF86AudioLowerVolume", + expectedDisp: "exec", + expectedDesc: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", + }, + { + name: "bindr (release trigger)", + line: "bindr = SUPER, SUPER_L, exec, pkill wofi || wofi", + expectedFlags: "r", + expectedKey: "SUPER_L", + expectedDisp: "exec", + expectedDesc: "pkill wofi || wofi", + }, + { + name: "bindd (description)", + line: "bindd = SUPER, Q, Open my favourite terminal, exec, kitty", + expectedFlags: "d", + expectedKey: "Q", + expectedDisp: "exec", + expectedDesc: "Open my favourite terminal", + }, + { + name: "bindo (long press)", + line: "bindo = SUPER, XF86AudioNext, exec, playerctl next", + expectedFlags: "o", + expectedKey: "XF86AudioNext", + expectedDisp: "exec", + expectedDesc: "playerctl next", + }, + { + name: "bindel (combined flags)", + line: "bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+", + expectedFlags: "el", + expectedKey: "XF86AudioRaiseVolume", + expectedDisp: "exec", + expectedDesc: "wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewHyprlandParser("") + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if result == nil { + t.Fatal("Expected keybind, got nil") + } + + if result.Flags != tt.expectedFlags { + t.Errorf("Flags = %q, want %q", result.Flags, tt.expectedFlags) + } + if result.Key != tt.expectedKey { + t.Errorf("Key = %q, want %q", result.Key, tt.expectedKey) + } + if result.Dispatcher != tt.expectedDisp { + t.Errorf("Dispatcher = %q, want %q", result.Dispatcher, tt.expectedDisp) + } + if result.Comment != tt.expectedDesc { + t.Errorf("Comment = %q, want %q", result.Comment, tt.expectedDesc) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_test.go new file mode 100644 index 0000000..90dfe97 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/hyprland_test.go @@ -0,0 +1,220 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewHyprlandProvider(t *testing.T) { + t.Run("custom path", func(t *testing.T) { + p := NewHyprlandProvider("/custom/path") + if p == nil { + t.Fatal("NewHyprlandProvider returned nil") + } + if p.configPath != "/custom/path" { + t.Errorf("configPath = %q, want %q", p.configPath, "/custom/path") + } + }) + + t.Run("empty path defaults", func(t *testing.T) { + p := NewHyprlandProvider("") + if p == nil { + t.Fatal("NewHyprlandProvider returned nil") + } + configDir, err := os.UserConfigDir() + if err != nil { + t.Fatalf("UserConfigDir failed: %v", err) + } + expected := filepath.Join(configDir, "hypr") + if p.configPath != expected { + t.Errorf("configPath = %q, want %q", p.configPath, expected) + } + }) +} + +func TestHyprlandProviderName(t *testing.T) { + p := NewHyprlandProvider("") + if p.Name() != "hyprland" { + t.Errorf("Name() = %q, want %q", p.Name(), "hyprland") + } +} + +func TestHyprlandProviderGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "hyprland.conf") + + content := `##! Window Management +bind = SUPER, Q, killactive +bind = SUPER, F, fullscreen, 0 + +###! Movement +bind = SUPER, left, movefocus, l +bind = SUPER, right, movefocus, r + +##! Applications +bind = SUPER, T, exec, kitty # Terminal +bind = SUPER, 1, workspace, 1 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + p := NewHyprlandProvider(tmpDir) + sheet, err := p.GetCheatSheet() + + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet.Title != "Hyprland Keybinds" { + t.Errorf("Title = %q, want %q", sheet.Title, "Hyprland Keybinds") + } + + if sheet.Provider != "hyprland" { + t.Errorf("Provider = %q, want %q", sheet.Provider, "hyprland") + } + + if len(sheet.Binds) == 0 { + t.Error("expected categorized bindings, got none") + } + + if windowBinds, ok := sheet.Binds["Window"]; !ok || len(windowBinds) == 0 { + t.Error("expected Window category with bindings") + } + + if execBinds, ok := sheet.Binds["Execute"]; !ok || len(execBinds) == 0 { + t.Error("expected Execute category with bindings") + } + + if wsBinds, ok := sheet.Binds["Workspace"]; !ok || len(wsBinds) == 0 { + t.Error("expected Workspace category with bindings") + } +} + +func TestHyprlandProviderGetCheatSheetError(t *testing.T) { + p := NewHyprlandProvider("/nonexistent/path") + _, err := p.GetCheatSheet() + + if err == nil { + t.Error("expected error for nonexistent path, got nil") + } +} + +func TestFormatKey(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "hyprland.conf") + + tests := []struct { + name string + content string + expected string + category string + }{ + { + name: "single mod", + content: "bind = SUPER, Q, killactive", + expected: "SUPER+Q", + category: "Window", + }, + { + name: "multiple mods", + content: "bind = SUPER+SHIFT, F, fullscreen, 0", + expected: "SUPER+SHIFT+F", + category: "Window", + }, + { + name: "no mods", + content: "bind = , Print, exec, screenshot", + expected: "Print", + category: "Execute", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.WriteFile(configFile, []byte(tt.content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + p := NewHyprlandProvider(tmpDir) + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + categoryBinds, ok := sheet.Binds[tt.category] + if !ok || len(categoryBinds) == 0 { + t.Fatalf("expected binds in category %q", tt.category) + } + + if categoryBinds[0].Key != tt.expected { + t.Errorf("Key = %q, want %q", categoryBinds[0].Key, tt.expected) + } + }) + } +} + +func TestDescriptionFallback(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "hyprland.conf") + + tests := []struct { + name string + content string + wantDesc string + }{ + { + name: "autogenerated description for known dispatcher", + content: "bind = SUPER, Q, killactive", + wantDesc: "Close window", + }, + { + name: "custom comment overrides autogeneration", + content: "bind = SUPER, T, exec, kitty # Open terminal", + wantDesc: "Open terminal", + }, + { + name: "fallback for unknown dispatcher without params", + content: "bind = SUPER, W, unknowndispatcher", + wantDesc: "unknowndispatcher", + }, + { + name: "fallback for unknown dispatcher with params", + content: "bind = SUPER, X, customdispatcher, arg1", + wantDesc: "customdispatcher arg1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := os.WriteFile(configFile, []byte(tt.content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + p := NewHyprlandProvider(tmpDir) + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + found := false + for _, binds := range sheet.Binds { + for _, bind := range binds { + if bind.Description == tt.wantDesc { + found = true + break + } + } + if found { + break + } + } + + if !found { + t.Errorf("expected description %q not found in any bind", tt.wantDesc) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile.go new file mode 100644 index 0000000..08cf449 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile.go @@ -0,0 +1,119 @@ +package providers + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type JSONFileProvider struct { + filePath string + name string +} + +func NewJSONFileProvider(filePath string) (*JSONFileProvider, error) { + if filePath == "" { + return nil, fmt.Errorf("file path cannot be empty") + } + + expandedPath, err := utils.ExpandPath(filePath) + if err != nil { + return nil, fmt.Errorf("failed to expand path: %w", err) + } + + name := filepath.Base(expandedPath) + name = name[:len(name)-len(filepath.Ext(name))] + + return &JSONFileProvider{ + filePath: expandedPath, + name: name, + }, nil +} + +func (j *JSONFileProvider) Name() string { + return j.name +} + +func (j *JSONFileProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + data, err := os.ReadFile(j.filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + var rawData map[string]any + if err := json.Unmarshal(data, &rawData); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + title, _ := rawData["title"].(string) + provider, _ := rawData["provider"].(string) + if provider == "" { + provider = j.name + } + + categorizedBinds := make(map[string][]keybinds.Keybind) + + bindsRaw, ok := rawData["binds"] + if !ok { + return nil, fmt.Errorf("missing 'binds' field") + } + + switch binds := bindsRaw.(type) { + case map[string]any: + for category, categoryBindsRaw := range binds { + categoryBindsList, ok := categoryBindsRaw.([]any) + if !ok { + continue + } + + var keybindsList []keybinds.Keybind + categoryBindsJSON, _ := json.Marshal(categoryBindsList) + if err := json.Unmarshal(categoryBindsJSON, &keybindsList); err != nil { + continue + } + + categorizedBinds[category] = keybindsList + } + + case []any: + flatBindsJSON, _ := json.Marshal(binds) + var flatBinds []struct { + Key string `json:"key"` + Description string `json:"desc"` + Action string `json:"action,omitempty"` + Category string `json:"cat,omitempty"` + Subcategory string `json:"subcat,omitempty"` + } + if err := json.Unmarshal(flatBindsJSON, &flatBinds); err != nil { + return nil, fmt.Errorf("failed to parse flat binds array: %w", err) + } + + for _, bind := range flatBinds { + category := bind.Category + if category == "" { + category = "Other" + } + + kb := keybinds.Keybind{ + Key: bind.Key, + Description: bind.Description, + Action: bind.Action, + Subcategory: bind.Subcategory, + } + categorizedBinds[category] = append(categorizedBinds[category], kb) + } + + default: + return nil, fmt.Errorf("'binds' must be either an object (categorized) or array (flat)") + } + + return &keybinds.CheatSheet{ + Title: title, + Provider: provider, + Binds: categorizedBinds, + }, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile_test.go new file mode 100644 index 0000000..ded4385 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/jsonfile_test.go @@ -0,0 +1,281 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func TestNewJSONFileProvider(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.json") + + if err := os.WriteFile(testFile, []byte("{}"), 0o644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + tests := []struct { + name string + filePath string + expectError bool + wantName string + }{ + { + name: "valid file", + filePath: testFile, + expectError: false, + wantName: "test", + }, + { + name: "empty path", + filePath: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := NewJSONFileProvider(tt.filePath) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if p.Name() != tt.wantName { + t.Errorf("Name() = %q, want %q", p.Name(), tt.wantName) + } + }) + } +} + +func TestJSONFileProviderGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "tmux.json") + + content := `{ + "title": "Tmux Binds", + "provider": "tmux", + "binds": { + "Pane": [ + { + "key": "Ctrl+Alt+J", + "desc": "Resize split downward", + "subcat": "Sizing" + }, + { + "key": "Ctrl+K", + "desc": "Move Focus Up", + "subcat": "Navigation" + } + ] + } +}` + + if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + p, err := NewJSONFileProvider(testFile) + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet.Title != "Tmux Binds" { + t.Errorf("Title = %q, want %q", sheet.Title, "Tmux Binds") + } + + if sheet.Provider != "tmux" { + t.Errorf("Provider = %q, want %q", sheet.Provider, "tmux") + } + + paneBinds, ok := sheet.Binds["Pane"] + if !ok { + t.Fatal("expected Pane category") + } + + if len(paneBinds) != 2 { + t.Errorf("len(Pane binds) = %d, want 2", len(paneBinds)) + } + + if len(paneBinds) > 0 { + bind := paneBinds[0] + if bind.Key != "Ctrl+Alt+J" { + t.Errorf("Pane[0].Key = %q, want %q", bind.Key, "Ctrl+Alt+J") + } + if bind.Description != "Resize split downward" { + t.Errorf("Pane[0].Description = %q, want %q", bind.Description, "Resize split downward") + } + if bind.Subcategory != "Sizing" { + t.Errorf("Pane[0].Subcategory = %q, want %q", bind.Subcategory, "Sizing") + } + } +} + +func TestJSONFileProviderGetCheatSheetNoProvider(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "custom.json") + + content := `{ + "title": "Custom Binds", + "binds": {} +}` + + if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + p, err := NewJSONFileProvider(testFile) + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet.Provider != "custom" { + t.Errorf("Provider = %q, want %q (should default to filename)", sheet.Provider, "custom") + } +} + +func TestJSONFileProviderFlatArrayBackwardsCompat(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy.json") + + content := `{ + "title": "Legacy Format", + "provider": "legacy", + "binds": [ + { + "key": "Ctrl+S", + "desc": "Save file", + "cat": "File", + "subcat": "Operations" + }, + { + "key": "Ctrl+O", + "desc": "Open file", + "cat": "File" + }, + { + "key": "Ctrl+Q", + "desc": "Quit", + "subcat": "Exit" + } + ] +}` + + if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + p, err := NewJSONFileProvider(testFile) + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + sheet, err := p.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + fileBinds, ok := sheet.Binds["File"] + if !ok || len(fileBinds) != 2 { + t.Errorf("expected 2 binds in File category, got %d", len(fileBinds)) + } + + otherBinds, ok := sheet.Binds["Other"] + if !ok || len(otherBinds) != 1 { + t.Errorf("expected 1 bind in Other category (no cat specified), got %d", len(otherBinds)) + } + + if len(fileBinds) > 0 { + if fileBinds[0].Subcategory != "Operations" { + t.Errorf("expected subcategory %q, got %q", "Operations", fileBinds[0].Subcategory) + } + } +} + +func TestJSONFileProviderInvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "invalid.json") + + if err := os.WriteFile(testFile, []byte("not valid json"), 0o644); err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + p, err := NewJSONFileProvider(testFile) + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + _, err = p.GetCheatSheet() + if err == nil { + t.Error("expected error for invalid JSON, got nil") + } +} + +func TestJSONFileProviderNonexistentFile(t *testing.T) { + p, err := NewJSONFileProvider("/nonexistent/file.json") + if err != nil { + t.Fatalf("NewJSONFileProvider failed: %v", err) + } + + _, err = p.GetCheatSheet() + if err == nil { + t.Error("expected error for nonexistent file, got nil") + } +} + +func TestExpandPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "tilde expansion", + input: "~/test", + expected: filepath.Join(home, "test"), + }, + { + name: "no expansion needed", + input: "/absolute/path", + expected: "/absolute/path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := utils.ExpandPath(tt.input) + if err != nil { + t.Fatalf("expandPath failed: %v", err) + } + + if result != tt.expected { + t.Errorf("utils.ExpandPath(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc.go new file mode 100644 index 0000000..348ee7c --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc.go @@ -0,0 +1,442 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type MangoWCProvider struct { + configPath string + dmsBindsIncluded bool + parsed bool +} + +func NewMangoWCProvider(configPath string) *MangoWCProvider { + if configPath == "" { + configPath = defaultMangoWCConfigDir() + } + return &MangoWCProvider{ + configPath: configPath, + } +} + +func defaultMangoWCConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "mango") +} + +func (m *MangoWCProvider) Name() string { + return "mangowc" +} + +func (m *MangoWCProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + result, err := ParseMangoWCKeysWithDMS(m.configPath) + if err != nil { + return nil, fmt.Errorf("failed to parse mangowc config: %w", err) + } + + m.dmsBindsIncluded = result.DMSBindsIncluded + m.parsed = true + + categorizedBinds := make(map[string][]keybinds.Keybind) + for _, kb := range result.Keybinds { + category := m.categorizeByCommand(kb.Command) + bind := m.convertKeybind(&kb, result.ConflictingConfigs) + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + sheet := &keybinds.CheatSheet{ + Title: "MangoWC Keybinds", + Provider: m.Name(), + Binds: categorizedBinds, + DMSBindsIncluded: result.DMSBindsIncluded, + } + + if result.DMSStatus != nil { + sheet.DMSStatus = &keybinds.DMSBindsStatus{ + Exists: result.DMSStatus.Exists, + Included: result.DMSStatus.Included, + IncludePosition: result.DMSStatus.IncludePosition, + TotalIncludes: result.DMSStatus.TotalIncludes, + BindsAfterDMS: result.DMSStatus.BindsAfterDMS, + Effective: result.DMSStatus.Effective, + OverriddenBy: result.DMSStatus.OverriddenBy, + StatusMessage: result.DMSStatus.StatusMessage, + } + } + + return sheet, nil +} + +func (m *MangoWCProvider) HasDMSBindsIncluded() bool { + if m.parsed { + return m.dmsBindsIncluded + } + + result, err := ParseMangoWCKeysWithDMS(m.configPath) + if err != nil { + return false + } + + m.dmsBindsIncluded = result.DMSBindsIncluded + m.parsed = true + return m.dmsBindsIncluded +} + +func (m *MangoWCProvider) categorizeByCommand(command string) string { + switch { + case strings.Contains(command, "mon"): + return "Monitor" + case command == "toggleoverview": + return "Overview" + case command == "toggle_scratchpad": + return "Scratchpad" + case strings.Contains(command, "layout") || strings.Contains(command, "proportion"): + return "Layout" + case strings.Contains(command, "gaps"): + return "Gaps" + case strings.Contains(command, "view") || strings.Contains(command, "tag"): + return "Tags" + case command == "focusstack" || + command == "focusdir" || + command == "exchange_client" || + command == "killclient" || + command == "togglefloating" || + command == "togglefullscreen" || + command == "togglefakefullscreen" || + command == "togglemaximizescreen" || + command == "toggleglobal" || + command == "toggleoverlay" || + command == "minimized" || + command == "restore_minimized" || + command == "movewin" || + command == "resizewin": + return "Window" + case command == "spawn" || command == "spawn_shell": + return "Execute" + case command == "quit" || command == "reload_config": + return "System" + default: + return "Other" + } +} + +func (m *MangoWCProvider) convertKeybind(kb *MangoWCKeyBinding, conflicts map[string]*MangoWCKeyBinding) keybinds.Keybind { + keyStr := m.formatKey(kb) + rawAction := m.formatRawAction(kb.Command, kb.Params) + desc := kb.Comment + + if desc == "" { + desc = rawAction + } + + source := "config" + if strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(filepath.Separator)+"binds.conf") { + source = "dms" + } + + bind := keybinds.Keybind{ + Key: keyStr, + Description: desc, + Action: rawAction, + Source: source, + } + + if source == "dms" && conflicts != nil { + normalizedKey := strings.ToLower(keyStr) + if conflictKb, ok := conflicts[normalizedKey]; ok { + bind.Conflict = &keybinds.Keybind{ + Key: keyStr, + Description: conflictKb.Comment, + Action: m.formatRawAction(conflictKb.Command, conflictKb.Params), + Source: "config", + } + } + } + + return bind +} + +func (m *MangoWCProvider) formatRawAction(command, params string) string { + if params != "" { + return command + " " + params + } + return command +} + +func (m *MangoWCProvider) formatKey(kb *MangoWCKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (m *MangoWCProvider) GetOverridePath() string { + expanded, err := utils.ExpandPath(m.configPath) + if err != nil { + return filepath.Join(m.configPath, "dms", "binds.conf") + } + return filepath.Join(expanded, "dms", "binds.conf") +} + +func (m *MangoWCProvider) validateAction(action string) error { + action = strings.TrimSpace(action) + switch { + case action == "": + return fmt.Errorf("action cannot be empty") + case action == "spawn" || action == "spawn ": + return fmt.Errorf("spawn command requires arguments") + case action == "spawn_shell" || action == "spawn_shell ": + return fmt.Errorf("spawn_shell command requires arguments") + case strings.HasPrefix(action, "spawn "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn ")) + if rest == "" { + return fmt.Errorf("spawn command requires arguments") + } + case strings.HasPrefix(action, "spawn_shell "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn_shell ")) + if rest == "" { + return fmt.Errorf("spawn_shell command requires arguments") + } + } + return nil +} + +func (m *MangoWCProvider) SetBind(key, action, description string, options map[string]any) error { + if err := m.validateAction(action); err != nil { + return err + } + + overridePath := m.GetOverridePath() + + if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil { + return fmt.Errorf("failed to create dms directory: %w", err) + } + + existingBinds, err := m.loadOverrideBinds() + if err != nil { + existingBinds = make(map[string]*mangowcOverrideBind) + } + + normalizedKey := strings.ToLower(key) + existingBinds[normalizedKey] = &mangowcOverrideBind{ + Key: key, + Action: action, + Description: description, + Options: options, + } + + return m.writeOverrideBinds(existingBinds) +} + +func (m *MangoWCProvider) RemoveBind(key string) error { + existingBinds, err := m.loadOverrideBinds() + if err != nil { + return nil + } + + normalizedKey := strings.ToLower(key) + delete(existingBinds, normalizedKey) + return m.writeOverrideBinds(existingBinds) +} + +type mangowcOverrideBind struct { + Key string + Action string + Description string + Options map[string]any +} + +func (m *MangoWCProvider) loadOverrideBinds() (map[string]*mangowcOverrideBind, error) { + overridePath := m.GetOverridePath() + binds := make(map[string]*mangowcOverrideBind) + + data, err := os.ReadFile(overridePath) + if os.IsNotExist(err) { + return binds, nil + } + if err != nil { + return nil, err + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if !strings.HasPrefix(line, "bind") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + continue + } + + content := strings.TrimSpace(parts[1]) + commentParts := strings.SplitN(content, "#", 2) + bindContent := strings.TrimSpace(commentParts[0]) + + var comment string + if len(commentParts) > 1 { + comment = strings.TrimSpace(commentParts[1]) + } + + fields := strings.SplitN(bindContent, ",", 4) + if len(fields) < 3 { + continue + } + + mods := strings.TrimSpace(fields[0]) + keyName := strings.TrimSpace(fields[1]) + command := strings.TrimSpace(fields[2]) + + var params string + if len(fields) > 3 { + params = strings.TrimSpace(fields[3]) + } + + keyStr := m.buildKeyString(mods, keyName) + normalizedKey := strings.ToLower(keyStr) + action := command + if params != "" { + action = command + " " + params + } + + binds[normalizedKey] = &mangowcOverrideBind{ + Key: keyStr, + Action: action, + Description: comment, + } + } + + return binds, nil +} + +func (m *MangoWCProvider) buildKeyString(mods, key string) string { + if mods == "" || strings.EqualFold(mods, "none") { + return key + } + + modList := strings.FieldsFunc(mods, func(r rune) bool { + return r == '+' || r == ' ' + }) + + parts := append(modList, key) + return strings.Join(parts, "+") +} + +func (m *MangoWCProvider) getBindSortPriority(action string) int { + switch { + case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"): + return 0 + case strings.Contains(action, "view") || strings.Contains(action, "tag"): + return 1 + case strings.Contains(action, "focus") || strings.Contains(action, "exchange") || + strings.Contains(action, "resize") || strings.Contains(action, "move"): + return 2 + case strings.Contains(action, "mon"): + return 3 + case strings.HasPrefix(action, "spawn"): + return 4 + case action == "quit" || action == "reload_config": + return 5 + default: + return 6 + } +} + +func (m *MangoWCProvider) writeOverrideBinds(binds map[string]*mangowcOverrideBind) error { + overridePath := m.GetOverridePath() + content := m.generateBindsContent(binds) + return os.WriteFile(overridePath, []byte(content), 0o644) +} + +func (m *MangoWCProvider) generateBindsContent(binds map[string]*mangowcOverrideBind) string { + if len(binds) == 0 { + return "" + } + + bindList := make([]*mangowcOverrideBind, 0, len(binds)) + for _, bind := range binds { + bindList = append(bindList, bind) + } + + sort.Slice(bindList, func(i, j int) bool { + pi, pj := m.getBindSortPriority(bindList[i].Action), m.getBindSortPriority(bindList[j].Action) + if pi != pj { + return pi < pj + } + return bindList[i].Key < bindList[j].Key + }) + + var sb strings.Builder + for _, bind := range bindList { + m.writeBindLine(&sb, bind) + } + + return sb.String() +} + +func (m *MangoWCProvider) writeBindLine(sb *strings.Builder, bind *mangowcOverrideBind) { + mods, key := m.parseKeyString(bind.Key) + command, params := m.parseAction(bind.Action) + + sb.WriteString("bind=") + if mods == "" { + sb.WriteString("none") + } else { + sb.WriteString(mods) + } + sb.WriteString(",") + sb.WriteString(key) + sb.WriteString(",") + sb.WriteString(command) + + if params != "" { + sb.WriteString(",") + sb.WriteString(params) + } + + if bind.Description != "" { + sb.WriteString(" # ") + sb.WriteString(bind.Description) + } + + sb.WriteString("\n") +} + +func (m *MangoWCProvider) parseKeyString(keyStr string) (mods, key string) { + parts := strings.Split(keyStr, "+") + switch len(parts) { + case 0: + return "", keyStr + case 1: + return "", parts[0] + default: + return strings.Join(parts[:len(parts)-1], "+"), parts[len(parts)-1] + } +} + +func (m *MangoWCProvider) parseAction(action string) (command, params string) { + parts := strings.SplitN(action, " ", 2) + switch len(parts) { + case 0: + return action, "" + case 1: + return parts[0], "" + default: + return parts[0], parts[1] + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser.go new file mode 100644 index 0000000..5dac4b6 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser.go @@ -0,0 +1,613 @@ +package providers + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +const ( + MangoWCHideComment = "[hidden]" +) + +var MangoWCModSeparators = []rune{'+', ' '} + +type MangoWCKeyBinding struct { + Mods []string `json:"mods"` + Key string `json:"key"` + Command string `json:"command"` + Params string `json:"params"` + Comment string `json:"comment"` + Source string `json:"source"` +} + +type MangoWCParser struct { + contentLines []string + readingLine int + configDir string + currentSource string + dmsBindsExists bool + dmsBindsIncluded bool + includeCount int + dmsIncludePos int + bindsAfterDMS int + dmsBindKeys map[string]bool + configBindKeys map[string]bool + conflictingConfigs map[string]*MangoWCKeyBinding + bindMap map[string]*MangoWCKeyBinding + bindOrder []string + processedFiles map[string]bool + dmsProcessed bool +} + +func NewMangoWCParser(configDir string) *MangoWCParser { + return &MangoWCParser{ + contentLines: []string{}, + readingLine: 0, + configDir: configDir, + dmsIncludePos: -1, + dmsBindKeys: make(map[string]bool), + configBindKeys: make(map[string]bool), + conflictingConfigs: make(map[string]*MangoWCKeyBinding), + bindMap: make(map[string]*MangoWCKeyBinding), + bindOrder: []string{}, + processedFiles: make(map[string]bool), + } +} + +func (p *MangoWCParser) ReadContent(path string) error { + expandedPath, err := utils.ExpandPath(path) + if err != nil { + return err + } + + info, err := os.Stat(expandedPath) + if err != nil { + return err + } + + var files []string + if info.IsDir() { + confFiles, err := filepath.Glob(filepath.Join(expandedPath, "*.conf")) + if err != nil { + return err + } + if len(confFiles) == 0 { + return os.ErrNotExist + } + files = confFiles + } else { + files = []string{expandedPath} + } + + var combinedContent []string + for _, file := range files { + if fileInfo, err := os.Stat(file); err == nil && fileInfo.Mode().IsRegular() { + data, err := os.ReadFile(file) + if err == nil { + combinedContent = append(combinedContent, string(data)) + } + } + } + + if len(combinedContent) == 0 { + return os.ErrNotExist + } + + fullContent := strings.Join(combinedContent, "\n") + p.contentLines = strings.Split(fullContent, "\n") + return nil +} + +func mangowcAutogenerateComment(command, params string) string { + switch command { + case "spawn", "spawn_shell": + return params + case "killclient": + return "Close window" + case "quit": + return "Exit MangoWC" + case "reload_config": + return "Reload configuration" + case "focusstack": + if params == "next" { + return "Focus next window" + } + if params == "prev" { + return "Focus previous window" + } + return "Focus stack " + params + case "focusdir": + dirMap := map[string]string{ + "left": "left", + "right": "right", + "up": "up", + "down": "down", + } + if dir, ok := dirMap[params]; ok { + return "Focus " + dir + } + return "Focus " + params + case "exchange_client": + dirMap := map[string]string{ + "left": "left", + "right": "right", + "up": "up", + "down": "down", + } + if dir, ok := dirMap[params]; ok { + return "Swap window " + dir + } + return "Swap window " + params + case "togglefloating": + return "Float/unfloat window" + case "togglefullscreen": + return "Toggle fullscreen" + case "togglefakefullscreen": + return "Toggle fake fullscreen" + case "togglemaximizescreen": + return "Toggle maximize" + case "toggleglobal": + return "Toggle global" + case "toggleoverview": + return "Toggle overview" + case "toggleoverlay": + return "Toggle overlay" + case "minimized": + return "Minimize window" + case "restore_minimized": + return "Restore minimized" + case "toggle_scratchpad": + return "Toggle scratchpad" + case "setlayout": + return "Set layout " + params + case "switch_layout": + return "Switch layout" + case "view": + parts := strings.Split(params, ",") + if len(parts) > 0 { + return "View tag " + parts[0] + } + return "View tag" + case "tag": + parts := strings.Split(params, ",") + if len(parts) > 0 { + return "Move to tag " + parts[0] + } + return "Move to tag" + case "toggleview": + parts := strings.Split(params, ",") + if len(parts) > 0 { + return "Toggle tag " + parts[0] + } + return "Toggle tag" + case "viewtoleft", "viewtoleft_have_client": + return "View left tag" + case "viewtoright", "viewtoright_have_client": + return "View right tag" + case "tagtoleft": + return "Move to left tag" + case "tagtoright": + return "Move to right tag" + case "focusmon": + return "Focus monitor " + params + case "tagmon": + return "Move to monitor " + params + case "incgaps": + if strings.HasPrefix(params, "-") { + return "Decrease gaps" + } + return "Increase gaps" + case "togglegaps": + return "Toggle gaps" + case "movewin": + return "Move window by " + params + case "resizewin": + return "Resize window by " + params + case "set_proportion": + return "Set proportion " + params + case "switch_proportion_preset": + return "Switch proportion preset" + default: + return "" + } +} + +func (p *MangoWCParser) getKeybindAtLine(lineNumber int) *MangoWCKeyBinding { + if lineNumber >= len(p.contentLines) { + return nil + } + + line := p.contentLines[lineNumber] + + bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`) + matches := bindMatch.FindStringSubmatch(line) + if len(matches) < 3 { + return nil + } + + bindType := matches[1] + content := matches[2] + + parts := strings.SplitN(content, "#", 2) + keys := parts[0] + + var comment string + if len(parts) > 1 { + comment = strings.TrimSpace(parts[1]) + } + + if strings.HasPrefix(comment, MangoWCHideComment) { + return nil + } + + keyFields := strings.SplitN(keys, ",", 4) + if len(keyFields) < 3 { + return nil + } + + mods := strings.TrimSpace(keyFields[0]) + key := strings.TrimSpace(keyFields[1]) + command := strings.TrimSpace(keyFields[2]) + + var params string + if len(keyFields) > 3 { + params = strings.TrimSpace(keyFields[3]) + } + + if comment == "" { + comment = mangowcAutogenerateComment(command, params) + } + + var modList []string + if mods != "" && !strings.EqualFold(mods, "none") { + modstring := mods + string(MangoWCModSeparators[0]) + p := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range MangoWCModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-p > 1 { + modList = append(modList, modstring[p:index]) + } + p = index + 1 + } + } + } + + _ = bindType + + return &MangoWCKeyBinding{ + Mods: modList, + Key: key, + Command: command, + Params: params, + Comment: comment, + } +} + +func (p *MangoWCParser) ParseKeys() []MangoWCKeyBinding { + var keybinds []MangoWCKeyBinding + + for lineNumber := 0; lineNumber < len(p.contentLines); lineNumber++ { + line := p.contentLines[lineNumber] + if line == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + + if !strings.HasPrefix(strings.TrimSpace(line), "bind") { + continue + } + + keybind := p.getKeybindAtLine(lineNumber) + if keybind != nil { + keybinds = append(keybinds, *keybind) + } + } + + return keybinds +} + +func ParseMangoWCKeys(path string) ([]MangoWCKeyBinding, error) { + parser := NewMangoWCParser(path) + if err := parser.ReadContent(path); err != nil { + return nil, err + } + return parser.ParseKeys(), nil +} + +type MangoWCParseResult struct { + Keybinds []MangoWCKeyBinding + DMSBindsIncluded bool + DMSStatus *MangoWCDMSStatus + ConflictingConfigs map[string]*MangoWCKeyBinding +} + +type MangoWCDMSStatus struct { + Exists bool + Included bool + IncludePosition int + TotalIncludes int + BindsAfterDMS int + Effective bool + OverriddenBy int + StatusMessage string +} + +func (p *MangoWCParser) buildDMSStatus() *MangoWCDMSStatus { + status := &MangoWCDMSStatus{ + Exists: p.dmsBindsExists, + Included: p.dmsBindsIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + BindsAfterDMS: p.bindsAfterDMS, + } + + switch { + case !p.dmsBindsExists: + status.Effective = false + status.StatusMessage = "dms/binds.conf does not exist" + case !p.dmsBindsIncluded: + status.Effective = false + status.StatusMessage = "dms/binds.conf is not sourced in config" + case p.bindsAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.bindsAfterDMS + status.StatusMessage = "Some DMS binds may be overridden by config binds" + default: + status.Effective = true + status.StatusMessage = "DMS binds are active" + } + + return status +} + +func (p *MangoWCParser) formatBindKey(kb *MangoWCKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (p *MangoWCParser) normalizeKey(key string) string { + return strings.ToLower(key) +} + +func (p *MangoWCParser) addBind(kb *MangoWCKeyBinding) { + key := p.formatBindKey(kb) + normalizedKey := p.normalizeKey(key) + isDMSBind := strings.Contains(kb.Source, "dms/binds.conf") || strings.Contains(kb.Source, "dms"+string(os.PathSeparator)+"binds.conf") + + if isDMSBind { + p.dmsBindKeys[normalizedKey] = true + } else if p.dmsBindKeys[normalizedKey] { + p.bindsAfterDMS++ + p.conflictingConfigs[normalizedKey] = kb + p.configBindKeys[normalizedKey] = true + return + } else { + p.configBindKeys[normalizedKey] = true + } + + if _, exists := p.bindMap[normalizedKey]; !exists { + p.bindOrder = append(p.bindOrder, key) + } + p.bindMap[normalizedKey] = kb +} + +func (p *MangoWCParser) ParseWithDMS() ([]MangoWCKeyBinding, error) { + expandedDir, err := utils.ExpandPath(p.configDir) + if err != nil { + return nil, err + } + + dmsBindsPath := filepath.Join(expandedDir, "dms", "binds.conf") + if _, err := os.Stat(dmsBindsPath); err == nil { + p.dmsBindsExists = true + } + + mainConfig := filepath.Join(expandedDir, "config.conf") + if _, err := os.Stat(mainConfig); os.IsNotExist(err) { + mainConfig = filepath.Join(expandedDir, "mango.conf") + } + + _, err = p.parseFileWithSource(mainConfig) + if err != nil { + return nil, err + } + + if p.dmsBindsExists && !p.dmsProcessed { + p.parseDMSBindsDirectly(dmsBindsPath) + } + + var keybinds []MangoWCKeyBinding + for _, key := range p.bindOrder { + normalizedKey := p.normalizeKey(key) + if kb, exists := p.bindMap[normalizedKey]; exists { + keybinds = append(keybinds, *kb) + } + } + + return keybinds, nil +} + +func (p *MangoWCParser) parseFileWithSource(filePath string) ([]MangoWCKeyBinding, error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + + if p.processedFiles[absPath] { + return nil, nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, err + } + + prevSource := p.currentSource + p.currentSource = absPath + + var keybinds []MangoWCKeyBinding + lines := strings.Split(string(data), "\n") + + for lineNum, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "source") { + p.handleSource(trimmed, filepath.Dir(absPath), &keybinds) + continue + } + + if !strings.HasPrefix(trimmed, "bind") { + continue + } + + kb := p.getKeybindAtLineContent(line, lineNum) + if kb == nil { + continue + } + kb.Source = p.currentSource + p.addBind(kb) + keybinds = append(keybinds, *kb) + } + + p.currentSource = prevSource + return keybinds, nil +} + +func (p *MangoWCParser) handleSource(line, baseDir string, keybinds *[]MangoWCKeyBinding) { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return + } + + sourcePath := strings.TrimSpace(parts[1]) + isDMSSource := sourcePath == "dms/binds.conf" || sourcePath == "./dms/binds.conf" || strings.HasSuffix(sourcePath, "/dms/binds.conf") + + p.includeCount++ + if isDMSSource { + p.dmsBindsIncluded = true + p.dmsIncludePos = p.includeCount + p.dmsProcessed = true + } + + expanded, err := utils.ExpandPath(sourcePath) + if err != nil { + return + } + + fullPath := expanded + if !filepath.IsAbs(expanded) { + fullPath = filepath.Join(baseDir, expanded) + } + + includedBinds, err := p.parseFileWithSource(fullPath) + if err != nil { + return + } + + *keybinds = append(*keybinds, includedBinds...) +} + +func (p *MangoWCParser) parseDMSBindsDirectly(dmsBindsPath string) []MangoWCKeyBinding { + keybinds, err := p.parseFileWithSource(dmsBindsPath) + if err != nil { + return nil + } + p.dmsProcessed = true + return keybinds +} + +func (p *MangoWCParser) getKeybindAtLineContent(line string, _ int) *MangoWCKeyBinding { + bindMatch := regexp.MustCompile(`^(bind[lsr]*)\s*=\s*(.+)$`) + matches := bindMatch.FindStringSubmatch(line) + if len(matches) < 3 { + return nil + } + + content := matches[2] + parts := strings.SplitN(content, "#", 2) + keys := parts[0] + + var comment string + if len(parts) > 1 { + comment = strings.TrimSpace(parts[1]) + } + + if strings.HasPrefix(comment, MangoWCHideComment) { + return nil + } + + keyFields := strings.SplitN(keys, ",", 4) + if len(keyFields) < 3 { + return nil + } + + mods := strings.TrimSpace(keyFields[0]) + key := strings.TrimSpace(keyFields[1]) + command := strings.TrimSpace(keyFields[2]) + + var params string + if len(keyFields) > 3 { + params = strings.TrimSpace(keyFields[3]) + } + + if comment == "" { + comment = mangowcAutogenerateComment(command, params) + } + + var modList []string + if mods != "" && !strings.EqualFold(mods, "none") { + modstring := mods + string(MangoWCModSeparators[0]) + idx := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range MangoWCModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-idx > 1 { + modList = append(modList, modstring[idx:index]) + } + idx = index + 1 + } + } + } + + return &MangoWCKeyBinding{ + Mods: modList, + Key: key, + Command: command, + Params: params, + Comment: comment, + } +} + +func ParseMangoWCKeysWithDMS(path string) (*MangoWCParseResult, error) { + parser := NewMangoWCParser(path) + keybinds, err := parser.ParseWithDMS() + if err != nil { + return nil, err + } + + return &MangoWCParseResult{ + Keybinds: keybinds, + DMSBindsIncluded: parser.dmsBindsIncluded, + DMSStatus: parser.buildDMSStatus(), + ConflictingConfigs: parser.conflictingConfigs, + }, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser_test.go new file mode 100644 index 0000000..016f50e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_parser_test.go @@ -0,0 +1,499 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMangoWCAutogenerateComment(t *testing.T) { + tests := []struct { + command string + params string + expected string + }{ + {"spawn", "kitty", "kitty"}, + {"spawn_shell", "firefox", "firefox"}, + {"killclient", "", "Close window"}, + {"quit", "", "Exit MangoWC"}, + {"reload_config", "", "Reload configuration"}, + {"focusstack", "next", "Focus next window"}, + {"focusstack", "prev", "Focus previous window"}, + {"focusdir", "left", "Focus left"}, + {"focusdir", "right", "Focus right"}, + {"focusdir", "up", "Focus up"}, + {"focusdir", "down", "Focus down"}, + {"exchange_client", "left", "Swap window left"}, + {"exchange_client", "right", "Swap window right"}, + {"togglefloating", "", "Float/unfloat window"}, + {"togglefullscreen", "", "Toggle fullscreen"}, + {"togglefakefullscreen", "", "Toggle fake fullscreen"}, + {"togglemaximizescreen", "", "Toggle maximize"}, + {"toggleglobal", "", "Toggle global"}, + {"toggleoverview", "", "Toggle overview"}, + {"toggleoverlay", "", "Toggle overlay"}, + {"minimized", "", "Minimize window"}, + {"restore_minimized", "", "Restore minimized"}, + {"toggle_scratchpad", "", "Toggle scratchpad"}, + {"setlayout", "tile", "Set layout tile"}, + {"switch_layout", "", "Switch layout"}, + {"view", "1,0", "View tag 1"}, + {"tag", "2,0", "Move to tag 2"}, + {"toggleview", "3,0", "Toggle tag 3"}, + {"viewtoleft", "", "View left tag"}, + {"viewtoright", "", "View right tag"}, + {"viewtoleft_have_client", "", "View left tag"}, + {"viewtoright_have_client", "", "View right tag"}, + {"tagtoleft", "", "Move to left tag"}, + {"tagtoright", "", "Move to right tag"}, + {"focusmon", "left", "Focus monitor left"}, + {"tagmon", "right", "Move to monitor right"}, + {"incgaps", "1", "Increase gaps"}, + {"incgaps", "-1", "Decrease gaps"}, + {"togglegaps", "", "Toggle gaps"}, + {"movewin", "+0,-50", "Move window by +0,-50"}, + {"resizewin", "+0,+50", "Resize window by +0,+50"}, + {"set_proportion", "1.0", "Set proportion 1.0"}, + {"switch_proportion_preset", "", "Switch proportion preset"}, + {"unknown", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.command+"_"+tt.params, func(t *testing.T) { + result := mangowcAutogenerateComment(tt.command, tt.params) + if result != tt.expected { + t.Errorf("mangowcAutogenerateComment(%q, %q) = %q, want %q", + tt.command, tt.params, result, tt.expected) + } + }) + } +} + +func TestMangoWCGetKeybindAtLine(t *testing.T) { + tests := []struct { + name string + line string + expected *MangoWCKeyBinding + }{ + { + name: "basic_keybind", + line: "bind=ALT,q,killclient,", + expected: &MangoWCKeyBinding{ + Mods: []string{"ALT"}, + Key: "q", + Command: "killclient", + Params: "", + Comment: "Close window", + }, + }, + { + name: "keybind_with_params", + line: "bind=ALT,Left,focusdir,left", + expected: &MangoWCKeyBinding{ + Mods: []string{"ALT"}, + Key: "Left", + Command: "focusdir", + Params: "left", + Comment: "Focus left", + }, + }, + { + name: "keybind_with_comment", + line: "bind=Alt,t,spawn,kitty # Open terminal", + expected: &MangoWCKeyBinding{ + Mods: []string{"Alt"}, + Key: "t", + Command: "spawn", + Params: "kitty", + Comment: "Open terminal", + }, + }, + { + name: "keybind_hidden", + line: "bind=SUPER,h,spawn,secret # [hidden]", + expected: nil, + }, + { + name: "keybind_multiple_mods", + line: "bind=SUPER+SHIFT,Up,exchange_client,up", + expected: &MangoWCKeyBinding{ + Mods: []string{"SUPER", "SHIFT"}, + Key: "Up", + Command: "exchange_client", + Params: "up", + Comment: "Swap window up", + }, + }, + { + name: "keybind_no_mods", + line: "bind=NONE,Print,spawn,screenshot", + expected: &MangoWCKeyBinding{ + Mods: []string{}, + Key: "Print", + Command: "spawn", + Params: "screenshot", + Comment: "screenshot", + }, + }, + { + name: "keybind_multiple_params", + line: "bind=Ctrl,1,view,1,0", + expected: &MangoWCKeyBinding{ + Mods: []string{"Ctrl"}, + Key: "1", + Command: "view", + Params: "1,0", + Comment: "View tag 1", + }, + }, + { + name: "bindl_flag", + line: "bindl=SUPER+ALT,l,spawn,dms ipc call lock lock", + expected: &MangoWCKeyBinding{ + Mods: []string{"SUPER", "ALT"}, + Key: "l", + Command: "spawn", + Params: "dms ipc call lock lock", + Comment: "dms ipc call lock lock", + }, + }, + { + name: "keybind_with_spaces", + line: "bind = SUPER, r, reload_config", + expected: &MangoWCKeyBinding{ + Mods: []string{"SUPER"}, + Key: "r", + Command: "reload_config", + Params: "", + Comment: "Reload configuration", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewMangoWCParser("") + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if tt.expected == nil { + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + return + } + + if result == nil { + t.Errorf("expected %+v, got nil", tt.expected) + return + } + + if result.Key != tt.expected.Key { + t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key) + } + if result.Command != tt.expected.Command { + t.Errorf("Command = %q, want %q", result.Command, tt.expected.Command) + } + if result.Params != tt.expected.Params { + t.Errorf("Params = %q, want %q", result.Params, tt.expected.Params) + } + if result.Comment != tt.expected.Comment { + t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment) + } + if len(result.Mods) != len(tt.expected.Mods) { + t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods)) + } else { + for i := range result.Mods { + if result.Mods[i] != tt.expected.Mods[i] { + t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i]) + } + } + } + }) + } +} + +func TestMangoWCParseKeys(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := `# MangoWC Configuration +blur=0 +border_radius=12 + +# Key Bindings +bind=SUPER,r,reload_config +bind=Alt,t,spawn,kitty # Terminal +bind=ALT,q,killclient, +bind=ALT,Left,focusdir,left + +# Hidden binding +bind=SUPER,h,spawn,secret # [hidden] + +# Multiple modifiers +bind=SUPER+SHIFT,Up,exchange_client,up + +# Workspace bindings +bind=Ctrl,1,view,1,0 +bind=Ctrl,2,view,2,0 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + keybinds, err := ParseMangoWCKeys(configFile) + if err != nil { + t.Fatalf("ParseMangoWCKeys failed: %v", err) + } + + expectedCount := 7 + if len(keybinds) != expectedCount { + t.Errorf("Expected %d keybinds, got %d", expectedCount, len(keybinds)) + } + + if len(keybinds) > 0 && keybinds[0].Command != "reload_config" { + t.Errorf("First keybind command = %q, want %q", keybinds[0].Command, "reload_config") + } + + foundHidden := false + for _, kb := range keybinds { + if kb.Command == "spawn" && kb.Params == "secret" { + foundHidden = true + } + } + if foundHidden { + t.Error("Hidden keybind should not be included in results") + } +} + +func TestMangoWCReadContentMultipleFiles(t *testing.T) { + tmpDir := t.TempDir() + + file1 := filepath.Join(tmpDir, "a.conf") + file2 := filepath.Join(tmpDir, "b.conf") + + content1 := "bind=ALT,q,killclient,\n" + content2 := "bind=Alt,t,spawn,kitty\n" + + if err := os.WriteFile(file1, []byte(content1), 0o644); err != nil { + t.Fatalf("Failed to write file1: %v", err) + } + if err := os.WriteFile(file2, []byte(content2), 0o644); err != nil { + t.Fatalf("Failed to write file2: %v", err) + } + + parser := NewMangoWCParser("") + if err := parser.ReadContent(tmpDir); err != nil { + t.Fatalf("ReadContent failed: %v", err) + } + + keybinds := parser.ParseKeys() + if len(keybinds) != 2 { + t.Errorf("Expected 2 keybinds from multiple files, got %d", len(keybinds)) + } +} + +func TestMangoWCReadContentSingleFile(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := "bind=ALT,q,killclient,\n" + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + parser := NewMangoWCParser("") + if err := parser.ReadContent(configFile); err != nil { + t.Fatalf("ReadContent failed: %v", err) + } + + keybinds := parser.ParseKeys() + if len(keybinds) != 1 { + t.Errorf("Expected 1 keybind, got %d", len(keybinds)) + } +} + +func TestMangoWCReadContentErrors(t *testing.T) { + tests := []struct { + name string + path string + }{ + { + name: "nonexistent_directory", + path: "/nonexistent/path/that/does/not/exist", + }, + { + name: "empty_directory", + path: t.TempDir(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseMangoWCKeys(tt.path) + if err == nil { + t.Error("Expected error, got nil") + } + }) + } +} + +func TestMangoWCReadContentWithTildeExpansion(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tmpSubdir := filepath.Join(homeDir, ".config", "test-mango-"+t.Name()) + if err := os.MkdirAll(tmpSubdir, 0o755); err != nil { + t.Skip("Cannot create test directory in home") + } + defer os.RemoveAll(tmpSubdir) + + configFile := filepath.Join(tmpSubdir, "config.conf") + if err := os.WriteFile(configFile, []byte("bind=ALT,q,killclient,\n"), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + relPath, err := filepath.Rel(homeDir, tmpSubdir) + if err != nil { + t.Skip("Cannot create relative path") + } + + parser := NewMangoWCParser("") + tildePathMatch := "~/" + relPath + err = parser.ReadContent(tildePathMatch) + + if err != nil { + t.Errorf("ReadContent with tilde path failed: %v", err) + } +} + +func TestMangoWCEmptyAndCommentLines(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := ` +# This is a comment +bind=ALT,q,killclient, + +# Another comment + +bind=Alt,t,spawn,kitty +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + keybinds, err := ParseMangoWCKeys(configFile) + if err != nil { + t.Fatalf("ParseMangoWCKeys failed: %v", err) + } + + if len(keybinds) != 2 { + t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(keybinds)) + } +} + +func TestMangoWCInvalidBindLines(t *testing.T) { + tests := []struct { + name string + line string + }{ + { + name: "missing_parts", + line: "bind=SUPER,q", + }, + { + name: "not_bind", + line: "blur=0", + }, + { + name: "empty_line", + line: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewMangoWCParser("") + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if result != nil { + t.Errorf("expected nil for invalid line, got %+v", result) + } + }) + } +} + +func TestMangoWCRealWorldConfig(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := `# Application Launchers +bind=Alt,t,spawn,kitty +bind=Alt,space,spawn,dms ipc call spotlight toggle +bind=Alt,v,spawn,dms ipc call clipboard toggle + +# exit +bind=ALT+SHIFT,e,quit +bind=ALT,q,killclient, + +# switch window focus +bind=SUPER,Tab,focusstack,next +bind=ALT,Left,focusdir,left +bind=ALT,Right,focusdir,right + +# tag switch +bind=SUPER,Left,viewtoleft,0 +bind=CTRL,Left,viewtoleft_have_client,0 +bind=SUPER,Right,viewtoright,0 + +bind=Ctrl,1,view,1,0 +bind=Ctrl,2,view,2,0 +bind=Ctrl,3,view,3,0 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + keybinds, err := ParseMangoWCKeys(configFile) + if err != nil { + t.Fatalf("ParseMangoWCKeys failed: %v", err) + } + + if len(keybinds) < 14 { + t.Errorf("Expected at least 14 keybinds, got %d", len(keybinds)) + } + + foundSpawn := false + foundQuit := false + foundView := false + + for _, kb := range keybinds { + if kb.Command == "spawn" && kb.Params == "kitty" { + foundSpawn = true + } + if kb.Command == "quit" { + foundQuit = true + } + if kb.Command == "view" && kb.Params == "1,0" { + foundView = true + } + } + + if !foundSpawn { + t.Error("Did not find spawn kitty keybind") + } + if !foundQuit { + t.Error("Did not find quit keybind") + } + if !foundView { + t.Error("Did not find view workspace 1 keybind") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_test.go new file mode 100644 index 0000000..d417ce1 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/mangowc_test.go @@ -0,0 +1,320 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMangoWCProviderName(t *testing.T) { + provider := NewMangoWCProvider("") + if provider.Name() != "mangowc" { + t.Errorf("Name() = %q, want %q", provider.Name(), "mangowc") + } +} + +func TestMangoWCProviderDefaultPath(t *testing.T) { + provider := NewMangoWCProvider("") + configDir, err := os.UserConfigDir() + if err != nil { + // Fall back to testing for non-empty path + if provider.configPath == "" { + t.Error("configPath should not be empty") + } + return + } + expected := filepath.Join(configDir, "mango") + if provider.configPath != expected { + t.Errorf("configPath = %q, want %q", provider.configPath, expected) + } +} + +func TestMangoWCProviderCustomPath(t *testing.T) { + customPath := "/custom/path" + provider := NewMangoWCProvider(customPath) + if provider.configPath != customPath { + t.Errorf("configPath = %q, want %q", provider.configPath, customPath) + } +} + +func TestMangoWCCategorizeByCommand(t *testing.T) { + tests := []struct { + command string + expected string + }{ + {"view", "Tags"}, + {"tag", "Tags"}, + {"toggleview", "Tags"}, + {"viewtoleft", "Tags"}, + {"viewtoright", "Tags"}, + {"viewtoleft_have_client", "Tags"}, + {"tagtoleft", "Tags"}, + {"tagtoright", "Tags"}, + {"focusmon", "Monitor"}, + {"tagmon", "Monitor"}, + {"focusstack", "Window"}, + {"focusdir", "Window"}, + {"exchange_client", "Window"}, + {"killclient", "Window"}, + {"togglefloating", "Window"}, + {"togglefullscreen", "Window"}, + {"togglefakefullscreen", "Window"}, + {"togglemaximizescreen", "Window"}, + {"toggleglobal", "Window"}, + {"toggleoverlay", "Window"}, + {"minimized", "Window"}, + {"restore_minimized", "Window"}, + {"movewin", "Window"}, + {"resizewin", "Window"}, + {"toggleoverview", "Overview"}, + {"toggle_scratchpad", "Scratchpad"}, + {"setlayout", "Layout"}, + {"switch_layout", "Layout"}, + {"set_proportion", "Layout"}, + {"switch_proportion_preset", "Layout"}, + {"incgaps", "Gaps"}, + {"togglegaps", "Gaps"}, + {"spawn", "Execute"}, + {"spawn_shell", "Execute"}, + {"quit", "System"}, + {"reload_config", "System"}, + {"unknown_command", "Other"}, + } + + provider := NewMangoWCProvider("") + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + result := provider.categorizeByCommand(tt.command) + if result != tt.expected { + t.Errorf("categorizeByCommand(%q) = %q, want %q", tt.command, result, tt.expected) + } + }) + } +} + +func TestMangoWCFormatKey(t *testing.T) { + tests := []struct { + name string + keybind *MangoWCKeyBinding + expected string + }{ + { + name: "single_mod", + keybind: &MangoWCKeyBinding{ + Mods: []string{"ALT"}, + Key: "q", + }, + expected: "ALT+q", + }, + { + name: "multiple_mods", + keybind: &MangoWCKeyBinding{ + Mods: []string{"SUPER", "SHIFT"}, + Key: "Up", + }, + expected: "SUPER+SHIFT+Up", + }, + { + name: "no_mods", + keybind: &MangoWCKeyBinding{ + Mods: []string{}, + Key: "Print", + }, + expected: "Print", + }, + } + + provider := NewMangoWCProvider("") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.formatKey(tt.keybind) + if result != tt.expected { + t.Errorf("formatKey() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestMangoWCConvertKeybind(t *testing.T) { + tests := []struct { + name string + keybind *MangoWCKeyBinding + wantKey string + wantDesc string + }{ + { + name: "with_comment", + keybind: &MangoWCKeyBinding{ + Mods: []string{"ALT"}, + Key: "t", + Command: "spawn", + Params: "kitty", + Comment: "Open terminal", + }, + wantKey: "ALT+t", + wantDesc: "Open terminal", + }, + { + name: "without_comment", + keybind: &MangoWCKeyBinding{ + Mods: []string{"SUPER"}, + Key: "r", + Command: "reload_config", + Params: "", + Comment: "", + }, + wantKey: "SUPER+r", + wantDesc: "reload_config", + }, + { + name: "with_params_no_comment", + keybind: &MangoWCKeyBinding{ + Mods: []string{"CTRL"}, + Key: "1", + Command: "view", + Params: "1,0", + Comment: "", + }, + wantKey: "CTRL+1", + wantDesc: "view 1,0", + }, + } + + provider := NewMangoWCProvider("") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.convertKeybind(tt.keybind, nil) + if result.Key != tt.wantKey { + t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey) + } + if result.Description != tt.wantDesc { + t.Errorf("convertKeybind().Description = %q, want %q", result.Description, tt.wantDesc) + } + }) + } +} + +func TestMangoWCGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := `# MangoWC Configuration +blur=0 + +# Key Bindings +bind=SUPER,r,reload_config +bind=Alt,t,spawn,kitty # Terminal +bind=ALT,q,killclient, + +# Window management +bind=ALT,Left,focusdir,left +bind=ALT,Right,focusdir,right +bind=SUPER+SHIFT,Up,exchange_client,up + +# Tags +bind=Ctrl,1,view,1,0 +bind=Ctrl,2,view,2,0 +bind=Alt,1,tag,1,0 + +# Layout +bind=SUPER,n,switch_layout + +# Gaps +bind=ALT+SHIFT,X,incgaps,1 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewMangoWCProvider(tmpDir) + sheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet == nil { + t.Fatal("Expected non-nil CheatSheet") + } + + if sheet.Title != "MangoWC Keybinds" { + t.Errorf("Title = %q, want %q", sheet.Title, "MangoWC Keybinds") + } + + if sheet.Provider != "mangowc" { + t.Errorf("Provider = %q, want %q", sheet.Provider, "mangowc") + } + + categories := []string{"System", "Execute", "Window", "Tags", "Layout", "Gaps"} + for _, category := range categories { + if _, exists := sheet.Binds[category]; !exists { + t.Errorf("Expected category %q to exist", category) + } + } + + if len(sheet.Binds["System"]) < 1 { + t.Error("Expected at least 1 System keybind") + } + if len(sheet.Binds["Execute"]) < 1 { + t.Error("Expected at least 1 Execute keybind") + } + if len(sheet.Binds["Window"]) < 3 { + t.Error("Expected at least 3 Window keybinds") + } + if len(sheet.Binds["Tags"]) < 3 { + t.Error("Expected at least 3 Tags keybinds") + } +} + +func TestMangoWCGetCheatSheetError(t *testing.T) { + provider := NewMangoWCProvider("/nonexistent/path") + _, err := provider.GetCheatSheet() + if err == nil { + t.Error("Expected error for nonexistent path, got nil") + } +} + +func TestMangoWCIntegration(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.conf") + + content := `bind=Alt,t,spawn,kitty # Open terminal +bind=ALT,q,killclient, +bind=SUPER,r,reload_config # Reload config +bind=ALT,Left,focusdir,left +bind=Ctrl,1,view,1,0 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewMangoWCProvider(tmpDir) + sheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + totalBinds := 0 + for _, binds := range sheet.Binds { + totalBinds += len(binds) + } + + expectedBinds := 5 + if totalBinds != expectedBinds { + t.Errorf("Expected %d total keybinds, got %d", expectedBinds, totalBinds) + } + + foundTerminal := false + for _, binds := range sheet.Binds { + for _, bind := range binds { + if bind.Description == "Open terminal" && bind.Key == "Alt+t" { + foundTerminal = true + } + } + } + + if !foundTerminal { + t.Error("Did not find terminal keybind with correct key and description") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle.go new file mode 100644 index 0000000..43fc58e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle.go @@ -0,0 +1,95 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type MiracleProvider struct { + configPath string +} + +func NewMiracleProvider(configPath string) *MiracleProvider { + if configPath == "" { + configDir, err := os.UserConfigDir() + if err == nil { + configPath = filepath.Join(configDir, "miracle-wm") + } + } + return &MiracleProvider{configPath: configPath} +} + +func (m *MiracleProvider) Name() string { + return "miracle" +} + +func (m *MiracleProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + config, err := ParseMiracleConfig(m.configPath) + if err != nil { + return nil, fmt.Errorf("failed to parse miracle-wm config: %w", err) + } + + bindings := MiracleConfigToBindings(config) + categorizedBinds := make(map[string][]keybinds.Keybind) + + for _, kb := range bindings { + category := m.categorizeAction(kb.Action) + bind := keybinds.Keybind{ + Key: m.formatKey(kb), + Description: kb.Comment, + Action: kb.Action, + } + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + return &keybinds.CheatSheet{ + Title: "Miracle WM Keybinds", + Provider: m.Name(), + Binds: categorizedBinds, + }, nil +} + +func (m *MiracleProvider) GetOverridePath() string { + expanded, err := utils.ExpandPath(m.configPath) + if err != nil { + return filepath.Join(m.configPath, "config.yaml") + } + return filepath.Join(expanded, "config.yaml") +} + +func (m *MiracleProvider) formatKey(kb MiracleKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (m *MiracleProvider) categorizeAction(action string) string { + switch { + case strings.HasPrefix(action, "select_workspace_") || strings.HasPrefix(action, "move_to_workspace_"): + return "Workspace" + case strings.Contains(action, "select_") || strings.Contains(action, "move_"): + return "Window" + case action == "toggle_resize" || strings.HasPrefix(action, "resize_"): + return "Window" + case action == "fullscreen" || action == "toggle_floating" || action == "quit_active_window" || action == "toggle_pinned_to_workspace": + return "Window" + case action == "toggle_tabbing" || action == "toggle_stacking" || action == "request_vertical" || action == "request_horizontal": + return "Layout" + case action == "quit_compositor": + return "System" + case action == "terminal": + return "Execute" + case strings.HasPrefix(action, "magnifier_"): + return "Accessibility" + case strings.HasPrefix(action, "dms ") || strings.Contains(action, "dms ipc"): + return "Execute" + default: + return "Execute" + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle_parser.go new file mode 100644 index 0000000..425c64a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/miracle_parser.go @@ -0,0 +1,320 @@ +package providers + +import ( + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "gopkg.in/yaml.v3" +) + +type MiracleConfig struct { + Terminal string `yaml:"terminal"` + ActionKey string `yaml:"action_key"` + DefaultActionOverrides []MiracleActionOverride `yaml:"default_action_overrides"` + CustomActions []MiracleCustomAction `yaml:"custom_actions"` +} + +type MiracleActionOverride struct { + Name string `yaml:"name"` + Action string `yaml:"action"` + Modifiers []string `yaml:"modifiers"` + Key string `yaml:"key"` +} + +type MiracleCustomAction struct { + Command string `yaml:"command"` + Action string `yaml:"action"` + Modifiers []string `yaml:"modifiers"` + Key string `yaml:"key"` +} + +type MiracleKeyBinding struct { + Mods []string + Key string + Action string + Comment string +} + +var miracleDefaultBinds = []MiracleKeyBinding{ + {Mods: []string{"Super"}, Key: "Return", Action: "terminal", Comment: "Open terminal"}, + {Mods: []string{"Super"}, Key: "v", Action: "request_vertical", Comment: "Layout windows vertically"}, + {Mods: []string{"Super"}, Key: "h", Action: "request_horizontal", Comment: "Layout windows horizontally"}, + {Mods: []string{"Super"}, Key: "Up", Action: "select_up", Comment: "Select window above"}, + {Mods: []string{"Super"}, Key: "Down", Action: "select_down", Comment: "Select window below"}, + {Mods: []string{"Super"}, Key: "Left", Action: "select_left", Comment: "Select window left"}, + {Mods: []string{"Super"}, Key: "Right", Action: "select_right", Comment: "Select window right"}, + {Mods: []string{"Super", "Shift"}, Key: "Up", Action: "move_up", Comment: "Move window up"}, + {Mods: []string{"Super", "Shift"}, Key: "Down", Action: "move_down", Comment: "Move window down"}, + {Mods: []string{"Super", "Shift"}, Key: "Left", Action: "move_left", Comment: "Move window left"}, + {Mods: []string{"Super", "Shift"}, Key: "Right", Action: "move_right", Comment: "Move window right"}, + {Mods: []string{"Super"}, Key: "r", Action: "toggle_resize", Comment: "Toggle resize mode"}, + {Mods: []string{"Super"}, Key: "f", Action: "fullscreen", Comment: "Toggle fullscreen"}, + {Mods: []string{"Super", "Shift"}, Key: "q", Action: "quit_active_window", Comment: "Close window"}, + {Mods: []string{"Super", "Shift"}, Key: "e", Action: "quit_compositor", Comment: "Exit compositor"}, + {Mods: []string{"Super"}, Key: "Space", Action: "toggle_floating", Comment: "Toggle floating"}, + {Mods: []string{"Super", "Shift"}, Key: "p", Action: "toggle_pinned_to_workspace", Comment: "Toggle pinned to workspace"}, + {Mods: []string{"Super"}, Key: "w", Action: "toggle_tabbing", Comment: "Toggle tabbing layout"}, + {Mods: []string{"Super"}, Key: "s", Action: "toggle_stacking", Comment: "Toggle stacking layout"}, + {Mods: []string{"Super"}, Key: "1", Action: "select_workspace_0", Comment: "Workspace 1"}, + {Mods: []string{"Super"}, Key: "2", Action: "select_workspace_1", Comment: "Workspace 2"}, + {Mods: []string{"Super"}, Key: "3", Action: "select_workspace_2", Comment: "Workspace 3"}, + {Mods: []string{"Super"}, Key: "4", Action: "select_workspace_3", Comment: "Workspace 4"}, + {Mods: []string{"Super"}, Key: "5", Action: "select_workspace_4", Comment: "Workspace 5"}, + {Mods: []string{"Super"}, Key: "6", Action: "select_workspace_5", Comment: "Workspace 6"}, + {Mods: []string{"Super"}, Key: "7", Action: "select_workspace_6", Comment: "Workspace 7"}, + {Mods: []string{"Super"}, Key: "8", Action: "select_workspace_7", Comment: "Workspace 8"}, + {Mods: []string{"Super"}, Key: "9", Action: "select_workspace_8", Comment: "Workspace 9"}, + {Mods: []string{"Super"}, Key: "0", Action: "select_workspace_9", Comment: "Workspace 10"}, + {Mods: []string{"Super", "Shift"}, Key: "1", Action: "move_to_workspace_0", Comment: "Move to workspace 1"}, + {Mods: []string{"Super", "Shift"}, Key: "2", Action: "move_to_workspace_1", Comment: "Move to workspace 2"}, + {Mods: []string{"Super", "Shift"}, Key: "3", Action: "move_to_workspace_2", Comment: "Move to workspace 3"}, + {Mods: []string{"Super", "Shift"}, Key: "4", Action: "move_to_workspace_3", Comment: "Move to workspace 4"}, + {Mods: []string{"Super", "Shift"}, Key: "5", Action: "move_to_workspace_4", Comment: "Move to workspace 5"}, + {Mods: []string{"Super", "Shift"}, Key: "6", Action: "move_to_workspace_5", Comment: "Move to workspace 6"}, + {Mods: []string{"Super", "Shift"}, Key: "7", Action: "move_to_workspace_6", Comment: "Move to workspace 7"}, + {Mods: []string{"Super", "Shift"}, Key: "8", Action: "move_to_workspace_7", Comment: "Move to workspace 8"}, + {Mods: []string{"Super", "Shift"}, Key: "9", Action: "move_to_workspace_8", Comment: "Move to workspace 9"}, + {Mods: []string{"Super", "Shift"}, Key: "0", Action: "move_to_workspace_9", Comment: "Move to workspace 10"}, +} + +func ParseMiracleConfig(configPath string) (*MiracleConfig, error) { + expanded, err := utils.ExpandPath(configPath) + if err != nil { + return nil, err + } + + info, err := os.Stat(expanded) + if err != nil { + return nil, err + } + + var configFile string + if info.IsDir() { + configFile = filepath.Join(expanded, "config.yaml") + } else { + configFile = expanded + } + + data, err := os.ReadFile(configFile) + if err != nil { + return nil, err + } + + var config MiracleConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, err + } + + if config.ActionKey == "" { + config.ActionKey = "meta" + } + + return &config, nil +} + +func resolveMiracleModifier(mod, actionKey string) string { + switch mod { + case "primary": + return resolveActionKey(actionKey) + case "alt", "alt_left", "alt_right": + return "Alt" + case "shift", "shift_left", "shift_right": + return "Shift" + case "ctrl", "ctrl_left", "ctrl_right": + return "Ctrl" + case "meta", "meta_left", "meta_right": + return "Super" + default: + return mod + } +} + +func resolveActionKey(actionKey string) string { + switch actionKey { + case "meta": + return "Super" + case "alt": + return "Alt" + case "ctrl": + return "Ctrl" + default: + return "Super" + } +} + +func miracleKeyCodeToName(keyCode string) string { + name := strings.TrimPrefix(keyCode, "KEY_") + name = strings.ToLower(name) + + switch name { + case "enter": + return "Return" + case "space": + return "Space" + case "up": + return "Up" + case "down": + return "Down" + case "left": + return "Left" + case "right": + return "Right" + case "tab": + return "Tab" + case "escape", "esc": + return "Escape" + case "delete": + return "Delete" + case "backspace": + return "BackSpace" + case "home": + return "Home" + case "end": + return "End" + case "pageup": + return "Page_Up" + case "pagedown": + return "Page_Down" + case "print": + return "Print" + case "pause": + return "Pause" + case "volumeup": + return "XF86AudioRaiseVolume" + case "volumedown": + return "XF86AudioLowerVolume" + case "mute": + return "XF86AudioMute" + case "micmute": + return "XF86AudioMicMute" + case "brightnessup": + return "XF86MonBrightnessUp" + case "brightnessdown": + return "XF86MonBrightnessDown" + case "kbdillumup": + return "XF86KbdBrightnessUp" + case "kbdillumdown": + return "XF86KbdBrightnessDown" + case "comma": + return "comma" + case "minus": + return "minus" + case "equal": + return "equal" + } + + if len(name) == 1 { + return name + } + + return name +} + +func MiracleConfigToBindings(config *MiracleConfig) []MiracleKeyBinding { + overridden := make(map[string]bool) + var bindings []MiracleKeyBinding + + for _, override := range config.DefaultActionOverrides { + mods := make([]string, 0, len(override.Modifiers)) + for _, mod := range override.Modifiers { + mods = append(mods, resolveMiracleModifier(mod, config.ActionKey)) + } + + bindings = append(bindings, MiracleKeyBinding{ + Mods: mods, + Key: miracleKeyCodeToName(override.Key), + Action: override.Name, + Comment: miracleActionDescription(override.Name), + }) + overridden[override.Name] = true + } + + for _, def := range miracleDefaultBinds { + if overridden[def.Action] { + continue + } + bindings = append(bindings, def) + } + + for _, custom := range config.CustomActions { + mods := make([]string, 0, len(custom.Modifiers)) + for _, mod := range custom.Modifiers { + mods = append(mods, resolveMiracleModifier(mod, config.ActionKey)) + } + + bindings = append(bindings, MiracleKeyBinding{ + Mods: mods, + Key: miracleKeyCodeToName(custom.Key), + Action: custom.Command, + Comment: custom.Command, + }) + } + + return bindings +} + +func miracleActionDescription(action string) string { + switch action { + case "terminal": + return "Open terminal" + case "request_vertical": + return "Layout windows vertically" + case "request_horizontal": + return "Layout windows horizontally" + case "select_up": + return "Select window above" + case "select_down": + return "Select window below" + case "select_left": + return "Select window left" + case "select_right": + return "Select window right" + case "move_up": + return "Move window up" + case "move_down": + return "Move window down" + case "move_left": + return "Move window left" + case "move_right": + return "Move window right" + case "toggle_resize": + return "Toggle resize mode" + case "fullscreen": + return "Toggle fullscreen" + case "quit_active_window": + return "Close window" + case "quit_compositor": + return "Exit compositor" + case "toggle_floating": + return "Toggle floating" + case "toggle_pinned_to_workspace": + return "Toggle pinned to workspace" + case "toggle_tabbing": + return "Toggle tabbing layout" + case "toggle_stacking": + return "Toggle stacking layout" + case "magnifier_on": + return "Enable magnifier" + case "magnifier_off": + return "Disable magnifier" + case "magnifier_increase_size": + return "Increase magnifier area" + case "magnifier_decrease_size": + return "Decrease magnifier area" + case "magnifier_increase_scale": + return "Increase magnifier scale" + case "magnifier_decrease_scale": + return "Decrease magnifier scale" + } + + if num, ok := strings.CutPrefix(action, "select_workspace_"); ok { + return "Workspace " + num + } + if num, ok := strings.CutPrefix(action, "move_to_workspace_"); ok { + return "Move to workspace " + num + } + + return action +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri.go new file mode 100644 index 0000000..aec20b5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri.go @@ -0,0 +1,650 @@ +package providers + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" + "github.com/sblinch/kdl-go" + "github.com/sblinch/kdl-go/document" +) + +type NiriProvider struct { + configDir string + dmsBindsIncluded bool + parsed bool +} + +func NewNiriProvider(configDir string) *NiriProvider { + if configDir == "" { + configDir = defaultNiriConfigDir() + } + return &NiriProvider{ + configDir: configDir, + } +} + +func defaultNiriConfigDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "niri") +} + +func (n *NiriProvider) Name() string { + return "niri" +} + +func (n *NiriProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + result, err := ParseNiriKeys(n.configDir) + if err != nil { + return nil, fmt.Errorf("failed to parse niri config: %w", err) + } + + n.dmsBindsIncluded = result.DMSBindsIncluded + n.parsed = true + + categorizedBinds := make(map[string][]keybinds.Keybind) + n.convertSection(result.Section, "", categorizedBinds, result.ConflictingConfigs) + + sheet := &keybinds.CheatSheet{ + Title: "Niri Keybinds", + Provider: n.Name(), + Binds: categorizedBinds, + DMSBindsIncluded: result.DMSBindsIncluded, + } + + if result.DMSStatus != nil { + sheet.DMSStatus = &keybinds.DMSBindsStatus{ + Exists: result.DMSStatus.Exists, + Included: result.DMSStatus.Included, + IncludePosition: result.DMSStatus.IncludePosition, + TotalIncludes: result.DMSStatus.TotalIncludes, + BindsAfterDMS: result.DMSStatus.BindsAfterDMS, + Effective: result.DMSStatus.Effective, + OverriddenBy: result.DMSStatus.OverriddenBy, + StatusMessage: result.DMSStatus.StatusMessage, + } + } + + return sheet, nil +} + +func (n *NiriProvider) HasDMSBindsIncluded() bool { + if n.parsed { + return n.dmsBindsIncluded + } + + result, err := ParseNiriKeys(n.configDir) + if err != nil { + return false + } + + n.dmsBindsIncluded = result.DMSBindsIncluded + n.parsed = true + return n.dmsBindsIncluded +} + +func (n *NiriProvider) convertSection(section *NiriSection, subcategory string, categorizedBinds map[string][]keybinds.Keybind, conflicts map[string]*NiriKeyBinding) { + currentSubcat := subcategory + if section.Name != "" { + currentSubcat = section.Name + } + + for _, kb := range section.Keybinds { + category := n.categorizeByAction(kb.Action) + bind := n.convertKeybind(&kb, currentSubcat, conflicts) + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + for _, child := range section.Children { + n.convertSection(&child, currentSubcat, categorizedBinds, conflicts) + } +} + +func (n *NiriProvider) categorizeByAction(action string) string { + switch { + case action == "next-window" || action == "previous-window": + return "Alt-Tab" + case strings.Contains(action, "screenshot"): + return "Screenshot" + case action == "show-hotkey-overlay" || action == "toggle-overview": + return "Overview" + case action == "quit" || + action == "power-off-monitors" || + action == "power-on-monitors" || + action == "suspend" || + action == "do-screen-transition" || + action == "toggle-keyboard-shortcuts-inhibit" || + strings.Contains(action, "dpms"): + return "System" + case action == "spawn": + return "Execute" + case strings.Contains(action, "workspace"): + return "Workspace" + case strings.HasPrefix(action, "focus-monitor") || + strings.HasPrefix(action, "move-column-to-monitor") || + strings.HasPrefix(action, "move-window-to-monitor"): + return "Monitor" + case strings.Contains(action, "window") || + strings.Contains(action, "focus") || + strings.Contains(action, "move") || + strings.Contains(action, "swap") || + strings.Contains(action, "resize") || + strings.Contains(action, "column"): + return "Window" + default: + return "Other" + } +} + +func (n *NiriProvider) convertKeybind(kb *NiriKeyBinding, subcategory string, conflicts map[string]*NiriKeyBinding) keybinds.Keybind { + rawAction := n.formatRawAction(kb.Action, kb.Args) + keyStr := n.formatKey(kb) + + source := "config" + if strings.Contains(kb.Source, "dms/binds.kdl") { + source = "dms" + } + + bind := keybinds.Keybind{ + Key: keyStr, + Description: kb.Description, + Action: rawAction, + Subcategory: subcategory, + Source: source, + HideOnOverlay: kb.HideOnOverlay, + CooldownMs: kb.CooldownMs, + AllowWhenLocked: kb.AllowWhenLocked, + AllowInhibiting: kb.AllowInhibiting, + Repeat: kb.Repeat, + } + + if source == "dms" && conflicts != nil { + if conflictKb, ok := conflicts[keyStr]; ok { + bind.Conflict = &keybinds.Keybind{ + Key: keyStr, + Description: conflictKb.Description, + Action: n.formatRawAction(conflictKb.Action, conflictKb.Args), + Source: "config", + } + } + } + + return bind +} + +func (n *NiriProvider) formatRawAction(action string, args []string) string { + if len(args) == 0 { + return action + } + + if action == "spawn" && len(args) >= 3 && args[1] == "-c" { + switch args[0] { + case "sh", "bash": + cmd := strings.Join(args[2:], " ") + return fmt.Sprintf("spawn %s -c \"%s\"", args[0], strings.ReplaceAll(cmd, "\"", "\\\"")) + } + } + + quotedArgs := make([]string, len(args)) + for i, arg := range args { + if arg == "" { + quotedArgs[i] = `""` + } else { + quotedArgs[i] = arg + } + } + return action + " " + strings.Join(quotedArgs, " ") +} + +func (n *NiriProvider) formatKey(kb *NiriKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (n *NiriProvider) GetOverridePath() string { + return filepath.Join(n.configDir, "dms", "binds.kdl") +} + +func (n *NiriProvider) validateAction(action string) error { + action = strings.TrimSpace(action) + switch { + case action == "": + return fmt.Errorf("action cannot be empty") + case action == "spawn" || action == "spawn ": + return fmt.Errorf("spawn command requires arguments") + case strings.HasPrefix(action, "spawn "): + rest := strings.TrimSpace(strings.TrimPrefix(action, "spawn ")) + switch rest { + case "": + return fmt.Errorf("spawn command requires arguments") + case "sh -c \"\"", "sh -c ''", "bash -c \"\"", "bash -c ''": + return fmt.Errorf("shell command cannot be empty") + } + } + return nil +} + +func (n *NiriProvider) SetBind(key, action, description string, options map[string]any) error { + if err := n.validateAction(action); err != nil { + return err + } + + overridePath := n.GetOverridePath() + + if err := os.MkdirAll(filepath.Dir(overridePath), 0o755); err != nil { + return fmt.Errorf("failed to create dms directory: %w", err) + } + + existingBinds, err := n.loadOverrideBinds() + if err != nil { + existingBinds = make(map[string]*overrideBind) + } + + existingBinds[key] = &overrideBind{ + Key: key, + Action: action, + Description: description, + Options: options, + } + + return n.writeOverrideBinds(existingBinds) +} + +func (n *NiriProvider) RemoveBind(key string) error { + existingBinds, err := n.loadOverrideBinds() + if err != nil { + return nil + } + + delete(existingBinds, key) + return n.writeOverrideBinds(existingBinds) +} + +type overrideBind struct { + Key string + Action string + Description string + Options map[string]any +} + +func (n *NiriProvider) loadOverrideBinds() (map[string]*overrideBind, error) { + overridePath := n.GetOverridePath() + binds := make(map[string]*overrideBind) + + data, err := os.ReadFile(overridePath) + if os.IsNotExist(err) { + return binds, nil + } + if err != nil { + return nil, err + } + + parser := NewNiriParser(filepath.Dir(overridePath)) + parser.currentSource = overridePath + + doc, err := kdl.Parse(strings.NewReader(string(data))) + if err != nil { + return nil, err + } + + for _, node := range doc.Nodes { + if node.Name.String() != "binds" || node.Children == nil { + continue + } + for _, child := range node.Children { + kb := parser.parseKeybindNode(child, "") + if kb == nil { + continue + } + keyStr := parser.formatBindKey(kb) + + action := n.buildActionFromNode(child) + if action == "" { + action = n.formatRawAction(kb.Action, kb.Args) + } + + binds[keyStr] = &overrideBind{ + Key: keyStr, + Action: action, + Description: kb.Description, + Options: n.extractOptions(child), + } + } + } + + return binds, nil +} + +func (n *NiriProvider) buildActionFromNode(bindNode *document.Node) string { + if len(bindNode.Children) == 0 { + return "" + } + + actionNode := bindNode.Children[0] + actionName := actionNode.Name.String() + if actionName == "" { + return "" + } + + parts := []string{actionName} + for _, arg := range actionNode.Arguments { + val := arg.ValueString() + if val == "" { + parts = append(parts, `""`) + } else if strings.ContainsAny(val, " \t") { + parts = append(parts, `"`+strings.ReplaceAll(val, `"`, `\"`)+`"`) + } else { + parts = append(parts, val) + } + } + + if actionNode.Properties != nil { + for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} { + if val, ok := actionNode.Properties.Get(propName); ok { + parts = append(parts, propName+"="+val.String()) + } + } + } + + return strings.Join(parts, " ") +} + +func (n *NiriProvider) extractOptions(node *document.Node) map[string]any { + if node.Properties == nil { + return make(map[string]any) + } + + opts := make(map[string]any) + if val, ok := node.Properties.Get("repeat"); ok { + opts["repeat"] = val.String() == "true" + } + if val, ok := node.Properties.Get("cooldown-ms"); ok { + if ms, err := strconv.Atoi(val.String()); err == nil { + opts["cooldown-ms"] = ms + } + } + if val, ok := node.Properties.Get("allow-when-locked"); ok { + opts["allow-when-locked"] = val.String() == "true" + } + if val, ok := node.Properties.Get("allow-inhibiting"); ok { + opts["allow-inhibiting"] = val.String() == "true" + } + return opts +} + +func (n *NiriProvider) isRecentWindowsAction(action string) bool { + switch action { + case "next-window", "previous-window": + return true + default: + return false + } +} + +func (n *NiriProvider) buildBindNode(bind *overrideBind) *document.Node { + node := document.NewNode() + node.SetName(bind.Key) + + if bind.Options != nil { + if v, ok := bind.Options["repeat"]; ok && v == false { + node.AddProperty("repeat", false, "") + } + if v, ok := bind.Options["cooldown-ms"]; ok { + switch val := v.(type) { + case int: + node.AddProperty("cooldown-ms", val, "") + case string: + if ms, err := strconv.Atoi(val); err == nil { + node.AddProperty("cooldown-ms", ms, "") + } + } + } + if v, ok := bind.Options["allow-when-locked"]; ok && v == true { + node.AddProperty("allow-when-locked", true, "") + } + if v, ok := bind.Options["allow-inhibiting"]; ok && v == false { + node.AddProperty("allow-inhibiting", false, "") + } + } + + if bind.Description != "" { + node.AddProperty("hotkey-overlay-title", bind.Description, "") + } + + actionNode := n.buildActionNode(bind.Action) + node.AddNode(actionNode) + + return node +} + +func (n *NiriProvider) buildActionNode(action string) *document.Node { + action = strings.TrimSpace(action) + node := document.NewNode() + + parts := n.parseActionParts(action) + if len(parts) == 0 { + node.SetName(action) + return node + } + + node.SetName(parts[0]) + for _, arg := range parts[1:] { + if strings.Contains(arg, "=") { + kv := strings.SplitN(arg, "=", 2) + switch kv[1] { + case "true": + node.AddProperty(kv[0], true, "") + case "false": + node.AddProperty(kv[0], false, "") + default: + node.AddProperty(kv[0], kv[1], "") + } + continue + } + node.AddArgument(arg, "") + } + return node +} + +func (n *NiriProvider) parseActionParts(action string) []string { + var parts []string + var current strings.Builder + var inQuote, escaped, wasQuoted bool + + for _, r := range action { + switch { + case escaped: + current.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case r == '"': + wasQuoted = true + inQuote = !inQuote + case r == ' ' && !inQuote: + if current.Len() > 0 || wasQuoted { + parts = append(parts, current.String()) + current.Reset() + wasQuoted = false + } + default: + current.WriteRune(r) + } + } + if current.Len() > 0 || wasQuoted { + parts = append(parts, current.String()) + } + return parts +} + +func (n *NiriProvider) writeOverrideBinds(binds map[string]*overrideBind) error { + overridePath := n.GetOverridePath() + content := n.generateBindsContent(binds) + + if err := n.validateBindsContent(content); err != nil { + return err + } + + return os.WriteFile(overridePath, []byte(content), 0o644) +} + +func (n *NiriProvider) getBindSortPriority(action string) int { + switch { + case strings.HasPrefix(action, "spawn") && strings.Contains(action, "dms"): + return 0 + case strings.Contains(action, "workspace"): + return 1 + case strings.Contains(action, "window") || strings.Contains(action, "column") || + strings.Contains(action, "focus") || strings.Contains(action, "move") || + strings.Contains(action, "swap") || strings.Contains(action, "resize"): + return 2 + case strings.HasPrefix(action, "focus-monitor") || strings.Contains(action, "monitor"): + return 3 + case strings.Contains(action, "screenshot"): + return 4 + case action == "quit" || action == "power-off-monitors" || strings.Contains(action, "dpms"): + return 5 + case strings.HasPrefix(action, "spawn"): + return 6 + default: + return 7 + } +} + +func (n *NiriProvider) generateBindsContent(binds map[string]*overrideBind) string { + if len(binds) == 0 { + return "binds {}\n" + } + + var regularBinds, recentWindowsBinds []*overrideBind + for _, bind := range binds { + switch { + case n.isRecentWindowsAction(bind.Action): + recentWindowsBinds = append(recentWindowsBinds, bind) + default: + regularBinds = append(regularBinds, bind) + } + } + + sort.Slice(regularBinds, func(i, j int) bool { + pi, pj := n.getBindSortPriority(regularBinds[i].Action), n.getBindSortPriority(regularBinds[j].Action) + if pi != pj { + return pi < pj + } + return regularBinds[i].Key < regularBinds[j].Key + }) + + sort.Slice(recentWindowsBinds, func(i, j int) bool { + return recentWindowsBinds[i].Key < recentWindowsBinds[j].Key + }) + + var sb strings.Builder + + sb.WriteString("binds {\n") + for _, bind := range regularBinds { + n.writeBindNode(&sb, bind, " ") + } + sb.WriteString("}\n") + + if len(recentWindowsBinds) > 0 { + sb.WriteString("\nrecent-windows {\n") + sb.WriteString(" binds {\n") + for _, bind := range recentWindowsBinds { + n.writeBindNode(&sb, bind, " ") + } + sb.WriteString(" }\n") + sb.WriteString("}\n") + } + + return sb.String() +} + +func (n *NiriProvider) writeBindNode(sb *strings.Builder, bind *overrideBind, indent string) { + node := n.buildBindNode(bind) + + sb.WriteString(indent) + sb.WriteString(node.Name.String()) + + if node.Properties.Exist() { + sb.WriteString(" ") + sb.WriteString(strings.TrimLeft(node.Properties.String(), " ")) + } + + sb.WriteString(" { ") + if len(node.Children) > 0 { + child := node.Children[0] + actionName := child.Name.String() + sb.WriteString(actionName) + forceQuote := actionName == "spawn" + for _, arg := range child.Arguments { + sb.WriteString(" ") + n.writeArg(sb, arg.ValueString(), forceQuote) + } + if child.Properties.Exist() { + sb.WriteString(" ") + sb.WriteString(strings.TrimLeft(child.Properties.String(), " ")) + } + } + sb.WriteString("; }\n") +} + +func (n *NiriProvider) writeArg(sb *strings.Builder, val string, forceQuote bool) { + if !forceQuote && n.isNumericArg(val) { + sb.WriteString(val) + return + } + sb.WriteString("\"") + sb.WriteString(strings.ReplaceAll(val, "\"", "\\\"")) + sb.WriteString("\"") +} + +func (n *NiriProvider) isNumericArg(val string) bool { + if val == "" { + return false + } + start := 0 + if val[0] == '-' || val[0] == '+' { + if len(val) == 1 { + return false + } + start = 1 + } + for i := start; i < len(val); i++ { + if val[i] < '0' || val[i] > '9' { + return false + } + } + return true +} + +func (n *NiriProvider) validateBindsContent(content string) error { + tmpFile, err := os.CreateTemp("", "dms-binds-*.kdl") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(content); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to write temp file: %w", err) + } + tmpFile.Close() + + cmd := exec.Command("niri", "validate", "-c", tmpFile.Name()) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("invalid config: %s", strings.TrimSpace(string(output))) + } + + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser.go new file mode 100644 index 0000000..46520ff --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser.go @@ -0,0 +1,400 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/sblinch/kdl-go" + "github.com/sblinch/kdl-go/document" +) + +type NiriKeyBinding struct { + Mods []string + Key string + Action string + Args []string + Description string + HideOnOverlay bool + CooldownMs int + AllowWhenLocked bool + AllowInhibiting *bool + Repeat *bool + Source string +} + +type NiriSection struct { + Name string + Keybinds []NiriKeyBinding + Children []NiriSection +} + +type NiriParser struct { + configDir string + processedFiles map[string]bool + bindMap map[string]*NiriKeyBinding + bindOrder []string + currentSource string + dmsBindsIncluded bool + dmsBindsExists bool + includeCount int + dmsIncludePos int + bindsBeforeDMS int + bindsAfterDMS int + dmsBindKeys map[string]bool + configBindKeys map[string]bool + dmsProcessed bool + dmsBindMap map[string]*NiriKeyBinding + conflictingConfigs map[string]*NiriKeyBinding +} + +func NewNiriParser(configDir string) *NiriParser { + return &NiriParser{ + configDir: configDir, + processedFiles: make(map[string]bool), + bindMap: make(map[string]*NiriKeyBinding), + bindOrder: []string{}, + currentSource: "", + dmsIncludePos: -1, + dmsBindKeys: make(map[string]bool), + configBindKeys: make(map[string]bool), + dmsBindMap: make(map[string]*NiriKeyBinding), + conflictingConfigs: make(map[string]*NiriKeyBinding), + } +} + +func (p *NiriParser) Parse() (*NiriSection, error) { + dmsBindsPath := filepath.Join(p.configDir, "dms", "binds.kdl") + if _, err := os.Stat(dmsBindsPath); err == nil { + p.dmsBindsExists = true + } + + configPath := filepath.Join(p.configDir, "config.kdl") + section, err := p.parseFile(configPath, "") + if err != nil { + return nil, err + } + + if p.dmsBindsExists && !p.dmsProcessed { + p.parseDMSBindsDirectly(dmsBindsPath, section) + } + + section.Keybinds = p.finalizeBinds() + return section, nil +} + +func (p *NiriParser) parseDMSBindsDirectly(dmsBindsPath string, section *NiriSection) { + data, err := os.ReadFile(dmsBindsPath) + if err != nil { + return + } + + doc, err := kdl.Parse(strings.NewReader(string(data))) + if err != nil { + return + } + + prevSource := p.currentSource + p.currentSource = dmsBindsPath + baseDir := filepath.Dir(dmsBindsPath) + p.processNodes(doc.Nodes, section, baseDir) + p.currentSource = prevSource + p.dmsProcessed = true +} + +func (p *NiriParser) finalizeBinds() []NiriKeyBinding { + binds := make([]NiriKeyBinding, 0, len(p.bindOrder)) + for _, key := range p.bindOrder { + if kb, ok := p.bindMap[key]; ok { + binds = append(binds, *kb) + } + } + return binds +} + +func (p *NiriParser) addBind(kb *NiriKeyBinding) { + key := p.formatBindKey(kb) + isDMSBind := strings.Contains(kb.Source, "dms/binds.kdl") + + if isDMSBind { + p.dmsBindKeys[key] = true + p.dmsBindMap[key] = kb + } else if p.dmsBindKeys[key] { + p.bindsAfterDMS++ + p.conflictingConfigs[key] = kb + p.configBindKeys[key] = true + return + } else { + p.configBindKeys[key] = true + } + + if _, exists := p.bindMap[key]; !exists { + p.bindOrder = append(p.bindOrder, key) + } + p.bindMap[key] = kb +} + +func (p *NiriParser) formatBindKey(kb *NiriKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} + +func (p *NiriParser) parseFile(filePath, sectionName string) (*NiriSection, error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, fmt.Errorf("failed to resolve path %s: %w", filePath, err) + } + + if p.processedFiles[absPath] { + return &NiriSection{Name: sectionName}, nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", absPath, err) + } + + doc, err := kdl.Parse(strings.NewReader(string(data))) + if err != nil { + return nil, fmt.Errorf("failed to parse KDL in %s: %w", absPath, err) + } + + section := &NiriSection{ + Name: sectionName, + } + + prevSource := p.currentSource + p.currentSource = absPath + baseDir := filepath.Dir(absPath) + p.processNodes(doc.Nodes, section, baseDir) + p.currentSource = prevSource + + return section, nil +} + +func (p *NiriParser) processNodes(nodes []*document.Node, section *NiriSection, baseDir string) { + for _, node := range nodes { + name := node.Name.String() + + switch name { + case "include": + p.handleInclude(node, section, baseDir) + case "binds": + p.extractBinds(node, section, "") + case "recent-windows": + p.handleRecentWindows(node, section) + } + } +} + +func (p *NiriParser) handleInclude(node *document.Node, section *NiriSection, baseDir string) { + if len(node.Arguments) == 0 { + return + } + + includePath := strings.Trim(node.Arguments[0].String(), "\"") + isDMSInclude := includePath == "dms/binds.kdl" || strings.HasSuffix(includePath, "/dms/binds.kdl") + + p.includeCount++ + if isDMSInclude { + p.dmsBindsIncluded = true + p.dmsIncludePos = p.includeCount + p.bindsBeforeDMS = len(p.bindMap) + } + + fullPath := filepath.Join(baseDir, includePath) + if filepath.IsAbs(includePath) { + fullPath = includePath + } + + if isDMSInclude { + p.dmsProcessed = true + } + + includedSection, err := p.parseFile(fullPath, "") + if err != nil { + return + } + + section.Children = append(section.Children, includedSection.Children...) +} + +func (p *NiriParser) HasDMSBindsIncluded() bool { + return p.dmsBindsIncluded +} + +func (p *NiriParser) handleRecentWindows(node *document.Node, section *NiriSection) { + if node.Children == nil { + return + } + + for _, child := range node.Children { + if child.Name.String() != "binds" { + continue + } + p.extractBinds(child, section, "Alt-Tab") + } +} + +func (p *NiriParser) extractBinds(node *document.Node, section *NiriSection, subcategory string) { + if node.Children == nil { + return + } + + for _, child := range node.Children { + kb := p.parseKeybindNode(child, subcategory) + if kb == nil { + continue + } + p.addBind(kb) + } +} + +func (p *NiriParser) parseKeybindNode(node *document.Node, _ string) *NiriKeyBinding { + keyCombo := node.Name.String() + if keyCombo == "" { + return nil + } + + mods, key := p.parseKeyCombo(keyCombo) + + var action string + var args []string + if len(node.Children) > 0 { + actionNode := node.Children[0] + action = actionNode.Name.String() + for _, arg := range actionNode.Arguments { + args = append(args, arg.ValueString()) + } + if actionNode.Properties != nil { + for _, propName := range []string{"focus", "show-pointer", "write-to-disk", "skip-confirmation", "delay-ms"} { + if val, ok := actionNode.Properties.Get(propName); ok { + args = append(args, propName+"="+val.String()) + } + } + } + } + + var description string + var hideOnOverlay bool + var cooldownMs int + var allowWhenLocked bool + var allowInhibiting *bool + var repeat *bool + if node.Properties != nil { + if val, ok := node.Properties.Get("hotkey-overlay-title"); ok { + switch val.ValueString() { + case "null", "": + hideOnOverlay = true + default: + description = val.ValueString() + } + } + if val, ok := node.Properties.Get("cooldown-ms"); ok { + cooldownMs, _ = strconv.Atoi(val.String()) + } + if val, ok := node.Properties.Get("allow-when-locked"); ok { + allowWhenLocked = val.String() == "true" + } + if val, ok := node.Properties.Get("allow-inhibiting"); ok { + v := val.String() == "true" + allowInhibiting = &v + } + if val, ok := node.Properties.Get("repeat"); ok { + v := val.String() == "true" + repeat = &v + } + } + + return &NiriKeyBinding{ + Mods: mods, + Key: key, + Action: action, + Args: args, + Description: description, + HideOnOverlay: hideOnOverlay, + CooldownMs: cooldownMs, + AllowWhenLocked: allowWhenLocked, + AllowInhibiting: allowInhibiting, + Repeat: repeat, + Source: p.currentSource, + } +} + +func (p *NiriParser) parseKeyCombo(combo string) ([]string, string) { + parts := strings.Split(combo, "+") + + switch len(parts) { + case 0: + return nil, combo + case 1: + return nil, parts[0] + default: + return parts[:len(parts)-1], parts[len(parts)-1] + } +} + +type NiriParseResult struct { + Section *NiriSection + DMSBindsIncluded bool + DMSStatus *DMSBindsStatusInfo + ConflictingConfigs map[string]*NiriKeyBinding +} + +type DMSBindsStatusInfo struct { + Exists bool + Included bool + IncludePosition int + TotalIncludes int + BindsAfterDMS int + Effective bool + OverriddenBy int + StatusMessage string +} + +func (p *NiriParser) buildDMSStatus() *DMSBindsStatusInfo { + status := &DMSBindsStatusInfo{ + Exists: p.dmsBindsExists, + Included: p.dmsBindsIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + BindsAfterDMS: p.bindsAfterDMS, + } + + switch { + case !p.dmsBindsExists: + status.Effective = false + status.StatusMessage = "dms/binds.kdl does not exist" + case !p.dmsBindsIncluded: + status.Effective = false + status.StatusMessage = "dms/binds.kdl is not included in config.kdl" + case p.bindsAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.bindsAfterDMS + status.StatusMessage = "Some DMS binds may be overridden by config binds" + default: + status.Effective = true + status.StatusMessage = "DMS binds are active" + } + + return status +} + +func ParseNiriKeys(configDir string) (*NiriParseResult, error) { + parser := NewNiriParser(configDir) + section, err := parser.Parse() + if err != nil { + return nil, err + } + return &NiriParseResult{ + Section: section, + DMSBindsIncluded: parser.HasDMSBindsIncluded(), + DMSStatus: parser.buildDMSStatus(), + ConflictingConfigs: parser.conflictingConfigs, + }, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser_test.go new file mode 100644 index 0000000..e21fde5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_parser_test.go @@ -0,0 +1,630 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNiriParseKeyCombo(t *testing.T) { + tests := []struct { + combo string + expectedMods []string + expectedKey string + }{ + {"Mod+Q", []string{"Mod"}, "Q"}, + {"Mod+Shift+F", []string{"Mod", "Shift"}, "F"}, + {"Ctrl+Alt+Delete", []string{"Ctrl", "Alt"}, "Delete"}, + {"Print", nil, "Print"}, + {"XF86AudioMute", nil, "XF86AudioMute"}, + {"Super+Tab", []string{"Super"}, "Tab"}, + {"Mod+Shift+Ctrl+H", []string{"Mod", "Shift", "Ctrl"}, "H"}, + } + + parser := NewNiriParser("") + for _, tt := range tests { + t.Run(tt.combo, func(t *testing.T) { + mods, key := parser.parseKeyCombo(tt.combo) + + if len(mods) != len(tt.expectedMods) { + t.Errorf("Mods length = %d, want %d", len(mods), len(tt.expectedMods)) + } else { + for i := range mods { + if mods[i] != tt.expectedMods[i] { + t.Errorf("Mods[%d] = %q, want %q", i, mods[i], tt.expectedMods[i]) + } + } + } + + if key != tt.expectedKey { + t.Errorf("Key = %q, want %q", key, tt.expectedKey) + } + }) + } +} + +func TestNiriParseBasicBinds(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Q { close-window; } + Mod+F { fullscreen-window; } + Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 3 { + t.Errorf("Expected 3 keybinds, got %d", len(result.Section.Keybinds)) + } + + foundClose := false + foundFullscreen := false + foundTerminal := false + + for _, kb := range result.Section.Keybinds { + switch kb.Action { + case "close-window": + foundClose = true + if kb.Key != "Q" || len(kb.Mods) != 1 || kb.Mods[0] != "Mod" { + t.Errorf("close-window keybind mismatch: %+v", kb) + } + case "fullscreen-window": + foundFullscreen = true + case "spawn": + foundTerminal = true + if kb.Description != "Open Terminal" { + t.Errorf("spawn description = %q, want %q", kb.Description, "Open Terminal") + } + if len(kb.Args) != 1 || kb.Args[0] != "kitty" { + t.Errorf("spawn args = %v, want [kitty]", kb.Args) + } + } + } + + if !foundClose { + t.Error("close-window keybind not found") + } + if !foundFullscreen { + t.Error("fullscreen-window keybind not found") + } + if !foundTerminal { + t.Error("spawn keybind not found") + } +} + +func TestNiriParseRecentWindows(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `recent-windows { + binds { + Alt+Tab { next-window scope="output"; } + Alt+Shift+Tab { previous-window scope="output"; } + } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds from recent-windows, got %d", len(result.Section.Keybinds)) + } + + foundNext := false + foundPrev := false + + for _, kb := range result.Section.Keybinds { + switch kb.Action { + case "next-window": + foundNext = true + case "previous-window": + foundPrev = true + } + } + + if !foundNext { + t.Error("next-window keybind not found") + } + if !foundPrev { + t.Error("previous-window keybind not found") + } +} + +func TestNiriParseInclude(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + mainConfig := filepath.Join(tmpDir, "config.kdl") + includeConfig := filepath.Join(subDir, "binds.kdl") + + mainContent := `binds { + Mod+Q { close-window; } +} +include "dms/binds.kdl" +` + includeContent := `binds { + Mod+T hotkey-overlay-title="Terminal" { spawn "kitty"; } +} +` + + if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(includeConfig, []byte(includeContent), 0o644); err != nil { + t.Fatalf("Failed to write include config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (1 main + 1 include), got %d", len(result.Section.Keybinds)) + } +} + +func TestNiriParseIncludeOverride(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + mainConfig := filepath.Join(tmpDir, "config.kdl") + includeConfig := filepath.Join(subDir, "binds.kdl") + + mainContent := `binds { + Mod+T hotkey-overlay-title="Main Terminal" { spawn "alacritty"; } +} +include "dms/binds.kdl" +` + includeContent := `binds { + Mod+T hotkey-overlay-title="Override Terminal" { spawn "kitty"; } +} +` + + if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(includeConfig, []byte(includeContent), 0o644); err != nil { + t.Fatalf("Failed to write include config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 1 { + t.Errorf("Expected 1 keybind (later overrides earlier), got %d", len(result.Section.Keybinds)) + } + + if len(result.Section.Keybinds) > 0 { + kb := result.Section.Keybinds[0] + if kb.Description != "Override Terminal" { + t.Errorf("Expected description 'Override Terminal' (from include), got %q", kb.Description) + } + if len(kb.Args) != 1 || kb.Args[0] != "kitty" { + t.Errorf("Expected args [kitty] (from include), got %v", kb.Args) + } + } +} + +func TestNiriParseCircularInclude(t *testing.T) { + tmpDir := t.TempDir() + + mainConfig := filepath.Join(tmpDir, "config.kdl") + otherConfig := filepath.Join(tmpDir, "other.kdl") + + mainContent := `binds { + Mod+Q { close-window; } +} +include "other.kdl" +` + otherContent := `binds { + Mod+T { spawn "kitty"; } +} +include "config.kdl" +` + + if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(otherConfig, []byte(otherContent), 0o644); err != nil { + t.Fatalf("Failed to write other config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed (should handle circular includes): %v", err) + } + + if len(result.Section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (circular include handled), got %d", len(result.Section.Keybinds)) + } +} + +func TestNiriParseMissingInclude(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Q { close-window; } +} +include "nonexistent/file.kdl" +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed (should skip missing include): %v", err) + } + + if len(result.Section.Keybinds) != 1 { + t.Errorf("Expected 1 keybind (missing include skipped), got %d", len(result.Section.Keybinds)) + } +} + +func TestNiriParseNoBinds(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `cursor { + xcursor-theme "Bibata" + xcursor-size 24 +} + +input { + keyboard { + numlock + } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 0 { + t.Errorf("Expected 0 keybinds, got %d", len(result.Section.Keybinds)) + } +} + +func TestNiriParseErrors(t *testing.T) { + tests := []struct { + name string + path string + }{ + { + name: "nonexistent_directory", + path: "/nonexistent/path/that/does/not/exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseNiriKeys(tt.path) + if err == nil { + t.Error("Expected error, got nil") + } + }) + } +} + +func TestNiriBindOverrideBehavior(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+T hotkey-overlay-title="First" { spawn "first"; } + Mod+Q { close-window; } + Mod+T hotkey-overlay-title="Second" { spawn "second"; } + Mod+F { fullscreen-window; } + Mod+T hotkey-overlay-title="Third" { spawn "third"; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 3 { + t.Fatalf("Expected 3 unique keybinds, got %d", len(result.Section.Keybinds)) + } + + var modT *NiriKeyBinding + for i := range result.Section.Keybinds { + kb := &result.Section.Keybinds[i] + if len(kb.Mods) == 1 && kb.Mods[0] == "Mod" && kb.Key == "T" { + modT = kb + break + } + } + + if modT == nil { + t.Fatal("Mod+T keybind not found") + } + + if modT.Description != "Third" { + t.Errorf("Mod+T description = %q, want 'Third' (last definition wins)", modT.Description) + } + + if len(modT.Args) != 1 || modT.Args[0] != "third" { + t.Errorf("Mod+T args = %v, want [third] (last definition wins)", modT.Args) + } +} + +func TestNiriBindOverrideWithIncludes(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "custom") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatalf("Failed to create subdir: %v", err) + } + + mainConfig := filepath.Join(tmpDir, "config.kdl") + includeConfig := filepath.Join(subDir, "overrides.kdl") + + mainContent := `binds { + Mod+1 { focus-workspace 1; } + Mod+2 { focus-workspace 2; } + Mod+T hotkey-overlay-title="Default Terminal" { spawn "xterm"; } +} +include "custom/overrides.kdl" +binds { + Mod+3 { focus-workspace 3; } +} +` + includeContent := `binds { + Mod+T hotkey-overlay-title="Custom Terminal" { spawn "kitty"; } + Mod+2 { focus-workspace 22; } +} +` + + if err := os.WriteFile(mainConfig, []byte(mainContent), 0o644); err != nil { + t.Fatalf("Failed to write main config: %v", err) + } + if err := os.WriteFile(includeConfig, []byte(includeContent), 0o644); err != nil { + t.Fatalf("Failed to write include config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 4 { + t.Errorf("Expected 4 unique keybinds, got %d", len(result.Section.Keybinds)) + } + + bindMap := make(map[string]*NiriKeyBinding) + for i := range result.Section.Keybinds { + kb := &result.Section.Keybinds[i] + key := "" + for _, m := range kb.Mods { + key += m + "+" + } + key += kb.Key + bindMap[key] = kb + } + + if kb, ok := bindMap["Mod+T"]; ok { + if kb.Description != "Custom Terminal" { + t.Errorf("Mod+T should be overridden by include, got description %q", kb.Description) + } + } else { + t.Error("Mod+T not found") + } + + if kb, ok := bindMap["Mod+2"]; ok { + if len(kb.Args) != 1 || kb.Args[0] != "22" { + t.Errorf("Mod+2 should be overridden by include with workspace 22, got args %v", kb.Args) + } + } else { + t.Error("Mod+2 not found") + } + + if _, ok := bindMap["Mod+1"]; !ok { + t.Error("Mod+1 should exist (not overridden)") + } + + if _, ok := bindMap["Mod+3"]; !ok { + t.Error("Mod+3 should exist (added after include)") + } +} + +func TestNiriParseMultipleArgs(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Space hotkey-overlay-title="Application Launcher" { + spawn "dms" "ipc" "call" "spotlight" "toggle"; + } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 1 { + t.Fatalf("Expected 1 keybind, got %d", len(result.Section.Keybinds)) + } + + kb := result.Section.Keybinds[0] + if len(kb.Args) != 5 { + t.Errorf("Expected 5 args, got %d: %v", len(kb.Args), kb.Args) + } + + expectedArgs := []string{"dms", "ipc", "call", "spotlight", "toggle"} + for i, arg := range expectedArgs { + if i < len(kb.Args) && kb.Args[i] != arg { + t.Errorf("Args[%d] = %q, want %q", i, kb.Args[i], arg) + } + } +} + +func TestNiriParseNumericWorkspaceBinds(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; } + Mod+2 hotkey-overlay-title="Focus Workspace 2" { focus-workspace 2; } + Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; } + Mod+Shift+1 hotkey-overlay-title="Move to Workspace 1" { move-column-to-workspace 1; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 4 { + t.Errorf("Expected 4 keybinds, got %d", len(result.Section.Keybinds)) + } + + for _, kb := range result.Section.Keybinds { + switch kb.Key { + case "1": + if len(kb.Mods) == 1 && kb.Mods[0] == "Mod" { + if kb.Action != "focus-workspace" || len(kb.Args) != 1 || kb.Args[0] != "1" { + t.Errorf("Mod+1 action/args mismatch: %+v", kb) + } + if kb.Description != "Focus Workspace 1" { + t.Errorf("Mod+1 description = %q, want 'Focus Workspace 1'", kb.Description) + } + } + case "0": + if kb.Action != "focus-workspace" || len(kb.Args) != 1 || kb.Args[0] != "10" { + t.Errorf("Mod+0 action/args mismatch: %+v", kb) + } + } + } +} + +func TestNiriParseQuotedStringArgs(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; } + Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; } + Super+Shift+Minus hotkey-overlay-title="Adjust Window Height -10%" { set-window-height "-10%"; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 3 { + t.Errorf("Expected 3 keybinds, got %d", len(result.Section.Keybinds)) + } + + for _, kb := range result.Section.Keybinds { + if kb.Action == "set-column-width" { + if len(kb.Args) != 1 { + t.Errorf("set-column-width should have 1 arg, got %d", len(kb.Args)) + continue + } + if kb.Args[0] != "-10%" && kb.Args[0] != "+10%" { + t.Errorf("set-column-width arg = %q, want -10%% or +10%%", kb.Args[0]) + } + } + } +} + +func TestNiriParseActionWithProperties(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Shift+1 hotkey-overlay-title="Move to Workspace 1" { move-column-to-workspace 1 focus=false; } + Mod+Shift+2 hotkey-overlay-title="Move to Workspace 2" { move-column-to-workspace 2 focus=false; } + Alt+Tab { next-window scope="output"; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("ParseNiriKeys failed: %v", err) + } + + if len(result.Section.Keybinds) != 3 { + t.Errorf("Expected 3 keybinds, got %d", len(result.Section.Keybinds)) + } + + for _, kb := range result.Section.Keybinds { + switch kb.Action { + case "move-column-to-workspace": + if len(kb.Args) != 2 { + t.Errorf("move-column-to-workspace should have 2 args (index + focus), got %d", len(kb.Args)) + } + hasIndex := false + hasFocus := false + for _, arg := range kb.Args { + if arg == "1" || arg == "2" { + hasIndex = true + } + if arg == "focus=false" { + hasFocus = true + } + } + if !hasIndex { + t.Errorf("move-column-to-workspace missing index arg") + } + if !hasFocus { + t.Errorf("move-column-to-workspace missing focus=false arg") + } + case "next-window": + if kb.Key != "Tab" { + t.Errorf("next-window key = %q, want 'Tab'", kb.Key) + } + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_test.go new file mode 100644 index 0000000..b04c5b2 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/niri_test.go @@ -0,0 +1,661 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNiriProviderName(t *testing.T) { + provider := NewNiriProvider("") + if provider.Name() != "niri" { + t.Errorf("Name() = %q, want %q", provider.Name(), "niri") + } +} + +func TestNiriProviderGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Q { close-window; } + Mod+F { fullscreen-window; } + Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } + Mod+1 { focus-workspace 1; } + Mod+Shift+1 { move-column-to-workspace 1; } + Print { screenshot; } + Mod+Shift+E { quit; } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewNiriProvider(tmpDir) + cheatSheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if cheatSheet.Title != "Niri Keybinds" { + t.Errorf("Title = %q, want %q", cheatSheet.Title, "Niri Keybinds") + } + + if cheatSheet.Provider != "niri" { + t.Errorf("Provider = %q, want %q", cheatSheet.Provider, "niri") + } + + windowBinds := cheatSheet.Binds["Window"] + if len(windowBinds) < 2 { + t.Errorf("Expected at least 2 Window binds, got %d", len(windowBinds)) + } + + execBinds := cheatSheet.Binds["Execute"] + if len(execBinds) < 1 { + t.Errorf("Expected at least 1 Execute bind, got %d", len(execBinds)) + } + + workspaceBinds := cheatSheet.Binds["Workspace"] + if len(workspaceBinds) < 2 { + t.Errorf("Expected at least 2 Workspace binds, got %d", len(workspaceBinds)) + } + + screenshotBinds := cheatSheet.Binds["Screenshot"] + if len(screenshotBinds) < 1 { + t.Errorf("Expected at least 1 Screenshot bind, got %d", len(screenshotBinds)) + } + + systemBinds := cheatSheet.Binds["System"] + if len(systemBinds) < 1 { + t.Errorf("Expected at least 1 System bind, got %d", len(systemBinds)) + } +} + +func TestNiriCategorizeByAction(t *testing.T) { + provider := NewNiriProvider("") + + tests := []struct { + action string + expected string + }{ + {"focus-workspace", "Workspace"}, + {"focus-workspace-up", "Workspace"}, + {"move-column-to-workspace", "Workspace"}, + {"focus-monitor-left", "Monitor"}, + {"move-column-to-monitor-right", "Monitor"}, + {"close-window", "Window"}, + {"fullscreen-window", "Window"}, + {"maximize-column", "Window"}, + {"toggle-window-floating", "Window"}, + {"focus-column-left", "Window"}, + {"move-column-right", "Window"}, + {"spawn", "Execute"}, + {"quit", "System"}, + {"power-off-monitors", "System"}, + {"screenshot", "Screenshot"}, + {"screenshot-window", "Screenshot"}, + {"toggle-overview", "Overview"}, + {"show-hotkey-overlay", "Overview"}, + {"next-window", "Alt-Tab"}, + {"previous-window", "Alt-Tab"}, + {"unknown-action", "Other"}, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + result := provider.categorizeByAction(tt.action) + if result != tt.expected { + t.Errorf("categorizeByAction(%q) = %q, want %q", tt.action, result, tt.expected) + } + }) + } +} + +func TestNiriFormatRawAction(t *testing.T) { + provider := NewNiriProvider("") + + tests := []struct { + action string + args []string + expected string + }{ + {"spawn", []string{"kitty"}, "spawn kitty"}, + {"spawn", []string{"dms", "ipc", "call"}, "spawn dms ipc call"}, + {"spawn", []string{"dms", "ipc", "call", "brightness", "increment", "5", ""}, `spawn dms ipc call brightness increment 5 ""`}, + {"spawn", []string{"dms", "ipc", "call", "dash", "toggle", ""}, `spawn dms ipc call dash toggle ""`}, + {"close-window", nil, "close-window"}, + {"fullscreen-window", nil, "fullscreen-window"}, + {"focus-workspace", []string{"1"}, "focus-workspace 1"}, + {"move-column-to-workspace", []string{"5"}, "move-column-to-workspace 5"}, + {"set-column-width", []string{"+10%"}, "set-column-width +10%"}, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + result := provider.formatRawAction(tt.action, tt.args) + if result != tt.expected { + t.Errorf("formatRawAction(%q, %v) = %q, want %q", tt.action, tt.args, result, tt.expected) + } + }) + } +} + +func TestNiriFormatKey(t *testing.T) { + provider := NewNiriProvider("") + + tests := []struct { + mods []string + key string + expected string + }{ + {[]string{"Mod"}, "Q", "Mod+Q"}, + {[]string{"Mod", "Shift"}, "F", "Mod+Shift+F"}, + {[]string{"Ctrl", "Alt"}, "Delete", "Ctrl+Alt+Delete"}, + {nil, "Print", "Print"}, + {[]string{}, "XF86AudioMute", "XF86AudioMute"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + kb := &NiriKeyBinding{ + Mods: tt.mods, + Key: tt.key, + } + result := provider.formatKey(kb) + if result != tt.expected { + t.Errorf("formatKey(%v) = %q, want %q", kb, result, tt.expected) + } + }) + } +} + +func TestNiriDefaultConfigDir(t *testing.T) { + originalXDG := os.Getenv("XDG_CONFIG_HOME") + defer os.Setenv("XDG_CONFIG_HOME", originalXDG) + + os.Setenv("XDG_CONFIG_HOME", "/custom/config") + dir := defaultNiriConfigDir() + if dir != "/custom/config/niri" { + t.Errorf("With XDG_CONFIG_HOME set, got %q, want %q", dir, "/custom/config/niri") + } + + os.Unsetenv("XDG_CONFIG_HOME") + dir = defaultNiriConfigDir() + home, _ := os.UserHomeDir() + expected := filepath.Join(home, ".config", "niri") + if dir != expected { + t.Errorf("Without XDG_CONFIG_HOME, got %q, want %q", dir, expected) + } +} + +func TestNiriGenerateBindsContent(t *testing.T) { + provider := NewNiriProvider("") + + tests := []struct { + name string + binds map[string]*overrideBind + expected string + }{ + { + name: "empty binds", + binds: map[string]*overrideBind{}, + expected: "binds {}\n", + }, + { + name: "simple spawn bind", + binds: map[string]*overrideBind{ + "Mod+T": { + Key: "Mod+T", + Action: "spawn kitty", + Description: "Open Terminal", + }, + }, + expected: `binds { + Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } +} +`, + }, + { + name: "spawn with multiple args", + binds: map[string]*overrideBind{ + "Mod+Space": { + Key: "Mod+Space", + Action: `spawn "dms" "ipc" "call" "spotlight" "toggle"`, + Description: "Application Launcher", + }, + }, + expected: `binds { + Mod+Space hotkey-overlay-title="Application Launcher" { spawn "dms" "ipc" "call" "spotlight" "toggle"; } +} +`, + }, + { + name: "bind with allow-when-locked", + binds: map[string]*overrideBind{ + "XF86AudioMute": { + Key: "XF86AudioMute", + Action: `spawn "dms" "ipc" "call" "audio" "mute"`, + Options: map[string]any{"allow-when-locked": true}, + }, + }, + expected: `binds { + XF86AudioMute allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "mute"; } +} +`, + }, + { + name: "simple action without args", + binds: map[string]*overrideBind{ + "Mod+Q": { + Key: "Mod+Q", + Action: "close-window", + Description: "Close Window", + }, + }, + expected: `binds { + Mod+Q hotkey-overlay-title="Close Window" { close-window; } +} +`, + }, + { + name: "recent-windows action", + binds: map[string]*overrideBind{ + "Alt+Tab": { + Key: "Alt+Tab", + Action: "next-window", + }, + }, + expected: `binds { +} + +recent-windows { + binds { + Alt+Tab { next-window; } + } +} +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.generateBindsContent(tt.binds) + if result != tt.expected { + t.Errorf("generateBindsContent() =\n%q\nwant:\n%q", result, tt.expected) + } + }) + } +} + +func TestNiriGenerateBindsContentRoundTrip(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "Mod+Space": { + Key: "Mod+Space", + Action: `spawn "dms" "ipc" "call" "spotlight" "toggle"`, + Description: "Application Launcher", + }, + "XF86AudioMute": { + Key: "XF86AudioMute", + Action: `spawn "dms" "ipc" "call" "audio" "mute"`, + Options: map[string]any{"allow-when-locked": true}, + }, + "Mod+Q": { + Key: "Mod+Q", + Action: "close-window", + Description: "Close Window", + }, + } + + content := provider.generateBindsContent(binds) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write temp file: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("Failed to parse generated content: %v\nContent was:\n%s", err, content) + } + + if len(result.Section.Keybinds) != 3 { + t.Errorf("Expected 3 keybinds after round-trip, got %d", len(result.Section.Keybinds)) + } +} + +func TestNiriEmptyArgsPreservation(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "XF86MonBrightnessUp": { + Key: "XF86MonBrightnessUp", + Action: `spawn dms ipc call brightness increment 5 ""`, + Description: "Brightness Up", + }, + "XF86MonBrightnessDown": { + Key: "XF86MonBrightnessDown", + Action: `spawn dms ipc call brightness decrement 5 ""`, + Description: "Brightness Down", + }, + "Super+Alt+Page_Up": { + Key: "Super+Alt+Page_Up", + Action: `spawn dms ipc call dash toggle ""`, + Description: "Dashboard Toggle", + }, + } + + content := provider.generateBindsContent(binds) + + tmpDir := t.TempDir() + dmsDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + t.Fatalf("Failed to create dms directory: %v", err) + } + + bindsFile := filepath.Join(dmsDir, "binds.kdl") + if err := os.WriteFile(bindsFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write binds file: %v", err) + } + + testProvider := NewNiriProvider(tmpDir) + loadedBinds, err := testProvider.loadOverrideBinds() + if err != nil { + t.Fatalf("Failed to load binds: %v\nContent was:\n%s", err, content) + } + + for key, expected := range binds { + loaded, ok := loadedBinds[key] + if !ok { + t.Errorf("Missing bind for key %s", key) + continue + } + if loaded.Action != expected.Action { + t.Errorf("Action mismatch for %s:\n got: %q\n want: %q", key, loaded.Action, expected.Action) + } + } +} + +func TestNiriProviderWithRealWorldConfig(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + + content := `binds { + Mod+Shift+Ctrl+D { debug-toggle-damage; } + Super+D { spawn "niri" "msg" "action" "toggle-overview"; } + Super+Tab repeat=false { toggle-overview; } + Mod+Shift+Slash { show-hotkey-overlay; } + + Mod+T hotkey-overlay-title="Open Terminal" { spawn "kitty"; } + Mod+Space hotkey-overlay-title="Application Launcher" { + spawn "dms" "ipc" "call" "spotlight" "toggle"; + } + + XF86AudioRaiseVolume allow-when-locked=true { + spawn "dms" "ipc" "call" "audio" "increment" "3"; + } + XF86AudioLowerVolume allow-when-locked=true { + spawn "dms" "ipc" "call" "audio" "decrement" "3"; + } + + Mod+Q repeat=false { close-window; } + Mod+F { maximize-column; } + Mod+Shift+F { fullscreen-window; } + + Mod+Left { focus-column-left; } + Mod+Down { focus-window-down; } + Mod+Up { focus-window-up; } + Mod+Right { focus-column-right; } + + Mod+1 { focus-workspace 1; } + Mod+2 { focus-workspace 2; } + Mod+Shift+1 { move-column-to-workspace 1; } + Mod+Shift+2 { move-column-to-workspace 2; } + + Print { screenshot; } + Ctrl+Print { screenshot-screen; } + Alt+Print { screenshot-window; } + + Mod+Shift+E { quit; } +} + +recent-windows { + binds { + Alt+Tab { next-window scope="output"; } + Alt+Shift+Tab { previous-window scope="output"; } + } +} +` + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewNiriProvider(tmpDir) + cheatSheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + totalBinds := 0 + for _, binds := range cheatSheet.Binds { + totalBinds += len(binds) + } + + if totalBinds < 20 { + t.Errorf("Expected at least 20 keybinds, got %d", totalBinds) + } + + if len(cheatSheet.Binds["Alt-Tab"]) < 2 { + t.Errorf("Expected at least 2 Alt-Tab binds, got %d", len(cheatSheet.Binds["Alt-Tab"])) + } +} + +func TestNiriGenerateBindsContentNumericArgs(t *testing.T) { + provider := NewNiriProvider("") + + tests := []struct { + name string + binds map[string]*overrideBind + expected string + }{ + { + name: "workspace with numeric arg", + binds: map[string]*overrideBind{ + "Mod+1": { + Key: "Mod+1", + Action: "focus-workspace 1", + Description: "Focus Workspace 1", + }, + }, + expected: `binds { + Mod+1 hotkey-overlay-title="Focus Workspace 1" { focus-workspace 1; } +} +`, + }, + { + name: "workspace with large numeric arg", + binds: map[string]*overrideBind{ + "Mod+0": { + Key: "Mod+0", + Action: "focus-workspace 10", + Description: "Focus Workspace 10", + }, + }, + expected: `binds { + Mod+0 hotkey-overlay-title="Focus Workspace 10" { focus-workspace 10; } +} +`, + }, + { + name: "percentage string arg (should be quoted)", + binds: map[string]*overrideBind{ + "Super+Minus": { + Key: "Super+Minus", + Action: `set-column-width "-10%"`, + Description: "Adjust Column Width -10%", + }, + }, + expected: `binds { + Super+Minus hotkey-overlay-title="Adjust Column Width -10%" { set-column-width "-10%"; } +} +`, + }, + { + name: "positive percentage string arg", + binds: map[string]*overrideBind{ + "Super+Equal": { + Key: "Super+Equal", + Action: `set-column-width "+10%"`, + Description: "Adjust Column Width +10%", + }, + }, + expected: `binds { + Super+Equal hotkey-overlay-title="Adjust Column Width +10%" { set-column-width "+10%"; } +} +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.generateBindsContent(tt.binds) + if result != tt.expected { + t.Errorf("generateBindsContent() =\n%q\nwant:\n%q", result, tt.expected) + } + }) + } +} + +func TestNiriGenerateActionWithUnquotedPercentArg(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "Super+Equal": { + Key: "Super+Equal", + Action: "set-window-height +10%", + Description: "Adjust Window Height +10%", + }, + } + + content := provider.generateBindsContent(binds) + expected := `binds { + Super+Equal hotkey-overlay-title="Adjust Window Height +10%" { set-window-height "+10%"; } +} +` + if content != expected { + t.Errorf("Content mismatch.\nGot:\n%s\nWant:\n%s", content, expected) + } +} + +func TestNiriGenerateSpawnWithNumericArgs(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "XF86AudioLowerVolume": { + Key: "XF86AudioLowerVolume", + Action: `spawn "dms" "ipc" "call" "audio" "decrement" "3"`, + Options: map[string]any{"allow-when-locked": true}, + }, + } + + content := provider.generateBindsContent(binds) + expected := `binds { + XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; } +} +` + if content != expected { + t.Errorf("Content mismatch.\nGot:\n%s\nWant:\n%s", content, expected) + } +} + +func TestNiriGenerateSpawnNumericArgFromCLI(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "XF86AudioLowerVolume": { + Key: "XF86AudioLowerVolume", + Action: "spawn dms ipc call audio decrement 3", + Options: map[string]any{"allow-when-locked": true}, + }, + } + + content := provider.generateBindsContent(binds) + expected := `binds { + XF86AudioLowerVolume allow-when-locked=true { spawn "dms" "ipc" "call" "audio" "decrement" "3"; } +} +` + if content != expected { + t.Errorf("Content mismatch.\nGot:\n%s\nWant:\n%s", content, expected) + } +} + +func TestNiriGenerateWorkspaceBindsRoundTrip(t *testing.T) { + provider := NewNiriProvider("") + + binds := map[string]*overrideBind{ + "Mod+1": { + Key: "Mod+1", + Action: "focus-workspace 1", + Description: "Focus Workspace 1", + }, + "Mod+2": { + Key: "Mod+2", + Action: "focus-workspace 2", + Description: "Focus Workspace 2", + }, + "Mod+Shift+1": { + Key: "Mod+Shift+1", + Action: "move-column-to-workspace 1", + Description: "Move to Workspace 1", + }, + "Super+Minus": { + Key: "Super+Minus", + Action: "set-column-width -10%", + Description: "Adjust Column Width -10%", + }, + } + + content := provider.generateBindsContent(binds) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.kdl") + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write temp file: %v", err) + } + + result, err := ParseNiriKeys(tmpDir) + if err != nil { + t.Fatalf("Failed to parse generated content: %v\nContent was:\n%s", err, content) + } + + if len(result.Section.Keybinds) != 4 { + t.Errorf("Expected 4 keybinds after round-trip, got %d", len(result.Section.Keybinds)) + } + + foundFocusWS1 := false + foundMoveWS1 := false + foundSetWidth := false + + for _, kb := range result.Section.Keybinds { + switch { + case kb.Action == "focus-workspace" && len(kb.Args) > 0 && kb.Args[0] == "1": + foundFocusWS1 = true + case kb.Action == "move-column-to-workspace" && len(kb.Args) > 0 && kb.Args[0] == "1": + foundMoveWS1 = true + case kb.Action == "set-column-width" && len(kb.Args) > 0 && kb.Args[0] == "-10%": + foundSetWidth = true + } + } + + if !foundFocusWS1 { + t.Error("focus-workspace 1 not found after round-trip") + } + if !foundMoveWS1 { + t.Error("move-column-to-workspace 1 not found after round-trip") + } + if !foundSetWidth { + t.Error("set-column-width -10% not found after round-trip") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway.go new file mode 100644 index 0000000..93ceb73 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway.go @@ -0,0 +1,150 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds" +) + +type SwayProvider struct { + configPath string + isScroll bool +} + +func NewSwayProvider(configPath string) *SwayProvider { + isScroll := false + _, scrollEnvSet := os.LookupEnv("SCROLLSOCK") + + if configPath == "" { + configDir, err := os.UserConfigDir() + if err != nil { + configDir = "" + } + if scrollEnvSet { + if configDir != "" { + configPath = filepath.Join(configDir, "scroll") + } + isScroll = true + } else { + if configDir != "" { + configPath = filepath.Join(configDir, "sway") + } + } + } else { + isScroll = strings.Contains(configPath, "scroll") + } + + return &SwayProvider{ + configPath: configPath, + isScroll: isScroll, + } +} + +func (s *SwayProvider) Name() string { + if s == nil { + if os.Getenv("SCROLLSOCK") != "" { + return "scroll" + } + return "sway" + } + + if s.isScroll { + return "scroll" + } + return "sway" +} + +func (s *SwayProvider) GetCheatSheet() (*keybinds.CheatSheet, error) { + section, err := ParseSwayKeys(s.configPath) + if err != nil { + return nil, fmt.Errorf("failed to parse sway config: %w", err) + } + + categorizedBinds := make(map[string][]keybinds.Keybind) + s.convertSection(section, "", categorizedBinds) + + cheatSheetTitle := "Sway Keybinds" + if s != nil && s.isScroll { + cheatSheetTitle = "Scroll Keybinds" + } + + return &keybinds.CheatSheet{ + Title: cheatSheetTitle, + Provider: s.Name(), + Binds: categorizedBinds, + }, nil +} + +func (s *SwayProvider) convertSection(section *SwaySection, subcategory string, categorizedBinds map[string][]keybinds.Keybind) { + currentSubcat := subcategory + if section.Name != "" { + currentSubcat = section.Name + } + + for _, kb := range section.Keybinds { + category := s.categorizeByCommand(kb.Command) + bind := s.convertKeybind(&kb, currentSubcat) + categorizedBinds[category] = append(categorizedBinds[category], bind) + } + + for _, child := range section.Children { + s.convertSection(&child, currentSubcat, categorizedBinds) + } +} + +func (s *SwayProvider) categorizeByCommand(command string) string { + command = strings.ToLower(command) + + switch { + case strings.Contains(command, "scratchpad"): + return "Scratchpad" + case strings.Contains(command, "workspace") && strings.Contains(command, "output"): + return "Monitor" + case strings.Contains(command, "workspace"): + return "Workspace" + case strings.Contains(command, "output"): + return "Monitor" + case strings.Contains(command, "layout"): + return "Layout" + case command == "kill" || + command == "fullscreen" || strings.Contains(command, "fullscreen") || + command == "floating toggle" || strings.Contains(command, "floating") || + strings.Contains(command, "focus") || + strings.Contains(command, "move") || + strings.Contains(command, "resize") || + strings.Contains(command, "split"): + return "Window" + case strings.HasPrefix(command, "exec"): + return "Execute" + case command == "exit" || command == "reload": + return "System" + default: + return "Other" + } +} + +func (s *SwayProvider) convertKeybind(kb *SwayKeyBinding, subcategory string) keybinds.Keybind { + key := s.formatKey(kb) + desc := kb.Comment + + if desc == "" { + desc = kb.Command + } + + return keybinds.Keybind{ + Key: key, + Description: desc, + Action: kb.Command, + Subcategory: subcategory, + } +} + +func (s *SwayProvider) formatKey(kb *SwayKeyBinding) string { + parts := make([]string, 0, len(kb.Mods)+1) + parts = append(parts, kb.Mods...) + parts = append(parts, kb.Key) + return strings.Join(parts, "+") +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser.go new file mode 100644 index 0000000..647c9f7 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser.go @@ -0,0 +1,364 @@ +package providers + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +const ( + SwayTitleRegex = "#+!" + SwayHideComment = "[hidden]" +) + +var SwayModSeparators = []rune{'+', ' '} + +type SwayKeyBinding struct { + Mods []string `json:"mods"` + Key string `json:"key"` + Command string `json:"command"` + Comment string `json:"comment"` +} + +type SwaySection struct { + Children []SwaySection `json:"children"` + Keybinds []SwayKeyBinding `json:"keybinds"` + Name string `json:"name"` +} + +type SwayParser struct { + contentLines []string + readingLine int + variables map[string]string +} + +func NewSwayParser() *SwayParser { + return &SwayParser{ + contentLines: []string{}, + readingLine: 0, + variables: make(map[string]string), + } +} + +func (p *SwayParser) ReadContent(path string) error { + expandedPath, err := utils.ExpandPath(path) + if err != nil { + return err + } + + info, err := os.Stat(expandedPath) + if err != nil { + return err + } + + var files []string + if info.IsDir() { + mainConfig := filepath.Join(expandedPath, "config") + if fileInfo, err := os.Stat(mainConfig); err == nil && fileInfo.Mode().IsRegular() { + files = []string{mainConfig} + } else { + return os.ErrNotExist + } + } else { + files = []string{expandedPath} + } + + var combinedContent []string + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + return err + } + combinedContent = append(combinedContent, string(data)) + } + + if len(combinedContent) == 0 { + return os.ErrNotExist + } + + fullContent := strings.Join(combinedContent, "\n") + p.contentLines = strings.Split(fullContent, "\n") + p.parseVariables() + return nil +} + +func (p *SwayParser) parseVariables() { + setRegex := regexp.MustCompile(`^\s*set\s+\$(\w+)\s+(.+)$`) + for _, line := range p.contentLines { + matches := setRegex.FindStringSubmatch(line) + if len(matches) == 3 { + varName := matches[1] + varValue := strings.TrimSpace(matches[2]) + p.variables[varName] = varValue + } + } +} + +func (p *SwayParser) expandVariables(text string) string { + result := text + for varName, varValue := range p.variables { + result = strings.ReplaceAll(result, "$"+varName, varValue) + } + return result +} + +func swayAutogenerateComment(command string) string { + command = strings.TrimSpace(command) + + if strings.HasPrefix(command, "exec ") { + cmdPart := strings.TrimPrefix(command, "exec ") + cmdPart = strings.TrimPrefix(cmdPart, "--no-startup-id ") + return cmdPart + } + + switch { + case command == "kill": + return "Close window" + case command == "exit": + return "Exit Sway" + case command == "reload": + return "Reload configuration" + case strings.HasPrefix(command, "fullscreen"): + return "Toggle fullscreen" + case strings.HasPrefix(command, "floating toggle"): + return "Float/unfloat window" + case strings.HasPrefix(command, "focus mode_toggle"): + return "Toggle focus mode" + case strings.HasPrefix(command, "focus parent"): + return "Focus parent container" + case strings.HasPrefix(command, "focus left"): + return "Focus left" + case strings.HasPrefix(command, "focus right"): + return "Focus right" + case strings.HasPrefix(command, "focus up"): + return "Focus up" + case strings.HasPrefix(command, "focus down"): + return "Focus down" + case strings.HasPrefix(command, "focus output"): + return "Focus monitor" + case strings.HasPrefix(command, "move left"): + return "Move window left" + case strings.HasPrefix(command, "move right"): + return "Move window right" + case strings.HasPrefix(command, "move up"): + return "Move window up" + case strings.HasPrefix(command, "move down"): + return "Move window down" + case strings.HasPrefix(command, "move container to workspace"): + if strings.Contains(command, "prev") { + return "Move to previous workspace" + } + if strings.Contains(command, "next") { + return "Move to next workspace" + } + parts := strings.Fields(command) + if len(parts) > 4 { + return "Move to workspace " + parts[len(parts)-1] + } + return "Move to workspace" + case strings.HasPrefix(command, "move workspace to output"): + return "Move workspace to monitor" + case strings.HasPrefix(command, "workspace"): + if strings.Contains(command, "prev") { + return "Previous workspace" + } + if strings.Contains(command, "next") { + return "Next workspace" + } + parts := strings.Fields(command) + if len(parts) > 1 { + wsNum := parts[len(parts)-1] + return "Workspace " + wsNum + } + return "Switch workspace" + case strings.HasPrefix(command, "layout"): + parts := strings.Fields(command) + if len(parts) > 1 { + return "Layout " + parts[1] + } + return "Change layout" + case strings.HasPrefix(command, "split"): + if strings.Contains(command, "h") { + return "Split horizontal" + } + if strings.Contains(command, "v") { + return "Split vertical" + } + return "Split container" + case strings.HasPrefix(command, "resize"): + return "Resize window" + case strings.Contains(command, "scratchpad"): + return "Toggle scratchpad" + default: + return command + } +} + +func (p *SwayParser) getKeybindAtLine(lineNumber int) *SwayKeyBinding { + if lineNumber >= len(p.contentLines) { + return nil + } + + line := p.contentLines[lineNumber] + + bindMatch := regexp.MustCompile(`^\s*(bindsym|bindcode)\s+(.+)$`) + matches := bindMatch.FindStringSubmatch(line) + if len(matches) < 3 { + return nil + } + + content := matches[2] + + parts := strings.SplitN(content, "#", 2) + keys := strings.TrimSpace(parts[0]) + + var comment string + if len(parts) > 1 { + comment = strings.TrimSpace(parts[1]) + } + + if strings.HasPrefix(comment, SwayHideComment) { + return nil + } + + flags := "" + if strings.HasPrefix(keys, "--") { + spaceIdx := strings.Index(keys, " ") + if spaceIdx > 0 { + flags = keys[:spaceIdx] + keys = strings.TrimSpace(keys[spaceIdx+1:]) + } + } + + keyParts := strings.Fields(keys) + if len(keyParts) < 2 { + return nil + } + + keyCombo := keyParts[0] + keyCombo = p.expandVariables(keyCombo) + command := strings.Join(keyParts[1:], " ") + command = p.expandVariables(command) + + var modList []string + var key string + + modstring := keyCombo + string(SwayModSeparators[0]) + pos := 0 + for index, char := range modstring { + isModSep := false + for _, sep := range SwayModSeparators { + if char == sep { + isModSep = true + break + } + } + if isModSep { + if index-pos > 0 { + part := modstring[pos:index] + if swayIsMod(part) { + modList = append(modList, part) + } else { + key = part + } + } + pos = index + 1 + } + } + + if comment == "" { + comment = swayAutogenerateComment(command) + } + + _ = flags + + return &SwayKeyBinding{ + Mods: modList, + Key: key, + Command: command, + Comment: comment, + } +} + +func swayIsMod(s string) bool { + s = strings.ToLower(s) + if s == "mod1" || s == "mod2" || s == "mod3" || s == "mod4" || s == "mod5" || + s == "shift" || s == "control" || s == "ctrl" || s == "alt" || s == "super" || + strings.HasPrefix(s, "$") { + return true + } + + isNumeric := true + for _, c := range s { + if c < '0' || c > '9' { + isNumeric = false + break + } + } + if isNumeric && len(s) >= 2 { + return true + } + return false +} + +func (p *SwayParser) getBindsRecursive(currentContent *SwaySection, scope int) *SwaySection { + titleRegex := regexp.MustCompile(SwayTitleRegex) + + for p.readingLine < len(p.contentLines) { + line := p.contentLines[p.readingLine] + + loc := titleRegex.FindStringIndex(line) + if loc != nil && loc[0] == 0 { + headingScope := strings.Index(line, "!") + + if headingScope <= scope { + p.readingLine-- + return currentContent + } + + sectionName := strings.TrimSpace(line[headingScope+1:]) + p.readingLine++ + + childSection := &SwaySection{ + Children: []SwaySection{}, + Keybinds: []SwayKeyBinding{}, + Name: sectionName, + } + result := p.getBindsRecursive(childSection, headingScope) + currentContent.Children = append(currentContent.Children, *result) + + } else if line == "" || (!strings.Contains(line, "bindsym") && !strings.Contains(line, "bindcode")) { + + } else { + keybind := p.getKeybindAtLine(p.readingLine) + if keybind != nil { + currentContent.Keybinds = append(currentContent.Keybinds, *keybind) + } + } + + p.readingLine++ + } + + return currentContent +} + +func (p *SwayParser) ParseKeys() *SwaySection { + p.readingLine = 0 + rootSection := &SwaySection{ + Children: []SwaySection{}, + Keybinds: []SwayKeyBinding{}, + Name: "", + } + return p.getBindsRecursive(rootSection, 0) +} + +func ParseSwayKeys(path string) (*SwaySection, error) { + parser := NewSwayParser() + if err := parser.ReadContent(path); err != nil { + return nil, err + } + return parser.ParseKeys(), nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser_test.go new file mode 100644 index 0000000..6bb5ecf --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_parser_test.go @@ -0,0 +1,471 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSwayAutogenerateComment(t *testing.T) { + tests := []struct { + command string + expected string + }{ + {"exec kitty", "kitty"}, + {"exec --no-startup-id firefox", "firefox"}, + {"kill", "Close window"}, + {"exit", "Exit Sway"}, + {"reload", "Reload configuration"}, + {"fullscreen toggle", "Toggle fullscreen"}, + {"floating toggle", "Float/unfloat window"}, + {"focus mode_toggle", "Toggle focus mode"}, + {"focus parent", "Focus parent container"}, + {"focus left", "Focus left"}, + {"focus right", "Focus right"}, + {"focus up", "Focus up"}, + {"focus down", "Focus down"}, + {"focus output left", "Focus monitor"}, + {"move left", "Move window left"}, + {"move right", "Move window right"}, + {"move up", "Move window up"}, + {"move down", "Move window down"}, + {"move container to workspace number 1", "Move to workspace 1"}, + {"move container to workspace prev", "Move to previous workspace"}, + {"move container to workspace next", "Move to next workspace"}, + {"move workspace to output left", "Move workspace to monitor"}, + {"workspace number 1", "Workspace 1"}, + {"workspace prev", "Previous workspace"}, + {"workspace next", "Next workspace"}, + {"layout tabbed", "Layout tabbed"}, + {"layout stacking", "Layout stacking"}, + {"splith", "Split horizontal"}, + {"splitv", "Split vertical"}, + {"resize grow width 10 ppt", "Resize window"}, + {"move scratchpad", "Toggle scratchpad"}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + result := swayAutogenerateComment(tt.command) + if result != tt.expected { + t.Errorf("swayAutogenerateComment(%q) = %q, want %q", + tt.command, result, tt.expected) + } + }) + } +} + +func TestSwayGetKeybindAtLine(t *testing.T) { + tests := []struct { + name string + line string + expected *SwayKeyBinding + }{ + { + name: "basic_keybind", + line: "bindsym Mod4+q kill", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "q", + Command: "kill", + Comment: "Close window", + }, + }, + { + name: "keybind_with_exec", + line: "bindsym Mod4+t exec kitty", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "t", + Command: "exec kitty", + Comment: "kitty", + }, + }, + { + name: "keybind_with_comment", + line: "bindsym Mod4+Space exec dms ipc call spotlight toggle # Open launcher", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "Space", + Command: "exec dms ipc call spotlight toggle", + Comment: "Open launcher", + }, + }, + { + name: "keybind_hidden", + line: "bindsym Mod4+h exec secret # [hidden]", + expected: nil, + }, + { + name: "keybind_multiple_mods", + line: "bindsym Mod4+Shift+e exit", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4", "Shift"}, + Key: "e", + Command: "exit", + Comment: "Exit Sway", + }, + }, + { + name: "keybind_no_mods", + line: "bindsym Print exec grim screenshot.png", + expected: &SwayKeyBinding{ + Mods: []string{}, + Key: "Print", + Command: "exec grim screenshot.png", + Comment: "grim screenshot.png", + }, + }, + { + name: "keybind_with_flags", + line: "bindsym --release Mod4+x exec notify-send released", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "x", + Command: "exec notify-send released", + Comment: "notify-send released", + }, + }, + { + name: "keybind_focus_direction", + line: "bindsym Mod4+Left focus left", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "Left", + Command: "focus left", + Comment: "Focus left", + }, + }, + { + name: "keybind_workspace", + line: "bindsym Mod4+1 workspace number 1", + expected: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "1", + Command: "workspace number 1", + Comment: "Workspace 1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := NewSwayParser() + parser.contentLines = []string{tt.line} + result := parser.getKeybindAtLine(0) + + if tt.expected == nil { + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + return + } + + if result == nil { + t.Errorf("expected %+v, got nil", tt.expected) + return + } + + if result.Key != tt.expected.Key { + t.Errorf("Key = %q, want %q", result.Key, tt.expected.Key) + } + if result.Command != tt.expected.Command { + t.Errorf("Command = %q, want %q", result.Command, tt.expected.Command) + } + if result.Comment != tt.expected.Comment { + t.Errorf("Comment = %q, want %q", result.Comment, tt.expected.Comment) + } + if len(result.Mods) != len(tt.expected.Mods) { + t.Errorf("Mods length = %d, want %d", len(result.Mods), len(tt.expected.Mods)) + } else { + for i := range result.Mods { + if result.Mods[i] != tt.expected.Mods[i] { + t.Errorf("Mods[%d] = %q, want %q", i, result.Mods[i], tt.expected.Mods[i]) + } + } + } + }) + } +} + +func TestSwayVariableExpansion(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 +set $term kitty +set $menu rofi + +bindsym $mod+t exec $term +bindsym $mod+d exec $menu +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseSwayKeys(configFile) + if err != nil { + t.Fatalf("ParseSwayKeys failed: %v", err) + } + + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds, got %d", len(section.Keybinds)) + } + + if len(section.Keybinds) > 0 { + if section.Keybinds[0].Mods[0] != "Mod4" { + t.Errorf("Expected Mod4, got %q", section.Keybinds[0].Mods[0]) + } + if section.Keybinds[0].Command != "exec kitty" { + t.Errorf("Expected 'exec kitty', got %q", section.Keybinds[0].Command) + } + } + + if len(section.Keybinds) > 1 { + if section.Keybinds[1].Command != "exec rofi" { + t.Errorf("Expected 'exec rofi', got %q", section.Keybinds[1].Command) + } + } +} + +func TestSwayParseKeysWithSections(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 + +##! Window Management +bindsym $mod+q kill +bindsym $mod+f fullscreen toggle + +###! Focus +bindsym $mod+Left focus left +bindsym $mod+Right focus right + +##! Applications +bindsym $mod+t exec kitty # Terminal +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseSwayKeys(tmpDir) + if err != nil { + t.Fatalf("ParseSwayKeys failed: %v", err) + } + + if len(section.Children) != 2 { + t.Errorf("Expected 2 top-level sections, got %d", len(section.Children)) + } + + if len(section.Children) >= 1 { + windowMgmt := section.Children[0] + if windowMgmt.Name != "Window Management" { + t.Errorf("First section name = %q, want %q", windowMgmt.Name, "Window Management") + } + if len(windowMgmt.Keybinds) != 2 { + t.Errorf("Window Management keybinds = %d, want 2", len(windowMgmt.Keybinds)) + } + + if len(windowMgmt.Children) != 1 { + t.Errorf("Window Management children = %d, want 1", len(windowMgmt.Children)) + } else { + focus := windowMgmt.Children[0] + if focus.Name != "Focus" { + t.Errorf("Focus section name = %q, want %q", focus.Name, "Focus") + } + if len(focus.Keybinds) != 2 { + t.Errorf("Focus keybinds = %d, want 2", len(focus.Keybinds)) + } + } + } + + if len(section.Children) >= 2 { + apps := section.Children[1] + if apps.Name != "Applications" { + t.Errorf("Second section name = %q, want %q", apps.Name, "Applications") + } + if len(apps.Keybinds) != 1 { + t.Errorf("Applications keybinds = %d, want 1", len(apps.Keybinds)) + } + if len(apps.Keybinds) > 0 && apps.Keybinds[0].Comment != "Terminal" { + t.Errorf("Applications keybind comment = %q, want %q", apps.Keybinds[0].Comment, "Terminal") + } + } +} + +func TestSwayReadContentErrors(t *testing.T) { + tests := []struct { + name string + path string + }{ + { + name: "nonexistent_directory", + path: "/nonexistent/path/that/does/not/exist", + }, + { + name: "empty_directory", + path: t.TempDir(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseSwayKeys(tt.path) + if err == nil { + t.Error("Expected error, got nil") + } + }) + } +} + +func TestSwayReadContentWithTildeExpansion(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Skip("Cannot get home directory") + } + + tmpSubdir := filepath.Join(homeDir, ".config", "test-sway-"+t.Name()) + if err := os.MkdirAll(tmpSubdir, 0o755); err != nil { + t.Skip("Cannot create test directory in home") + } + defer os.RemoveAll(tmpSubdir) + + configFile := filepath.Join(tmpSubdir, "config") + if err := os.WriteFile(configFile, []byte("bindsym Mod4+q kill\n"), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + relPath, err := filepath.Rel(homeDir, tmpSubdir) + if err != nil { + t.Skip("Cannot create relative path") + } + + parser := NewSwayParser() + tildePathMatch := "~/" + relPath + err = parser.ReadContent(tildePathMatch) + + if err != nil { + t.Errorf("ReadContent with tilde path failed: %v", err) + } +} + +func TestSwayEmptyAndCommentLines(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := ` +# This is a comment +bindsym Mod4+q kill + +# Another comment + +bindsym Mod4+t exec kitty +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseSwayKeys(configFile) + if err != nil { + t.Fatalf("ParseSwayKeys failed: %v", err) + } + + if len(section.Keybinds) != 2 { + t.Errorf("Expected 2 keybinds (comments ignored), got %d", len(section.Keybinds)) + } +} + +func TestSwayRealWorldConfig(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 +set $term kitty + +## Application Launchers +bindsym $mod+t exec $term +bindsym $mod+Space exec rofi + +## Window Management +bindsym $mod+q kill +bindsym $mod+f fullscreen toggle + +## Focus Navigation +bindsym $mod+Left focus left +bindsym $mod+Right focus right + +## Workspace Navigation +bindsym $mod+1 workspace number 1 +bindsym $mod+2 workspace number 2 +bindsym $mod+Shift+1 move container to workspace number 1 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + section, err := ParseSwayKeys(configFile) + if err != nil { + t.Fatalf("ParseSwayKeys failed: %v", err) + } + + if len(section.Keybinds) < 9 { + t.Errorf("Expected at least 9 keybinds, got %d", len(section.Keybinds)) + } + + foundExec := false + foundKill := false + foundWorkspace := false + + for _, kb := range section.Keybinds { + if kb.Command == "exec kitty" { + foundExec = true + } + if kb.Command == "kill" { + foundKill = true + } + if kb.Command == "workspace number 1" { + foundWorkspace = true + } + } + + if !foundExec { + t.Error("Did not find exec kitty keybind") + } + if !foundKill { + t.Error("Did not find kill keybind") + } + if !foundWorkspace { + t.Error("Did not find workspace 1 keybind") + } +} + +func TestSwayIsMod(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"Mod4", true}, + {"Shift", true}, + {"Control", true}, + {"Alt", true}, + {"Super", true}, + {"$mod", true}, + {"Left", false}, + {"q", false}, + {"1", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := swayIsMod(tt.input) + if result != tt.expected { + t.Errorf("swayIsMod(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_test.go new file mode 100644 index 0000000..fd476d8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/providers/sway_test.go @@ -0,0 +1,293 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSwayProviderName(t *testing.T) { + provider := NewSwayProvider("") + if provider.Name() != "sway" { + t.Errorf("Name() = %q, want %q", provider.Name(), "sway") + } +} + +func TestSwayProviderDefaultPath(t *testing.T) { + provider := NewSwayProvider("") + configDir, err := os.UserConfigDir() + if err != nil { + t.Skip("UserConfigDir not available") + } + expected := filepath.Join(configDir, "sway") + if provider.configPath != expected { + t.Errorf("configPath = %q, want %q", provider.configPath, expected) + } +} + +func TestSwayProviderCustomPath(t *testing.T) { + customPath := "/custom/path" + provider := NewSwayProvider(customPath) + if provider.configPath != customPath { + t.Errorf("configPath = %q, want %q", provider.configPath, customPath) + } +} + +func TestSwayCategorizeByCommand(t *testing.T) { + tests := []struct { + command string + expected string + }{ + {"workspace number 1", "Workspace"}, + {"workspace prev", "Workspace"}, + {"workspace next", "Workspace"}, + {"move container to workspace number 1", "Workspace"}, + {"focus output left", "Monitor"}, + {"move workspace to output right", "Monitor"}, + {"kill", "Window"}, + {"fullscreen toggle", "Window"}, + {"floating toggle", "Window"}, + {"focus left", "Window"}, + {"focus right", "Window"}, + {"move left", "Window"}, + {"move right", "Window"}, + {"resize grow width 10px", "Window"}, + {"splith", "Window"}, + {"splitv", "Window"}, + {"layout tabbed", "Layout"}, + {"layout stacking", "Layout"}, + {"move scratchpad", "Scratchpad"}, + {"scratchpad show", "Scratchpad"}, + {"exec kitty", "Execute"}, + {"exec --no-startup-id firefox", "Execute"}, + {"exit", "System"}, + {"reload", "System"}, + {"unknown command", "Other"}, + } + + provider := NewSwayProvider("") + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + result := provider.categorizeByCommand(tt.command) + if result != tt.expected { + t.Errorf("categorizeByCommand(%q) = %q, want %q", tt.command, result, tt.expected) + } + }) + } +} + +func TestSwayFormatKey(t *testing.T) { + tests := []struct { + name string + keybind *SwayKeyBinding + expected string + }{ + { + name: "single_mod", + keybind: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "q", + }, + expected: "Mod4+q", + }, + { + name: "multiple_mods", + keybind: &SwayKeyBinding{ + Mods: []string{"Mod4", "Shift"}, + Key: "e", + }, + expected: "Mod4+Shift+e", + }, + { + name: "no_mods", + keybind: &SwayKeyBinding{ + Mods: []string{}, + Key: "Print", + }, + expected: "Print", + }, + } + + provider := NewSwayProvider("") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.formatKey(tt.keybind) + if result != tt.expected { + t.Errorf("formatKey() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestSwayConvertKeybind(t *testing.T) { + tests := []struct { + name string + keybind *SwayKeyBinding + wantKey string + wantDesc string + }{ + { + name: "with_comment", + keybind: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "t", + Command: "exec kitty", + Comment: "Open terminal", + }, + wantKey: "Mod4+t", + wantDesc: "Open terminal", + }, + { + name: "without_comment", + keybind: &SwayKeyBinding{ + Mods: []string{"Mod4"}, + Key: "r", + Command: "reload", + Comment: "", + }, + wantKey: "Mod4+r", + wantDesc: "reload", + }, + } + + provider := NewSwayProvider("") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.convertKeybind(tt.keybind, "") + if result.Key != tt.wantKey { + t.Errorf("convertKeybind().Key = %q, want %q", result.Key, tt.wantKey) + } + if result.Description != tt.wantDesc { + t.Errorf("convertKeybind().Description = %q, want %q", result.Description, tt.wantDesc) + } + }) + } +} + +func TestSwayGetCheatSheet(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 +set $term kitty + +# System +bindsym $mod+Shift+c reload +bindsym $mod+Shift+e exit + +# Applications +bindsym $mod+t exec $term +bindsym $mod+Space exec rofi + +# Window Management +bindsym $mod+q kill +bindsym $mod+f fullscreen toggle +bindsym $mod+Left focus left +bindsym $mod+Right focus right + +# Workspace +bindsym $mod+1 workspace number 1 +bindsym $mod+2 workspace number 2 +bindsym $mod+Shift+1 move container to workspace number 1 + +# Layout +bindsym $mod+s layout stacking +bindsym $mod+w layout tabbed +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewSwayProvider(tmpDir) + sheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + if sheet == nil { + t.Fatal("Expected non-nil CheatSheet") + } + + if sheet.Title != "Sway Keybinds" { + t.Errorf("Title = %q, want %q", sheet.Title, "Sway Keybinds") + } + + if sheet.Provider != "sway" { + t.Errorf("Provider = %q, want %q", sheet.Provider, "sway") + } + + categories := []string{"System", "Execute", "Window", "Workspace", "Layout"} + for _, category := range categories { + if _, exists := sheet.Binds[category]; !exists { + t.Errorf("Expected category %q to exist", category) + } + } + + if len(sheet.Binds["System"]) < 2 { + t.Error("Expected at least 2 System keybinds") + } + if len(sheet.Binds["Execute"]) < 2 { + t.Error("Expected at least 2 Execute keybinds") + } + if len(sheet.Binds["Window"]) < 4 { + t.Error("Expected at least 4 Window keybinds") + } + if len(sheet.Binds["Workspace"]) < 3 { + t.Error("Expected at least 3 Workspace keybinds") + } +} + +func TestSwayGetCheatSheetError(t *testing.T) { + provider := NewSwayProvider("/nonexistent/path") + _, err := provider.GetCheatSheet() + if err == nil { + t.Error("Expected error for nonexistent path, got nil") + } +} + +func TestSwayIntegration(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + content := `set $mod Mod4 + +bindsym $mod+t exec kitty # Terminal +bindsym $mod+q kill +bindsym $mod+f fullscreen toggle +bindsym $mod+1 workspace number 1 +` + + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + provider := NewSwayProvider(tmpDir) + sheet, err := provider.GetCheatSheet() + if err != nil { + t.Fatalf("GetCheatSheet failed: %v", err) + } + + totalBinds := 0 + for _, binds := range sheet.Binds { + totalBinds += len(binds) + } + + expectedBinds := 4 + if totalBinds != expectedBinds { + t.Errorf("Expected %d total keybinds, got %d", expectedBinds, totalBinds) + } + + foundTerminal := false + for _, binds := range sheet.Binds { + for _, bind := range binds { + if bind.Description == "Terminal" && bind.Key == "Mod4+t" { + foundTerminal = true + } + } + } + + if !foundTerminal { + t.Error("Did not find terminal keybind with correct key and description") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry.go new file mode 100644 index 0000000..d726838 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry.go @@ -0,0 +1,79 @@ +package keybinds + +import ( + "fmt" + "sync" +) + +type Registry struct { + mu sync.RWMutex + providers map[string]Provider +} + +func NewRegistry() *Registry { + return &Registry{ + providers: make(map[string]Provider), + } +} + +func (r *Registry) Register(provider Provider) error { + if provider == nil { + return fmt.Errorf("cannot register nil provider") + } + + name := provider.Name() + if name == "" { + return fmt.Errorf("provider name cannot be empty") + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.providers[name]; exists { + return fmt.Errorf("provider %q already registered", name) + } + + r.providers[name] = provider + return nil +} + +func (r *Registry) Get(name string) (Provider, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + provider, exists := r.providers[name] + if !exists { + return nil, fmt.Errorf("provider %q not found", name) + } + + return provider, nil +} + +func (r *Registry) List() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + names := make([]string, 0, len(r.providers)) + for name := range r.providers { + names = append(names, name) + } + return names +} + +var defaultRegistry = NewRegistry() + +func GetDefaultRegistry() *Registry { + return defaultRegistry +} + +func Register(provider Provider) error { + return defaultRegistry.Register(provider) +} + +func Get(name string) (Provider, error) { + return defaultRegistry.Get(name) +} + +func List() []string { + return defaultRegistry.List() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry_test.go new file mode 100644 index 0000000..c3ddfee --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/registry_test.go @@ -0,0 +1,183 @@ +package keybinds + +import ( + "testing" +) + +type mockProvider struct { + name string + err error +} + +func (m *mockProvider) Name() string { + return m.name +} + +func (m *mockProvider) GetCheatSheet() (*CheatSheet, error) { + if m.err != nil { + return nil, m.err + } + return &CheatSheet{ + Title: "Test", + Provider: m.name, + Binds: make(map[string][]Keybind), + }, nil +} + +func TestNewRegistry(t *testing.T) { + r := NewRegistry() + if r == nil { + t.Fatal("NewRegistry returned nil") + } + + if r.providers == nil { + t.Error("providers map is nil") + } +} + +func TestRegisterProvider(t *testing.T) { + tests := []struct { + name string + provider Provider + expectError bool + errorMsg string + }{ + { + name: "valid provider", + provider: &mockProvider{name: "test"}, + expectError: false, + }, + { + name: "nil provider", + provider: nil, + expectError: true, + errorMsg: "cannot register nil provider", + }, + { + name: "empty name", + provider: &mockProvider{name: ""}, + expectError: true, + errorMsg: "provider name cannot be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewRegistry() + err := r.Register(tt.provider) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestRegisterDuplicate(t *testing.T) { + r := NewRegistry() + p := &mockProvider{name: "test"} + + if err := r.Register(p); err != nil { + t.Fatalf("first registration failed: %v", err) + } + + err := r.Register(p) + if err == nil { + t.Error("expected error when registering duplicate, got nil") + } +} + +func TestGetProvider(t *testing.T) { + r := NewRegistry() + p := &mockProvider{name: "test"} + + if err := r.Register(p); err != nil { + t.Fatalf("registration failed: %v", err) + } + + retrieved, err := r.Get("test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if retrieved.Name() != "test" { + t.Errorf("Got provider name %q, want %q", retrieved.Name(), "test") + } +} + +func TestGetNonexistent(t *testing.T) { + r := NewRegistry() + + _, err := r.Get("nonexistent") + if err == nil { + t.Error("expected error for nonexistent provider, got nil") + } +} + +func TestListProviders(t *testing.T) { + r := NewRegistry() + + p1 := &mockProvider{name: "test1"} + p2 := &mockProvider{name: "test2"} + p3 := &mockProvider{name: "test3"} + + r.Register(p1) + r.Register(p2) + r.Register(p3) + + list := r.List() + + if len(list) != 3 { + t.Errorf("expected 3 providers, got %d", len(list)) + } + + found := make(map[string]bool) + for _, name := range list { + found[name] = true + } + + expected := []string{"test1", "test2", "test3"} + for _, name := range expected { + if !found[name] { + t.Errorf("expected provider %q not found in list", name) + } + } +} + +func TestDefaultRegistry(t *testing.T) { + p := &mockProvider{name: "default-test"} + + err := Register(p) + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + retrieved, err := Get("default-test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if retrieved.Name() != "default-test" { + t.Errorf("Got provider name %q, want %q", retrieved.Name(), "default-test") + } + + list := List() + found := false + for _, name := range list { + if name == "default-test" { + found = true + break + } + } + + if !found { + t.Error("provider not found in default registry list") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/types.go new file mode 100644 index 0000000..109b53b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/keybinds/types.go @@ -0,0 +1,47 @@ +package keybinds + +type Keybind struct { + Key string `json:"key"` + Description string `json:"desc"` + Action string `json:"action,omitempty"` + Subcategory string `json:"subcat,omitempty"` + Source string `json:"source,omitempty"` + HideOnOverlay bool `json:"hideOnOverlay,omitempty"` + CooldownMs int `json:"cooldownMs,omitempty"` + Flags string `json:"flags,omitempty"` // Hyprland bind flags: e=repeat, l=locked, r=release, o=long-press + AllowWhenLocked bool `json:"allowWhenLocked,omitempty"` + AllowInhibiting *bool `json:"allowInhibiting,omitempty"` // nil=default(true), false=explicitly disabled + Repeat *bool `json:"repeat,omitempty"` // nil=default(true), false=explicitly disabled + Conflict *Keybind `json:"conflict,omitempty"` +} + +type DMSBindsStatus struct { + Exists bool `json:"exists"` + Included bool `json:"included"` + IncludePosition int `json:"includePosition"` + TotalIncludes int `json:"totalIncludes"` + BindsAfterDMS int `json:"bindsAfterDms"` + Effective bool `json:"effective"` + OverriddenBy int `json:"overriddenBy"` + StatusMessage string `json:"statusMessage"` +} + +type CheatSheet struct { + Title string `json:"title"` + Provider string `json:"provider"` + Binds map[string][]Keybind `json:"binds"` + DMSBindsIncluded bool `json:"dmsBindsIncluded"` + DMSStatus *DMSBindsStatus `json:"dmsStatus,omitempty"` +} + +type Provider interface { + Name() string + GetCheatSheet() (*CheatSheet, error) +} + +type WritableProvider interface { + Provider + SetBind(key, action, description string, options map[string]any) error + RemoveBind(key string) error + GetOverridePath() string +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/log/filelogger.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/log/filelogger.go new file mode 100644 index 0000000..e3ff42b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/log/filelogger.go @@ -0,0 +1,114 @@ +package log + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "sync" + "time" +) + +type FileLogger struct { + file *os.File + writer *bufio.Writer + logPath string + mu sync.Mutex + stopChan chan struct{} + doneChan chan struct{} + passwordRe *regexp.Regexp +} + +func NewFileLogger() (*FileLogger, error) { + timestamp := time.Now().Unix() + + // Use DANKINSTALL_LOG_DIR if set, otherwise fall back to /tmp. + logDir := os.Getenv("DANKINSTALL_LOG_DIR") + if logDir == "" { + logDir = "/tmp" + } + if err := os.MkdirAll(logDir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + logPath := filepath.Join(logDir, fmt.Sprintf("dankinstall-%d.log", timestamp)) + + file, err := os.Create(logPath) + if err != nil { + return nil, fmt.Errorf("failed to create log file: %w", err) + } + + passwordRe := regexp.MustCompile(`(?i)(password[:\s=]+)[^\s]+`) + + logger := &FileLogger{ + file: file, + writer: bufio.NewWriter(file), + logPath: logPath, + stopChan: make(chan struct{}), + doneChan: make(chan struct{}), + passwordRe: passwordRe, + } + + header := fmt.Sprintf("=== DankInstall Log ===\nStarted: %s\n\n", time.Now().Format(time.RFC3339)) + logger.writeToFile(header) + + return logger, nil +} + +func (l *FileLogger) GetLogPath() string { + return l.logPath +} + +func (l *FileLogger) redactPassword(message string) string { + redacted := l.passwordRe.ReplaceAllString(message, "${1}[REDACTED]") + + redacted = regexp.MustCompile(`echo\s+'[^']+'`).ReplaceAllString(redacted, "echo '[REDACTED]'") + + return redacted +} + +func (l *FileLogger) writeToFile(message string) { + l.mu.Lock() + defer l.mu.Unlock() + + redacted := l.redactPassword(message) + timestamp := time.Now().Format("15:04:05.000") + + fmt.Fprintf(l.writer, "[%s] %s\n", timestamp, redacted) + l.writer.Flush() +} + +func (l *FileLogger) StartListening(logChan <-chan string) { + go func() { + defer close(l.doneChan) + for { + select { + case msg, ok := <-logChan: + if !ok { + return + } + l.writeToFile(msg) + case <-l.stopChan: + return + } + } + }() +} + +func (l *FileLogger) Close() error { + close(l.stopChan) + <-l.doneChan + + l.mu.Lock() + defer l.mu.Unlock() + + footer := fmt.Sprintf("\n=== DankInstall Log End ===\nCompleted: %s\n", time.Now().Format(time.RFC3339)) + l.writer.WriteString(footer) //nolint:errcheck + l.writer.Flush() + + if err := l.file.Sync(); err != nil { + return err + } + + return l.file.Close() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/log/log.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/log/log.go new file mode 100644 index 0000000..6b3ede3 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/log/log.go @@ -0,0 +1,116 @@ +package log + +import ( + "os" + "strings" + "sync" + + "github.com/charmbracelet/lipgloss" + cblog "github.com/charmbracelet/log" +) + +// Logger embeds the Charm Logger and adds Printf/Fatalf +type Logger struct{ *cblog.Logger } + +// Printf routes goose/info-style logs through Infof. +func (l *Logger) Printf(format string, v ...any) { l.Infof(format, v...) } + +// Fatalf keeps goose’s contract of exiting the program. +func (l *Logger) Fatalf(format string, v ...any) { l.Logger.Fatalf(format, v...) } + +var ( + logger *Logger + initLogger sync.Once +) + +func parseLogLevel(level string) cblog.Level { + switch strings.ToLower(level) { + case "debug": + return cblog.DebugLevel + case "info": + return cblog.InfoLevel + case "warn", "warning": + return cblog.WarnLevel + case "error": + return cblog.ErrorLevel + case "fatal": + return cblog.FatalLevel + default: + return cblog.InfoLevel + } +} + +func GetQtLoggingRules() string { + level := os.Getenv("DMS_LOG_LEVEL") + if level == "" { + level = "info" + } + + var rules []string + switch strings.ToLower(level) { + case "fatal": + rules = []string{"*.debug=false", "*.info=false", "*.warning=false", "*.critical=false"} + case "error": + rules = []string{"*.debug=false", "*.info=false", "*.warning=false"} + case "warn", "warning": + rules = []string{"*.debug=false", "*.info=false"} + case "info": + rules = []string{"*.debug=false"} + case "debug": + return "" + default: + rules = []string{"*.debug=false"} + } + + return strings.Join(rules, ";") +} + +// GetLogger returns a logger instance +func GetLogger() *Logger { + initLogger.Do(func() { + styles := cblog.DefaultStyles() + // Attempt to match the colors used by qml/quickshell logs + styles.Levels[cblog.FatalLevel] = lipgloss.NewStyle(). + SetString(" FATAL"). + Foreground(lipgloss.Color("1")) + styles.Levels[cblog.ErrorLevel] = lipgloss.NewStyle(). + SetString(" ERROR"). + Foreground(lipgloss.Color("9")) + styles.Levels[cblog.WarnLevel] = lipgloss.NewStyle(). + SetString(" WARN"). + Foreground(lipgloss.Color("3")) + styles.Levels[cblog.InfoLevel] = lipgloss.NewStyle(). + SetString(" INFO"). + Foreground(lipgloss.Color("2")) + styles.Levels[cblog.DebugLevel] = lipgloss.NewStyle(). + SetString(" DEBUG"). + Foreground(lipgloss.Color("4")) + + base := cblog.New(os.Stderr) + base.SetStyles(styles) + base.SetReportTimestamp(false) + + level := cblog.InfoLevel + if envLevel := os.Getenv("DMS_LOG_LEVEL"); envLevel != "" { + level = parseLogLevel(envLevel) + } + base.SetLevel(level) + base.SetPrefix(" go") + + logger = &Logger{base} + }) + return logger +} + +// * Convenience wrappers + +func Debug(msg any, keyvals ...any) { GetLogger().Debug(msg, keyvals...) } +func Debugf(format string, v ...any) { GetLogger().Debugf(format, v...) } +func Info(msg any, keyvals ...any) { GetLogger().Info(msg, keyvals...) } +func Infof(format string, v ...any) { GetLogger().Infof(format, v...) } +func Warn(msg any, keyvals ...any) { GetLogger().Warn(msg, keyvals...) } +func Warnf(format string, v ...any) { GetLogger().Warnf(format, v...) } +func Error(msg any, keyvals ...any) { GetLogger().Error(msg, keyvals...) } +func Errorf(format string, v ...any) { GetLogger().Errorf(format, v...) } +func Fatal(msg any, keyvals ...any) { GetLogger().Fatal(msg, keyvals...) } +func Fatalf(format string, v ...any) { GetLogger().Fatalf(format, v...) } diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/matugen.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/matugen.go new file mode 100644 index 0000000..a29d324 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/matugen.go @@ -0,0 +1,978 @@ +package matugen + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/dank16" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/lucasb-eyer/go-colorful" +) + +var ErrNoChanges = errors.New("no color changes") + +type ColorMode string + +const ( + ColorModeDark ColorMode = "dark" + ColorModeLight ColorMode = "light" +) + +type TemplateKind int + +const ( + TemplateKindNormal TemplateKind = iota + TemplateKindTerminal + TemplateKindGTK + TemplateKindVSCode + TemplateKindEmacs +) + +type TemplateDef struct { + ID string + Commands []string + Flatpaks []string + ConfigFile string + Kind TemplateKind + RunUnconditionally bool +} + +var templateRegistry = []TemplateDef{ + {ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true}, + {ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"}, + {ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"}, + {ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml"}, + {ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"}, + {ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"}, + {ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"}, + {ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"}, + {ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"}, + {ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"}, + {ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"}, + {ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal}, + {ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal}, + {ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal}, + {ID: "alacritty", Commands: []string{"alacritty"}, ConfigFile: "alacritty.toml", Kind: TemplateKindTerminal}, + {ID: "wezterm", Commands: []string{"wezterm"}, ConfigFile: "wezterm.toml", Kind: TemplateKindTerminal}, + {ID: "nvim", Commands: []string{"nvim"}, ConfigFile: "neovim.toml", Kind: TemplateKindTerminal}, + {ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"}, + {ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true}, + {ID: "vscode", Kind: TemplateKindVSCode}, + {ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml", Kind: TemplateKindEmacs}, + {ID: "zed", Commands: []string{"zed", "zeditor", "zedit"}, ConfigFile: "zed.toml"}, +} + +func (c *ColorMode) GTKTheme() string { + switch *c { + case ColorModeDark: + return "adw-gtk3-dark" + default: + return "adw-gtk3" + } +} + +var ( + matugenVersionMu sync.Mutex + matugenVersionOK bool + matugenSupportsCOE bool + matugenIsV4 bool +) + +type Options struct { + StateDir string + ShellDir string + ConfigDir string + Kind string + Value string + Mode ColorMode + IconTheme string + MatugenType string + Contrast float64 + RunUserTemplates bool + ColorsOnly bool + StockColors string + SyncModeWithPortal bool + TerminalsAlwaysDark bool + SkipTemplates string + AppChecker utils.AppChecker +} + +type ColorsOutput struct { + Colors struct { + Dark map[string]string `json:"dark"` + Light map[string]string `json:"light"` + } `json:"colors"` +} + +func (o *Options) ColorsOutput() string { + return filepath.Join(o.StateDir, "dms-colors.json") +} + +func (o *Options) ShouldSkipTemplate(name string) bool { + if o.SkipTemplates == "" { + return false + } + for _, skip := range strings.Split(o.SkipTemplates, ",") { + if strings.TrimSpace(skip) == name { + return true + } + } + return false +} + +func Run(opts Options) error { + if opts.StateDir == "" { + return fmt.Errorf("state-dir is required") + } + if opts.ShellDir == "" { + return fmt.Errorf("shell-dir is required") + } + if opts.ConfigDir == "" { + return fmt.Errorf("config-dir is required") + } + if opts.Kind == "" { + return fmt.Errorf("kind is required") + } + if opts.Value == "" { + return fmt.Errorf("value is required") + } + if opts.Mode == "" { + opts.Mode = ColorModeDark + } + if opts.MatugenType == "" { + opts.MatugenType = "scheme-tonal-spot" + } + if opts.IconTheme == "" { + opts.IconTheme = "System Default" + } + if opts.AppChecker == nil { + opts.AppChecker = utils.DefaultAppChecker{} + } + + if err := os.MkdirAll(opts.StateDir, 0o755); err != nil { + return fmt.Errorf("failed to create state dir: %w", err) + } + + log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode) + + changed, buildErr := buildOnce(&opts) + if buildErr != nil { + return buildErr + } + + if !changed { + log.Info("No color changes detected, skipping refresh") + return ErrNoChanges + } + + if opts.SyncModeWithPortal { + syncColorScheme(opts.Mode) + } + + log.Info("Done") + return nil +} + +func buildOnce(opts *Options) (bool, error) { + cfgFile, err := os.CreateTemp("", "matugen-config-*.toml") + if err != nil { + return false, fmt.Errorf("failed to create temp config: %w", err) + } + defer os.Remove(cfgFile.Name()) + defer cfgFile.Close() + + tmpDir, err := os.MkdirTemp("", "matugen-templates-*") + if err != nil { + return false, fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err := buildMergedConfig(opts, cfgFile, tmpDir); err != nil { + return false, fmt.Errorf("failed to build config: %w", err) + } + cfgFile.Close() + + oldColors, _ := os.ReadFile(opts.ColorsOutput()) + + var primaryDark, primaryLight, surface string + var dank16JSON string + var importArgs []string + + if opts.StockColors != "" { + log.Info("Using stock/custom theme colors with matugen base") + primaryDark = extractNestedColor(opts.StockColors, "primary", "dark") + primaryLight = extractNestedColor(opts.StockColors, "primary", "light") + surface = extractNestedColor(opts.StockColors, "surface", "dark") + + if primaryDark == "" { + return false, fmt.Errorf("failed to extract primary dark from stock colors") + } + if primaryLight == "" { + primaryLight = primaryDark + } + + dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode) + importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON) + importArgs = []string{"--import-json-string", importData} + + log.Info("Running matugen color hex with stock color overrides") + args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()} + args = appendContrastArg(args, opts.Contrast) + args = append(args, importArgs...) + if err := runMatugen(args); err != nil { + return false, err + } + } else { + log.Infof("Using dynamic theme from %s: %s", opts.Kind, opts.Value) + + matJSON, err := runMatugenDryRun(opts) + if err != nil { + return false, fmt.Errorf("matugen dry-run failed: %w", err) + } + + primaryDark = extractMatugenColor(matJSON, "primary", "dark") + primaryLight = extractMatugenColor(matJSON, "primary", "light") + surface = extractMatugenColor(matJSON, "surface", "dark") + + if primaryDark == "" { + return false, fmt.Errorf("failed to extract primary color") + } + if primaryLight == "" { + primaryLight = primaryDark + } + + dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode) + importData := fmt.Sprintf(`{"dank16": %s}`, dank16JSON) + importArgs = []string{"--import-json-string", importData} + + log.Infof("Running matugen %s with dank16 injection", opts.Kind) + var args []string + switch opts.Kind { + case "hex": + args = []string{"color", "hex", opts.Value} + default: + args = []string{opts.Kind, opts.Value} + } + args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()) + args = appendContrastArg(args, opts.Contrast) + args = append(args, importArgs...) + if err := runMatugen(args); err != nil { + return false, err + } + } + + newColors, _ := os.ReadFile(opts.ColorsOutput()) + if bytes.Equal(oldColors, newColors) && len(oldColors) > 0 { + return false, nil + } + + if opts.ColorsOnly { + return true, nil + } + + if isDMSGTKActive(opts.ConfigDir) { + switch opts.Mode { + case ColorModeLight: + syncAccentColor(primaryLight) + default: + syncAccentColor(primaryDark) + } + refreshGTK(opts.Mode) + refreshGTK4() + } + + if !opts.ShouldSkipTemplate("qt6ct") && appExists(opts.AppChecker, []string{"qt6ct"}, nil) { + refreshQt6ct() + } + + signalTerminals(opts) + + return true, nil +} + +func appendContrastArg(args []string, contrast float64) []string { + if contrast == 0 { + return args + } + return append(args, "--contrast", strconv.FormatFloat(contrast, 'f', -1, 64)) +} + +func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error { + userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml") + + wroteConfig := false + if opts.RunUserTemplates { + if data, err := os.ReadFile(userConfigPath); err == nil { + configSection := extractTOMLSection(string(data), "[config]", "[templates]") + if configSection != "" { + cfgFile.WriteString(configSection) + cfgFile.WriteString("\n") + wroteConfig = true + } + } + } + if !wroteConfig { + cfgFile.WriteString("[config]\n\n") + } + + baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml") + if data, err := os.ReadFile(baseConfigPath); err == nil { + content := string(data) + lines := strings.Split(content, "\n") + for _, line := range lines { + if strings.TrimSpace(line) == "[config]" { + continue + } + cfgFile.WriteString(substituteVars(line, opts.ShellDir) + "\n") + } + cfgFile.WriteString("\n") + } + + fmt.Fprintf(cfgFile, `[templates.dank] +input_path = '%s/matugen/templates/dank.json' +output_path = '%s' + +`, opts.ShellDir, opts.ColorsOutput()) + + if opts.ColorsOnly { + return nil + } + + homeDir, _ := os.UserHomeDir() + for _, tmpl := range templateRegistry { + if opts.ShouldSkipTemplate(tmpl.ID) { + continue + } + + switch tmpl.Kind { + case TemplateKindGTK: + switch opts.Mode { + case ColorModeLight: + appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml") + default: + appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml") + } + case TemplateKindTerminal: + appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) + case TemplateKindVSCode: + appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir) + appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir) + appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir) + appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir) + appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir) + appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir) + case TemplateKindEmacs: + if utils.EmacsConfigDir() != "" { + appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) + } + default: + appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) + } + } + + if opts.RunUserTemplates { + if data, err := os.ReadFile(userConfigPath); err == nil { + templatesSection := extractTOMLSection(string(data), "[templates]", "") + if templatesSection != "" { + cfgFile.WriteString(templatesSection) + cfgFile.WriteString("\n") + } + } + } + + userPluginConfigDir := filepath.Join(opts.ConfigDir, "matugen", "dms", "configs") + if entries, err := os.ReadDir(userPluginConfigDir); err == nil { + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".toml") { + continue + } + if data, err := os.ReadFile(filepath.Join(userPluginConfigDir, entry.Name())); err == nil { + cfgFile.WriteString(string(data)) + cfgFile.WriteString("\n") + } + } + } + + return nil +} + +func appendConfig( + opts *Options, + cfgFile *os.File, + checkCmd []string, + checkFlatpaks []string, + fileName string, +) { + configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName) + if _, err := os.Stat(configPath); err != nil { + return + } + if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) { + return + } + data, err := os.ReadFile(configPath) + if err != nil { + return + } + cfgFile.WriteString(substituteVars(string(data), opts.ShellDir)) + cfgFile.WriteString("\n") +} + +func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkCmd []string, checkFlatpaks []string, fileName string) { + configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName) + if _, err := os.Stat(configPath); err != nil { + return + } + if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) { + return + } + data, err := os.ReadFile(configPath) + if err != nil { + return + } + + content := string(data) + + if !opts.TerminalsAlwaysDark { + cfgFile.WriteString(substituteVars(content, opts.ShellDir)) + cfgFile.WriteString("\n") + return + } + + lines := strings.Split(content, "\n") + for _, line := range lines { + if !strings.Contains(line, "input_path") || !strings.Contains(line, "SHELL_DIR/matugen/templates/") { + continue + } + + start := strings.Index(line, "'SHELL_DIR/matugen/templates/") + if start == -1 { + continue + } + end := strings.Index(line[start+1:], "'") + if end == -1 { + continue + } + templateName := line[start+len("'SHELL_DIR/matugen/templates/") : start+1+end] + origPath := filepath.Join(opts.ShellDir, "matugen", "templates", templateName) + + origData, err := os.ReadFile(origPath) + if err != nil { + continue + } + + modified := strings.ReplaceAll(string(origData), ".default.", ".dark.") + tmpPath := filepath.Join(tmpDir, templateName) + if err := os.WriteFile(tmpPath, []byte(modified), 0o644); err != nil { + continue + } + + content = strings.ReplaceAll(content, + fmt.Sprintf("'SHELL_DIR/matugen/templates/%s'", templateName), + fmt.Sprintf("'%s'", tmpPath)) + } + + cfgFile.WriteString(substituteVars(content, opts.ShellDir)) + cfgFile.WriteString("\n") +} + +func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool { + // Both nil is treated as "skip check" / unconditionally run + if checkCmd == nil && checkFlatpaks == nil { + return true + } + if checkCmd != nil && checker.AnyCommandExists(checkCmd...) { + return true + } + if checkFlatpaks != nil && checker.AnyFlatpakExists(checkFlatpaks...) { + return true + } + return false +} + +func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) { + pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*") + matches, err := filepath.Glob(pattern) + if err != nil || len(matches) == 0 { + return + } + + extDir := matches[0] + templateDir := filepath.Join(shellDir, "matugen", "templates") + fmt.Fprintf(cfgFile, `[templates.dms%sdefault] +input_path = '%s/vscode-color-theme-default.json' +output_path = '%s/themes/dankshell-default.json' + +[templates.dms%sdark] +input_path = '%s/vscode-color-theme-dark.json' +output_path = '%s/themes/dankshell-dark.json' + +[templates.dms%slight] +input_path = '%s/vscode-color-theme-light.json' +output_path = '%s/themes/dankshell-light.json' + +`, name, templateDir, extDir, + name, templateDir, extDir, + name, templateDir, extDir) + log.Infof("Added %s theme config (extension found at %s)", name, extDir) +} + +func substituteVars(content, shellDir string) string { + result := strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/") + result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/") + result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/") + result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/") + if emacsDir := utils.EmacsConfigDir(); emacsDir != "" { + result = strings.ReplaceAll(result, "'EMACS_DIR/", "'"+emacsDir+"/") + } + return result +} + +func extractTOMLSection(content, startMarker, endMarker string) string { + startIdx := strings.Index(content, startMarker) + if startIdx == -1 { + return "" + } + + if endMarker == "" { + return content[startIdx:] + } + + endIdx := strings.Index(content[startIdx:], endMarker) + if endIdx == -1 { + return content[startIdx:] + } + return content[startIdx : startIdx+endIdx] +} + +type matugenFlags struct { + supportsCOE bool + isV4 bool +} + +func detectMatugenVersion() (matugenFlags, error) { + matugenVersionMu.Lock() + defer matugenVersionMu.Unlock() + + if matugenVersionOK { + return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil + } + + return detectMatugenVersionLocked() +} + +func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) { + matugenVersionMu.Lock() + defer matugenVersionMu.Unlock() + + matugenVersionOK = false + flags, err := detectMatugenVersionLocked() + if err != nil { + return old, false + } + changed := flags.supportsCOE != old.supportsCOE || flags.isV4 != old.isV4 + return flags, changed +} + +func detectMatugenVersionLocked() (matugenFlags, error) { + cmd := exec.Command("matugen", "--version") + output, err := cmd.Output() + if err != nil { + return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err) + } + + versionStr := strings.TrimSpace(string(output)) + versionStr = strings.TrimPrefix(versionStr, "matugen ") + + parts := strings.Split(versionStr, ".") + if len(parts) < 2 { + return matugenFlags{}, fmt.Errorf("unexpected matugen version format: %q", versionStr) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return matugenFlags{}, fmt.Errorf("failed to parse matugen major version %q: %w", parts[0], err) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return matugenFlags{}, fmt.Errorf("failed to parse matugen minor version %q: %w", parts[1], err) + } + + matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1) + matugenIsV4 = major >= 4 + matugenVersionOK = true + + if matugenSupportsCOE { + log.Debugf("Matugen %s detected: continue-on-error support enabled", versionStr) + } + if matugenIsV4 { + log.Debugf("Matugen %s detected: using v4 compatibility flags", versionStr) + } + return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil +} + +func buildMatugenArgs(baseArgs []string, flags matugenFlags) []string { + args := make([]string, 0, len(baseArgs)+4) + if flags.supportsCOE { + args = append(args, "--continue-on-error") + } + args = append(args, baseArgs...) + if flags.isV4 { + args = append(args, "--source-color-index", "0") + } + return args +} + +func runMatugen(baseArgs []string) error { + flags, err := detectMatugenVersion() + if err != nil { + return err + } + + args := buildMatugenArgs(baseArgs, flags) + cmd := exec.Command("matugen", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + runErr := cmd.Run() + if runErr == nil { + return nil + } + + log.Warnf("Matugen failed (v4=%v): %v", flags.isV4, runErr) + + newFlags, changed := redetectMatugenVersion(flags) + if !changed { + return runErr + } + + log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4) + args = buildMatugenArgs(baseArgs, newFlags) + retryCmd := exec.Command("matugen", args...) + retryCmd.Stdout = os.Stdout + retryCmd.Stderr = os.Stderr + return retryCmd.Run() +} + +func runMatugenDryRun(opts *Options) (string, error) { + flags, err := detectMatugenVersion() + if err != nil { + return "", err + } + + output, dryErr := execDryRun(opts, flags) + if dryErr == nil { + return output, nil + } + + log.Warnf("Matugen dry-run failed (v4=%v): %v", flags.isV4, dryErr) + + newFlags, changed := redetectMatugenVersion(flags) + if !changed { + return "", dryErr + } + + log.Warnf("Matugen version changed (v4: %v -> %v), retrying dry-run", flags.isV4, newFlags.isV4) + return execDryRun(opts, newFlags) +} + +func execDryRun(opts *Options, flags matugenFlags) (string, error) { + var baseArgs []string + switch opts.Kind { + case "hex": + baseArgs = []string{"color", "hex", opts.Value} + default: + baseArgs = []string{opts.Kind, opts.Value} + } + baseArgs = append(baseArgs, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run") + baseArgs = appendContrastArg(baseArgs, opts.Contrast) + if flags.isV4 { + baseArgs = append(baseArgs, "--source-color-index", "0", "--old-json-output") + } + + cmd := exec.Command("matugen", baseArgs...) + var stderr strings.Builder + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + if stderr.Len() > 0 { + return "", fmt.Errorf("matugen %v failed (v4=%v): %s", baseArgs, flags.isV4, strings.TrimSpace(stderr.String())) + } + return "", fmt.Errorf("matugen %v failed (v4=%v): %w", baseArgs, flags.isV4, err) + } + return strings.ReplaceAll(string(output), "\n", ""), nil +} + +func extractMatugenColor(jsonStr, colorName, variant string) string { + var data map[string]any + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + return "" + } + + colors, ok := data["colors"].(map[string]any) + if !ok { + return "" + } + + colorData, ok := colors[colorName].(map[string]any) + if !ok { + return "" + } + + variantData, ok := colorData[variant].(string) + if !ok { + return "" + } + + return variantData +} + +func extractNestedColor(jsonStr, colorName, variant string) string { + var data map[string]any + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + return "" + } + + colorData, ok := data[colorName].(map[string]any) + if !ok { + return "" + } + + variantData, ok := colorData[variant].(map[string]any) + if !ok { + return "" + } + + color, ok := variantData["color"].(string) + if !ok { + return "" + } + + return color +} + +func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string { + variantOpts := dank16.VariantOptions{ + PrimaryDark: primaryDark, + PrimaryLight: primaryLight, + Background: surface, + UseDPS: true, + IsLightMode: mode == ColorModeLight, + } + variantColors := dank16.GenerateVariantPalette(variantOpts) + return dank16.GenerateVariantJSON(variantColors) +} + +func isDMSGTKActive(configDir string) bool { + gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css") + + info, err := os.Lstat(gtkCSS) + if err != nil { + return false + } + + if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(gtkCSS) + return err == nil && strings.Contains(target, "dank-colors.css") + } + + data, err := os.ReadFile(gtkCSS) + return err == nil && strings.Contains(string(data), "dank-colors.css") +} + +func refreshGTK(mode ColorMode) { + if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil { + log.Warnf("Failed to reset gtk-theme: %v", err) + } + if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil { + log.Warnf("Failed to set gtk-theme: %v", err) + } +} + +func refreshGTK4() { + output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme") + if err != nil { + return + } + current := strings.Trim(output, "'") + + var toggle string + if current == "prefer-dark" { + toggle = "default" + } else { + toggle = "prefer-dark" + } + + if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil { + log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err) + return + } + time.Sleep(50 * time.Millisecond) + if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil { + log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err) + } +} + +func refreshQt6ct() { + confPath := filepath.Join(utils.XDGConfigHome(), "qt6ct", "qt6ct.conf") + now := time.Now() + if err := os.Chtimes(confPath, now, now); err != nil { + log.Warnf("Failed to touch qt6ct.conf: %v", err) + } +} + +func signalTerminals(opts *Options) { + if !opts.ShouldSkipTemplate("kitty") && appExists(opts.AppChecker, []string{"kitty"}, nil) { + signalByName("kitty", syscall.SIGUSR1) + signalByName(".kitty-wrapped", syscall.SIGUSR1) + } + if !opts.ShouldSkipTemplate("ghostty") && appExists(opts.AppChecker, []string{"ghostty"}, nil) { + signalByName("ghostty", syscall.SIGUSR2) + signalByName(".ghostty-wrappe", syscall.SIGUSR2) + } +} + +func signalByName(name string, sig syscall.Signal) { + entries, err := os.ReadDir("/proc") + if err != nil { + return + } + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + comm, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "comm")) + if err != nil { + continue + } + if strings.TrimSpace(string(comm)) == name { + syscall.Kill(pid, sig) + } + } +} + +func syncColorScheme(mode ColorMode) { + scheme := "prefer-dark" + if mode == ColorModeLight { + scheme = "default" + } + + if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil { + log.Warnf("Failed to sync color-scheme: %v", err) + } +} + +var adwaitaAccents = []struct { + name string + colors []colorful.Color +}{ + {"blue", hexColors("#3f8ae5", "#438de6", "#a4caee")}, + {"green", hexColors("#26a269", "#39ac76", "#81d5ad")}, + {"orange", hexColors("#f17738", "#ff7800", "#ffc994")}, + {"pink", hexColors("#e4358a", "#e64392", "#f9b3d5")}, + {"purple", hexColors("#954ab5", "#9c46b9", "#d099d6")}, + {"red", hexColors("#e84053", "#e01b24", "#f2a1a5")}, + {"slate", hexColors("#557b9f", "#6a8daf", "#b4c6d6")}, + {"teal", hexColors("#129eb0", "#2190a4", "#7bdff4")}, + {"yellow", hexColors("#cbac10", "#d4b411", "#f5c211")}, +} + +func hexColors(hexes ...string) []colorful.Color { + out := make([]colorful.Color, len(hexes)) + for i, h := range hexes { + out[i], _ = colorful.Hex(h) + } + return out +} + +func closestAdwaitaAccent(primaryHex string) string { + c, err := colorful.Hex(primaryHex) + if err != nil { + return "blue" + } + + best := "blue" + bestDist := math.MaxFloat64 + for _, a := range adwaitaAccents { + for _, ref := range a.colors { + d := c.DistanceCIEDE2000(ref) + if d < bestDist { + bestDist = d + best = a.name + } + } + } + return best +} + +func syncAccentColor(primaryHex string) { + accent := closestAdwaitaAccent(primaryHex) + log.Infof("Setting GNOME accent color: %s", accent) + if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil { + log.Warnf("Failed to set accent-color: %v", err) + } +} + +type TemplateCheck struct { + ID string `json:"id"` + Detected bool `json:"detected"` +} + +func CheckTemplates(checker utils.AppChecker) []TemplateCheck { + if checker == nil { + checker = utils.DefaultAppChecker{} + } + + homeDir, _ := os.UserHomeDir() + checks := make([]TemplateCheck, 0, len(templateRegistry)) + + for _, tmpl := range templateRegistry { + detected := false + + switch { + case tmpl.RunUnconditionally: + detected = true + case tmpl.Kind == TemplateKindVSCode: + detected = checkVSCodeExtension(homeDir) + case tmpl.Kind == TemplateKindEmacs: + detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != "" + default: + detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) + } + + checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected}) + } + + return checks +} + +func checkVSCodeExtension(homeDir string) bool { + extDirs := []string{ + filepath.Join(homeDir, ".vscode/extensions"), + filepath.Join(homeDir, ".vscode-oss/extensions"), + filepath.Join(homeDir, ".config/Code - OSS/extensions"), + filepath.Join(homeDir, ".cursor/extensions"), + filepath.Join(homeDir, ".windsurf/extensions"), + } + + for _, extDir := range extDirs { + pattern := filepath.Join(extDir, "danklinux.dms-theme-*") + if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 { + return true + } + } + return false +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/matugen_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/matugen_test.go new file mode 100644 index 0000000..48880df --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/matugen_test.go @@ -0,0 +1,443 @@ +package matugen + +import ( + "os" + "path/filepath" + "strings" + "testing" + + mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/stretchr/testify/assert" +) + +func TestAppendConfigBinaryExists(t *testing.T) { + tempDir := t.TempDir() + + shellDir := filepath.Join(tempDir, "shell") + configsDir := filepath.Join(shellDir, "matugen", "configs") + if err := os.MkdirAll(configsDir, 0o755); err != nil { + t.Fatalf("failed to create configs dir: %v", err) + } + + testConfig := "test config content" + configPath := filepath.Join(configsDir, "test.toml") + if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + outFile := filepath.Join(tempDir, "output.toml") + cfgFile, err := os.Create(outFile) + if err != nil { + t.Fatalf("failed to create output file: %v", err) + } + defer cfgFile.Close() + + mockChecker := mocks_utils.NewMockAppChecker(t) + mockChecker.EXPECT().AnyCommandExists("sh").Return(true) + + opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} + + appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml") + + cfgFile.Close() + output, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + if len(output) == 0 { + t.Errorf("expected config to be written when binary exists") + } + if string(output) != testConfig+"\n" { + t.Errorf("expected %q, got %q", testConfig+"\n", string(output)) + } +} + +func TestAppendConfigBinaryDoesNotExist(t *testing.T) { + tempDir := t.TempDir() + + shellDir := filepath.Join(tempDir, "shell") + configsDir := filepath.Join(shellDir, "matugen", "configs") + if err := os.MkdirAll(configsDir, 0o755); err != nil { + t.Fatalf("failed to create configs dir: %v", err) + } + + testConfig := "test config content" + configPath := filepath.Join(configsDir, "test.toml") + if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + outFile := filepath.Join(tempDir, "output.toml") + cfgFile, err := os.Create(outFile) + if err != nil { + t.Fatalf("failed to create output file: %v", err) + } + defer cfgFile.Close() + + mockChecker := mocks_utils.NewMockAppChecker(t) + mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false) + mockChecker.EXPECT().AnyFlatpakExists().Return(false) + + opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} + + appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml") + + cfgFile.Close() + output, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + if len(output) != 0 { + t.Errorf("expected no config when binary doesn't exist, got: %q", string(output)) + } +} + +func TestAppendConfigFlatpakExists(t *testing.T) { + tempDir := t.TempDir() + + shellDir := filepath.Join(tempDir, "shell") + configsDir := filepath.Join(shellDir, "matugen", "configs") + if err := os.MkdirAll(configsDir, 0o755); err != nil { + t.Fatalf("failed to create configs dir: %v", err) + } + + testConfig := "zen config content" + configPath := filepath.Join(configsDir, "test.toml") + if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + outFile := filepath.Join(tempDir, "output.toml") + cfgFile, err := os.Create(outFile) + if err != nil { + t.Fatalf("failed to create output file: %v", err) + } + defer cfgFile.Close() + + mockChecker := mocks_utils.NewMockAppChecker(t) + mockChecker.EXPECT().AnyFlatpakExists("app.zen_browser.zen").Return(true) + + opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} + + appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml") + + cfgFile.Close() + output, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + if len(output) == 0 { + t.Errorf("expected config to be written when flatpak exists") + } +} + +func TestAppendConfigFlatpakDoesNotExist(t *testing.T) { + tempDir := t.TempDir() + + shellDir := filepath.Join(tempDir, "shell") + configsDir := filepath.Join(shellDir, "matugen", "configs") + if err := os.MkdirAll(configsDir, 0o755); err != nil { + t.Fatalf("failed to create configs dir: %v", err) + } + + testConfig := "test config content" + configPath := filepath.Join(configsDir, "test.toml") + if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + outFile := filepath.Join(tempDir, "output.toml") + cfgFile, err := os.Create(outFile) + if err != nil { + t.Fatalf("failed to create output file: %v", err) + } + defer cfgFile.Close() + + mockChecker := mocks_utils.NewMockAppChecker(t) + mockChecker.EXPECT().AnyCommandExists().Return(false) + mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false) + + opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} + + appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml") + + cfgFile.Close() + output, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + if len(output) != 0 { + t.Errorf("expected no config when flatpak doesn't exist, got: %q", string(output)) + } +} + +func TestAppendConfigBothExist(t *testing.T) { + tempDir := t.TempDir() + + shellDir := filepath.Join(tempDir, "shell") + configsDir := filepath.Join(shellDir, "matugen", "configs") + if err := os.MkdirAll(configsDir, 0o755); err != nil { + t.Fatalf("failed to create configs dir: %v", err) + } + + testConfig := "zen config content" + configPath := filepath.Join(configsDir, "test.toml") + if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + outFile := filepath.Join(tempDir, "output.toml") + cfgFile, err := os.Create(outFile) + if err != nil { + t.Fatalf("failed to create output file: %v", err) + } + defer cfgFile.Close() + + mockChecker := mocks_utils.NewMockAppChecker(t) + mockChecker.EXPECT().AnyCommandExists("sh").Return(true) + + opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} + + appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml") + + cfgFile.Close() + output, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + if len(output) == 0 { + t.Errorf("expected config to be written when both binary and flatpak exist") + } +} + +func TestAppendConfigNeitherExists(t *testing.T) { + tempDir := t.TempDir() + + shellDir := filepath.Join(tempDir, "shell") + configsDir := filepath.Join(shellDir, "matugen", "configs") + if err := os.MkdirAll(configsDir, 0o755); err != nil { + t.Fatalf("failed to create configs dir: %v", err) + } + + testConfig := "test config content" + configPath := filepath.Join(configsDir, "test.toml") + if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + outFile := filepath.Join(tempDir, "output.toml") + cfgFile, err := os.Create(outFile) + if err != nil { + t.Fatalf("failed to create output file: %v", err) + } + defer cfgFile.Close() + + mockChecker := mocks_utils.NewMockAppChecker(t) + mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false) + mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false) + + opts := &Options{ShellDir: shellDir, AppChecker: mockChecker} + + appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml") + + cfgFile.Close() + output, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + if len(output) != 0 { + t.Errorf("expected no config when neither exists, got: %q", string(output)) + } +} + +func TestAppendConfigNoChecks(t *testing.T) { + tempDir := t.TempDir() + + shellDir := filepath.Join(tempDir, "shell") + configsDir := filepath.Join(shellDir, "matugen", "configs") + if err := os.MkdirAll(configsDir, 0o755); err != nil { + t.Fatalf("failed to create configs dir: %v", err) + } + + testConfig := "always include" + configPath := filepath.Join(configsDir, "test.toml") + if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + outFile := filepath.Join(tempDir, "output.toml") + cfgFile, err := os.Create(outFile) + if err != nil { + t.Fatalf("failed to create output file: %v", err) + } + defer cfgFile.Close() + + opts := &Options{ShellDir: shellDir} + + appendConfig(opts, cfgFile, nil, nil, "test.toml") + + cfgFile.Close() + output, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + if len(output) == 0 { + t.Errorf("expected config to be written when no checks specified") + } +} + +func TestAppendConfigFileDoesNotExist(t *testing.T) { + tempDir := t.TempDir() + + shellDir := filepath.Join(tempDir, "shell") + configsDir := filepath.Join(shellDir, "matugen", "configs") + if err := os.MkdirAll(configsDir, 0o755); err != nil { + t.Fatalf("failed to create configs dir: %v", err) + } + + outFile := filepath.Join(tempDir, "output.toml") + cfgFile, err := os.Create(outFile) + if err != nil { + t.Fatalf("failed to create output file: %v", err) + } + defer cfgFile.Close() + + opts := &Options{ShellDir: shellDir} + + appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml") + + cfgFile.Close() + output, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("failed to read output: %v", err) + } + + if len(output) != 0 { + t.Errorf("expected no config when file doesn't exist, got: %q", string(output)) + } +} + +func TestSubstituteVars(t *testing.T) { + configDir := utils.XDGConfigHome() + dataDir := utils.XDGDataHome() + cacheDir := utils.XDGCacheHome() + + tests := []struct { + name string + input string + shellDir string + expected string + }{ + { + name: "substitutes SHELL_DIR", + input: "input_path = 'SHELL_DIR/matugen/templates/foo.conf'", + shellDir: "/home/user/shell", + expected: "input_path = '/home/user/shell/matugen/templates/foo.conf'", + }, + { + name: "substitutes CONFIG_DIR", + input: "output_path = 'CONFIG_DIR/kitty/theme.conf'", + shellDir: "/home/user/shell", + expected: "output_path = '" + configDir + "/kitty/theme.conf'", + }, + { + name: "substitutes DATA_DIR", + input: "output_path = 'DATA_DIR/color-schemes/theme.colors'", + shellDir: "/home/user/shell", + expected: "output_path = '" + dataDir + "/color-schemes/theme.colors'", + }, + { + name: "substitutes CACHE_DIR", + input: "output_path = 'CACHE_DIR/wal/colors.json'", + shellDir: "/home/user/shell", + expected: "output_path = '" + cacheDir + "/wal/colors.json'", + }, + { + name: "substitutes all dir types", + input: "'SHELL_DIR/a' 'CONFIG_DIR/b' 'DATA_DIR/c' 'CACHE_DIR/d'", + shellDir: "/shell", + expected: "'/shell/a' '" + configDir + "/b' '" + dataDir + "/c' '" + cacheDir + "/d'", + }, + { + name: "no substitution when no placeholders", + input: "input_path = '/absolute/path/foo.conf'", + shellDir: "/home/user/shell", + expected: "input_path = '/absolute/path/foo.conf'", + }, + { + name: "multiple SHELL_DIR occurrences", + input: "'SHELL_DIR/a' and 'SHELL_DIR/b'", + shellDir: "/shell", + expected: "'/shell/a' and '/shell/b'", + }, + { + name: "only substitutes quoted paths", + input: "SHELL_DIR/unquoted and 'SHELL_DIR/quoted'", + shellDir: "/shell", + expected: "SHELL_DIR/unquoted and '/shell/quoted'", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := substituteVars(tc.input, tc.shellDir) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestBuildMergedConfigColorsOnly(t *testing.T) { + tempDir := t.TempDir() + + shellDir := filepath.Join(tempDir, "shell") + configsDir := filepath.Join(shellDir, "matugen", "configs") + if err := os.MkdirAll(configsDir, 0o755); err != nil { + t.Fatalf("failed to create configs dir: %v", err) + } + + baseConfig := "[config]\ncustom_keywords = []\n" + if err := os.WriteFile(filepath.Join(configsDir, "base.toml"), []byte(baseConfig), 0o644); err != nil { + t.Fatalf("failed to write base config: %v", err) + } + + cfgFile, err := os.CreateTemp(tempDir, "merged-*.toml") + if err != nil { + t.Fatalf("failed to create temp config: %v", err) + } + defer os.Remove(cfgFile.Name()) + defer cfgFile.Close() + + opts := &Options{ + ShellDir: shellDir, + ConfigDir: filepath.Join(tempDir, "config"), + StateDir: filepath.Join(tempDir, "state"), + ColorsOnly: true, + } + + if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil { + t.Fatalf("buildMergedConfig failed: %v", err) + } + + if err := cfgFile.Close(); err != nil { + t.Fatalf("failed to close merged config: %v", err) + } + + output, err := os.ReadFile(cfgFile.Name()) + if err != nil { + t.Fatalf("failed to read merged config: %v", err) + } + + content := string(output) + assert.Contains(t, content, "[templates.dank]") + assert.Contains(t, content, "output_path = '"+filepath.Join(opts.StateDir, "dms-colors.json")+"'") + assert.NotContains(t, content, "[templates.gtk]") + assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs") +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/queue.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/queue.go new file mode 100644 index 0000000..38cf72a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/matugen/queue.go @@ -0,0 +1,143 @@ +package matugen + +import ( + "context" + "errors" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +type Result struct { + Success bool + Error error +} + +type QueuedJob struct { + Options Options + Done chan Result + Ctx context.Context + Cancel context.CancelFunc +} + +type Queue struct { + mu sync.Mutex + current *QueuedJob + pending *QueuedJob + jobDone chan struct{} +} + +var globalQueue *Queue +var queueOnce sync.Once + +func GetQueue() *Queue { + queueOnce.Do(func() { + globalQueue = &Queue{ + jobDone: make(chan struct{}, 1), + } + }) + return globalQueue +} + +func (q *Queue) Submit(opts Options) <-chan Result { + result := make(chan Result, 1) + ctx, cancel := context.WithCancel(context.Background()) + + job := &QueuedJob{ + Options: opts, + Done: result, + Ctx: ctx, + Cancel: cancel, + } + + q.mu.Lock() + + if q.pending != nil { + log.Info("Cancelling pending theme request") + q.pending.Cancel() + q.pending.Done <- Result{Success: false, Error: context.Canceled} + close(q.pending.Done) + } + + if q.current != nil { + q.pending = job + q.mu.Unlock() + log.Info("Theme request queued (worker running)") + return result + } + + q.current = job + q.mu.Unlock() + + go q.runWorker() + return result +} + +func (q *Queue) runWorker() { + for { + q.mu.Lock() + job := q.current + if job == nil { + q.mu.Unlock() + return + } + q.mu.Unlock() + + select { + case <-job.Ctx.Done(): + q.finishJob(Result{Success: false, Error: context.Canceled}) + continue + default: + } + + log.Infof("Processing theme: %s %s (%s)", job.Options.Kind, job.Options.Value, job.Options.Mode) + err := Run(job.Options) + + var result Result + switch { + case err == nil: + result = Result{Success: true} + case errors.Is(err, ErrNoChanges): + result = Result{Success: true} + default: + result = Result{Success: false, Error: err} + } + + q.finishJob(result) + } +} + +func (q *Queue) finishJob(result Result) { + q.mu.Lock() + defer q.mu.Unlock() + + if q.current != nil { + select { + case q.current.Done <- result: + default: + } + close(q.current.Done) + } + + q.current = q.pending + q.pending = nil + + if q.current == nil { + select { + case q.jobDone <- struct{}{}: + default: + } + } +} + +func (q *Queue) IsRunning() bool { + q.mu.Lock() + defer q.mu.Unlock() + return q.current != nil +} + +func (q *Queue) HasPending() bool { + q.mu.Lock() + defer q.mu.Unlock() + return q.pending != nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/brightness/mock_DBusConn.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/brightness/mock_DBusConn.go new file mode 100644 index 0000000..ce9472d --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/brightness/mock_DBusConn.go @@ -0,0 +1,130 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_brightness + +import ( + dbus "github.com/godbus/dbus/v5" + mock "github.com/stretchr/testify/mock" +) + +// MockDBusConn is an autogenerated mock type for the DBusConn type +type MockDBusConn struct { + mock.Mock +} + +type MockDBusConn_Expecter struct { + mock *mock.Mock +} + +func (_m *MockDBusConn) EXPECT() *MockDBusConn_Expecter { + return &MockDBusConn_Expecter{mock: &_m.Mock} +} + +// Close provides a mock function with no fields +func (_m *MockDBusConn) Close() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Close") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDBusConn_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type MockDBusConn_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *MockDBusConn_Expecter) Close() *MockDBusConn_Close_Call { + return &MockDBusConn_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *MockDBusConn_Close_Call) Run(run func()) *MockDBusConn_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDBusConn_Close_Call) Return(_a0 error) *MockDBusConn_Close_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDBusConn_Close_Call) RunAndReturn(run func() error) *MockDBusConn_Close_Call { + _c.Call.Return(run) + return _c +} + +// Object provides a mock function with given fields: dest, path +func (_m *MockDBusConn) Object(dest string, path dbus.ObjectPath) dbus.BusObject { + ret := _m.Called(dest, path) + + if len(ret) == 0 { + panic("no return value specified for Object") + } + + var r0 dbus.BusObject + if rf, ok := ret.Get(0).(func(string, dbus.ObjectPath) dbus.BusObject); ok { + r0 = rf(dest, path) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(dbus.BusObject) + } + } + + return r0 +} + +// MockDBusConn_Object_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Object' +type MockDBusConn_Object_Call struct { + *mock.Call +} + +// Object is a helper method to define mock.On call +// - dest string +// - path dbus.ObjectPath +func (_e *MockDBusConn_Expecter) Object(dest any, path any) *MockDBusConn_Object_Call { + return &MockDBusConn_Object_Call{Call: _e.mock.On("Object", dest, path)} +} + +func (_c *MockDBusConn_Object_Call) Run(run func(dest string, path dbus.ObjectPath)) *MockDBusConn_Object_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(dbus.ObjectPath)) + }) + return _c +} + +func (_c *MockDBusConn_Object_Call) Return(_a0 dbus.BusObject) *MockDBusConn_Object_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDBusConn_Object_Call) RunAndReturn(run func(string, dbus.ObjectPath) dbus.BusObject) *MockDBusConn_Object_Call { + _c.Call.Return(run) + return _c +} + +// NewMockDBusConn creates a new instance of MockDBusConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockDBusConn(t interface { + mock.TestingT + Cleanup(func()) +}, +) *MockDBusConn { + mock := &MockDBusConn{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/cups/mock_CUPSClientInterface.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/cups/mock_CUPSClientInterface.go new file mode 100644 index 0000000..3c98074 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/cups/mock_CUPSClientInterface.go @@ -0,0 +1,1246 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_cups + +import ( + io "io" + + ipp "github.com/AvengeMedia/DankMaterialShell/core/pkg/ipp" + mock "github.com/stretchr/testify/mock" +) + +// MockCUPSClientInterface is an autogenerated mock type for the CUPSClientInterface type +type MockCUPSClientInterface struct { + mock.Mock +} + +type MockCUPSClientInterface_Expecter struct { + mock *mock.Mock +} + +func (_m *MockCUPSClientInterface) EXPECT() *MockCUPSClientInterface_Expecter { + return &MockCUPSClientInterface_Expecter{mock: &_m.Mock} +} + +// AcceptJobs provides a mock function with given fields: printer +func (_m *MockCUPSClientInterface) AcceptJobs(printer string) error { + ret := _m.Called(printer) + + if len(ret) == 0 { + panic("no return value specified for AcceptJobs") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(printer) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_AcceptJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AcceptJobs' +type MockCUPSClientInterface_AcceptJobs_Call struct { + *mock.Call +} + +// AcceptJobs is a helper method to define mock.On call +// - printer string +func (_e *MockCUPSClientInterface_Expecter) AcceptJobs(printer interface{}) *MockCUPSClientInterface_AcceptJobs_Call { + return &MockCUPSClientInterface_AcceptJobs_Call{Call: _e.mock.On("AcceptJobs", printer)} +} + +func (_c *MockCUPSClientInterface_AcceptJobs_Call) Run(run func(printer string)) *MockCUPSClientInterface_AcceptJobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_AcceptJobs_Call) Return(_a0 error) *MockCUPSClientInterface_AcceptJobs_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_AcceptJobs_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_AcceptJobs_Call { + _c.Call.Return(run) + return _c +} + +// AddPrinterToClass provides a mock function with given fields: class, printer +func (_m *MockCUPSClientInterface) AddPrinterToClass(class string, printer string) error { + ret := _m.Called(class, printer) + + if len(ret) == 0 { + panic("no return value specified for AddPrinterToClass") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(class, printer) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_AddPrinterToClass_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddPrinterToClass' +type MockCUPSClientInterface_AddPrinterToClass_Call struct { + *mock.Call +} + +// AddPrinterToClass is a helper method to define mock.On call +// - class string +// - printer string +func (_e *MockCUPSClientInterface_Expecter) AddPrinterToClass(class interface{}, printer interface{}) *MockCUPSClientInterface_AddPrinterToClass_Call { + return &MockCUPSClientInterface_AddPrinterToClass_Call{Call: _e.mock.On("AddPrinterToClass", class, printer)} +} + +func (_c *MockCUPSClientInterface_AddPrinterToClass_Call) Run(run func(class string, printer string)) *MockCUPSClientInterface_AddPrinterToClass_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_AddPrinterToClass_Call) Return(_a0 error) *MockCUPSClientInterface_AddPrinterToClass_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_AddPrinterToClass_Call) RunAndReturn(run func(string, string) error) *MockCUPSClientInterface_AddPrinterToClass_Call { + _c.Call.Return(run) + return _c +} + +// CancelAllJob provides a mock function with given fields: printer, purge +func (_m *MockCUPSClientInterface) CancelAllJob(printer string, purge bool) error { + ret := _m.Called(printer, purge) + + if len(ret) == 0 { + panic("no return value specified for CancelAllJob") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(printer, purge) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_CancelAllJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelAllJob' +type MockCUPSClientInterface_CancelAllJob_Call struct { + *mock.Call +} + +// CancelAllJob is a helper method to define mock.On call +// - printer string +// - purge bool +func (_e *MockCUPSClientInterface_Expecter) CancelAllJob(printer interface{}, purge interface{}) *MockCUPSClientInterface_CancelAllJob_Call { + return &MockCUPSClientInterface_CancelAllJob_Call{Call: _e.mock.On("CancelAllJob", printer, purge)} +} + +func (_c *MockCUPSClientInterface_CancelAllJob_Call) Run(run func(printer string, purge bool)) *MockCUPSClientInterface_CancelAllJob_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_CancelAllJob_Call) Return(_a0 error) *MockCUPSClientInterface_CancelAllJob_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_CancelAllJob_Call) RunAndReturn(run func(string, bool) error) *MockCUPSClientInterface_CancelAllJob_Call { + _c.Call.Return(run) + return _c +} + +// CancelJob provides a mock function with given fields: jobID, purge +func (_m *MockCUPSClientInterface) CancelJob(jobID int, purge bool) error { + ret := _m.Called(jobID, purge) + + if len(ret) == 0 { + panic("no return value specified for CancelJob") + } + + var r0 error + if rf, ok := ret.Get(0).(func(int, bool) error); ok { + r0 = rf(jobID, purge) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_CancelJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelJob' +type MockCUPSClientInterface_CancelJob_Call struct { + *mock.Call +} + +// CancelJob is a helper method to define mock.On call +// - jobID int +// - purge bool +func (_e *MockCUPSClientInterface_Expecter) CancelJob(jobID interface{}, purge interface{}) *MockCUPSClientInterface_CancelJob_Call { + return &MockCUPSClientInterface_CancelJob_Call{Call: _e.mock.On("CancelJob", jobID, purge)} +} + +func (_c *MockCUPSClientInterface_CancelJob_Call) Run(run func(jobID int, purge bool)) *MockCUPSClientInterface_CancelJob_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int), args[1].(bool)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_CancelJob_Call) Return(_a0 error) *MockCUPSClientInterface_CancelJob_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_CancelJob_Call) RunAndReturn(run func(int, bool) error) *MockCUPSClientInterface_CancelJob_Call { + _c.Call.Return(run) + return _c +} + +// CreatePrinter provides a mock function with given fields: name, deviceURI, ppd, shared, errorPolicy, information, location +func (_m *MockCUPSClientInterface) CreatePrinter(name string, deviceURI string, ppd string, shared bool, errorPolicy string, information string, location string) error { + ret := _m.Called(name, deviceURI, ppd, shared, errorPolicy, information, location) + + if len(ret) == 0 { + panic("no return value specified for CreatePrinter") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string, bool, string, string, string) error); ok { + r0 = rf(name, deviceURI, ppd, shared, errorPolicy, information, location) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_CreatePrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreatePrinter' +type MockCUPSClientInterface_CreatePrinter_Call struct { + *mock.Call +} + +// CreatePrinter is a helper method to define mock.On call +// - name string +// - deviceURI string +// - ppd string +// - shared bool +// - errorPolicy string +// - information string +// - location string +func (_e *MockCUPSClientInterface_Expecter) CreatePrinter(name interface{}, deviceURI interface{}, ppd interface{}, shared interface{}, errorPolicy interface{}, information interface{}, location interface{}) *MockCUPSClientInterface_CreatePrinter_Call { + return &MockCUPSClientInterface_CreatePrinter_Call{Call: _e.mock.On("CreatePrinter", name, deviceURI, ppd, shared, errorPolicy, information, location)} +} + +func (_c *MockCUPSClientInterface_CreatePrinter_Call) Run(run func(name string, deviceURI string, ppd string, shared bool, errorPolicy string, information string, location string)) *MockCUPSClientInterface_CreatePrinter_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(string), args[3].(bool), args[4].(string), args[5].(string), args[6].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_CreatePrinter_Call) Return(_a0 error) *MockCUPSClientInterface_CreatePrinter_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_CreatePrinter_Call) RunAndReturn(run func(string, string, string, bool, string, string, string) error) *MockCUPSClientInterface_CreatePrinter_Call { + _c.Call.Return(run) + return _c +} + +// DeleteClass provides a mock function with given fields: class +func (_m *MockCUPSClientInterface) DeleteClass(class string) error { + ret := _m.Called(class) + + if len(ret) == 0 { + panic("no return value specified for DeleteClass") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(class) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_DeleteClass_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteClass' +type MockCUPSClientInterface_DeleteClass_Call struct { + *mock.Call +} + +// DeleteClass is a helper method to define mock.On call +// - class string +func (_e *MockCUPSClientInterface_Expecter) DeleteClass(class interface{}) *MockCUPSClientInterface_DeleteClass_Call { + return &MockCUPSClientInterface_DeleteClass_Call{Call: _e.mock.On("DeleteClass", class)} +} + +func (_c *MockCUPSClientInterface_DeleteClass_Call) Run(run func(class string)) *MockCUPSClientInterface_DeleteClass_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_DeleteClass_Call) Return(_a0 error) *MockCUPSClientInterface_DeleteClass_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_DeleteClass_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_DeleteClass_Call { + _c.Call.Return(run) + return _c +} + +// DeletePrinter provides a mock function with given fields: printer +func (_m *MockCUPSClientInterface) DeletePrinter(printer string) error { + ret := _m.Called(printer) + + if len(ret) == 0 { + panic("no return value specified for DeletePrinter") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(printer) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_DeletePrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeletePrinter' +type MockCUPSClientInterface_DeletePrinter_Call struct { + *mock.Call +} + +// DeletePrinter is a helper method to define mock.On call +// - printer string +func (_e *MockCUPSClientInterface_Expecter) DeletePrinter(printer interface{}) *MockCUPSClientInterface_DeletePrinter_Call { + return &MockCUPSClientInterface_DeletePrinter_Call{Call: _e.mock.On("DeletePrinter", printer)} +} + +func (_c *MockCUPSClientInterface_DeletePrinter_Call) Run(run func(printer string)) *MockCUPSClientInterface_DeletePrinter_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_DeletePrinter_Call) Return(_a0 error) *MockCUPSClientInterface_DeletePrinter_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_DeletePrinter_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_DeletePrinter_Call { + _c.Call.Return(run) + return _c +} + +// DeletePrinterFromClass provides a mock function with given fields: class, printer +func (_m *MockCUPSClientInterface) DeletePrinterFromClass(class string, printer string) error { + ret := _m.Called(class, printer) + + if len(ret) == 0 { + panic("no return value specified for DeletePrinterFromClass") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(class, printer) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_DeletePrinterFromClass_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeletePrinterFromClass' +type MockCUPSClientInterface_DeletePrinterFromClass_Call struct { + *mock.Call +} + +// DeletePrinterFromClass is a helper method to define mock.On call +// - class string +// - printer string +func (_e *MockCUPSClientInterface_Expecter) DeletePrinterFromClass(class interface{}, printer interface{}) *MockCUPSClientInterface_DeletePrinterFromClass_Call { + return &MockCUPSClientInterface_DeletePrinterFromClass_Call{Call: _e.mock.On("DeletePrinterFromClass", class, printer)} +} + +func (_c *MockCUPSClientInterface_DeletePrinterFromClass_Call) Run(run func(class string, printer string)) *MockCUPSClientInterface_DeletePrinterFromClass_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_DeletePrinterFromClass_Call) Return(_a0 error) *MockCUPSClientInterface_DeletePrinterFromClass_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_DeletePrinterFromClass_Call) RunAndReturn(run func(string, string) error) *MockCUPSClientInterface_DeletePrinterFromClass_Call { + _c.Call.Return(run) + return _c +} + +// GetClasses provides a mock function with given fields: attributes +func (_m *MockCUPSClientInterface) GetClasses(attributes []string) (map[string]ipp.Attributes, error) { + ret := _m.Called(attributes) + + if len(ret) == 0 { + panic("no return value specified for GetClasses") + } + + var r0 map[string]ipp.Attributes + var r1 error + if rf, ok := ret.Get(0).(func([]string) (map[string]ipp.Attributes, error)); ok { + return rf(attributes) + } + if rf, ok := ret.Get(0).(func([]string) map[string]ipp.Attributes); ok { + r0 = rf(attributes) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]ipp.Attributes) + } + } + + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(attributes) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockCUPSClientInterface_GetClasses_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetClasses' +type MockCUPSClientInterface_GetClasses_Call struct { + *mock.Call +} + +// GetClasses is a helper method to define mock.On call +// - attributes []string +func (_e *MockCUPSClientInterface_Expecter) GetClasses(attributes interface{}) *MockCUPSClientInterface_GetClasses_Call { + return &MockCUPSClientInterface_GetClasses_Call{Call: _e.mock.On("GetClasses", attributes)} +} + +func (_c *MockCUPSClientInterface_GetClasses_Call) Run(run func(attributes []string)) *MockCUPSClientInterface_GetClasses_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([]string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_GetClasses_Call) Return(_a0 map[string]ipp.Attributes, _a1 error) *MockCUPSClientInterface_GetClasses_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockCUPSClientInterface_GetClasses_Call) RunAndReturn(run func([]string) (map[string]ipp.Attributes, error)) *MockCUPSClientInterface_GetClasses_Call { + _c.Call.Return(run) + return _c +} + +// GetDevices provides a mock function with no fields +func (_m *MockCUPSClientInterface) GetDevices() (map[string]ipp.Attributes, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetDevices") + } + + var r0 map[string]ipp.Attributes + var r1 error + if rf, ok := ret.Get(0).(func() (map[string]ipp.Attributes, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() map[string]ipp.Attributes); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]ipp.Attributes) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockCUPSClientInterface_GetDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDevices' +type MockCUPSClientInterface_GetDevices_Call struct { + *mock.Call +} + +// GetDevices is a helper method to define mock.On call +func (_e *MockCUPSClientInterface_Expecter) GetDevices() *MockCUPSClientInterface_GetDevices_Call { + return &MockCUPSClientInterface_GetDevices_Call{Call: _e.mock.On("GetDevices")} +} + +func (_c *MockCUPSClientInterface_GetDevices_Call) Run(run func()) *MockCUPSClientInterface_GetDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockCUPSClientInterface_GetDevices_Call) Return(_a0 map[string]ipp.Attributes, _a1 error) *MockCUPSClientInterface_GetDevices_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockCUPSClientInterface_GetDevices_Call) RunAndReturn(run func() (map[string]ipp.Attributes, error)) *MockCUPSClientInterface_GetDevices_Call { + _c.Call.Return(run) + return _c +} + +// GetJobs provides a mock function with given fields: printer, class, whichJobs, myJobs, firstJobId, limit, attributes +func (_m *MockCUPSClientInterface) GetJobs(printer string, class string, whichJobs string, myJobs bool, firstJobId int, limit int, attributes []string) (map[int]ipp.Attributes, error) { + ret := _m.Called(printer, class, whichJobs, myJobs, firstJobId, limit, attributes) + + if len(ret) == 0 { + panic("no return value specified for GetJobs") + } + + var r0 map[int]ipp.Attributes + var r1 error + if rf, ok := ret.Get(0).(func(string, string, string, bool, int, int, []string) (map[int]ipp.Attributes, error)); ok { + return rf(printer, class, whichJobs, myJobs, firstJobId, limit, attributes) + } + if rf, ok := ret.Get(0).(func(string, string, string, bool, int, int, []string) map[int]ipp.Attributes); ok { + r0 = rf(printer, class, whichJobs, myJobs, firstJobId, limit, attributes) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[int]ipp.Attributes) + } + } + + if rf, ok := ret.Get(1).(func(string, string, string, bool, int, int, []string) error); ok { + r1 = rf(printer, class, whichJobs, myJobs, firstJobId, limit, attributes) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockCUPSClientInterface_GetJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJobs' +type MockCUPSClientInterface_GetJobs_Call struct { + *mock.Call +} + +// GetJobs is a helper method to define mock.On call +// - printer string +// - class string +// - whichJobs string +// - myJobs bool +// - firstJobId int +// - limit int +// - attributes []string +func (_e *MockCUPSClientInterface_Expecter) GetJobs(printer interface{}, class interface{}, whichJobs interface{}, myJobs interface{}, firstJobId interface{}, limit interface{}, attributes interface{}) *MockCUPSClientInterface_GetJobs_Call { + return &MockCUPSClientInterface_GetJobs_Call{Call: _e.mock.On("GetJobs", printer, class, whichJobs, myJobs, firstJobId, limit, attributes)} +} + +func (_c *MockCUPSClientInterface_GetJobs_Call) Run(run func(printer string, class string, whichJobs string, myJobs bool, firstJobId int, limit int, attributes []string)) *MockCUPSClientInterface_GetJobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(string), args[3].(bool), args[4].(int), args[5].(int), args[6].([]string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_GetJobs_Call) Return(_a0 map[int]ipp.Attributes, _a1 error) *MockCUPSClientInterface_GetJobs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockCUPSClientInterface_GetJobs_Call) RunAndReturn(run func(string, string, string, bool, int, int, []string) (map[int]ipp.Attributes, error)) *MockCUPSClientInterface_GetJobs_Call { + _c.Call.Return(run) + return _c +} + +// GetPPDs provides a mock function with no fields +func (_m *MockCUPSClientInterface) GetPPDs() (map[string]ipp.Attributes, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPPDs") + } + + var r0 map[string]ipp.Attributes + var r1 error + if rf, ok := ret.Get(0).(func() (map[string]ipp.Attributes, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() map[string]ipp.Attributes); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]ipp.Attributes) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockCUPSClientInterface_GetPPDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPPDs' +type MockCUPSClientInterface_GetPPDs_Call struct { + *mock.Call +} + +// GetPPDs is a helper method to define mock.On call +func (_e *MockCUPSClientInterface_Expecter) GetPPDs() *MockCUPSClientInterface_GetPPDs_Call { + return &MockCUPSClientInterface_GetPPDs_Call{Call: _e.mock.On("GetPPDs")} +} + +func (_c *MockCUPSClientInterface_GetPPDs_Call) Run(run func()) *MockCUPSClientInterface_GetPPDs_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockCUPSClientInterface_GetPPDs_Call) Return(_a0 map[string]ipp.Attributes, _a1 error) *MockCUPSClientInterface_GetPPDs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockCUPSClientInterface_GetPPDs_Call) RunAndReturn(run func() (map[string]ipp.Attributes, error)) *MockCUPSClientInterface_GetPPDs_Call { + _c.Call.Return(run) + return _c +} + +// GetPrinters provides a mock function with given fields: attributes +func (_m *MockCUPSClientInterface) GetPrinters(attributes []string) (map[string]ipp.Attributes, error) { + ret := _m.Called(attributes) + + if len(ret) == 0 { + panic("no return value specified for GetPrinters") + } + + var r0 map[string]ipp.Attributes + var r1 error + if rf, ok := ret.Get(0).(func([]string) (map[string]ipp.Attributes, error)); ok { + return rf(attributes) + } + if rf, ok := ret.Get(0).(func([]string) map[string]ipp.Attributes); ok { + r0 = rf(attributes) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]ipp.Attributes) + } + } + + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(attributes) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockCUPSClientInterface_GetPrinters_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPrinters' +type MockCUPSClientInterface_GetPrinters_Call struct { + *mock.Call +} + +// GetPrinters is a helper method to define mock.On call +// - attributes []string +func (_e *MockCUPSClientInterface_Expecter) GetPrinters(attributes interface{}) *MockCUPSClientInterface_GetPrinters_Call { + return &MockCUPSClientInterface_GetPrinters_Call{Call: _e.mock.On("GetPrinters", attributes)} +} + +func (_c *MockCUPSClientInterface_GetPrinters_Call) Run(run func(attributes []string)) *MockCUPSClientInterface_GetPrinters_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([]string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_GetPrinters_Call) Return(_a0 map[string]ipp.Attributes, _a1 error) *MockCUPSClientInterface_GetPrinters_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockCUPSClientInterface_GetPrinters_Call) RunAndReturn(run func([]string) (map[string]ipp.Attributes, error)) *MockCUPSClientInterface_GetPrinters_Call { + _c.Call.Return(run) + return _c +} + +// HoldJobUntil provides a mock function with given fields: jobID, holdUntil +func (_m *MockCUPSClientInterface) HoldJobUntil(jobID int, holdUntil string) error { + ret := _m.Called(jobID, holdUntil) + + if len(ret) == 0 { + panic("no return value specified for HoldJobUntil") + } + + var r0 error + if rf, ok := ret.Get(0).(func(int, string) error); ok { + r0 = rf(jobID, holdUntil) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_HoldJobUntil_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HoldJobUntil' +type MockCUPSClientInterface_HoldJobUntil_Call struct { + *mock.Call +} + +// HoldJobUntil is a helper method to define mock.On call +// - jobID int +// - holdUntil string +func (_e *MockCUPSClientInterface_Expecter) HoldJobUntil(jobID interface{}, holdUntil interface{}) *MockCUPSClientInterface_HoldJobUntil_Call { + return &MockCUPSClientInterface_HoldJobUntil_Call{Call: _e.mock.On("HoldJobUntil", jobID, holdUntil)} +} + +func (_c *MockCUPSClientInterface_HoldJobUntil_Call) Run(run func(jobID int, holdUntil string)) *MockCUPSClientInterface_HoldJobUntil_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int), args[1].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_HoldJobUntil_Call) Return(_a0 error) *MockCUPSClientInterface_HoldJobUntil_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_HoldJobUntil_Call) RunAndReturn(run func(int, string) error) *MockCUPSClientInterface_HoldJobUntil_Call { + _c.Call.Return(run) + return _c +} + +// MoveJob provides a mock function with given fields: jobID, destPrinter +func (_m *MockCUPSClientInterface) MoveJob(jobID int, destPrinter string) error { + ret := _m.Called(jobID, destPrinter) + + if len(ret) == 0 { + panic("no return value specified for MoveJob") + } + + var r0 error + if rf, ok := ret.Get(0).(func(int, string) error); ok { + r0 = rf(jobID, destPrinter) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_MoveJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MoveJob' +type MockCUPSClientInterface_MoveJob_Call struct { + *mock.Call +} + +// MoveJob is a helper method to define mock.On call +// - jobID int +// - destPrinter string +func (_e *MockCUPSClientInterface_Expecter) MoveJob(jobID interface{}, destPrinter interface{}) *MockCUPSClientInterface_MoveJob_Call { + return &MockCUPSClientInterface_MoveJob_Call{Call: _e.mock.On("MoveJob", jobID, destPrinter)} +} + +func (_c *MockCUPSClientInterface_MoveJob_Call) Run(run func(jobID int, destPrinter string)) *MockCUPSClientInterface_MoveJob_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int), args[1].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_MoveJob_Call) Return(_a0 error) *MockCUPSClientInterface_MoveJob_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_MoveJob_Call) RunAndReturn(run func(int, string) error) *MockCUPSClientInterface_MoveJob_Call { + _c.Call.Return(run) + return _c +} + +// PausePrinter provides a mock function with given fields: printer +func (_m *MockCUPSClientInterface) PausePrinter(printer string) error { + ret := _m.Called(printer) + + if len(ret) == 0 { + panic("no return value specified for PausePrinter") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(printer) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_PausePrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PausePrinter' +type MockCUPSClientInterface_PausePrinter_Call struct { + *mock.Call +} + +// PausePrinter is a helper method to define mock.On call +// - printer string +func (_e *MockCUPSClientInterface_Expecter) PausePrinter(printer interface{}) *MockCUPSClientInterface_PausePrinter_Call { + return &MockCUPSClientInterface_PausePrinter_Call{Call: _e.mock.On("PausePrinter", printer)} +} + +func (_c *MockCUPSClientInterface_PausePrinter_Call) Run(run func(printer string)) *MockCUPSClientInterface_PausePrinter_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_PausePrinter_Call) Return(_a0 error) *MockCUPSClientInterface_PausePrinter_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_PausePrinter_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_PausePrinter_Call { + _c.Call.Return(run) + return _c +} + +// PrintTestPage provides a mock function with given fields: printer, testPageData, size +func (_m *MockCUPSClientInterface) PrintTestPage(printer string, testPageData io.Reader, size int) (int, error) { + ret := _m.Called(printer, testPageData, size) + + if len(ret) == 0 { + panic("no return value specified for PrintTestPage") + } + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func(string, io.Reader, int) (int, error)); ok { + return rf(printer, testPageData, size) + } + if rf, ok := ret.Get(0).(func(string, io.Reader, int) int); ok { + r0 = rf(printer, testPageData, size) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(string, io.Reader, int) error); ok { + r1 = rf(printer, testPageData, size) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockCUPSClientInterface_PrintTestPage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrintTestPage' +type MockCUPSClientInterface_PrintTestPage_Call struct { + *mock.Call +} + +// PrintTestPage is a helper method to define mock.On call +// - printer string +// - testPageData io.Reader +// - size int +func (_e *MockCUPSClientInterface_Expecter) PrintTestPage(printer interface{}, testPageData interface{}, size interface{}) *MockCUPSClientInterface_PrintTestPage_Call { + return &MockCUPSClientInterface_PrintTestPage_Call{Call: _e.mock.On("PrintTestPage", printer, testPageData, size)} +} + +func (_c *MockCUPSClientInterface_PrintTestPage_Call) Run(run func(printer string, testPageData io.Reader, size int)) *MockCUPSClientInterface_PrintTestPage_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(io.Reader), args[2].(int)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_PrintTestPage_Call) Return(_a0 int, _a1 error) *MockCUPSClientInterface_PrintTestPage_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockCUPSClientInterface_PrintTestPage_Call) RunAndReturn(run func(string, io.Reader, int) (int, error)) *MockCUPSClientInterface_PrintTestPage_Call { + _c.Call.Return(run) + return _c +} + +// RejectJobs provides a mock function with given fields: printer +func (_m *MockCUPSClientInterface) RejectJobs(printer string) error { + ret := _m.Called(printer) + + if len(ret) == 0 { + panic("no return value specified for RejectJobs") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(printer) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_RejectJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RejectJobs' +type MockCUPSClientInterface_RejectJobs_Call struct { + *mock.Call +} + +// RejectJobs is a helper method to define mock.On call +// - printer string +func (_e *MockCUPSClientInterface_Expecter) RejectJobs(printer interface{}) *MockCUPSClientInterface_RejectJobs_Call { + return &MockCUPSClientInterface_RejectJobs_Call{Call: _e.mock.On("RejectJobs", printer)} +} + +func (_c *MockCUPSClientInterface_RejectJobs_Call) Run(run func(printer string)) *MockCUPSClientInterface_RejectJobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_RejectJobs_Call) Return(_a0 error) *MockCUPSClientInterface_RejectJobs_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_RejectJobs_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_RejectJobs_Call { + _c.Call.Return(run) + return _c +} + +// RestartJob provides a mock function with given fields: jobID +func (_m *MockCUPSClientInterface) RestartJob(jobID int) error { + ret := _m.Called(jobID) + + if len(ret) == 0 { + panic("no return value specified for RestartJob") + } + + var r0 error + if rf, ok := ret.Get(0).(func(int) error); ok { + r0 = rf(jobID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_RestartJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestartJob' +type MockCUPSClientInterface_RestartJob_Call struct { + *mock.Call +} + +// RestartJob is a helper method to define mock.On call +// - jobID int +func (_e *MockCUPSClientInterface_Expecter) RestartJob(jobID interface{}) *MockCUPSClientInterface_RestartJob_Call { + return &MockCUPSClientInterface_RestartJob_Call{Call: _e.mock.On("RestartJob", jobID)} +} + +func (_c *MockCUPSClientInterface_RestartJob_Call) Run(run func(jobID int)) *MockCUPSClientInterface_RestartJob_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_RestartJob_Call) Return(_a0 error) *MockCUPSClientInterface_RestartJob_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_RestartJob_Call) RunAndReturn(run func(int) error) *MockCUPSClientInterface_RestartJob_Call { + _c.Call.Return(run) + return _c +} + +// ResumePrinter provides a mock function with given fields: printer +func (_m *MockCUPSClientInterface) ResumePrinter(printer string) error { + ret := _m.Called(printer) + + if len(ret) == 0 { + panic("no return value specified for ResumePrinter") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(printer) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_ResumePrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResumePrinter' +type MockCUPSClientInterface_ResumePrinter_Call struct { + *mock.Call +} + +// ResumePrinter is a helper method to define mock.On call +// - printer string +func (_e *MockCUPSClientInterface_Expecter) ResumePrinter(printer interface{}) *MockCUPSClientInterface_ResumePrinter_Call { + return &MockCUPSClientInterface_ResumePrinter_Call{Call: _e.mock.On("ResumePrinter", printer)} +} + +func (_c *MockCUPSClientInterface_ResumePrinter_Call) Run(run func(printer string)) *MockCUPSClientInterface_ResumePrinter_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_ResumePrinter_Call) Return(_a0 error) *MockCUPSClientInterface_ResumePrinter_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_ResumePrinter_Call) RunAndReturn(run func(string) error) *MockCUPSClientInterface_ResumePrinter_Call { + _c.Call.Return(run) + return _c +} + +// SendRequest provides a mock function with given fields: url, req, additionalResponseData +func (_m *MockCUPSClientInterface) SendRequest(url string, req *ipp.Request, additionalResponseData io.Writer) (*ipp.Response, error) { + ret := _m.Called(url, req, additionalResponseData) + + if len(ret) == 0 { + panic("no return value specified for SendRequest") + } + + var r0 *ipp.Response + var r1 error + if rf, ok := ret.Get(0).(func(string, *ipp.Request, io.Writer) (*ipp.Response, error)); ok { + return rf(url, req, additionalResponseData) + } + if rf, ok := ret.Get(0).(func(string, *ipp.Request, io.Writer) *ipp.Response); ok { + r0 = rf(url, req, additionalResponseData) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ipp.Response) + } + } + + if rf, ok := ret.Get(1).(func(string, *ipp.Request, io.Writer) error); ok { + r1 = rf(url, req, additionalResponseData) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockCUPSClientInterface_SendRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendRequest' +type MockCUPSClientInterface_SendRequest_Call struct { + *mock.Call +} + +// SendRequest is a helper method to define mock.On call +// - url string +// - req *ipp.Request +// - additionalResponseData io.Writer +func (_e *MockCUPSClientInterface_Expecter) SendRequest(url interface{}, req interface{}, additionalResponseData interface{}) *MockCUPSClientInterface_SendRequest_Call { + return &MockCUPSClientInterface_SendRequest_Call{Call: _e.mock.On("SendRequest", url, req, additionalResponseData)} +} + +func (_c *MockCUPSClientInterface_SendRequest_Call) Run(run func(url string, req *ipp.Request, additionalResponseData io.Writer)) *MockCUPSClientInterface_SendRequest_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(*ipp.Request), args[2].(io.Writer)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_SendRequest_Call) Return(_a0 *ipp.Response, _a1 error) *MockCUPSClientInterface_SendRequest_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockCUPSClientInterface_SendRequest_Call) RunAndReturn(run func(string, *ipp.Request, io.Writer) (*ipp.Response, error)) *MockCUPSClientInterface_SendRequest_Call { + _c.Call.Return(run) + return _c +} + +// SetPrinterInformation provides a mock function with given fields: printer, information +func (_m *MockCUPSClientInterface) SetPrinterInformation(printer string, information string) error { + ret := _m.Called(printer, information) + + if len(ret) == 0 { + panic("no return value specified for SetPrinterInformation") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(printer, information) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_SetPrinterInformation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPrinterInformation' +type MockCUPSClientInterface_SetPrinterInformation_Call struct { + *mock.Call +} + +// SetPrinterInformation is a helper method to define mock.On call +// - printer string +// - information string +func (_e *MockCUPSClientInterface_Expecter) SetPrinterInformation(printer interface{}, information interface{}) *MockCUPSClientInterface_SetPrinterInformation_Call { + return &MockCUPSClientInterface_SetPrinterInformation_Call{Call: _e.mock.On("SetPrinterInformation", printer, information)} +} + +func (_c *MockCUPSClientInterface_SetPrinterInformation_Call) Run(run func(printer string, information string)) *MockCUPSClientInterface_SetPrinterInformation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_SetPrinterInformation_Call) Return(_a0 error) *MockCUPSClientInterface_SetPrinterInformation_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_SetPrinterInformation_Call) RunAndReturn(run func(string, string) error) *MockCUPSClientInterface_SetPrinterInformation_Call { + _c.Call.Return(run) + return _c +} + +// SetPrinterIsShared provides a mock function with given fields: printer, shared +func (_m *MockCUPSClientInterface) SetPrinterIsShared(printer string, shared bool) error { + ret := _m.Called(printer, shared) + + if len(ret) == 0 { + panic("no return value specified for SetPrinterIsShared") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(printer, shared) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_SetPrinterIsShared_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPrinterIsShared' +type MockCUPSClientInterface_SetPrinterIsShared_Call struct { + *mock.Call +} + +// SetPrinterIsShared is a helper method to define mock.On call +// - printer string +// - shared bool +func (_e *MockCUPSClientInterface_Expecter) SetPrinterIsShared(printer interface{}, shared interface{}) *MockCUPSClientInterface_SetPrinterIsShared_Call { + return &MockCUPSClientInterface_SetPrinterIsShared_Call{Call: _e.mock.On("SetPrinterIsShared", printer, shared)} +} + +func (_c *MockCUPSClientInterface_SetPrinterIsShared_Call) Run(run func(printer string, shared bool)) *MockCUPSClientInterface_SetPrinterIsShared_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_SetPrinterIsShared_Call) Return(_a0 error) *MockCUPSClientInterface_SetPrinterIsShared_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_SetPrinterIsShared_Call) RunAndReturn(run func(string, bool) error) *MockCUPSClientInterface_SetPrinterIsShared_Call { + _c.Call.Return(run) + return _c +} + +// SetPrinterLocation provides a mock function with given fields: printer, location +func (_m *MockCUPSClientInterface) SetPrinterLocation(printer string, location string) error { + ret := _m.Called(printer, location) + + if len(ret) == 0 { + panic("no return value specified for SetPrinterLocation") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(printer, location) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockCUPSClientInterface_SetPrinterLocation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPrinterLocation' +type MockCUPSClientInterface_SetPrinterLocation_Call struct { + *mock.Call +} + +// SetPrinterLocation is a helper method to define mock.On call +// - printer string +// - location string +func (_e *MockCUPSClientInterface_Expecter) SetPrinterLocation(printer interface{}, location interface{}) *MockCUPSClientInterface_SetPrinterLocation_Call { + return &MockCUPSClientInterface_SetPrinterLocation_Call{Call: _e.mock.On("SetPrinterLocation", printer, location)} +} + +func (_c *MockCUPSClientInterface_SetPrinterLocation_Call) Run(run func(printer string, location string)) *MockCUPSClientInterface_SetPrinterLocation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockCUPSClientInterface_SetPrinterLocation_Call) Return(_a0 error) *MockCUPSClientInterface_SetPrinterLocation_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockCUPSClientInterface_SetPrinterLocation_Call) RunAndReturn(run func(string, string) error) *MockCUPSClientInterface_SetPrinterLocation_Call { + _c.Call.Return(run) + return _c +} + +// NewMockCUPSClientInterface creates a new instance of MockCUPSClientInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockCUPSClientInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *MockCUPSClientInterface { + mock := &MockCUPSClientInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/cups_pkhelper/mock_PkHelper.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/cups_pkhelper/mock_PkHelper.go new file mode 100644 index 0000000..cad5bfc --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/cups_pkhelper/mock_PkHelper.go @@ -0,0 +1,708 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_cups_pkhelper + +import ( + cups "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" + mock "github.com/stretchr/testify/mock" +) + +// MockPkHelper is an autogenerated mock type for the PkHelper type +type MockPkHelper struct { + mock.Mock +} + +type MockPkHelper_Expecter struct { + mock *mock.Mock +} + +func (_m *MockPkHelper) EXPECT() *MockPkHelper_Expecter { + return &MockPkHelper_Expecter{mock: &_m.Mock} +} + +// ClassAddPrinter provides a mock function with given fields: className, printerName +func (_m *MockPkHelper) ClassAddPrinter(className string, printerName string) error { + ret := _m.Called(className, printerName) + + if len(ret) == 0 { + panic("no return value specified for ClassAddPrinter") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(className, printerName) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_ClassAddPrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClassAddPrinter' +type MockPkHelper_ClassAddPrinter_Call struct { + *mock.Call +} + +// ClassAddPrinter is a helper method to define mock.On call +// - className string +// - printerName string +func (_e *MockPkHelper_Expecter) ClassAddPrinter(className interface{}, printerName interface{}) *MockPkHelper_ClassAddPrinter_Call { + return &MockPkHelper_ClassAddPrinter_Call{Call: _e.mock.On("ClassAddPrinter", className, printerName)} +} + +func (_c *MockPkHelper_ClassAddPrinter_Call) Run(run func(className string, printerName string)) *MockPkHelper_ClassAddPrinter_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockPkHelper_ClassAddPrinter_Call) Return(_a0 error) *MockPkHelper_ClassAddPrinter_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_ClassAddPrinter_Call) RunAndReturn(run func(string, string) error) *MockPkHelper_ClassAddPrinter_Call { + _c.Call.Return(run) + return _c +} + +// ClassDelete provides a mock function with given fields: className +func (_m *MockPkHelper) ClassDelete(className string) error { + ret := _m.Called(className) + + if len(ret) == 0 { + panic("no return value specified for ClassDelete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(className) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_ClassDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClassDelete' +type MockPkHelper_ClassDelete_Call struct { + *mock.Call +} + +// ClassDelete is a helper method to define mock.On call +// - className string +func (_e *MockPkHelper_Expecter) ClassDelete(className interface{}) *MockPkHelper_ClassDelete_Call { + return &MockPkHelper_ClassDelete_Call{Call: _e.mock.On("ClassDelete", className)} +} + +func (_c *MockPkHelper_ClassDelete_Call) Run(run func(className string)) *MockPkHelper_ClassDelete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockPkHelper_ClassDelete_Call) Return(_a0 error) *MockPkHelper_ClassDelete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_ClassDelete_Call) RunAndReturn(run func(string) error) *MockPkHelper_ClassDelete_Call { + _c.Call.Return(run) + return _c +} + +// ClassDeletePrinter provides a mock function with given fields: className, printerName +func (_m *MockPkHelper) ClassDeletePrinter(className string, printerName string) error { + ret := _m.Called(className, printerName) + + if len(ret) == 0 { + panic("no return value specified for ClassDeletePrinter") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(className, printerName) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_ClassDeletePrinter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClassDeletePrinter' +type MockPkHelper_ClassDeletePrinter_Call struct { + *mock.Call +} + +// ClassDeletePrinter is a helper method to define mock.On call +// - className string +// - printerName string +func (_e *MockPkHelper_Expecter) ClassDeletePrinter(className interface{}, printerName interface{}) *MockPkHelper_ClassDeletePrinter_Call { + return &MockPkHelper_ClassDeletePrinter_Call{Call: _e.mock.On("ClassDeletePrinter", className, printerName)} +} + +func (_c *MockPkHelper_ClassDeletePrinter_Call) Run(run func(className string, printerName string)) *MockPkHelper_ClassDeletePrinter_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockPkHelper_ClassDeletePrinter_Call) Return(_a0 error) *MockPkHelper_ClassDeletePrinter_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_ClassDeletePrinter_Call) RunAndReturn(run func(string, string) error) *MockPkHelper_ClassDeletePrinter_Call { + _c.Call.Return(run) + return _c +} + +// DevicesGet provides a mock function with given fields: timeout, limit, includeSchemes, excludeSchemes +func (_m *MockPkHelper) DevicesGet(timeout int, limit int, includeSchemes []string, excludeSchemes []string) ([]cups.Device, error) { + ret := _m.Called(timeout, limit, includeSchemes, excludeSchemes) + + if len(ret) == 0 { + panic("no return value specified for DevicesGet") + } + + var r0 []cups.Device + var r1 error + if rf, ok := ret.Get(0).(func(int, int, []string, []string) ([]cups.Device, error)); ok { + return rf(timeout, limit, includeSchemes, excludeSchemes) + } + if rf, ok := ret.Get(0).(func(int, int, []string, []string) []cups.Device); ok { + r0 = rf(timeout, limit, includeSchemes, excludeSchemes) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]cups.Device) + } + } + + if rf, ok := ret.Get(1).(func(int, int, []string, []string) error); ok { + r1 = rf(timeout, limit, includeSchemes, excludeSchemes) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockPkHelper_DevicesGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DevicesGet' +type MockPkHelper_DevicesGet_Call struct { + *mock.Call +} + +// DevicesGet is a helper method to define mock.On call +// - timeout int +// - limit int +// - includeSchemes []string +// - excludeSchemes []string +func (_e *MockPkHelper_Expecter) DevicesGet(timeout interface{}, limit interface{}, includeSchemes interface{}, excludeSchemes interface{}) *MockPkHelper_DevicesGet_Call { + return &MockPkHelper_DevicesGet_Call{Call: _e.mock.On("DevicesGet", timeout, limit, includeSchemes, excludeSchemes)} +} + +func (_c *MockPkHelper_DevicesGet_Call) Run(run func(timeout int, limit int, includeSchemes []string, excludeSchemes []string)) *MockPkHelper_DevicesGet_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int), args[1].(int), args[2].([]string), args[3].([]string)) + }) + return _c +} + +func (_c *MockPkHelper_DevicesGet_Call) Return(_a0 []cups.Device, _a1 error) *MockPkHelper_DevicesGet_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockPkHelper_DevicesGet_Call) RunAndReturn(run func(int, int, []string, []string) ([]cups.Device, error)) *MockPkHelper_DevicesGet_Call { + _c.Call.Return(run) + return _c +} + +// JobCancelPurge provides a mock function with given fields: jobID, purge +func (_m *MockPkHelper) JobCancelPurge(jobID int, purge bool) error { + ret := _m.Called(jobID, purge) + + if len(ret) == 0 { + panic("no return value specified for JobCancelPurge") + } + + var r0 error + if rf, ok := ret.Get(0).(func(int, bool) error); ok { + r0 = rf(jobID, purge) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_JobCancelPurge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JobCancelPurge' +type MockPkHelper_JobCancelPurge_Call struct { + *mock.Call +} + +// JobCancelPurge is a helper method to define mock.On call +// - jobID int +// - purge bool +func (_e *MockPkHelper_Expecter) JobCancelPurge(jobID interface{}, purge interface{}) *MockPkHelper_JobCancelPurge_Call { + return &MockPkHelper_JobCancelPurge_Call{Call: _e.mock.On("JobCancelPurge", jobID, purge)} +} + +func (_c *MockPkHelper_JobCancelPurge_Call) Run(run func(jobID int, purge bool)) *MockPkHelper_JobCancelPurge_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int), args[1].(bool)) + }) + return _c +} + +func (_c *MockPkHelper_JobCancelPurge_Call) Return(_a0 error) *MockPkHelper_JobCancelPurge_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_JobCancelPurge_Call) RunAndReturn(run func(int, bool) error) *MockPkHelper_JobCancelPurge_Call { + _c.Call.Return(run) + return _c +} + +// JobRestart provides a mock function with given fields: jobID +func (_m *MockPkHelper) JobRestart(jobID int) error { + ret := _m.Called(jobID) + + if len(ret) == 0 { + panic("no return value specified for JobRestart") + } + + var r0 error + if rf, ok := ret.Get(0).(func(int) error); ok { + r0 = rf(jobID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_JobRestart_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JobRestart' +type MockPkHelper_JobRestart_Call struct { + *mock.Call +} + +// JobRestart is a helper method to define mock.On call +// - jobID int +func (_e *MockPkHelper_Expecter) JobRestart(jobID interface{}) *MockPkHelper_JobRestart_Call { + return &MockPkHelper_JobRestart_Call{Call: _e.mock.On("JobRestart", jobID)} +} + +func (_c *MockPkHelper_JobRestart_Call) Run(run func(jobID int)) *MockPkHelper_JobRestart_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int)) + }) + return _c +} + +func (_c *MockPkHelper_JobRestart_Call) Return(_a0 error) *MockPkHelper_JobRestart_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_JobRestart_Call) RunAndReturn(run func(int) error) *MockPkHelper_JobRestart_Call { + _c.Call.Return(run) + return _c +} + +// JobSetHoldUntil provides a mock function with given fields: jobID, holdUntil +func (_m *MockPkHelper) JobSetHoldUntil(jobID int, holdUntil string) error { + ret := _m.Called(jobID, holdUntil) + + if len(ret) == 0 { + panic("no return value specified for JobSetHoldUntil") + } + + var r0 error + if rf, ok := ret.Get(0).(func(int, string) error); ok { + r0 = rf(jobID, holdUntil) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_JobSetHoldUntil_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JobSetHoldUntil' +type MockPkHelper_JobSetHoldUntil_Call struct { + *mock.Call +} + +// JobSetHoldUntil is a helper method to define mock.On call +// - jobID int +// - holdUntil string +func (_e *MockPkHelper_Expecter) JobSetHoldUntil(jobID interface{}, holdUntil interface{}) *MockPkHelper_JobSetHoldUntil_Call { + return &MockPkHelper_JobSetHoldUntil_Call{Call: _e.mock.On("JobSetHoldUntil", jobID, holdUntil)} +} + +func (_c *MockPkHelper_JobSetHoldUntil_Call) Run(run func(jobID int, holdUntil string)) *MockPkHelper_JobSetHoldUntil_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(int), args[1].(string)) + }) + return _c +} + +func (_c *MockPkHelper_JobSetHoldUntil_Call) Return(_a0 error) *MockPkHelper_JobSetHoldUntil_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_JobSetHoldUntil_Call) RunAndReturn(run func(int, string) error) *MockPkHelper_JobSetHoldUntil_Call { + _c.Call.Return(run) + return _c +} + +// PrinterAdd provides a mock function with given fields: name, uri, ppd, info, location +func (_m *MockPkHelper) PrinterAdd(name string, uri string, ppd string, info string, location string) error { + ret := _m.Called(name, uri, ppd, info, location) + + if len(ret) == 0 { + panic("no return value specified for PrinterAdd") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok { + r0 = rf(name, uri, ppd, info, location) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_PrinterAdd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrinterAdd' +type MockPkHelper_PrinterAdd_Call struct { + *mock.Call +} + +// PrinterAdd is a helper method to define mock.On call +// - name string +// - uri string +// - ppd string +// - info string +// - location string +func (_e *MockPkHelper_Expecter) PrinterAdd(name interface{}, uri interface{}, ppd interface{}, info interface{}, location interface{}) *MockPkHelper_PrinterAdd_Call { + return &MockPkHelper_PrinterAdd_Call{Call: _e.mock.On("PrinterAdd", name, uri, ppd, info, location)} +} + +func (_c *MockPkHelper_PrinterAdd_Call) Run(run func(name string, uri string, ppd string, info string, location string)) *MockPkHelper_PrinterAdd_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(string), args[3].(string), args[4].(string)) + }) + return _c +} + +func (_c *MockPkHelper_PrinterAdd_Call) Return(_a0 error) *MockPkHelper_PrinterAdd_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_PrinterAdd_Call) RunAndReturn(run func(string, string, string, string, string) error) *MockPkHelper_PrinterAdd_Call { + _c.Call.Return(run) + return _c +} + +// PrinterDelete provides a mock function with given fields: name +func (_m *MockPkHelper) PrinterDelete(name string) error { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for PrinterDelete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_PrinterDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrinterDelete' +type MockPkHelper_PrinterDelete_Call struct { + *mock.Call +} + +// PrinterDelete is a helper method to define mock.On call +// - name string +func (_e *MockPkHelper_Expecter) PrinterDelete(name interface{}) *MockPkHelper_PrinterDelete_Call { + return &MockPkHelper_PrinterDelete_Call{Call: _e.mock.On("PrinterDelete", name)} +} + +func (_c *MockPkHelper_PrinterDelete_Call) Run(run func(name string)) *MockPkHelper_PrinterDelete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockPkHelper_PrinterDelete_Call) Return(_a0 error) *MockPkHelper_PrinterDelete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_PrinterDelete_Call) RunAndReturn(run func(string) error) *MockPkHelper_PrinterDelete_Call { + _c.Call.Return(run) + return _c +} + +// PrinterSetAcceptJobs provides a mock function with given fields: name, enabled, reason +func (_m *MockPkHelper) PrinterSetAcceptJobs(name string, enabled bool, reason string) error { + ret := _m.Called(name, enabled, reason) + + if len(ret) == 0 { + panic("no return value specified for PrinterSetAcceptJobs") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool, string) error); ok { + r0 = rf(name, enabled, reason) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_PrinterSetAcceptJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrinterSetAcceptJobs' +type MockPkHelper_PrinterSetAcceptJobs_Call struct { + *mock.Call +} + +// PrinterSetAcceptJobs is a helper method to define mock.On call +// - name string +// - enabled bool +// - reason string +func (_e *MockPkHelper_Expecter) PrinterSetAcceptJobs(name interface{}, enabled interface{}, reason interface{}) *MockPkHelper_PrinterSetAcceptJobs_Call { + return &MockPkHelper_PrinterSetAcceptJobs_Call{Call: _e.mock.On("PrinterSetAcceptJobs", name, enabled, reason)} +} + +func (_c *MockPkHelper_PrinterSetAcceptJobs_Call) Run(run func(name string, enabled bool, reason string)) *MockPkHelper_PrinterSetAcceptJobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool), args[2].(string)) + }) + return _c +} + +func (_c *MockPkHelper_PrinterSetAcceptJobs_Call) Return(_a0 error) *MockPkHelper_PrinterSetAcceptJobs_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_PrinterSetAcceptJobs_Call) RunAndReturn(run func(string, bool, string) error) *MockPkHelper_PrinterSetAcceptJobs_Call { + _c.Call.Return(run) + return _c +} + +// PrinterSetEnabled provides a mock function with given fields: name, enabled +func (_m *MockPkHelper) PrinterSetEnabled(name string, enabled bool) error { + ret := _m.Called(name, enabled) + + if len(ret) == 0 { + panic("no return value specified for PrinterSetEnabled") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(name, enabled) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_PrinterSetEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrinterSetEnabled' +type MockPkHelper_PrinterSetEnabled_Call struct { + *mock.Call +} + +// PrinterSetEnabled is a helper method to define mock.On call +// - name string +// - enabled bool +func (_e *MockPkHelper_Expecter) PrinterSetEnabled(name interface{}, enabled interface{}) *MockPkHelper_PrinterSetEnabled_Call { + return &MockPkHelper_PrinterSetEnabled_Call{Call: _e.mock.On("PrinterSetEnabled", name, enabled)} +} + +func (_c *MockPkHelper_PrinterSetEnabled_Call) Run(run func(name string, enabled bool)) *MockPkHelper_PrinterSetEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockPkHelper_PrinterSetEnabled_Call) Return(_a0 error) *MockPkHelper_PrinterSetEnabled_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_PrinterSetEnabled_Call) RunAndReturn(run func(string, bool) error) *MockPkHelper_PrinterSetEnabled_Call { + _c.Call.Return(run) + return _c +} + +// PrinterSetInfo provides a mock function with given fields: name, info +func (_m *MockPkHelper) PrinterSetInfo(name string, info string) error { + ret := _m.Called(name, info) + + if len(ret) == 0 { + panic("no return value specified for PrinterSetInfo") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(name, info) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_PrinterSetInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrinterSetInfo' +type MockPkHelper_PrinterSetInfo_Call struct { + *mock.Call +} + +// PrinterSetInfo is a helper method to define mock.On call +// - name string +// - info string +func (_e *MockPkHelper_Expecter) PrinterSetInfo(name interface{}, info interface{}) *MockPkHelper_PrinterSetInfo_Call { + return &MockPkHelper_PrinterSetInfo_Call{Call: _e.mock.On("PrinterSetInfo", name, info)} +} + +func (_c *MockPkHelper_PrinterSetInfo_Call) Run(run func(name string, info string)) *MockPkHelper_PrinterSetInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockPkHelper_PrinterSetInfo_Call) Return(_a0 error) *MockPkHelper_PrinterSetInfo_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_PrinterSetInfo_Call) RunAndReturn(run func(string, string) error) *MockPkHelper_PrinterSetInfo_Call { + _c.Call.Return(run) + return _c +} + +// PrinterSetLocation provides a mock function with given fields: name, location +func (_m *MockPkHelper) PrinterSetLocation(name string, location string) error { + ret := _m.Called(name, location) + + if len(ret) == 0 { + panic("no return value specified for PrinterSetLocation") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(name, location) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_PrinterSetLocation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrinterSetLocation' +type MockPkHelper_PrinterSetLocation_Call struct { + *mock.Call +} + +// PrinterSetLocation is a helper method to define mock.On call +// - name string +// - location string +func (_e *MockPkHelper_Expecter) PrinterSetLocation(name interface{}, location interface{}) *MockPkHelper_PrinterSetLocation_Call { + return &MockPkHelper_PrinterSetLocation_Call{Call: _e.mock.On("PrinterSetLocation", name, location)} +} + +func (_c *MockPkHelper_PrinterSetLocation_Call) Run(run func(name string, location string)) *MockPkHelper_PrinterSetLocation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockPkHelper_PrinterSetLocation_Call) Return(_a0 error) *MockPkHelper_PrinterSetLocation_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_PrinterSetLocation_Call) RunAndReturn(run func(string, string) error) *MockPkHelper_PrinterSetLocation_Call { + _c.Call.Return(run) + return _c +} + +// PrinterSetShared provides a mock function with given fields: name, shared +func (_m *MockPkHelper) PrinterSetShared(name string, shared bool) error { + ret := _m.Called(name, shared) + + if len(ret) == 0 { + panic("no return value specified for PrinterSetShared") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(name, shared) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockPkHelper_PrinterSetShared_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrinterSetShared' +type MockPkHelper_PrinterSetShared_Call struct { + *mock.Call +} + +// PrinterSetShared is a helper method to define mock.On call +// - name string +// - shared bool +func (_e *MockPkHelper_Expecter) PrinterSetShared(name interface{}, shared interface{}) *MockPkHelper_PrinterSetShared_Call { + return &MockPkHelper_PrinterSetShared_Call{Call: _e.mock.On("PrinterSetShared", name, shared)} +} + +func (_c *MockPkHelper_PrinterSetShared_Call) Run(run func(name string, shared bool)) *MockPkHelper_PrinterSetShared_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockPkHelper_PrinterSetShared_Call) Return(_a0 error) *MockPkHelper_PrinterSetShared_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockPkHelper_PrinterSetShared_Call) RunAndReturn(run func(string, bool) error) *MockPkHelper_PrinterSetShared_Call { + _c.Call.Return(run) + return _c +} + +// NewMockPkHelper creates a new instance of MockPkHelper. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockPkHelper(t interface { + mock.TestingT + Cleanup(func()) +}) *MockPkHelper { + mock := &MockPkHelper{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/evdev/mock_EvdevDevice.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/evdev/mock_EvdevDevice.go new file mode 100644 index 0000000..4d54e3e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/evdev/mock_EvdevDevice.go @@ -0,0 +1,295 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_evdev + +import ( + go_evdev "github.com/holoplot/go-evdev" + mock "github.com/stretchr/testify/mock" +) + +// MockEvdevDevice is an autogenerated mock type for the EvdevDevice type +type MockEvdevDevice struct { + mock.Mock +} + +type MockEvdevDevice_Expecter struct { + mock *mock.Mock +} + +func (_m *MockEvdevDevice) EXPECT() *MockEvdevDevice_Expecter { + return &MockEvdevDevice_Expecter{mock: &_m.Mock} +} + +// Close provides a mock function with no fields +func (_m *MockEvdevDevice) Close() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Close") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockEvdevDevice_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type MockEvdevDevice_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *MockEvdevDevice_Expecter) Close() *MockEvdevDevice_Close_Call { + return &MockEvdevDevice_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *MockEvdevDevice_Close_Call) Run(run func()) *MockEvdevDevice_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockEvdevDevice_Close_Call) Return(_a0 error) *MockEvdevDevice_Close_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockEvdevDevice_Close_Call) RunAndReturn(run func() error) *MockEvdevDevice_Close_Call { + _c.Call.Return(run) + return _c +} + +// Name provides a mock function with no fields +func (_m *MockEvdevDevice) Name() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockEvdevDevice_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type MockEvdevDevice_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *MockEvdevDevice_Expecter) Name() *MockEvdevDevice_Name_Call { + return &MockEvdevDevice_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *MockEvdevDevice_Name_Call) Run(run func()) *MockEvdevDevice_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockEvdevDevice_Name_Call) Return(_a0 string, _a1 error) *MockEvdevDevice_Name_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockEvdevDevice_Name_Call) RunAndReturn(run func() (string, error)) *MockEvdevDevice_Name_Call { + _c.Call.Return(run) + return _c +} + +// Path provides a mock function with no fields +func (_m *MockEvdevDevice) Path() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Path") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// MockEvdevDevice_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path' +type MockEvdevDevice_Path_Call struct { + *mock.Call +} + +// Path is a helper method to define mock.On call +func (_e *MockEvdevDevice_Expecter) Path() *MockEvdevDevice_Path_Call { + return &MockEvdevDevice_Path_Call{Call: _e.mock.On("Path")} +} + +func (_c *MockEvdevDevice_Path_Call) Run(run func()) *MockEvdevDevice_Path_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockEvdevDevice_Path_Call) Return(_a0 string) *MockEvdevDevice_Path_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockEvdevDevice_Path_Call) RunAndReturn(run func() string) *MockEvdevDevice_Path_Call { + _c.Call.Return(run) + return _c +} + +// ReadOne provides a mock function with no fields +func (_m *MockEvdevDevice) ReadOne() (*go_evdev.InputEvent, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ReadOne") + } + + var r0 *go_evdev.InputEvent + var r1 error + if rf, ok := ret.Get(0).(func() (*go_evdev.InputEvent, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() *go_evdev.InputEvent); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*go_evdev.InputEvent) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockEvdevDevice_ReadOne_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadOne' +type MockEvdevDevice_ReadOne_Call struct { + *mock.Call +} + +// ReadOne is a helper method to define mock.On call +func (_e *MockEvdevDevice_Expecter) ReadOne() *MockEvdevDevice_ReadOne_Call { + return &MockEvdevDevice_ReadOne_Call{Call: _e.mock.On("ReadOne")} +} + +func (_c *MockEvdevDevice_ReadOne_Call) Run(run func()) *MockEvdevDevice_ReadOne_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockEvdevDevice_ReadOne_Call) Return(_a0 *go_evdev.InputEvent, _a1 error) *MockEvdevDevice_ReadOne_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockEvdevDevice_ReadOne_Call) RunAndReturn(run func() (*go_evdev.InputEvent, error)) *MockEvdevDevice_ReadOne_Call { + _c.Call.Return(run) + return _c +} + +// State provides a mock function with given fields: t +func (_m *MockEvdevDevice) State(t go_evdev.EvType) (go_evdev.StateMap, error) { + ret := _m.Called(t) + + if len(ret) == 0 { + panic("no return value specified for State") + } + + var r0 go_evdev.StateMap + var r1 error + if rf, ok := ret.Get(0).(func(go_evdev.EvType) (go_evdev.StateMap, error)); ok { + return rf(t) + } + if rf, ok := ret.Get(0).(func(go_evdev.EvType) go_evdev.StateMap); ok { + r0 = rf(t) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(go_evdev.StateMap) + } + } + + if rf, ok := ret.Get(1).(func(go_evdev.EvType) error); ok { + r1 = rf(t) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockEvdevDevice_State_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'State' +type MockEvdevDevice_State_Call struct { + *mock.Call +} + +// State is a helper method to define mock.On call +// - t go_evdev.EvType +func (_e *MockEvdevDevice_Expecter) State(t interface{}) *MockEvdevDevice_State_Call { + return &MockEvdevDevice_State_Call{Call: _e.mock.On("State", t)} +} + +func (_c *MockEvdevDevice_State_Call) Run(run func(t go_evdev.EvType)) *MockEvdevDevice_State_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(go_evdev.EvType)) + }) + return _c +} + +func (_c *MockEvdevDevice_State_Call) Return(_a0 go_evdev.StateMap, _a1 error) *MockEvdevDevice_State_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockEvdevDevice_State_Call) RunAndReturn(run func(go_evdev.EvType) (go_evdev.StateMap, error)) *MockEvdevDevice_State_Call { + _c.Call.Return(run) + return _c +} + +// NewMockEvdevDevice creates a new instance of MockEvdevDevice. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockEvdevDevice(t interface { + mock.TestingT + Cleanup(func()) +}) *MockEvdevDevice { + mock := &MockEvdevDevice{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/geolocation/mock_Client.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/geolocation/mock_Client.go new file mode 100644 index 0000000..bf097cb --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/geolocation/mock_Client.go @@ -0,0 +1,203 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_geolocation + +import ( + geolocation "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + mock "github.com/stretchr/testify/mock" +) + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// Close provides a mock function with no fields +func (_m *MockClient) Close() { + _m.Called() +} + +// MockClient_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type MockClient_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *MockClient_Expecter) Close() *MockClient_Close_Call { + return &MockClient_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *MockClient_Close_Call) Run(run func()) *MockClient_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockClient_Close_Call) Return() *MockClient_Close_Call { + _c.Call.Return() + return _c +} + +func (_c *MockClient_Close_Call) RunAndReturn(run func()) *MockClient_Close_Call { + _c.Run(run) + return _c +} + +// GetLocation provides a mock function with no fields +func (_m *MockClient) GetLocation() (geolocation.Location, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetLocation") + } + + var r0 geolocation.Location + var r1 error + if rf, ok := ret.Get(0).(func() (geolocation.Location, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() geolocation.Location); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(geolocation.Location) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetLocation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLocation' +type MockClient_GetLocation_Call struct { + *mock.Call +} + +// GetLocation is a helper method to define mock.On call +func (_e *MockClient_Expecter) GetLocation() *MockClient_GetLocation_Call { + return &MockClient_GetLocation_Call{Call: _e.mock.On("GetLocation")} +} + +func (_c *MockClient_GetLocation_Call) Run(run func()) *MockClient_GetLocation_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockClient_GetLocation_Call) Return(_a0 geolocation.Location, _a1 error) *MockClient_GetLocation_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetLocation_Call) RunAndReturn(run func() (geolocation.Location, error)) *MockClient_GetLocation_Call { + _c.Call.Return(run) + return _c +} + +// Subscribe provides a mock function with given fields: id +func (_m *MockClient) Subscribe(id string) chan geolocation.Location { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for Subscribe") + } + + var r0 chan geolocation.Location + if rf, ok := ret.Get(0).(func(string) chan geolocation.Location); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(chan geolocation.Location) + } + } + + return r0 +} + +// MockClient_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe' +type MockClient_Subscribe_Call struct { + *mock.Call +} + +// Subscribe is a helper method to define mock.On call +// - id string +func (_e *MockClient_Expecter) Subscribe(id interface{}) *MockClient_Subscribe_Call { + return &MockClient_Subscribe_Call{Call: _e.mock.On("Subscribe", id)} +} + +func (_c *MockClient_Subscribe_Call) Run(run func(id string)) *MockClient_Subscribe_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockClient_Subscribe_Call) Return(_a0 chan geolocation.Location) *MockClient_Subscribe_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockClient_Subscribe_Call) RunAndReturn(run func(string) chan geolocation.Location) *MockClient_Subscribe_Call { + _c.Call.Return(run) + return _c +} + +// Unsubscribe provides a mock function with given fields: id +func (_m *MockClient) Unsubscribe(id string) { + _m.Called(id) +} + +// MockClient_Unsubscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unsubscribe' +type MockClient_Unsubscribe_Call struct { + *mock.Call +} + +// Unsubscribe is a helper method to define mock.On call +// - id string +func (_e *MockClient_Expecter) Unsubscribe(id interface{}) *MockClient_Unsubscribe_Call { + return &MockClient_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe", id)} +} + +func (_c *MockClient_Unsubscribe_Call) Run(run func(id string)) *MockClient_Unsubscribe_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockClient_Unsubscribe_Call) Return() *MockClient_Unsubscribe_Call { + _c.Call.Return() + return _c +} + +func (_c *MockClient_Unsubscribe_Call) RunAndReturn(run func(string)) *MockClient_Unsubscribe_Call { + _c.Run(run) + return _c +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_AccessPoint.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_AccessPoint.go new file mode 100644 index 0000000..ae5124d --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_AccessPoint.go @@ -0,0 +1,689 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package gonetworkmanager + +import ( + gonetworkmanager "github.com/Wifx/gonetworkmanager/v2" + dbus "github.com/godbus/dbus/v5" + + mock "github.com/stretchr/testify/mock" +) + +// MockAccessPoint is an autogenerated mock type for the AccessPoint type +type MockAccessPoint struct { + mock.Mock +} + +type MockAccessPoint_Expecter struct { + mock *mock.Mock +} + +func (_m *MockAccessPoint) EXPECT() *MockAccessPoint_Expecter { + return &MockAccessPoint_Expecter{mock: &_m.Mock} +} + +// GetPath provides a mock function with no fields +func (_m *MockAccessPoint) GetPath() dbus.ObjectPath { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPath") + } + + var r0 dbus.ObjectPath + if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(dbus.ObjectPath) + } + + return r0 +} + +// MockAccessPoint_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath' +type MockAccessPoint_GetPath_Call struct { + *mock.Call +} + +// GetPath is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPath() *MockAccessPoint_GetPath_Call { + return &MockAccessPoint_GetPath_Call{Call: _e.mock.On("GetPath")} +} + +func (_c *MockAccessPoint_GetPath_Call) Run(run func()) *MockAccessPoint_GetPath_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockAccessPoint_GetPath_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockAccessPoint_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockAccessPoint_GetPath_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyFlags provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertyFlags() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyFlags") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertyFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFlags' +type MockAccessPoint_GetPropertyFlags_Call struct { + *mock.Call +} + +// GetPropertyFlags is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertyFlags() *MockAccessPoint_GetPropertyFlags_Call { + return &MockAccessPoint_GetPropertyFlags_Call{Call: _e.mock.On("GetPropertyFlags")} +} + +func (_c *MockAccessPoint_GetPropertyFlags_Call) Run(run func()) *MockAccessPoint_GetPropertyFlags_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertyFlags_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyFlags_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertyFlags_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyFlags_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyFrequency provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertyFrequency() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyFrequency") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertyFrequency_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFrequency' +type MockAccessPoint_GetPropertyFrequency_Call struct { + *mock.Call +} + +// GetPropertyFrequency is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertyFrequency() *MockAccessPoint_GetPropertyFrequency_Call { + return &MockAccessPoint_GetPropertyFrequency_Call{Call: _e.mock.On("GetPropertyFrequency")} +} + +func (_c *MockAccessPoint_GetPropertyFrequency_Call) Run(run func()) *MockAccessPoint_GetPropertyFrequency_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertyFrequency_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyFrequency_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertyFrequency_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyFrequency_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyHWAddress provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertyHWAddress() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyHWAddress") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertyHWAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyHWAddress' +type MockAccessPoint_GetPropertyHWAddress_Call struct { + *mock.Call +} + +// GetPropertyHWAddress is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertyHWAddress() *MockAccessPoint_GetPropertyHWAddress_Call { + return &MockAccessPoint_GetPropertyHWAddress_Call{Call: _e.mock.On("GetPropertyHWAddress")} +} + +func (_c *MockAccessPoint_GetPropertyHWAddress_Call) Run(run func()) *MockAccessPoint_GetPropertyHWAddress_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertyHWAddress_Call) Return(_a0 string, _a1 error) *MockAccessPoint_GetPropertyHWAddress_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertyHWAddress_Call) RunAndReturn(run func() (string, error)) *MockAccessPoint_GetPropertyHWAddress_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyLastSeen provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertyLastSeen() (int32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyLastSeen") + } + + var r0 int32 + var r1 error + if rf, ok := ret.Get(0).(func() (int32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() int32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertyLastSeen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyLastSeen' +type MockAccessPoint_GetPropertyLastSeen_Call struct { + *mock.Call +} + +// GetPropertyLastSeen is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertyLastSeen() *MockAccessPoint_GetPropertyLastSeen_Call { + return &MockAccessPoint_GetPropertyLastSeen_Call{Call: _e.mock.On("GetPropertyLastSeen")} +} + +func (_c *MockAccessPoint_GetPropertyLastSeen_Call) Run(run func()) *MockAccessPoint_GetPropertyLastSeen_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertyLastSeen_Call) Return(_a0 int32, _a1 error) *MockAccessPoint_GetPropertyLastSeen_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertyLastSeen_Call) RunAndReturn(run func() (int32, error)) *MockAccessPoint_GetPropertyLastSeen_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyMaxBitrate provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertyMaxBitrate() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyMaxBitrate") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertyMaxBitrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMaxBitrate' +type MockAccessPoint_GetPropertyMaxBitrate_Call struct { + *mock.Call +} + +// GetPropertyMaxBitrate is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertyMaxBitrate() *MockAccessPoint_GetPropertyMaxBitrate_Call { + return &MockAccessPoint_GetPropertyMaxBitrate_Call{Call: _e.mock.On("GetPropertyMaxBitrate")} +} + +func (_c *MockAccessPoint_GetPropertyMaxBitrate_Call) Run(run func()) *MockAccessPoint_GetPropertyMaxBitrate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertyMaxBitrate_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyMaxBitrate_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertyMaxBitrate_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyMaxBitrate_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyMode provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertyMode() (gonetworkmanager.Nm80211Mode, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyMode") + } + + var r0 gonetworkmanager.Nm80211Mode + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.Nm80211Mode, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.Nm80211Mode); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.Nm80211Mode) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertyMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMode' +type MockAccessPoint_GetPropertyMode_Call struct { + *mock.Call +} + +// GetPropertyMode is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertyMode() *MockAccessPoint_GetPropertyMode_Call { + return &MockAccessPoint_GetPropertyMode_Call{Call: _e.mock.On("GetPropertyMode")} +} + +func (_c *MockAccessPoint_GetPropertyMode_Call) Run(run func()) *MockAccessPoint_GetPropertyMode_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertyMode_Call) Return(_a0 gonetworkmanager.Nm80211Mode, _a1 error) *MockAccessPoint_GetPropertyMode_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertyMode_Call) RunAndReturn(run func() (gonetworkmanager.Nm80211Mode, error)) *MockAccessPoint_GetPropertyMode_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyRSNFlags provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertyRSNFlags() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyRSNFlags") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertyRSNFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyRSNFlags' +type MockAccessPoint_GetPropertyRSNFlags_Call struct { + *mock.Call +} + +// GetPropertyRSNFlags is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertyRSNFlags() *MockAccessPoint_GetPropertyRSNFlags_Call { + return &MockAccessPoint_GetPropertyRSNFlags_Call{Call: _e.mock.On("GetPropertyRSNFlags")} +} + +func (_c *MockAccessPoint_GetPropertyRSNFlags_Call) Run(run func()) *MockAccessPoint_GetPropertyRSNFlags_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertyRSNFlags_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyRSNFlags_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertyRSNFlags_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyRSNFlags_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertySSID provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertySSID() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertySSID") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertySSID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertySSID' +type MockAccessPoint_GetPropertySSID_Call struct { + *mock.Call +} + +// GetPropertySSID is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertySSID() *MockAccessPoint_GetPropertySSID_Call { + return &MockAccessPoint_GetPropertySSID_Call{Call: _e.mock.On("GetPropertySSID")} +} + +func (_c *MockAccessPoint_GetPropertySSID_Call) Run(run func()) *MockAccessPoint_GetPropertySSID_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertySSID_Call) Return(_a0 string, _a1 error) *MockAccessPoint_GetPropertySSID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertySSID_Call) RunAndReturn(run func() (string, error)) *MockAccessPoint_GetPropertySSID_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyStrength provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertyStrength() (uint8, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyStrength") + } + + var r0 uint8 + var r1 error + if rf, ok := ret.Get(0).(func() (uint8, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint8); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint8) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertyStrength_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyStrength' +type MockAccessPoint_GetPropertyStrength_Call struct { + *mock.Call +} + +// GetPropertyStrength is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertyStrength() *MockAccessPoint_GetPropertyStrength_Call { + return &MockAccessPoint_GetPropertyStrength_Call{Call: _e.mock.On("GetPropertyStrength")} +} + +func (_c *MockAccessPoint_GetPropertyStrength_Call) Run(run func()) *MockAccessPoint_GetPropertyStrength_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertyStrength_Call) Return(_a0 uint8, _a1 error) *MockAccessPoint_GetPropertyStrength_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertyStrength_Call) RunAndReturn(run func() (uint8, error)) *MockAccessPoint_GetPropertyStrength_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyWPAFlags provides a mock function with no fields +func (_m *MockAccessPoint) GetPropertyWPAFlags() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyWPAFlags") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_GetPropertyWPAFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWPAFlags' +type MockAccessPoint_GetPropertyWPAFlags_Call struct { + *mock.Call +} + +// GetPropertyWPAFlags is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) GetPropertyWPAFlags() *MockAccessPoint_GetPropertyWPAFlags_Call { + return &MockAccessPoint_GetPropertyWPAFlags_Call{Call: _e.mock.On("GetPropertyWPAFlags")} +} + +func (_c *MockAccessPoint_GetPropertyWPAFlags_Call) Run(run func()) *MockAccessPoint_GetPropertyWPAFlags_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_GetPropertyWPAFlags_Call) Return(_a0 uint32, _a1 error) *MockAccessPoint_GetPropertyWPAFlags_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_GetPropertyWPAFlags_Call) RunAndReturn(run func() (uint32, error)) *MockAccessPoint_GetPropertyWPAFlags_Call { + _c.Call.Return(run) + return _c +} + +// MarshalJSON provides a mock function with no fields +func (_m *MockAccessPoint) MarshalJSON() ([]byte, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MarshalJSON") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func() ([]byte, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []byte); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockAccessPoint_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON' +type MockAccessPoint_MarshalJSON_Call struct { + *mock.Call +} + +// MarshalJSON is a helper method to define mock.On call +func (_e *MockAccessPoint_Expecter) MarshalJSON() *MockAccessPoint_MarshalJSON_Call { + return &MockAccessPoint_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")} +} + +func (_c *MockAccessPoint_MarshalJSON_Call) Run(run func()) *MockAccessPoint_MarshalJSON_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockAccessPoint_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockAccessPoint_MarshalJSON_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockAccessPoint_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockAccessPoint_MarshalJSON_Call { + _c.Call.Return(run) + return _c +} + +// NewMockAccessPoint creates a new instance of MockAccessPoint. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAccessPoint(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAccessPoint { + mock := &MockAccessPoint{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_ActiveConnection.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_ActiveConnection.go new file mode 100644 index 0000000..a4e3344 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_ActiveConnection.go @@ -0,0 +1,1025 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package gonetworkmanager + +import ( + gonetworkmanager "github.com/Wifx/gonetworkmanager/v2" + dbus "github.com/godbus/dbus/v5" + + mock "github.com/stretchr/testify/mock" +) + +// MockActiveConnection is an autogenerated mock type for the ActiveConnection type +type MockActiveConnection struct { + mock.Mock +} + +type MockActiveConnection_Expecter struct { + mock *mock.Mock +} + +func (_m *MockActiveConnection) EXPECT() *MockActiveConnection_Expecter { + return &MockActiveConnection_Expecter{mock: &_m.Mock} +} + +// GetPath provides a mock function with no fields +func (_m *MockActiveConnection) GetPath() dbus.ObjectPath { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPath") + } + + var r0 dbus.ObjectPath + if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(dbus.ObjectPath) + } + + return r0 +} + +// MockActiveConnection_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath' +type MockActiveConnection_GetPath_Call struct { + *mock.Call +} + +// GetPath is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPath() *MockActiveConnection_GetPath_Call { + return &MockActiveConnection_GetPath_Call{Call: _e.mock.On("GetPath")} +} + +func (_c *MockActiveConnection_GetPath_Call) Run(run func()) *MockActiveConnection_GetPath_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockActiveConnection_GetPath_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockActiveConnection_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockActiveConnection_GetPath_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyConnection provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyConnection() (gonetworkmanager.Connection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyConnection") + } + + var r0 gonetworkmanager.Connection + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.Connection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.Connection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.Connection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyConnection' +type MockActiveConnection_GetPropertyConnection_Call struct { + *mock.Call +} + +// GetPropertyConnection is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyConnection() *MockActiveConnection_GetPropertyConnection_Call { + return &MockActiveConnection_GetPropertyConnection_Call{Call: _e.mock.On("GetPropertyConnection")} +} + +func (_c *MockActiveConnection_GetPropertyConnection_Call) Run(run func()) *MockActiveConnection_GetPropertyConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyConnection_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockActiveConnection_GetPropertyConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyConnection_Call) RunAndReturn(run func() (gonetworkmanager.Connection, error)) *MockActiveConnection_GetPropertyConnection_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDHCP4Config provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyDHCP4Config() (gonetworkmanager.DHCP4Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDHCP4Config") + } + + var r0 gonetworkmanager.DHCP4Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP4Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP4Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.DHCP4Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyDHCP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP4Config' +type MockActiveConnection_GetPropertyDHCP4Config_Call struct { + *mock.Call +} + +// GetPropertyDHCP4Config is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyDHCP4Config() *MockActiveConnection_GetPropertyDHCP4Config_Call { + return &MockActiveConnection_GetPropertyDHCP4Config_Call{Call: _e.mock.On("GetPropertyDHCP4Config")} +} + +func (_c *MockActiveConnection_GetPropertyDHCP4Config_Call) Run(run func()) *MockActiveConnection_GetPropertyDHCP4Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDHCP4Config_Call) Return(_a0 gonetworkmanager.DHCP4Config, _a1 error) *MockActiveConnection_GetPropertyDHCP4Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDHCP4Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP4Config, error)) *MockActiveConnection_GetPropertyDHCP4Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDHCP6Config provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyDHCP6Config() (gonetworkmanager.DHCP6Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDHCP6Config") + } + + var r0 gonetworkmanager.DHCP6Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP6Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP6Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.DHCP6Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyDHCP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP6Config' +type MockActiveConnection_GetPropertyDHCP6Config_Call struct { + *mock.Call +} + +// GetPropertyDHCP6Config is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyDHCP6Config() *MockActiveConnection_GetPropertyDHCP6Config_Call { + return &MockActiveConnection_GetPropertyDHCP6Config_Call{Call: _e.mock.On("GetPropertyDHCP6Config")} +} + +func (_c *MockActiveConnection_GetPropertyDHCP6Config_Call) Run(run func()) *MockActiveConnection_GetPropertyDHCP6Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDHCP6Config_Call) Return(_a0 gonetworkmanager.DHCP6Config, _a1 error) *MockActiveConnection_GetPropertyDHCP6Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDHCP6Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP6Config, error)) *MockActiveConnection_GetPropertyDHCP6Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDefault provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyDefault() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDefault") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyDefault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDefault' +type MockActiveConnection_GetPropertyDefault_Call struct { + *mock.Call +} + +// GetPropertyDefault is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyDefault() *MockActiveConnection_GetPropertyDefault_Call { + return &MockActiveConnection_GetPropertyDefault_Call{Call: _e.mock.On("GetPropertyDefault")} +} + +func (_c *MockActiveConnection_GetPropertyDefault_Call) Run(run func()) *MockActiveConnection_GetPropertyDefault_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDefault_Call) Return(_a0 bool, _a1 error) *MockActiveConnection_GetPropertyDefault_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDefault_Call) RunAndReturn(run func() (bool, error)) *MockActiveConnection_GetPropertyDefault_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDefault6 provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyDefault6() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDefault6") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyDefault6_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDefault6' +type MockActiveConnection_GetPropertyDefault6_Call struct { + *mock.Call +} + +// GetPropertyDefault6 is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyDefault6() *MockActiveConnection_GetPropertyDefault6_Call { + return &MockActiveConnection_GetPropertyDefault6_Call{Call: _e.mock.On("GetPropertyDefault6")} +} + +func (_c *MockActiveConnection_GetPropertyDefault6_Call) Run(run func()) *MockActiveConnection_GetPropertyDefault6_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDefault6_Call) Return(_a0 bool, _a1 error) *MockActiveConnection_GetPropertyDefault6_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDefault6_Call) RunAndReturn(run func() (bool, error)) *MockActiveConnection_GetPropertyDefault6_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDevices provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyDevices() ([]gonetworkmanager.Device, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDevices") + } + + var r0 []gonetworkmanager.Device + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.Device) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDevices' +type MockActiveConnection_GetPropertyDevices_Call struct { + *mock.Call +} + +// GetPropertyDevices is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyDevices() *MockActiveConnection_GetPropertyDevices_Call { + return &MockActiveConnection_GetPropertyDevices_Call{Call: _e.mock.On("GetPropertyDevices")} +} + +func (_c *MockActiveConnection_GetPropertyDevices_Call) Run(run func()) *MockActiveConnection_GetPropertyDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockActiveConnection_GetPropertyDevices_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockActiveConnection_GetPropertyDevices_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyID provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyID() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyID") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyID' +type MockActiveConnection_GetPropertyID_Call struct { + *mock.Call +} + +// GetPropertyID is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyID() *MockActiveConnection_GetPropertyID_Call { + return &MockActiveConnection_GetPropertyID_Call{Call: _e.mock.On("GetPropertyID")} +} + +func (_c *MockActiveConnection_GetPropertyID_Call) Run(run func()) *MockActiveConnection_GetPropertyID_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyID_Call) Return(_a0 string, _a1 error) *MockActiveConnection_GetPropertyID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyID_Call) RunAndReturn(run func() (string, error)) *MockActiveConnection_GetPropertyID_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIP4Config provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyIP4Config() (gonetworkmanager.IP4Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIP4Config") + } + + var r0 gonetworkmanager.IP4Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP4Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.IP4Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.IP4Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyIP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP4Config' +type MockActiveConnection_GetPropertyIP4Config_Call struct { + *mock.Call +} + +// GetPropertyIP4Config is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyIP4Config() *MockActiveConnection_GetPropertyIP4Config_Call { + return &MockActiveConnection_GetPropertyIP4Config_Call{Call: _e.mock.On("GetPropertyIP4Config")} +} + +func (_c *MockActiveConnection_GetPropertyIP4Config_Call) Run(run func()) *MockActiveConnection_GetPropertyIP4Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyIP4Config_Call) Return(_a0 gonetworkmanager.IP4Config, _a1 error) *MockActiveConnection_GetPropertyIP4Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyIP4Config_Call) RunAndReturn(run func() (gonetworkmanager.IP4Config, error)) *MockActiveConnection_GetPropertyIP4Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIP6Config provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyIP6Config() (gonetworkmanager.IP6Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIP6Config") + } + + var r0 gonetworkmanager.IP6Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP6Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.IP6Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.IP6Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyIP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP6Config' +type MockActiveConnection_GetPropertyIP6Config_Call struct { + *mock.Call +} + +// GetPropertyIP6Config is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyIP6Config() *MockActiveConnection_GetPropertyIP6Config_Call { + return &MockActiveConnection_GetPropertyIP6Config_Call{Call: _e.mock.On("GetPropertyIP6Config")} +} + +func (_c *MockActiveConnection_GetPropertyIP6Config_Call) Run(run func()) *MockActiveConnection_GetPropertyIP6Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyIP6Config_Call) Return(_a0 gonetworkmanager.IP6Config, _a1 error) *MockActiveConnection_GetPropertyIP6Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyIP6Config_Call) RunAndReturn(run func() (gonetworkmanager.IP6Config, error)) *MockActiveConnection_GetPropertyIP6Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyMaster provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyMaster() (gonetworkmanager.Device, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyMaster") + } + + var r0 gonetworkmanager.Device + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.Device, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.Device); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.Device) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyMaster_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMaster' +type MockActiveConnection_GetPropertyMaster_Call struct { + *mock.Call +} + +// GetPropertyMaster is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyMaster() *MockActiveConnection_GetPropertyMaster_Call { + return &MockActiveConnection_GetPropertyMaster_Call{Call: _e.mock.On("GetPropertyMaster")} +} + +func (_c *MockActiveConnection_GetPropertyMaster_Call) Run(run func()) *MockActiveConnection_GetPropertyMaster_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyMaster_Call) Return(_a0 gonetworkmanager.Device, _a1 error) *MockActiveConnection_GetPropertyMaster_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyMaster_Call) RunAndReturn(run func() (gonetworkmanager.Device, error)) *MockActiveConnection_GetPropertyMaster_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertySpecificObject provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertySpecificObject() (gonetworkmanager.AccessPoint, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertySpecificObject") + } + + var r0 gonetworkmanager.AccessPoint + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.AccessPoint, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.AccessPoint); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.AccessPoint) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertySpecificObject_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertySpecificObject' +type MockActiveConnection_GetPropertySpecificObject_Call struct { + *mock.Call +} + +// GetPropertySpecificObject is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertySpecificObject() *MockActiveConnection_GetPropertySpecificObject_Call { + return &MockActiveConnection_GetPropertySpecificObject_Call{Call: _e.mock.On("GetPropertySpecificObject")} +} + +func (_c *MockActiveConnection_GetPropertySpecificObject_Call) Run(run func()) *MockActiveConnection_GetPropertySpecificObject_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertySpecificObject_Call) Return(_a0 gonetworkmanager.AccessPoint, _a1 error) *MockActiveConnection_GetPropertySpecificObject_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertySpecificObject_Call) RunAndReturn(run func() (gonetworkmanager.AccessPoint, error)) *MockActiveConnection_GetPropertySpecificObject_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyState provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyState() (gonetworkmanager.NmActiveConnectionState, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyState") + } + + var r0 gonetworkmanager.NmActiveConnectionState + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmActiveConnectionState, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmActiveConnectionState); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmActiveConnectionState) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyState' +type MockActiveConnection_GetPropertyState_Call struct { + *mock.Call +} + +// GetPropertyState is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyState() *MockActiveConnection_GetPropertyState_Call { + return &MockActiveConnection_GetPropertyState_Call{Call: _e.mock.On("GetPropertyState")} +} + +func (_c *MockActiveConnection_GetPropertyState_Call) Run(run func()) *MockActiveConnection_GetPropertyState_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyState_Call) Return(_a0 gonetworkmanager.NmActiveConnectionState, _a1 error) *MockActiveConnection_GetPropertyState_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyState_Call) RunAndReturn(run func() (gonetworkmanager.NmActiveConnectionState, error)) *MockActiveConnection_GetPropertyState_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyStateFlags provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyStateFlags() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyStateFlags") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyStateFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyStateFlags' +type MockActiveConnection_GetPropertyStateFlags_Call struct { + *mock.Call +} + +// GetPropertyStateFlags is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyStateFlags() *MockActiveConnection_GetPropertyStateFlags_Call { + return &MockActiveConnection_GetPropertyStateFlags_Call{Call: _e.mock.On("GetPropertyStateFlags")} +} + +func (_c *MockActiveConnection_GetPropertyStateFlags_Call) Run(run func()) *MockActiveConnection_GetPropertyStateFlags_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyStateFlags_Call) Return(_a0 uint32, _a1 error) *MockActiveConnection_GetPropertyStateFlags_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyStateFlags_Call) RunAndReturn(run func() (uint32, error)) *MockActiveConnection_GetPropertyStateFlags_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyType provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyType() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyType") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyType' +type MockActiveConnection_GetPropertyType_Call struct { + *mock.Call +} + +// GetPropertyType is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyType() *MockActiveConnection_GetPropertyType_Call { + return &MockActiveConnection_GetPropertyType_Call{Call: _e.mock.On("GetPropertyType")} +} + +func (_c *MockActiveConnection_GetPropertyType_Call) Run(run func()) *MockActiveConnection_GetPropertyType_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyType_Call) Return(_a0 string, _a1 error) *MockActiveConnection_GetPropertyType_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyType_Call) RunAndReturn(run func() (string, error)) *MockActiveConnection_GetPropertyType_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyUUID provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyUUID() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyUUID") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyUUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyUUID' +type MockActiveConnection_GetPropertyUUID_Call struct { + *mock.Call +} + +// GetPropertyUUID is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyUUID() *MockActiveConnection_GetPropertyUUID_Call { + return &MockActiveConnection_GetPropertyUUID_Call{Call: _e.mock.On("GetPropertyUUID")} +} + +func (_c *MockActiveConnection_GetPropertyUUID_Call) Run(run func()) *MockActiveConnection_GetPropertyUUID_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyUUID_Call) Return(_a0 string, _a1 error) *MockActiveConnection_GetPropertyUUID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyUUID_Call) RunAndReturn(run func() (string, error)) *MockActiveConnection_GetPropertyUUID_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyVPN provides a mock function with no fields +func (_m *MockActiveConnection) GetPropertyVPN() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyVPN") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockActiveConnection_GetPropertyVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyVPN' +type MockActiveConnection_GetPropertyVPN_Call struct { + *mock.Call +} + +// GetPropertyVPN is a helper method to define mock.On call +func (_e *MockActiveConnection_Expecter) GetPropertyVPN() *MockActiveConnection_GetPropertyVPN_Call { + return &MockActiveConnection_GetPropertyVPN_Call{Call: _e.mock.On("GetPropertyVPN")} +} + +func (_c *MockActiveConnection_GetPropertyVPN_Call) Run(run func()) *MockActiveConnection_GetPropertyVPN_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockActiveConnection_GetPropertyVPN_Call) Return(_a0 bool, _a1 error) *MockActiveConnection_GetPropertyVPN_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockActiveConnection_GetPropertyVPN_Call) RunAndReturn(run func() (bool, error)) *MockActiveConnection_GetPropertyVPN_Call { + _c.Call.Return(run) + return _c +} + +// SubscribeState provides a mock function with given fields: receiver, exit +func (_m *MockActiveConnection) SubscribeState(receiver chan gonetworkmanager.StateChange, exit chan struct{}) error { + ret := _m.Called(receiver, exit) + + if len(ret) == 0 { + panic("no return value specified for SubscribeState") + } + + var r0 error + if rf, ok := ret.Get(0).(func(chan gonetworkmanager.StateChange, chan struct{}) error); ok { + r0 = rf(receiver, exit) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockActiveConnection_SubscribeState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubscribeState' +type MockActiveConnection_SubscribeState_Call struct { + *mock.Call +} + +// SubscribeState is a helper method to define mock.On call +// - receiver chan gonetworkmanager.StateChange +// - exit chan struct{} +func (_e *MockActiveConnection_Expecter) SubscribeState(receiver interface{}, exit interface{}) *MockActiveConnection_SubscribeState_Call { + return &MockActiveConnection_SubscribeState_Call{Call: _e.mock.On("SubscribeState", receiver, exit)} +} + +func (_c *MockActiveConnection_SubscribeState_Call) Run(run func(receiver chan gonetworkmanager.StateChange, exit chan struct{})) *MockActiveConnection_SubscribeState_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(chan gonetworkmanager.StateChange), args[1].(chan struct{})) + }) + return _c +} + +func (_c *MockActiveConnection_SubscribeState_Call) Return(err error) *MockActiveConnection_SubscribeState_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockActiveConnection_SubscribeState_Call) RunAndReturn(run func(chan gonetworkmanager.StateChange, chan struct{}) error) *MockActiveConnection_SubscribeState_Call { + _c.Call.Return(run) + return _c +} + +// NewMockActiveConnection creates a new instance of MockActiveConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockActiveConnection(t interface { + mock.TestingT + Cleanup(func()) +}) *MockActiveConnection { + mock := &MockActiveConnection{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Connection.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Connection.go new file mode 100644 index 0000000..84f59e1 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Connection.go @@ -0,0 +1,646 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package gonetworkmanager + +import ( + gonetworkmanager "github.com/Wifx/gonetworkmanager/v2" + dbus "github.com/godbus/dbus/v5" + + mock "github.com/stretchr/testify/mock" +) + +// MockConnection is an autogenerated mock type for the Connection type +type MockConnection struct { + mock.Mock +} + +type MockConnection_Expecter struct { + mock *mock.Mock +} + +func (_m *MockConnection) EXPECT() *MockConnection_Expecter { + return &MockConnection_Expecter{mock: &_m.Mock} +} + +// ClearSecrets provides a mock function with no fields +func (_m *MockConnection) ClearSecrets() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ClearSecrets") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_ClearSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearSecrets' +type MockConnection_ClearSecrets_Call struct { + *mock.Call +} + +// ClearSecrets is a helper method to define mock.On call +func (_e *MockConnection_Expecter) ClearSecrets() *MockConnection_ClearSecrets_Call { + return &MockConnection_ClearSecrets_Call{Call: _e.mock.On("ClearSecrets")} +} + +func (_c *MockConnection_ClearSecrets_Call) Run(run func()) *MockConnection_ClearSecrets_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConnection_ClearSecrets_Call) Return(_a0 error) *MockConnection_ClearSecrets_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_ClearSecrets_Call) RunAndReturn(run func() error) *MockConnection_ClearSecrets_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function with no fields +func (_m *MockConnection) Delete() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type MockConnection_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +func (_e *MockConnection_Expecter) Delete() *MockConnection_Delete_Call { + return &MockConnection_Delete_Call{Call: _e.mock.On("Delete")} +} + +func (_c *MockConnection_Delete_Call) Run(run func()) *MockConnection_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConnection_Delete_Call) Return(_a0 error) *MockConnection_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Delete_Call) RunAndReturn(run func() error) *MockConnection_Delete_Call { + _c.Call.Return(run) + return _c +} + +// GetPath provides a mock function with no fields +func (_m *MockConnection) GetPath() dbus.ObjectPath { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPath") + } + + var r0 dbus.ObjectPath + if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(dbus.ObjectPath) + } + + return r0 +} + +// MockConnection_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath' +type MockConnection_GetPath_Call struct { + *mock.Call +} + +// GetPath is a helper method to define mock.On call +func (_e *MockConnection_Expecter) GetPath() *MockConnection_GetPath_Call { + return &MockConnection_GetPath_Call{Call: _e.mock.On("GetPath")} +} + +func (_c *MockConnection_GetPath_Call) Run(run func()) *MockConnection_GetPath_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConnection_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockConnection_GetPath_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockConnection_GetPath_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyFilename provides a mock function with no fields +func (_m *MockConnection) GetPropertyFilename() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyFilename") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockConnection_GetPropertyFilename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFilename' +type MockConnection_GetPropertyFilename_Call struct { + *mock.Call +} + +// GetPropertyFilename is a helper method to define mock.On call +func (_e *MockConnection_Expecter) GetPropertyFilename() *MockConnection_GetPropertyFilename_Call { + return &MockConnection_GetPropertyFilename_Call{Call: _e.mock.On("GetPropertyFilename")} +} + +func (_c *MockConnection_GetPropertyFilename_Call) Run(run func()) *MockConnection_GetPropertyFilename_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConnection_GetPropertyFilename_Call) Return(_a0 string, _a1 error) *MockConnection_GetPropertyFilename_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockConnection_GetPropertyFilename_Call) RunAndReturn(run func() (string, error)) *MockConnection_GetPropertyFilename_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyFlags provides a mock function with no fields +func (_m *MockConnection) GetPropertyFlags() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyFlags") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockConnection_GetPropertyFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFlags' +type MockConnection_GetPropertyFlags_Call struct { + *mock.Call +} + +// GetPropertyFlags is a helper method to define mock.On call +func (_e *MockConnection_Expecter) GetPropertyFlags() *MockConnection_GetPropertyFlags_Call { + return &MockConnection_GetPropertyFlags_Call{Call: _e.mock.On("GetPropertyFlags")} +} + +func (_c *MockConnection_GetPropertyFlags_Call) Run(run func()) *MockConnection_GetPropertyFlags_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConnection_GetPropertyFlags_Call) Return(_a0 uint32, _a1 error) *MockConnection_GetPropertyFlags_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockConnection_GetPropertyFlags_Call) RunAndReturn(run func() (uint32, error)) *MockConnection_GetPropertyFlags_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyUnsaved provides a mock function with no fields +func (_m *MockConnection) GetPropertyUnsaved() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyUnsaved") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockConnection_GetPropertyUnsaved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyUnsaved' +type MockConnection_GetPropertyUnsaved_Call struct { + *mock.Call +} + +// GetPropertyUnsaved is a helper method to define mock.On call +func (_e *MockConnection_Expecter) GetPropertyUnsaved() *MockConnection_GetPropertyUnsaved_Call { + return &MockConnection_GetPropertyUnsaved_Call{Call: _e.mock.On("GetPropertyUnsaved")} +} + +func (_c *MockConnection_GetPropertyUnsaved_Call) Run(run func()) *MockConnection_GetPropertyUnsaved_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConnection_GetPropertyUnsaved_Call) Return(_a0 bool, _a1 error) *MockConnection_GetPropertyUnsaved_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockConnection_GetPropertyUnsaved_Call) RunAndReturn(run func() (bool, error)) *MockConnection_GetPropertyUnsaved_Call { + _c.Call.Return(run) + return _c +} + +// GetSecrets provides a mock function with given fields: settingName +func (_m *MockConnection) GetSecrets(settingName string) (gonetworkmanager.ConnectionSettings, error) { + ret := _m.Called(settingName) + + if len(ret) == 0 { + panic("no return value specified for GetSecrets") + } + + var r0 gonetworkmanager.ConnectionSettings + var r1 error + if rf, ok := ret.Get(0).(func(string) (gonetworkmanager.ConnectionSettings, error)); ok { + return rf(settingName) + } + if rf, ok := ret.Get(0).(func(string) gonetworkmanager.ConnectionSettings); ok { + r0 = rf(settingName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ConnectionSettings) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(settingName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockConnection_GetSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSecrets' +type MockConnection_GetSecrets_Call struct { + *mock.Call +} + +// GetSecrets is a helper method to define mock.On call +// - settingName string +func (_e *MockConnection_Expecter) GetSecrets(settingName interface{}) *MockConnection_GetSecrets_Call { + return &MockConnection_GetSecrets_Call{Call: _e.mock.On("GetSecrets", settingName)} +} + +func (_c *MockConnection_GetSecrets_Call) Run(run func(settingName string)) *MockConnection_GetSecrets_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockConnection_GetSecrets_Call) Return(_a0 gonetworkmanager.ConnectionSettings, _a1 error) *MockConnection_GetSecrets_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockConnection_GetSecrets_Call) RunAndReturn(run func(string) (gonetworkmanager.ConnectionSettings, error)) *MockConnection_GetSecrets_Call { + _c.Call.Return(run) + return _c +} + +// GetSettings provides a mock function with no fields +func (_m *MockConnection) GetSettings() (gonetworkmanager.ConnectionSettings, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetSettings") + } + + var r0 gonetworkmanager.ConnectionSettings + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.ConnectionSettings, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.ConnectionSettings); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ConnectionSettings) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockConnection_GetSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSettings' +type MockConnection_GetSettings_Call struct { + *mock.Call +} + +// GetSettings is a helper method to define mock.On call +func (_e *MockConnection_Expecter) GetSettings() *MockConnection_GetSettings_Call { + return &MockConnection_GetSettings_Call{Call: _e.mock.On("GetSettings")} +} + +func (_c *MockConnection_GetSettings_Call) Run(run func()) *MockConnection_GetSettings_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConnection_GetSettings_Call) Return(_a0 gonetworkmanager.ConnectionSettings, _a1 error) *MockConnection_GetSettings_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockConnection_GetSettings_Call) RunAndReturn(run func() (gonetworkmanager.ConnectionSettings, error)) *MockConnection_GetSettings_Call { + _c.Call.Return(run) + return _c +} + +// MarshalJSON provides a mock function with no fields +func (_m *MockConnection) MarshalJSON() ([]byte, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MarshalJSON") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func() ([]byte, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []byte); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockConnection_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON' +type MockConnection_MarshalJSON_Call struct { + *mock.Call +} + +// MarshalJSON is a helper method to define mock.On call +func (_e *MockConnection_Expecter) MarshalJSON() *MockConnection_MarshalJSON_Call { + return &MockConnection_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")} +} + +func (_c *MockConnection_MarshalJSON_Call) Run(run func()) *MockConnection_MarshalJSON_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConnection_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockConnection_MarshalJSON_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockConnection_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockConnection_MarshalJSON_Call { + _c.Call.Return(run) + return _c +} + +// Save provides a mock function with no fields +func (_m *MockConnection) Save() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Save") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save' +type MockConnection_Save_Call struct { + *mock.Call +} + +// Save is a helper method to define mock.On call +func (_e *MockConnection_Expecter) Save() *MockConnection_Save_Call { + return &MockConnection_Save_Call{Call: _e.mock.On("Save")} +} + +func (_c *MockConnection_Save_Call) Run(run func()) *MockConnection_Save_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConnection_Save_Call) Return(_a0 error) *MockConnection_Save_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Save_Call) RunAndReturn(run func() error) *MockConnection_Save_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function with given fields: settings +func (_m *MockConnection) Update(settings gonetworkmanager.ConnectionSettings) error { + ret := _m.Called(settings) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) error); ok { + r0 = rf(settings) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type MockConnection_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - settings gonetworkmanager.ConnectionSettings +func (_e *MockConnection_Expecter) Update(settings interface{}) *MockConnection_Update_Call { + return &MockConnection_Update_Call{Call: _e.mock.On("Update", settings)} +} + +func (_c *MockConnection_Update_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockConnection_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.ConnectionSettings)) + }) + return _c +} + +func (_c *MockConnection_Update_Call) Return(_a0 error) *MockConnection_Update_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Update_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) error) *MockConnection_Update_Call { + _c.Call.Return(run) + return _c +} + +// UpdateUnsaved provides a mock function with given fields: settings +func (_m *MockConnection) UpdateUnsaved(settings gonetworkmanager.ConnectionSettings) error { + ret := _m.Called(settings) + + if len(ret) == 0 { + panic("no return value specified for UpdateUnsaved") + } + + var r0 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) error); ok { + r0 = rf(settings) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_UpdateUnsaved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUnsaved' +type MockConnection_UpdateUnsaved_Call struct { + *mock.Call +} + +// UpdateUnsaved is a helper method to define mock.On call +// - settings gonetworkmanager.ConnectionSettings +func (_e *MockConnection_Expecter) UpdateUnsaved(settings interface{}) *MockConnection_UpdateUnsaved_Call { + return &MockConnection_UpdateUnsaved_Call{Call: _e.mock.On("UpdateUnsaved", settings)} +} + +func (_c *MockConnection_UpdateUnsaved_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockConnection_UpdateUnsaved_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.ConnectionSettings)) + }) + return _c +} + +func (_c *MockConnection_UpdateUnsaved_Call) Return(_a0 error) *MockConnection_UpdateUnsaved_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_UpdateUnsaved_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) error) *MockConnection_UpdateUnsaved_Call { + _c.Call.Return(run) + return _c +} + +// NewMockConnection creates a new instance of MockConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockConnection(t interface { + mock.TestingT + Cleanup(func()) +}) *MockConnection { + mock := &MockConnection{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Device.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Device.go new file mode 100644 index 0000000..b2fc0b5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Device.go @@ -0,0 +1,1638 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package gonetworkmanager + +import ( + gonetworkmanager "github.com/Wifx/gonetworkmanager/v2" + dbus "github.com/godbus/dbus/v5" + + mock "github.com/stretchr/testify/mock" +) + +// MockDevice is an autogenerated mock type for the Device type +type MockDevice struct { + mock.Mock +} + +type MockDevice_Expecter struct { + mock *mock.Mock +} + +func (_m *MockDevice) EXPECT() *MockDevice_Expecter { + return &MockDevice_Expecter{mock: &_m.Mock} +} + +// Delete provides a mock function with no fields +func (_m *MockDevice) Delete() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDevice_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type MockDevice_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +func (_e *MockDevice_Expecter) Delete() *MockDevice_Delete_Call { + return &MockDevice_Delete_Call{Call: _e.mock.On("Delete")} +} + +func (_c *MockDevice_Delete_Call) Run(run func()) *MockDevice_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_Delete_Call) Return(_a0 error) *MockDevice_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDevice_Delete_Call) RunAndReturn(run func() error) *MockDevice_Delete_Call { + _c.Call.Return(run) + return _c +} + +// Disconnect provides a mock function with no fields +func (_m *MockDevice) Disconnect() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Disconnect") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDevice_Disconnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Disconnect' +type MockDevice_Disconnect_Call struct { + *mock.Call +} + +// Disconnect is a helper method to define mock.On call +func (_e *MockDevice_Expecter) Disconnect() *MockDevice_Disconnect_Call { + return &MockDevice_Disconnect_Call{Call: _e.mock.On("Disconnect")} +} + +func (_c *MockDevice_Disconnect_Call) Run(run func()) *MockDevice_Disconnect_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_Disconnect_Call) Return(_a0 error) *MockDevice_Disconnect_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDevice_Disconnect_Call) RunAndReturn(run func() error) *MockDevice_Disconnect_Call { + _c.Call.Return(run) + return _c +} + +// GetPath provides a mock function with no fields +func (_m *MockDevice) GetPath() dbus.ObjectPath { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPath") + } + + var r0 dbus.ObjectPath + if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(dbus.ObjectPath) + } + + return r0 +} + +// MockDevice_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath' +type MockDevice_GetPath_Call struct { + *mock.Call +} + +// GetPath is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPath() *MockDevice_GetPath_Call { + return &MockDevice_GetPath_Call{Call: _e.mock.On("GetPath")} +} + +func (_c *MockDevice_GetPath_Call) Run(run func()) *MockDevice_GetPath_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockDevice_GetPath_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDevice_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockDevice_GetPath_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyActiveConnection provides a mock function with no fields +func (_m *MockDevice) GetPropertyActiveConnection() (gonetworkmanager.ActiveConnection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyActiveConnection") + } + + var r0 gonetworkmanager.ActiveConnection + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.ActiveConnection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.ActiveConnection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ActiveConnection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyActiveConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActiveConnection' +type MockDevice_GetPropertyActiveConnection_Call struct { + *mock.Call +} + +// GetPropertyActiveConnection is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyActiveConnection() *MockDevice_GetPropertyActiveConnection_Call { + return &MockDevice_GetPropertyActiveConnection_Call{Call: _e.mock.On("GetPropertyActiveConnection")} +} + +func (_c *MockDevice_GetPropertyActiveConnection_Call) Run(run func()) *MockDevice_GetPropertyActiveConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyActiveConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockDevice_GetPropertyActiveConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyActiveConnection_Call) RunAndReturn(run func() (gonetworkmanager.ActiveConnection, error)) *MockDevice_GetPropertyActiveConnection_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyAutoConnect provides a mock function with no fields +func (_m *MockDevice) GetPropertyAutoConnect() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyAutoConnect") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyAutoConnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAutoConnect' +type MockDevice_GetPropertyAutoConnect_Call struct { + *mock.Call +} + +// GetPropertyAutoConnect is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyAutoConnect() *MockDevice_GetPropertyAutoConnect_Call { + return &MockDevice_GetPropertyAutoConnect_Call{Call: _e.mock.On("GetPropertyAutoConnect")} +} + +func (_c *MockDevice_GetPropertyAutoConnect_Call) Run(run func()) *MockDevice_GetPropertyAutoConnect_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyAutoConnect_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyAutoConnect_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyAutoConnect_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyAutoConnect_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyAvailableConnections provides a mock function with no fields +func (_m *MockDevice) GetPropertyAvailableConnections() ([]gonetworkmanager.Connection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyAvailableConnections") + } + + var r0 []gonetworkmanager.Connection + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Connection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.Connection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.Connection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyAvailableConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAvailableConnections' +type MockDevice_GetPropertyAvailableConnections_Call struct { + *mock.Call +} + +// GetPropertyAvailableConnections is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyAvailableConnections() *MockDevice_GetPropertyAvailableConnections_Call { + return &MockDevice_GetPropertyAvailableConnections_Call{Call: _e.mock.On("GetPropertyAvailableConnections")} +} + +func (_c *MockDevice_GetPropertyAvailableConnections_Call) Run(run func()) *MockDevice_GetPropertyAvailableConnections_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyAvailableConnections_Call) Return(_a0 []gonetworkmanager.Connection, _a1 error) *MockDevice_GetPropertyAvailableConnections_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyAvailableConnections_Call) RunAndReturn(run func() ([]gonetworkmanager.Connection, error)) *MockDevice_GetPropertyAvailableConnections_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDHCP4Config provides a mock function with no fields +func (_m *MockDevice) GetPropertyDHCP4Config() (gonetworkmanager.DHCP4Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDHCP4Config") + } + + var r0 gonetworkmanager.DHCP4Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP4Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP4Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.DHCP4Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyDHCP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP4Config' +type MockDevice_GetPropertyDHCP4Config_Call struct { + *mock.Call +} + +// GetPropertyDHCP4Config is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyDHCP4Config() *MockDevice_GetPropertyDHCP4Config_Call { + return &MockDevice_GetPropertyDHCP4Config_Call{Call: _e.mock.On("GetPropertyDHCP4Config")} +} + +func (_c *MockDevice_GetPropertyDHCP4Config_Call) Run(run func()) *MockDevice_GetPropertyDHCP4Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyDHCP4Config_Call) Return(_a0 gonetworkmanager.DHCP4Config, _a1 error) *MockDevice_GetPropertyDHCP4Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyDHCP4Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP4Config, error)) *MockDevice_GetPropertyDHCP4Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDHCP6Config provides a mock function with no fields +func (_m *MockDevice) GetPropertyDHCP6Config() (gonetworkmanager.DHCP6Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDHCP6Config") + } + + var r0 gonetworkmanager.DHCP6Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP6Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP6Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.DHCP6Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyDHCP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP6Config' +type MockDevice_GetPropertyDHCP6Config_Call struct { + *mock.Call +} + +// GetPropertyDHCP6Config is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyDHCP6Config() *MockDevice_GetPropertyDHCP6Config_Call { + return &MockDevice_GetPropertyDHCP6Config_Call{Call: _e.mock.On("GetPropertyDHCP6Config")} +} + +func (_c *MockDevice_GetPropertyDHCP6Config_Call) Run(run func()) *MockDevice_GetPropertyDHCP6Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyDHCP6Config_Call) Return(_a0 gonetworkmanager.DHCP6Config, _a1 error) *MockDevice_GetPropertyDHCP6Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyDHCP6Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP6Config, error)) *MockDevice_GetPropertyDHCP6Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDeviceType provides a mock function with no fields +func (_m *MockDevice) GetPropertyDeviceType() (gonetworkmanager.NmDeviceType, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDeviceType") + } + + var r0 gonetworkmanager.NmDeviceType + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmDeviceType, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmDeviceType); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmDeviceType) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyDeviceType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDeviceType' +type MockDevice_GetPropertyDeviceType_Call struct { + *mock.Call +} + +// GetPropertyDeviceType is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyDeviceType() *MockDevice_GetPropertyDeviceType_Call { + return &MockDevice_GetPropertyDeviceType_Call{Call: _e.mock.On("GetPropertyDeviceType")} +} + +func (_c *MockDevice_GetPropertyDeviceType_Call) Run(run func()) *MockDevice_GetPropertyDeviceType_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyDeviceType_Call) Return(_a0 gonetworkmanager.NmDeviceType, _a1 error) *MockDevice_GetPropertyDeviceType_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyDeviceType_Call) RunAndReturn(run func() (gonetworkmanager.NmDeviceType, error)) *MockDevice_GetPropertyDeviceType_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDriver provides a mock function with no fields +func (_m *MockDevice) GetPropertyDriver() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDriver") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyDriver_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDriver' +type MockDevice_GetPropertyDriver_Call struct { + *mock.Call +} + +// GetPropertyDriver is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyDriver() *MockDevice_GetPropertyDriver_Call { + return &MockDevice_GetPropertyDriver_Call{Call: _e.mock.On("GetPropertyDriver")} +} + +func (_c *MockDevice_GetPropertyDriver_Call) Run(run func()) *MockDevice_GetPropertyDriver_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyDriver_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyDriver_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyDriver_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyDriver_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDriverVersion provides a mock function with no fields +func (_m *MockDevice) GetPropertyDriverVersion() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDriverVersion") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyDriverVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDriverVersion' +type MockDevice_GetPropertyDriverVersion_Call struct { + *mock.Call +} + +// GetPropertyDriverVersion is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyDriverVersion() *MockDevice_GetPropertyDriverVersion_Call { + return &MockDevice_GetPropertyDriverVersion_Call{Call: _e.mock.On("GetPropertyDriverVersion")} +} + +func (_c *MockDevice_GetPropertyDriverVersion_Call) Run(run func()) *MockDevice_GetPropertyDriverVersion_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyDriverVersion_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyDriverVersion_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyDriverVersion_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyDriverVersion_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyFirmwareMissing provides a mock function with no fields +func (_m *MockDevice) GetPropertyFirmwareMissing() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyFirmwareMissing") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyFirmwareMissing_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFirmwareMissing' +type MockDevice_GetPropertyFirmwareMissing_Call struct { + *mock.Call +} + +// GetPropertyFirmwareMissing is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyFirmwareMissing() *MockDevice_GetPropertyFirmwareMissing_Call { + return &MockDevice_GetPropertyFirmwareMissing_Call{Call: _e.mock.On("GetPropertyFirmwareMissing")} +} + +func (_c *MockDevice_GetPropertyFirmwareMissing_Call) Run(run func()) *MockDevice_GetPropertyFirmwareMissing_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyFirmwareMissing_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyFirmwareMissing_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyFirmwareMissing_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyFirmwareMissing_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyFirmwareVersion provides a mock function with no fields +func (_m *MockDevice) GetPropertyFirmwareVersion() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyFirmwareVersion") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyFirmwareVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFirmwareVersion' +type MockDevice_GetPropertyFirmwareVersion_Call struct { + *mock.Call +} + +// GetPropertyFirmwareVersion is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyFirmwareVersion() *MockDevice_GetPropertyFirmwareVersion_Call { + return &MockDevice_GetPropertyFirmwareVersion_Call{Call: _e.mock.On("GetPropertyFirmwareVersion")} +} + +func (_c *MockDevice_GetPropertyFirmwareVersion_Call) Run(run func()) *MockDevice_GetPropertyFirmwareVersion_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyFirmwareVersion_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyFirmwareVersion_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyFirmwareVersion_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyFirmwareVersion_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIP4Config provides a mock function with no fields +func (_m *MockDevice) GetPropertyIP4Config() (gonetworkmanager.IP4Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIP4Config") + } + + var r0 gonetworkmanager.IP4Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP4Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.IP4Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.IP4Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyIP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP4Config' +type MockDevice_GetPropertyIP4Config_Call struct { + *mock.Call +} + +// GetPropertyIP4Config is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyIP4Config() *MockDevice_GetPropertyIP4Config_Call { + return &MockDevice_GetPropertyIP4Config_Call{Call: _e.mock.On("GetPropertyIP4Config")} +} + +func (_c *MockDevice_GetPropertyIP4Config_Call) Run(run func()) *MockDevice_GetPropertyIP4Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyIP4Config_Call) Return(_a0 gonetworkmanager.IP4Config, _a1 error) *MockDevice_GetPropertyIP4Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyIP4Config_Call) RunAndReturn(run func() (gonetworkmanager.IP4Config, error)) *MockDevice_GetPropertyIP4Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIP6Config provides a mock function with no fields +func (_m *MockDevice) GetPropertyIP6Config() (gonetworkmanager.IP6Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIP6Config") + } + + var r0 gonetworkmanager.IP6Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP6Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.IP6Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.IP6Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyIP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP6Config' +type MockDevice_GetPropertyIP6Config_Call struct { + *mock.Call +} + +// GetPropertyIP6Config is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyIP6Config() *MockDevice_GetPropertyIP6Config_Call { + return &MockDevice_GetPropertyIP6Config_Call{Call: _e.mock.On("GetPropertyIP6Config")} +} + +func (_c *MockDevice_GetPropertyIP6Config_Call) Run(run func()) *MockDevice_GetPropertyIP6Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyIP6Config_Call) Return(_a0 gonetworkmanager.IP6Config, _a1 error) *MockDevice_GetPropertyIP6Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyIP6Config_Call) RunAndReturn(run func() (gonetworkmanager.IP6Config, error)) *MockDevice_GetPropertyIP6Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyInterface provides a mock function with no fields +func (_m *MockDevice) GetPropertyInterface() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyInterface") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyInterface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyInterface' +type MockDevice_GetPropertyInterface_Call struct { + *mock.Call +} + +// GetPropertyInterface is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyInterface() *MockDevice_GetPropertyInterface_Call { + return &MockDevice_GetPropertyInterface_Call{Call: _e.mock.On("GetPropertyInterface")} +} + +func (_c *MockDevice_GetPropertyInterface_Call) Run(run func()) *MockDevice_GetPropertyInterface_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyInterface_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyInterface_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyInterface_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyInterface_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIp4Connectivity provides a mock function with no fields +func (_m *MockDevice) GetPropertyIp4Connectivity() (gonetworkmanager.NmConnectivity, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIp4Connectivity") + } + + var r0 gonetworkmanager.NmConnectivity + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmConnectivity, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmConnectivity); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmConnectivity) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyIp4Connectivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIp4Connectivity' +type MockDevice_GetPropertyIp4Connectivity_Call struct { + *mock.Call +} + +// GetPropertyIp4Connectivity is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyIp4Connectivity() *MockDevice_GetPropertyIp4Connectivity_Call { + return &MockDevice_GetPropertyIp4Connectivity_Call{Call: _e.mock.On("GetPropertyIp4Connectivity")} +} + +func (_c *MockDevice_GetPropertyIp4Connectivity_Call) Run(run func()) *MockDevice_GetPropertyIp4Connectivity_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyIp4Connectivity_Call) Return(_a0 gonetworkmanager.NmConnectivity, _a1 error) *MockDevice_GetPropertyIp4Connectivity_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyIp4Connectivity_Call) RunAndReturn(run func() (gonetworkmanager.NmConnectivity, error)) *MockDevice_GetPropertyIp4Connectivity_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIpInterface provides a mock function with no fields +func (_m *MockDevice) GetPropertyIpInterface() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIpInterface") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyIpInterface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIpInterface' +type MockDevice_GetPropertyIpInterface_Call struct { + *mock.Call +} + +// GetPropertyIpInterface is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyIpInterface() *MockDevice_GetPropertyIpInterface_Call { + return &MockDevice_GetPropertyIpInterface_Call{Call: _e.mock.On("GetPropertyIpInterface")} +} + +func (_c *MockDevice_GetPropertyIpInterface_Call) Run(run func()) *MockDevice_GetPropertyIpInterface_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyIpInterface_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyIpInterface_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyIpInterface_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyIpInterface_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyManaged provides a mock function with no fields +func (_m *MockDevice) GetPropertyManaged() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyManaged") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyManaged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyManaged' +type MockDevice_GetPropertyManaged_Call struct { + *mock.Call +} + +// GetPropertyManaged is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyManaged() *MockDevice_GetPropertyManaged_Call { + return &MockDevice_GetPropertyManaged_Call{Call: _e.mock.On("GetPropertyManaged")} +} + +func (_c *MockDevice_GetPropertyManaged_Call) Run(run func()) *MockDevice_GetPropertyManaged_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyManaged_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyManaged_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyManaged_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyManaged_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyMtu provides a mock function with no fields +func (_m *MockDevice) GetPropertyMtu() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyMtu") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyMtu_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMtu' +type MockDevice_GetPropertyMtu_Call struct { + *mock.Call +} + +// GetPropertyMtu is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyMtu() *MockDevice_GetPropertyMtu_Call { + return &MockDevice_GetPropertyMtu_Call{Call: _e.mock.On("GetPropertyMtu")} +} + +func (_c *MockDevice_GetPropertyMtu_Call) Run(run func()) *MockDevice_GetPropertyMtu_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyMtu_Call) Return(_a0 uint32, _a1 error) *MockDevice_GetPropertyMtu_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyMtu_Call) RunAndReturn(run func() (uint32, error)) *MockDevice_GetPropertyMtu_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyNmPluginMissing provides a mock function with no fields +func (_m *MockDevice) GetPropertyNmPluginMissing() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyNmPluginMissing") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyNmPluginMissing_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNmPluginMissing' +type MockDevice_GetPropertyNmPluginMissing_Call struct { + *mock.Call +} + +// GetPropertyNmPluginMissing is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyNmPluginMissing() *MockDevice_GetPropertyNmPluginMissing_Call { + return &MockDevice_GetPropertyNmPluginMissing_Call{Call: _e.mock.On("GetPropertyNmPluginMissing")} +} + +func (_c *MockDevice_GetPropertyNmPluginMissing_Call) Run(run func()) *MockDevice_GetPropertyNmPluginMissing_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyNmPluginMissing_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyNmPluginMissing_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyNmPluginMissing_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyNmPluginMissing_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyPhysicalPortId provides a mock function with no fields +func (_m *MockDevice) GetPropertyPhysicalPortId() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyPhysicalPortId") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyPhysicalPortId_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPhysicalPortId' +type MockDevice_GetPropertyPhysicalPortId_Call struct { + *mock.Call +} + +// GetPropertyPhysicalPortId is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyPhysicalPortId() *MockDevice_GetPropertyPhysicalPortId_Call { + return &MockDevice_GetPropertyPhysicalPortId_Call{Call: _e.mock.On("GetPropertyPhysicalPortId")} +} + +func (_c *MockDevice_GetPropertyPhysicalPortId_Call) Run(run func()) *MockDevice_GetPropertyPhysicalPortId_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyPhysicalPortId_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyPhysicalPortId_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyPhysicalPortId_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyPhysicalPortId_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyReal provides a mock function with no fields +func (_m *MockDevice) GetPropertyReal() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyReal") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyReal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyReal' +type MockDevice_GetPropertyReal_Call struct { + *mock.Call +} + +// GetPropertyReal is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyReal() *MockDevice_GetPropertyReal_Call { + return &MockDevice_GetPropertyReal_Call{Call: _e.mock.On("GetPropertyReal")} +} + +func (_c *MockDevice_GetPropertyReal_Call) Run(run func()) *MockDevice_GetPropertyReal_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyReal_Call) Return(_a0 bool, _a1 error) *MockDevice_GetPropertyReal_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyReal_Call) RunAndReturn(run func() (bool, error)) *MockDevice_GetPropertyReal_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyState provides a mock function with no fields +func (_m *MockDevice) GetPropertyState() (gonetworkmanager.NmDeviceState, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyState") + } + + var r0 gonetworkmanager.NmDeviceState + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmDeviceState, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmDeviceState); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmDeviceState) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyState' +type MockDevice_GetPropertyState_Call struct { + *mock.Call +} + +// GetPropertyState is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyState() *MockDevice_GetPropertyState_Call { + return &MockDevice_GetPropertyState_Call{Call: _e.mock.On("GetPropertyState")} +} + +func (_c *MockDevice_GetPropertyState_Call) Run(run func()) *MockDevice_GetPropertyState_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyState_Call) Return(_a0 gonetworkmanager.NmDeviceState, _a1 error) *MockDevice_GetPropertyState_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyState_Call) RunAndReturn(run func() (gonetworkmanager.NmDeviceState, error)) *MockDevice_GetPropertyState_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyUdi provides a mock function with no fields +func (_m *MockDevice) GetPropertyUdi() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyUdi") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_GetPropertyUdi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyUdi' +type MockDevice_GetPropertyUdi_Call struct { + *mock.Call +} + +// GetPropertyUdi is a helper method to define mock.On call +func (_e *MockDevice_Expecter) GetPropertyUdi() *MockDevice_GetPropertyUdi_Call { + return &MockDevice_GetPropertyUdi_Call{Call: _e.mock.On("GetPropertyUdi")} +} + +func (_c *MockDevice_GetPropertyUdi_Call) Run(run func()) *MockDevice_GetPropertyUdi_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_GetPropertyUdi_Call) Return(_a0 string, _a1 error) *MockDevice_GetPropertyUdi_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_GetPropertyUdi_Call) RunAndReturn(run func() (string, error)) *MockDevice_GetPropertyUdi_Call { + _c.Call.Return(run) + return _c +} + +// MarshalJSON provides a mock function with no fields +func (_m *MockDevice) MarshalJSON() ([]byte, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MarshalJSON") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func() ([]byte, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []byte); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDevice_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON' +type MockDevice_MarshalJSON_Call struct { + *mock.Call +} + +// MarshalJSON is a helper method to define mock.On call +func (_e *MockDevice_Expecter) MarshalJSON() *MockDevice_MarshalJSON_Call { + return &MockDevice_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")} +} + +func (_c *MockDevice_MarshalJSON_Call) Run(run func()) *MockDevice_MarshalJSON_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDevice_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockDevice_MarshalJSON_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDevice_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockDevice_MarshalJSON_Call { + _c.Call.Return(run) + return _c +} + +// Reapply provides a mock function with given fields: connection, versionId, flags +func (_m *MockDevice) Reapply(connection gonetworkmanager.Connection, versionId uint64, flags uint32) error { + ret := _m.Called(connection, versionId, flags) + + if len(ret) == 0 { + panic("no return value specified for Reapply") + } + + var r0 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, uint64, uint32) error); ok { + r0 = rf(connection, versionId, flags) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDevice_Reapply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reapply' +type MockDevice_Reapply_Call struct { + *mock.Call +} + +// Reapply is a helper method to define mock.On call +// - connection gonetworkmanager.Connection +// - versionId uint64 +// - flags uint32 +func (_e *MockDevice_Expecter) Reapply(connection interface{}, versionId interface{}, flags interface{}) *MockDevice_Reapply_Call { + return &MockDevice_Reapply_Call{Call: _e.mock.On("Reapply", connection, versionId, flags)} +} + +func (_c *MockDevice_Reapply_Call) Run(run func(connection gonetworkmanager.Connection, versionId uint64, flags uint32)) *MockDevice_Reapply_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.Connection), args[1].(uint64), args[2].(uint32)) + }) + return _c +} + +func (_c *MockDevice_Reapply_Call) Return(_a0 error) *MockDevice_Reapply_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDevice_Reapply_Call) RunAndReturn(run func(gonetworkmanager.Connection, uint64, uint32) error) *MockDevice_Reapply_Call { + _c.Call.Return(run) + return _c +} + +// SetPropertyAutoConnect provides a mock function with given fields: _a0 +func (_m *MockDevice) SetPropertyAutoConnect(_a0 bool) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for SetPropertyAutoConnect") + } + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDevice_SetPropertyAutoConnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyAutoConnect' +type MockDevice_SetPropertyAutoConnect_Call struct { + *mock.Call +} + +// SetPropertyAutoConnect is a helper method to define mock.On call +// - _a0 bool +func (_e *MockDevice_Expecter) SetPropertyAutoConnect(_a0 interface{}) *MockDevice_SetPropertyAutoConnect_Call { + return &MockDevice_SetPropertyAutoConnect_Call{Call: _e.mock.On("SetPropertyAutoConnect", _a0)} +} + +func (_c *MockDevice_SetPropertyAutoConnect_Call) Run(run func(_a0 bool)) *MockDevice_SetPropertyAutoConnect_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool)) + }) + return _c +} + +func (_c *MockDevice_SetPropertyAutoConnect_Call) Return(_a0 error) *MockDevice_SetPropertyAutoConnect_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDevice_SetPropertyAutoConnect_Call) RunAndReturn(run func(bool) error) *MockDevice_SetPropertyAutoConnect_Call { + _c.Call.Return(run) + return _c +} + +// SetPropertyManaged provides a mock function with given fields: _a0 +func (_m *MockDevice) SetPropertyManaged(_a0 bool) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for SetPropertyManaged") + } + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDevice_SetPropertyManaged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyManaged' +type MockDevice_SetPropertyManaged_Call struct { + *mock.Call +} + +// SetPropertyManaged is a helper method to define mock.On call +// - _a0 bool +func (_e *MockDevice_Expecter) SetPropertyManaged(_a0 interface{}) *MockDevice_SetPropertyManaged_Call { + return &MockDevice_SetPropertyManaged_Call{Call: _e.mock.On("SetPropertyManaged", _a0)} +} + +func (_c *MockDevice_SetPropertyManaged_Call) Run(run func(_a0 bool)) *MockDevice_SetPropertyManaged_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool)) + }) + return _c +} + +func (_c *MockDevice_SetPropertyManaged_Call) Return(_a0 error) *MockDevice_SetPropertyManaged_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDevice_SetPropertyManaged_Call) RunAndReturn(run func(bool) error) *MockDevice_SetPropertyManaged_Call { + _c.Call.Return(run) + return _c +} + +// SubscribeState provides a mock function with given fields: receiver, exit +func (_m *MockDevice) SubscribeState(receiver chan gonetworkmanager.DeviceStateChange, exit chan struct{}) error { + ret := _m.Called(receiver, exit) + + if len(ret) == 0 { + panic("no return value specified for SubscribeState") + } + + var r0 error + if rf, ok := ret.Get(0).(func(chan gonetworkmanager.DeviceStateChange, chan struct{}) error); ok { + r0 = rf(receiver, exit) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDevice_SubscribeState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubscribeState' +type MockDevice_SubscribeState_Call struct { + *mock.Call +} + +// SubscribeState is a helper method to define mock.On call +// - receiver chan gonetworkmanager.DeviceStateChange +// - exit chan struct{} +func (_e *MockDevice_Expecter) SubscribeState(receiver interface{}, exit interface{}) *MockDevice_SubscribeState_Call { + return &MockDevice_SubscribeState_Call{Call: _e.mock.On("SubscribeState", receiver, exit)} +} + +func (_c *MockDevice_SubscribeState_Call) Run(run func(receiver chan gonetworkmanager.DeviceStateChange, exit chan struct{})) *MockDevice_SubscribeState_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(chan gonetworkmanager.DeviceStateChange), args[1].(chan struct{})) + }) + return _c +} + +func (_c *MockDevice_SubscribeState_Call) Return(err error) *MockDevice_SubscribeState_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockDevice_SubscribeState_Call) RunAndReturn(run func(chan gonetworkmanager.DeviceStateChange, chan struct{}) error) *MockDevice_SubscribeState_Call { + _c.Call.Return(run) + return _c +} + +// NewMockDevice creates a new instance of MockDevice. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockDevice(t interface { + mock.TestingT + Cleanup(func()) +}) *MockDevice { + mock := &MockDevice{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_DeviceWireless.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_DeviceWireless.go new file mode 100644 index 0000000..d2d3f2d --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_DeviceWireless.go @@ -0,0 +1,2241 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package gonetworkmanager + +import ( + gonetworkmanager "github.com/Wifx/gonetworkmanager/v2" + dbus "github.com/godbus/dbus/v5" + + mock "github.com/stretchr/testify/mock" +) + +// MockDeviceWireless is an autogenerated mock type for the DeviceWireless type +type MockDeviceWireless struct { + mock.Mock +} + +type MockDeviceWireless_Expecter struct { + mock *mock.Mock +} + +func (_m *MockDeviceWireless) EXPECT() *MockDeviceWireless_Expecter { + return &MockDeviceWireless_Expecter{mock: &_m.Mock} +} + +// Delete provides a mock function with no fields +func (_m *MockDeviceWireless) Delete() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDeviceWireless_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type MockDeviceWireless_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) Delete() *MockDeviceWireless_Delete_Call { + return &MockDeviceWireless_Delete_Call{Call: _e.mock.On("Delete")} +} + +func (_c *MockDeviceWireless_Delete_Call) Run(run func()) *MockDeviceWireless_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_Delete_Call) Return(_a0 error) *MockDeviceWireless_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDeviceWireless_Delete_Call) RunAndReturn(run func() error) *MockDeviceWireless_Delete_Call { + _c.Call.Return(run) + return _c +} + +// Disconnect provides a mock function with no fields +func (_m *MockDeviceWireless) Disconnect() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Disconnect") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDeviceWireless_Disconnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Disconnect' +type MockDeviceWireless_Disconnect_Call struct { + *mock.Call +} + +// Disconnect is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) Disconnect() *MockDeviceWireless_Disconnect_Call { + return &MockDeviceWireless_Disconnect_Call{Call: _e.mock.On("Disconnect")} +} + +func (_c *MockDeviceWireless_Disconnect_Call) Run(run func()) *MockDeviceWireless_Disconnect_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_Disconnect_Call) Return(_a0 error) *MockDeviceWireless_Disconnect_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDeviceWireless_Disconnect_Call) RunAndReturn(run func() error) *MockDeviceWireless_Disconnect_Call { + _c.Call.Return(run) + return _c +} + +// GetAccessPoints provides a mock function with no fields +func (_m *MockDeviceWireless) GetAccessPoints() ([]gonetworkmanager.AccessPoint, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetAccessPoints") + } + + var r0 []gonetworkmanager.AccessPoint + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.AccessPoint, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.AccessPoint); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.AccessPoint) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetAccessPoints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccessPoints' +type MockDeviceWireless_GetAccessPoints_Call struct { + *mock.Call +} + +// GetAccessPoints is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetAccessPoints() *MockDeviceWireless_GetAccessPoints_Call { + return &MockDeviceWireless_GetAccessPoints_Call{Call: _e.mock.On("GetAccessPoints")} +} + +func (_c *MockDeviceWireless_GetAccessPoints_Call) Run(run func()) *MockDeviceWireless_GetAccessPoints_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetAccessPoints_Call) Return(_a0 []gonetworkmanager.AccessPoint, _a1 error) *MockDeviceWireless_GetAccessPoints_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetAccessPoints_Call) RunAndReturn(run func() ([]gonetworkmanager.AccessPoint, error)) *MockDeviceWireless_GetAccessPoints_Call { + _c.Call.Return(run) + return _c +} + +// GetAllAccessPoints provides a mock function with no fields +func (_m *MockDeviceWireless) GetAllAccessPoints() ([]gonetworkmanager.AccessPoint, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetAllAccessPoints") + } + + var r0 []gonetworkmanager.AccessPoint + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.AccessPoint, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.AccessPoint); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.AccessPoint) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetAllAccessPoints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllAccessPoints' +type MockDeviceWireless_GetAllAccessPoints_Call struct { + *mock.Call +} + +// GetAllAccessPoints is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetAllAccessPoints() *MockDeviceWireless_GetAllAccessPoints_Call { + return &MockDeviceWireless_GetAllAccessPoints_Call{Call: _e.mock.On("GetAllAccessPoints")} +} + +func (_c *MockDeviceWireless_GetAllAccessPoints_Call) Run(run func()) *MockDeviceWireless_GetAllAccessPoints_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetAllAccessPoints_Call) Return(_a0 []gonetworkmanager.AccessPoint, _a1 error) *MockDeviceWireless_GetAllAccessPoints_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetAllAccessPoints_Call) RunAndReturn(run func() ([]gonetworkmanager.AccessPoint, error)) *MockDeviceWireless_GetAllAccessPoints_Call { + _c.Call.Return(run) + return _c +} + +// GetPath provides a mock function with no fields +func (_m *MockDeviceWireless) GetPath() dbus.ObjectPath { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPath") + } + + var r0 dbus.ObjectPath + if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(dbus.ObjectPath) + } + + return r0 +} + +// MockDeviceWireless_GetPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPath' +type MockDeviceWireless_GetPath_Call struct { + *mock.Call +} + +// GetPath is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPath() *MockDeviceWireless_GetPath_Call { + return &MockDeviceWireless_GetPath_Call{Call: _e.mock.On("GetPath")} +} + +func (_c *MockDeviceWireless_GetPath_Call) Run(run func()) *MockDeviceWireless_GetPath_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPath_Call) Return(_a0 dbus.ObjectPath) *MockDeviceWireless_GetPath_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDeviceWireless_GetPath_Call) RunAndReturn(run func() dbus.ObjectPath) *MockDeviceWireless_GetPath_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyAccessPoints provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyAccessPoints() ([]gonetworkmanager.AccessPoint, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyAccessPoints") + } + + var r0 []gonetworkmanager.AccessPoint + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.AccessPoint, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.AccessPoint); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.AccessPoint) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyAccessPoints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAccessPoints' +type MockDeviceWireless_GetPropertyAccessPoints_Call struct { + *mock.Call +} + +// GetPropertyAccessPoints is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyAccessPoints() *MockDeviceWireless_GetPropertyAccessPoints_Call { + return &MockDeviceWireless_GetPropertyAccessPoints_Call{Call: _e.mock.On("GetPropertyAccessPoints")} +} + +func (_c *MockDeviceWireless_GetPropertyAccessPoints_Call) Run(run func()) *MockDeviceWireless_GetPropertyAccessPoints_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyAccessPoints_Call) Return(_a0 []gonetworkmanager.AccessPoint, _a1 error) *MockDeviceWireless_GetPropertyAccessPoints_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyAccessPoints_Call) RunAndReturn(run func() ([]gonetworkmanager.AccessPoint, error)) *MockDeviceWireless_GetPropertyAccessPoints_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyActiveAccessPoint provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyActiveAccessPoint() (gonetworkmanager.AccessPoint, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyActiveAccessPoint") + } + + var r0 gonetworkmanager.AccessPoint + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.AccessPoint, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.AccessPoint); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.AccessPoint) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyActiveAccessPoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActiveAccessPoint' +type MockDeviceWireless_GetPropertyActiveAccessPoint_Call struct { + *mock.Call +} + +// GetPropertyActiveAccessPoint is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyActiveAccessPoint() *MockDeviceWireless_GetPropertyActiveAccessPoint_Call { + return &MockDeviceWireless_GetPropertyActiveAccessPoint_Call{Call: _e.mock.On("GetPropertyActiveAccessPoint")} +} + +func (_c *MockDeviceWireless_GetPropertyActiveAccessPoint_Call) Run(run func()) *MockDeviceWireless_GetPropertyActiveAccessPoint_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyActiveAccessPoint_Call) Return(_a0 gonetworkmanager.AccessPoint, _a1 error) *MockDeviceWireless_GetPropertyActiveAccessPoint_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyActiveAccessPoint_Call) RunAndReturn(run func() (gonetworkmanager.AccessPoint, error)) *MockDeviceWireless_GetPropertyActiveAccessPoint_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyActiveConnection provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyActiveConnection() (gonetworkmanager.ActiveConnection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyActiveConnection") + } + + var r0 gonetworkmanager.ActiveConnection + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.ActiveConnection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.ActiveConnection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ActiveConnection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyActiveConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActiveConnection' +type MockDeviceWireless_GetPropertyActiveConnection_Call struct { + *mock.Call +} + +// GetPropertyActiveConnection is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyActiveConnection() *MockDeviceWireless_GetPropertyActiveConnection_Call { + return &MockDeviceWireless_GetPropertyActiveConnection_Call{Call: _e.mock.On("GetPropertyActiveConnection")} +} + +func (_c *MockDeviceWireless_GetPropertyActiveConnection_Call) Run(run func()) *MockDeviceWireless_GetPropertyActiveConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyActiveConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockDeviceWireless_GetPropertyActiveConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyActiveConnection_Call) RunAndReturn(run func() (gonetworkmanager.ActiveConnection, error)) *MockDeviceWireless_GetPropertyActiveConnection_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyAutoConnect provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyAutoConnect() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyAutoConnect") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyAutoConnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAutoConnect' +type MockDeviceWireless_GetPropertyAutoConnect_Call struct { + *mock.Call +} + +// GetPropertyAutoConnect is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyAutoConnect() *MockDeviceWireless_GetPropertyAutoConnect_Call { + return &MockDeviceWireless_GetPropertyAutoConnect_Call{Call: _e.mock.On("GetPropertyAutoConnect")} +} + +func (_c *MockDeviceWireless_GetPropertyAutoConnect_Call) Run(run func()) *MockDeviceWireless_GetPropertyAutoConnect_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyAutoConnect_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyAutoConnect_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyAutoConnect_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyAutoConnect_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyAvailableConnections provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyAvailableConnections() ([]gonetworkmanager.Connection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyAvailableConnections") + } + + var r0 []gonetworkmanager.Connection + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Connection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.Connection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.Connection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyAvailableConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAvailableConnections' +type MockDeviceWireless_GetPropertyAvailableConnections_Call struct { + *mock.Call +} + +// GetPropertyAvailableConnections is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyAvailableConnections() *MockDeviceWireless_GetPropertyAvailableConnections_Call { + return &MockDeviceWireless_GetPropertyAvailableConnections_Call{Call: _e.mock.On("GetPropertyAvailableConnections")} +} + +func (_c *MockDeviceWireless_GetPropertyAvailableConnections_Call) Run(run func()) *MockDeviceWireless_GetPropertyAvailableConnections_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyAvailableConnections_Call) Return(_a0 []gonetworkmanager.Connection, _a1 error) *MockDeviceWireless_GetPropertyAvailableConnections_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyAvailableConnections_Call) RunAndReturn(run func() ([]gonetworkmanager.Connection, error)) *MockDeviceWireless_GetPropertyAvailableConnections_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyBitrate provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyBitrate() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyBitrate") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyBitrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyBitrate' +type MockDeviceWireless_GetPropertyBitrate_Call struct { + *mock.Call +} + +// GetPropertyBitrate is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyBitrate() *MockDeviceWireless_GetPropertyBitrate_Call { + return &MockDeviceWireless_GetPropertyBitrate_Call{Call: _e.mock.On("GetPropertyBitrate")} +} + +func (_c *MockDeviceWireless_GetPropertyBitrate_Call) Run(run func()) *MockDeviceWireless_GetPropertyBitrate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyBitrate_Call) Return(_a0 uint32, _a1 error) *MockDeviceWireless_GetPropertyBitrate_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyBitrate_Call) RunAndReturn(run func() (uint32, error)) *MockDeviceWireless_GetPropertyBitrate_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDHCP4Config provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyDHCP4Config() (gonetworkmanager.DHCP4Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDHCP4Config") + } + + var r0 gonetworkmanager.DHCP4Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP4Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP4Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.DHCP4Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyDHCP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP4Config' +type MockDeviceWireless_GetPropertyDHCP4Config_Call struct { + *mock.Call +} + +// GetPropertyDHCP4Config is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyDHCP4Config() *MockDeviceWireless_GetPropertyDHCP4Config_Call { + return &MockDeviceWireless_GetPropertyDHCP4Config_Call{Call: _e.mock.On("GetPropertyDHCP4Config")} +} + +func (_c *MockDeviceWireless_GetPropertyDHCP4Config_Call) Run(run func()) *MockDeviceWireless_GetPropertyDHCP4Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDHCP4Config_Call) Return(_a0 gonetworkmanager.DHCP4Config, _a1 error) *MockDeviceWireless_GetPropertyDHCP4Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDHCP4Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP4Config, error)) *MockDeviceWireless_GetPropertyDHCP4Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDHCP6Config provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyDHCP6Config() (gonetworkmanager.DHCP6Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDHCP6Config") + } + + var r0 gonetworkmanager.DHCP6Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.DHCP6Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.DHCP6Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.DHCP6Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyDHCP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDHCP6Config' +type MockDeviceWireless_GetPropertyDHCP6Config_Call struct { + *mock.Call +} + +// GetPropertyDHCP6Config is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyDHCP6Config() *MockDeviceWireless_GetPropertyDHCP6Config_Call { + return &MockDeviceWireless_GetPropertyDHCP6Config_Call{Call: _e.mock.On("GetPropertyDHCP6Config")} +} + +func (_c *MockDeviceWireless_GetPropertyDHCP6Config_Call) Run(run func()) *MockDeviceWireless_GetPropertyDHCP6Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDHCP6Config_Call) Return(_a0 gonetworkmanager.DHCP6Config, _a1 error) *MockDeviceWireless_GetPropertyDHCP6Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDHCP6Config_Call) RunAndReturn(run func() (gonetworkmanager.DHCP6Config, error)) *MockDeviceWireless_GetPropertyDHCP6Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDeviceType provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyDeviceType() (gonetworkmanager.NmDeviceType, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDeviceType") + } + + var r0 gonetworkmanager.NmDeviceType + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmDeviceType, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmDeviceType); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmDeviceType) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyDeviceType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDeviceType' +type MockDeviceWireless_GetPropertyDeviceType_Call struct { + *mock.Call +} + +// GetPropertyDeviceType is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyDeviceType() *MockDeviceWireless_GetPropertyDeviceType_Call { + return &MockDeviceWireless_GetPropertyDeviceType_Call{Call: _e.mock.On("GetPropertyDeviceType")} +} + +func (_c *MockDeviceWireless_GetPropertyDeviceType_Call) Run(run func()) *MockDeviceWireless_GetPropertyDeviceType_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDeviceType_Call) Return(_a0 gonetworkmanager.NmDeviceType, _a1 error) *MockDeviceWireless_GetPropertyDeviceType_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDeviceType_Call) RunAndReturn(run func() (gonetworkmanager.NmDeviceType, error)) *MockDeviceWireless_GetPropertyDeviceType_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDriver provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyDriver() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDriver") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyDriver_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDriver' +type MockDeviceWireless_GetPropertyDriver_Call struct { + *mock.Call +} + +// GetPropertyDriver is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyDriver() *MockDeviceWireless_GetPropertyDriver_Call { + return &MockDeviceWireless_GetPropertyDriver_Call{Call: _e.mock.On("GetPropertyDriver")} +} + +func (_c *MockDeviceWireless_GetPropertyDriver_Call) Run(run func()) *MockDeviceWireless_GetPropertyDriver_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDriver_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyDriver_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDriver_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyDriver_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDriverVersion provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyDriverVersion() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDriverVersion") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyDriverVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDriverVersion' +type MockDeviceWireless_GetPropertyDriverVersion_Call struct { + *mock.Call +} + +// GetPropertyDriverVersion is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyDriverVersion() *MockDeviceWireless_GetPropertyDriverVersion_Call { + return &MockDeviceWireless_GetPropertyDriverVersion_Call{Call: _e.mock.On("GetPropertyDriverVersion")} +} + +func (_c *MockDeviceWireless_GetPropertyDriverVersion_Call) Run(run func()) *MockDeviceWireless_GetPropertyDriverVersion_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDriverVersion_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyDriverVersion_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyDriverVersion_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyDriverVersion_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyFirmwareMissing provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyFirmwareMissing() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyFirmwareMissing") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyFirmwareMissing_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFirmwareMissing' +type MockDeviceWireless_GetPropertyFirmwareMissing_Call struct { + *mock.Call +} + +// GetPropertyFirmwareMissing is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyFirmwareMissing() *MockDeviceWireless_GetPropertyFirmwareMissing_Call { + return &MockDeviceWireless_GetPropertyFirmwareMissing_Call{Call: _e.mock.On("GetPropertyFirmwareMissing")} +} + +func (_c *MockDeviceWireless_GetPropertyFirmwareMissing_Call) Run(run func()) *MockDeviceWireless_GetPropertyFirmwareMissing_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyFirmwareMissing_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyFirmwareMissing_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyFirmwareMissing_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyFirmwareMissing_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyFirmwareVersion provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyFirmwareVersion() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyFirmwareVersion") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyFirmwareVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyFirmwareVersion' +type MockDeviceWireless_GetPropertyFirmwareVersion_Call struct { + *mock.Call +} + +// GetPropertyFirmwareVersion is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyFirmwareVersion() *MockDeviceWireless_GetPropertyFirmwareVersion_Call { + return &MockDeviceWireless_GetPropertyFirmwareVersion_Call{Call: _e.mock.On("GetPropertyFirmwareVersion")} +} + +func (_c *MockDeviceWireless_GetPropertyFirmwareVersion_Call) Run(run func()) *MockDeviceWireless_GetPropertyFirmwareVersion_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyFirmwareVersion_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyFirmwareVersion_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyFirmwareVersion_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyFirmwareVersion_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyHwAddress provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyHwAddress() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyHwAddress") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyHwAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyHwAddress' +type MockDeviceWireless_GetPropertyHwAddress_Call struct { + *mock.Call +} + +// GetPropertyHwAddress is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyHwAddress() *MockDeviceWireless_GetPropertyHwAddress_Call { + return &MockDeviceWireless_GetPropertyHwAddress_Call{Call: _e.mock.On("GetPropertyHwAddress")} +} + +func (_c *MockDeviceWireless_GetPropertyHwAddress_Call) Run(run func()) *MockDeviceWireless_GetPropertyHwAddress_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyHwAddress_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyHwAddress_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyHwAddress_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyHwAddress_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIP4Config provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyIP4Config() (gonetworkmanager.IP4Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIP4Config") + } + + var r0 gonetworkmanager.IP4Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP4Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.IP4Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.IP4Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyIP4Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP4Config' +type MockDeviceWireless_GetPropertyIP4Config_Call struct { + *mock.Call +} + +// GetPropertyIP4Config is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyIP4Config() *MockDeviceWireless_GetPropertyIP4Config_Call { + return &MockDeviceWireless_GetPropertyIP4Config_Call{Call: _e.mock.On("GetPropertyIP4Config")} +} + +func (_c *MockDeviceWireless_GetPropertyIP4Config_Call) Run(run func()) *MockDeviceWireless_GetPropertyIP4Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyIP4Config_Call) Return(_a0 gonetworkmanager.IP4Config, _a1 error) *MockDeviceWireless_GetPropertyIP4Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyIP4Config_Call) RunAndReturn(run func() (gonetworkmanager.IP4Config, error)) *MockDeviceWireless_GetPropertyIP4Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIP6Config provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyIP6Config() (gonetworkmanager.IP6Config, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIP6Config") + } + + var r0 gonetworkmanager.IP6Config + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.IP6Config, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.IP6Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.IP6Config) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyIP6Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIP6Config' +type MockDeviceWireless_GetPropertyIP6Config_Call struct { + *mock.Call +} + +// GetPropertyIP6Config is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyIP6Config() *MockDeviceWireless_GetPropertyIP6Config_Call { + return &MockDeviceWireless_GetPropertyIP6Config_Call{Call: _e.mock.On("GetPropertyIP6Config")} +} + +func (_c *MockDeviceWireless_GetPropertyIP6Config_Call) Run(run func()) *MockDeviceWireless_GetPropertyIP6Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyIP6Config_Call) Return(_a0 gonetworkmanager.IP6Config, _a1 error) *MockDeviceWireless_GetPropertyIP6Config_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyIP6Config_Call) RunAndReturn(run func() (gonetworkmanager.IP6Config, error)) *MockDeviceWireless_GetPropertyIP6Config_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyInterface provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyInterface() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyInterface") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyInterface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyInterface' +type MockDeviceWireless_GetPropertyInterface_Call struct { + *mock.Call +} + +// GetPropertyInterface is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyInterface() *MockDeviceWireless_GetPropertyInterface_Call { + return &MockDeviceWireless_GetPropertyInterface_Call{Call: _e.mock.On("GetPropertyInterface")} +} + +func (_c *MockDeviceWireless_GetPropertyInterface_Call) Run(run func()) *MockDeviceWireless_GetPropertyInterface_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyInterface_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyInterface_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyInterface_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyInterface_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIp4Connectivity provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyIp4Connectivity() (gonetworkmanager.NmConnectivity, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIp4Connectivity") + } + + var r0 gonetworkmanager.NmConnectivity + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmConnectivity, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmConnectivity); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmConnectivity) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyIp4Connectivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIp4Connectivity' +type MockDeviceWireless_GetPropertyIp4Connectivity_Call struct { + *mock.Call +} + +// GetPropertyIp4Connectivity is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyIp4Connectivity() *MockDeviceWireless_GetPropertyIp4Connectivity_Call { + return &MockDeviceWireless_GetPropertyIp4Connectivity_Call{Call: _e.mock.On("GetPropertyIp4Connectivity")} +} + +func (_c *MockDeviceWireless_GetPropertyIp4Connectivity_Call) Run(run func()) *MockDeviceWireless_GetPropertyIp4Connectivity_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyIp4Connectivity_Call) Return(_a0 gonetworkmanager.NmConnectivity, _a1 error) *MockDeviceWireless_GetPropertyIp4Connectivity_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyIp4Connectivity_Call) RunAndReturn(run func() (gonetworkmanager.NmConnectivity, error)) *MockDeviceWireless_GetPropertyIp4Connectivity_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyIpInterface provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyIpInterface() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyIpInterface") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyIpInterface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyIpInterface' +type MockDeviceWireless_GetPropertyIpInterface_Call struct { + *mock.Call +} + +// GetPropertyIpInterface is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyIpInterface() *MockDeviceWireless_GetPropertyIpInterface_Call { + return &MockDeviceWireless_GetPropertyIpInterface_Call{Call: _e.mock.On("GetPropertyIpInterface")} +} + +func (_c *MockDeviceWireless_GetPropertyIpInterface_Call) Run(run func()) *MockDeviceWireless_GetPropertyIpInterface_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyIpInterface_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyIpInterface_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyIpInterface_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyIpInterface_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyLastScan provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyLastScan() (int64, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyLastScan") + } + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func() (int64, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() int64); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyLastScan_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyLastScan' +type MockDeviceWireless_GetPropertyLastScan_Call struct { + *mock.Call +} + +// GetPropertyLastScan is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyLastScan() *MockDeviceWireless_GetPropertyLastScan_Call { + return &MockDeviceWireless_GetPropertyLastScan_Call{Call: _e.mock.On("GetPropertyLastScan")} +} + +func (_c *MockDeviceWireless_GetPropertyLastScan_Call) Run(run func()) *MockDeviceWireless_GetPropertyLastScan_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyLastScan_Call) Return(_a0 int64, _a1 error) *MockDeviceWireless_GetPropertyLastScan_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyLastScan_Call) RunAndReturn(run func() (int64, error)) *MockDeviceWireless_GetPropertyLastScan_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyManaged provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyManaged() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyManaged") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyManaged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyManaged' +type MockDeviceWireless_GetPropertyManaged_Call struct { + *mock.Call +} + +// GetPropertyManaged is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyManaged() *MockDeviceWireless_GetPropertyManaged_Call { + return &MockDeviceWireless_GetPropertyManaged_Call{Call: _e.mock.On("GetPropertyManaged")} +} + +func (_c *MockDeviceWireless_GetPropertyManaged_Call) Run(run func()) *MockDeviceWireless_GetPropertyManaged_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyManaged_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyManaged_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyManaged_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyManaged_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyMode provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyMode() (gonetworkmanager.Nm80211Mode, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyMode") + } + + var r0 gonetworkmanager.Nm80211Mode + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.Nm80211Mode, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.Nm80211Mode); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.Nm80211Mode) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMode' +type MockDeviceWireless_GetPropertyMode_Call struct { + *mock.Call +} + +// GetPropertyMode is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyMode() *MockDeviceWireless_GetPropertyMode_Call { + return &MockDeviceWireless_GetPropertyMode_Call{Call: _e.mock.On("GetPropertyMode")} +} + +func (_c *MockDeviceWireless_GetPropertyMode_Call) Run(run func()) *MockDeviceWireless_GetPropertyMode_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyMode_Call) Return(_a0 gonetworkmanager.Nm80211Mode, _a1 error) *MockDeviceWireless_GetPropertyMode_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyMode_Call) RunAndReturn(run func() (gonetworkmanager.Nm80211Mode, error)) *MockDeviceWireless_GetPropertyMode_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyMtu provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyMtu() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyMtu") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyMtu_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMtu' +type MockDeviceWireless_GetPropertyMtu_Call struct { + *mock.Call +} + +// GetPropertyMtu is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyMtu() *MockDeviceWireless_GetPropertyMtu_Call { + return &MockDeviceWireless_GetPropertyMtu_Call{Call: _e.mock.On("GetPropertyMtu")} +} + +func (_c *MockDeviceWireless_GetPropertyMtu_Call) Run(run func()) *MockDeviceWireless_GetPropertyMtu_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyMtu_Call) Return(_a0 uint32, _a1 error) *MockDeviceWireless_GetPropertyMtu_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyMtu_Call) RunAndReturn(run func() (uint32, error)) *MockDeviceWireless_GetPropertyMtu_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyNmPluginMissing provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyNmPluginMissing() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyNmPluginMissing") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyNmPluginMissing_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNmPluginMissing' +type MockDeviceWireless_GetPropertyNmPluginMissing_Call struct { + *mock.Call +} + +// GetPropertyNmPluginMissing is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyNmPluginMissing() *MockDeviceWireless_GetPropertyNmPluginMissing_Call { + return &MockDeviceWireless_GetPropertyNmPluginMissing_Call{Call: _e.mock.On("GetPropertyNmPluginMissing")} +} + +func (_c *MockDeviceWireless_GetPropertyNmPluginMissing_Call) Run(run func()) *MockDeviceWireless_GetPropertyNmPluginMissing_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyNmPluginMissing_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyNmPluginMissing_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyNmPluginMissing_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyNmPluginMissing_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyPermHwAddress provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyPermHwAddress() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyPermHwAddress") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyPermHwAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPermHwAddress' +type MockDeviceWireless_GetPropertyPermHwAddress_Call struct { + *mock.Call +} + +// GetPropertyPermHwAddress is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyPermHwAddress() *MockDeviceWireless_GetPropertyPermHwAddress_Call { + return &MockDeviceWireless_GetPropertyPermHwAddress_Call{Call: _e.mock.On("GetPropertyPermHwAddress")} +} + +func (_c *MockDeviceWireless_GetPropertyPermHwAddress_Call) Run(run func()) *MockDeviceWireless_GetPropertyPermHwAddress_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyPermHwAddress_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyPermHwAddress_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyPermHwAddress_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyPermHwAddress_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyPhysicalPortId provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyPhysicalPortId() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyPhysicalPortId") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyPhysicalPortId_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPhysicalPortId' +type MockDeviceWireless_GetPropertyPhysicalPortId_Call struct { + *mock.Call +} + +// GetPropertyPhysicalPortId is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyPhysicalPortId() *MockDeviceWireless_GetPropertyPhysicalPortId_Call { + return &MockDeviceWireless_GetPropertyPhysicalPortId_Call{Call: _e.mock.On("GetPropertyPhysicalPortId")} +} + +func (_c *MockDeviceWireless_GetPropertyPhysicalPortId_Call) Run(run func()) *MockDeviceWireless_GetPropertyPhysicalPortId_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyPhysicalPortId_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyPhysicalPortId_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyPhysicalPortId_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyPhysicalPortId_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyReal provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyReal() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyReal") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyReal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyReal' +type MockDeviceWireless_GetPropertyReal_Call struct { + *mock.Call +} + +// GetPropertyReal is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyReal() *MockDeviceWireless_GetPropertyReal_Call { + return &MockDeviceWireless_GetPropertyReal_Call{Call: _e.mock.On("GetPropertyReal")} +} + +func (_c *MockDeviceWireless_GetPropertyReal_Call) Run(run func()) *MockDeviceWireless_GetPropertyReal_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyReal_Call) Return(_a0 bool, _a1 error) *MockDeviceWireless_GetPropertyReal_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyReal_Call) RunAndReturn(run func() (bool, error)) *MockDeviceWireless_GetPropertyReal_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyState provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyState() (gonetworkmanager.NmDeviceState, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyState") + } + + var r0 gonetworkmanager.NmDeviceState + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmDeviceState, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmDeviceState); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmDeviceState) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyState' +type MockDeviceWireless_GetPropertyState_Call struct { + *mock.Call +} + +// GetPropertyState is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyState() *MockDeviceWireless_GetPropertyState_Call { + return &MockDeviceWireless_GetPropertyState_Call{Call: _e.mock.On("GetPropertyState")} +} + +func (_c *MockDeviceWireless_GetPropertyState_Call) Run(run func()) *MockDeviceWireless_GetPropertyState_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyState_Call) Return(_a0 gonetworkmanager.NmDeviceState, _a1 error) *MockDeviceWireless_GetPropertyState_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyState_Call) RunAndReturn(run func() (gonetworkmanager.NmDeviceState, error)) *MockDeviceWireless_GetPropertyState_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyUdi provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyUdi() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyUdi") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyUdi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyUdi' +type MockDeviceWireless_GetPropertyUdi_Call struct { + *mock.Call +} + +// GetPropertyUdi is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyUdi() *MockDeviceWireless_GetPropertyUdi_Call { + return &MockDeviceWireless_GetPropertyUdi_Call{Call: _e.mock.On("GetPropertyUdi")} +} + +func (_c *MockDeviceWireless_GetPropertyUdi_Call) Run(run func()) *MockDeviceWireless_GetPropertyUdi_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyUdi_Call) Return(_a0 string, _a1 error) *MockDeviceWireless_GetPropertyUdi_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyUdi_Call) RunAndReturn(run func() (string, error)) *MockDeviceWireless_GetPropertyUdi_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyWirelessCapabilities provides a mock function with no fields +func (_m *MockDeviceWireless) GetPropertyWirelessCapabilities() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyWirelessCapabilities") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_GetPropertyWirelessCapabilities_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWirelessCapabilities' +type MockDeviceWireless_GetPropertyWirelessCapabilities_Call struct { + *mock.Call +} + +// GetPropertyWirelessCapabilities is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) GetPropertyWirelessCapabilities() *MockDeviceWireless_GetPropertyWirelessCapabilities_Call { + return &MockDeviceWireless_GetPropertyWirelessCapabilities_Call{Call: _e.mock.On("GetPropertyWirelessCapabilities")} +} + +func (_c *MockDeviceWireless_GetPropertyWirelessCapabilities_Call) Run(run func()) *MockDeviceWireless_GetPropertyWirelessCapabilities_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyWirelessCapabilities_Call) Return(_a0 uint32, _a1 error) *MockDeviceWireless_GetPropertyWirelessCapabilities_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_GetPropertyWirelessCapabilities_Call) RunAndReturn(run func() (uint32, error)) *MockDeviceWireless_GetPropertyWirelessCapabilities_Call { + _c.Call.Return(run) + return _c +} + +// MarshalJSON provides a mock function with no fields +func (_m *MockDeviceWireless) MarshalJSON() ([]byte, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MarshalJSON") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func() ([]byte, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []byte); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockDeviceWireless_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON' +type MockDeviceWireless_MarshalJSON_Call struct { + *mock.Call +} + +// MarshalJSON is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) MarshalJSON() *MockDeviceWireless_MarshalJSON_Call { + return &MockDeviceWireless_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")} +} + +func (_c *MockDeviceWireless_MarshalJSON_Call) Run(run func()) *MockDeviceWireless_MarshalJSON_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockDeviceWireless_MarshalJSON_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockDeviceWireless_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockDeviceWireless_MarshalJSON_Call { + _c.Call.Return(run) + return _c +} + +// Reapply provides a mock function with given fields: connection, versionId, flags +func (_m *MockDeviceWireless) Reapply(connection gonetworkmanager.Connection, versionId uint64, flags uint32) error { + ret := _m.Called(connection, versionId, flags) + + if len(ret) == 0 { + panic("no return value specified for Reapply") + } + + var r0 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, uint64, uint32) error); ok { + r0 = rf(connection, versionId, flags) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDeviceWireless_Reapply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reapply' +type MockDeviceWireless_Reapply_Call struct { + *mock.Call +} + +// Reapply is a helper method to define mock.On call +// - connection gonetworkmanager.Connection +// - versionId uint64 +// - flags uint32 +func (_e *MockDeviceWireless_Expecter) Reapply(connection interface{}, versionId interface{}, flags interface{}) *MockDeviceWireless_Reapply_Call { + return &MockDeviceWireless_Reapply_Call{Call: _e.mock.On("Reapply", connection, versionId, flags)} +} + +func (_c *MockDeviceWireless_Reapply_Call) Run(run func(connection gonetworkmanager.Connection, versionId uint64, flags uint32)) *MockDeviceWireless_Reapply_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.Connection), args[1].(uint64), args[2].(uint32)) + }) + return _c +} + +func (_c *MockDeviceWireless_Reapply_Call) Return(_a0 error) *MockDeviceWireless_Reapply_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDeviceWireless_Reapply_Call) RunAndReturn(run func(gonetworkmanager.Connection, uint64, uint32) error) *MockDeviceWireless_Reapply_Call { + _c.Call.Return(run) + return _c +} + +// RequestScan provides a mock function with no fields +func (_m *MockDeviceWireless) RequestScan() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for RequestScan") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDeviceWireless_RequestScan_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestScan' +type MockDeviceWireless_RequestScan_Call struct { + *mock.Call +} + +// RequestScan is a helper method to define mock.On call +func (_e *MockDeviceWireless_Expecter) RequestScan() *MockDeviceWireless_RequestScan_Call { + return &MockDeviceWireless_RequestScan_Call{Call: _e.mock.On("RequestScan")} +} + +func (_c *MockDeviceWireless_RequestScan_Call) Run(run func()) *MockDeviceWireless_RequestScan_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDeviceWireless_RequestScan_Call) Return(_a0 error) *MockDeviceWireless_RequestScan_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDeviceWireless_RequestScan_Call) RunAndReturn(run func() error) *MockDeviceWireless_RequestScan_Call { + _c.Call.Return(run) + return _c +} + +// SetPropertyAutoConnect provides a mock function with given fields: _a0 +func (_m *MockDeviceWireless) SetPropertyAutoConnect(_a0 bool) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for SetPropertyAutoConnect") + } + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDeviceWireless_SetPropertyAutoConnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyAutoConnect' +type MockDeviceWireless_SetPropertyAutoConnect_Call struct { + *mock.Call +} + +// SetPropertyAutoConnect is a helper method to define mock.On call +// - _a0 bool +func (_e *MockDeviceWireless_Expecter) SetPropertyAutoConnect(_a0 interface{}) *MockDeviceWireless_SetPropertyAutoConnect_Call { + return &MockDeviceWireless_SetPropertyAutoConnect_Call{Call: _e.mock.On("SetPropertyAutoConnect", _a0)} +} + +func (_c *MockDeviceWireless_SetPropertyAutoConnect_Call) Run(run func(_a0 bool)) *MockDeviceWireless_SetPropertyAutoConnect_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool)) + }) + return _c +} + +func (_c *MockDeviceWireless_SetPropertyAutoConnect_Call) Return(_a0 error) *MockDeviceWireless_SetPropertyAutoConnect_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDeviceWireless_SetPropertyAutoConnect_Call) RunAndReturn(run func(bool) error) *MockDeviceWireless_SetPropertyAutoConnect_Call { + _c.Call.Return(run) + return _c +} + +// SetPropertyManaged provides a mock function with given fields: _a0 +func (_m *MockDeviceWireless) SetPropertyManaged(_a0 bool) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for SetPropertyManaged") + } + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDeviceWireless_SetPropertyManaged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyManaged' +type MockDeviceWireless_SetPropertyManaged_Call struct { + *mock.Call +} + +// SetPropertyManaged is a helper method to define mock.On call +// - _a0 bool +func (_e *MockDeviceWireless_Expecter) SetPropertyManaged(_a0 interface{}) *MockDeviceWireless_SetPropertyManaged_Call { + return &MockDeviceWireless_SetPropertyManaged_Call{Call: _e.mock.On("SetPropertyManaged", _a0)} +} + +func (_c *MockDeviceWireless_SetPropertyManaged_Call) Run(run func(_a0 bool)) *MockDeviceWireless_SetPropertyManaged_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool)) + }) + return _c +} + +func (_c *MockDeviceWireless_SetPropertyManaged_Call) Return(_a0 error) *MockDeviceWireless_SetPropertyManaged_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockDeviceWireless_SetPropertyManaged_Call) RunAndReturn(run func(bool) error) *MockDeviceWireless_SetPropertyManaged_Call { + _c.Call.Return(run) + return _c +} + +// SubscribeState provides a mock function with given fields: receiver, exit +func (_m *MockDeviceWireless) SubscribeState(receiver chan gonetworkmanager.DeviceStateChange, exit chan struct{}) error { + ret := _m.Called(receiver, exit) + + if len(ret) == 0 { + panic("no return value specified for SubscribeState") + } + + var r0 error + if rf, ok := ret.Get(0).(func(chan gonetworkmanager.DeviceStateChange, chan struct{}) error); ok { + r0 = rf(receiver, exit) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockDeviceWireless_SubscribeState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubscribeState' +type MockDeviceWireless_SubscribeState_Call struct { + *mock.Call +} + +// SubscribeState is a helper method to define mock.On call +// - receiver chan gonetworkmanager.DeviceStateChange +// - exit chan struct{} +func (_e *MockDeviceWireless_Expecter) SubscribeState(receiver interface{}, exit interface{}) *MockDeviceWireless_SubscribeState_Call { + return &MockDeviceWireless_SubscribeState_Call{Call: _e.mock.On("SubscribeState", receiver, exit)} +} + +func (_c *MockDeviceWireless_SubscribeState_Call) Run(run func(receiver chan gonetworkmanager.DeviceStateChange, exit chan struct{})) *MockDeviceWireless_SubscribeState_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(chan gonetworkmanager.DeviceStateChange), args[1].(chan struct{})) + }) + return _c +} + +func (_c *MockDeviceWireless_SubscribeState_Call) Return(err error) *MockDeviceWireless_SubscribeState_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockDeviceWireless_SubscribeState_Call) RunAndReturn(run func(chan gonetworkmanager.DeviceStateChange, chan struct{}) error) *MockDeviceWireless_SubscribeState_Call { + _c.Call.Return(run) + return _c +} + +// NewMockDeviceWireless creates a new instance of MockDeviceWireless. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockDeviceWireless(t interface { + mock.TestingT + Cleanup(func()) +}) *MockDeviceWireless { + mock := &MockDeviceWireless{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_IP4Config.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_IP4Config.go new file mode 100644 index 0000000..235be86 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_IP4Config.go @@ -0,0 +1,772 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package gonetworkmanager + +import ( + gonetworkmanager "github.com/Wifx/gonetworkmanager/v2" + mock "github.com/stretchr/testify/mock" +) + +// MockIP4Config is an autogenerated mock type for the IP4Config type +type MockIP4Config struct { + mock.Mock +} + +type MockIP4Config_Expecter struct { + mock *mock.Mock +} + +func (_m *MockIP4Config) EXPECT() *MockIP4Config_Expecter { + return &MockIP4Config_Expecter{mock: &_m.Mock} +} + +// GetPropertyAddressData provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyAddressData() ([]gonetworkmanager.IP4AddressData, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyAddressData") + } + + var r0 []gonetworkmanager.IP4AddressData + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4AddressData, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4AddressData); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.IP4AddressData) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyAddressData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAddressData' +type MockIP4Config_GetPropertyAddressData_Call struct { + *mock.Call +} + +// GetPropertyAddressData is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyAddressData() *MockIP4Config_GetPropertyAddressData_Call { + return &MockIP4Config_GetPropertyAddressData_Call{Call: _e.mock.On("GetPropertyAddressData")} +} + +func (_c *MockIP4Config_GetPropertyAddressData_Call) Run(run func()) *MockIP4Config_GetPropertyAddressData_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyAddressData_Call) Return(_a0 []gonetworkmanager.IP4AddressData, _a1 error) *MockIP4Config_GetPropertyAddressData_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyAddressData_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4AddressData, error)) *MockIP4Config_GetPropertyAddressData_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyAddresses provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyAddresses() ([]gonetworkmanager.IP4Address, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyAddresses") + } + + var r0 []gonetworkmanager.IP4Address + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4Address, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4Address); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.IP4Address) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyAddresses_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAddresses' +type MockIP4Config_GetPropertyAddresses_Call struct { + *mock.Call +} + +// GetPropertyAddresses is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyAddresses() *MockIP4Config_GetPropertyAddresses_Call { + return &MockIP4Config_GetPropertyAddresses_Call{Call: _e.mock.On("GetPropertyAddresses")} +} + +func (_c *MockIP4Config_GetPropertyAddresses_Call) Run(run func()) *MockIP4Config_GetPropertyAddresses_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyAddresses_Call) Return(_a0 []gonetworkmanager.IP4Address, _a1 error) *MockIP4Config_GetPropertyAddresses_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyAddresses_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4Address, error)) *MockIP4Config_GetPropertyAddresses_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDnsOptions provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyDnsOptions() ([]string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDnsOptions") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func() ([]string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyDnsOptions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDnsOptions' +type MockIP4Config_GetPropertyDnsOptions_Call struct { + *mock.Call +} + +// GetPropertyDnsOptions is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyDnsOptions() *MockIP4Config_GetPropertyDnsOptions_Call { + return &MockIP4Config_GetPropertyDnsOptions_Call{Call: _e.mock.On("GetPropertyDnsOptions")} +} + +func (_c *MockIP4Config_GetPropertyDnsOptions_Call) Run(run func()) *MockIP4Config_GetPropertyDnsOptions_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyDnsOptions_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyDnsOptions_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyDnsOptions_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyDnsOptions_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDnsPriority provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyDnsPriority() (uint32, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDnsPriority") + } + + var r0 uint32 + var r1 error + if rf, ok := ret.Get(0).(func() (uint32, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() uint32); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(uint32) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyDnsPriority_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDnsPriority' +type MockIP4Config_GetPropertyDnsPriority_Call struct { + *mock.Call +} + +// GetPropertyDnsPriority is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyDnsPriority() *MockIP4Config_GetPropertyDnsPriority_Call { + return &MockIP4Config_GetPropertyDnsPriority_Call{Call: _e.mock.On("GetPropertyDnsPriority")} +} + +func (_c *MockIP4Config_GetPropertyDnsPriority_Call) Run(run func()) *MockIP4Config_GetPropertyDnsPriority_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyDnsPriority_Call) Return(_a0 uint32, _a1 error) *MockIP4Config_GetPropertyDnsPriority_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyDnsPriority_Call) RunAndReturn(run func() (uint32, error)) *MockIP4Config_GetPropertyDnsPriority_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDomains provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyDomains() ([]string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDomains") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func() ([]string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyDomains_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDomains' +type MockIP4Config_GetPropertyDomains_Call struct { + *mock.Call +} + +// GetPropertyDomains is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyDomains() *MockIP4Config_GetPropertyDomains_Call { + return &MockIP4Config_GetPropertyDomains_Call{Call: _e.mock.On("GetPropertyDomains")} +} + +func (_c *MockIP4Config_GetPropertyDomains_Call) Run(run func()) *MockIP4Config_GetPropertyDomains_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyDomains_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyDomains_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyDomains_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyDomains_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyGateway provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyGateway() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyGateway") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyGateway_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyGateway' +type MockIP4Config_GetPropertyGateway_Call struct { + *mock.Call +} + +// GetPropertyGateway is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyGateway() *MockIP4Config_GetPropertyGateway_Call { + return &MockIP4Config_GetPropertyGateway_Call{Call: _e.mock.On("GetPropertyGateway")} +} + +func (_c *MockIP4Config_GetPropertyGateway_Call) Run(run func()) *MockIP4Config_GetPropertyGateway_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyGateway_Call) Return(_a0 string, _a1 error) *MockIP4Config_GetPropertyGateway_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyGateway_Call) RunAndReturn(run func() (string, error)) *MockIP4Config_GetPropertyGateway_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyNameserverData provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyNameserverData() ([]gonetworkmanager.IP4NameserverData, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyNameserverData") + } + + var r0 []gonetworkmanager.IP4NameserverData + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4NameserverData, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4NameserverData); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.IP4NameserverData) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyNameserverData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNameserverData' +type MockIP4Config_GetPropertyNameserverData_Call struct { + *mock.Call +} + +// GetPropertyNameserverData is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyNameserverData() *MockIP4Config_GetPropertyNameserverData_Call { + return &MockIP4Config_GetPropertyNameserverData_Call{Call: _e.mock.On("GetPropertyNameserverData")} +} + +func (_c *MockIP4Config_GetPropertyNameserverData_Call) Run(run func()) *MockIP4Config_GetPropertyNameserverData_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyNameserverData_Call) Return(_a0 []gonetworkmanager.IP4NameserverData, _a1 error) *MockIP4Config_GetPropertyNameserverData_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyNameserverData_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4NameserverData, error)) *MockIP4Config_GetPropertyNameserverData_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyNameservers provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyNameservers() ([]string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyNameservers") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func() ([]string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyNameservers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNameservers' +type MockIP4Config_GetPropertyNameservers_Call struct { + *mock.Call +} + +// GetPropertyNameservers is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyNameservers() *MockIP4Config_GetPropertyNameservers_Call { + return &MockIP4Config_GetPropertyNameservers_Call{Call: _e.mock.On("GetPropertyNameservers")} +} + +func (_c *MockIP4Config_GetPropertyNameservers_Call) Run(run func()) *MockIP4Config_GetPropertyNameservers_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyNameservers_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyNameservers_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyNameservers_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyNameservers_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyRouteData provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyRouteData() ([]gonetworkmanager.IP4RouteData, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyRouteData") + } + + var r0 []gonetworkmanager.IP4RouteData + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4RouteData, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4RouteData); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.IP4RouteData) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyRouteData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyRouteData' +type MockIP4Config_GetPropertyRouteData_Call struct { + *mock.Call +} + +// GetPropertyRouteData is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyRouteData() *MockIP4Config_GetPropertyRouteData_Call { + return &MockIP4Config_GetPropertyRouteData_Call{Call: _e.mock.On("GetPropertyRouteData")} +} + +func (_c *MockIP4Config_GetPropertyRouteData_Call) Run(run func()) *MockIP4Config_GetPropertyRouteData_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyRouteData_Call) Return(_a0 []gonetworkmanager.IP4RouteData, _a1 error) *MockIP4Config_GetPropertyRouteData_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyRouteData_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4RouteData, error)) *MockIP4Config_GetPropertyRouteData_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyRoutes provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyRoutes() ([]gonetworkmanager.IP4Route, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyRoutes") + } + + var r0 []gonetworkmanager.IP4Route + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.IP4Route, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.IP4Route); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.IP4Route) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyRoutes' +type MockIP4Config_GetPropertyRoutes_Call struct { + *mock.Call +} + +// GetPropertyRoutes is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyRoutes() *MockIP4Config_GetPropertyRoutes_Call { + return &MockIP4Config_GetPropertyRoutes_Call{Call: _e.mock.On("GetPropertyRoutes")} +} + +func (_c *MockIP4Config_GetPropertyRoutes_Call) Run(run func()) *MockIP4Config_GetPropertyRoutes_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyRoutes_Call) Return(_a0 []gonetworkmanager.IP4Route, _a1 error) *MockIP4Config_GetPropertyRoutes_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyRoutes_Call) RunAndReturn(run func() ([]gonetworkmanager.IP4Route, error)) *MockIP4Config_GetPropertyRoutes_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertySearches provides a mock function with no fields +func (_m *MockIP4Config) GetPropertySearches() ([]string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertySearches") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func() ([]string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertySearches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertySearches' +type MockIP4Config_GetPropertySearches_Call struct { + *mock.Call +} + +// GetPropertySearches is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertySearches() *MockIP4Config_GetPropertySearches_Call { + return &MockIP4Config_GetPropertySearches_Call{Call: _e.mock.On("GetPropertySearches")} +} + +func (_c *MockIP4Config_GetPropertySearches_Call) Run(run func()) *MockIP4Config_GetPropertySearches_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertySearches_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertySearches_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertySearches_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertySearches_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyWinsServerData provides a mock function with no fields +func (_m *MockIP4Config) GetPropertyWinsServerData() ([]string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyWinsServerData") + } + + var r0 []string + var r1 error + if rf, ok := ret.Get(0).(func() ([]string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_GetPropertyWinsServerData_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWinsServerData' +type MockIP4Config_GetPropertyWinsServerData_Call struct { + *mock.Call +} + +// GetPropertyWinsServerData is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) GetPropertyWinsServerData() *MockIP4Config_GetPropertyWinsServerData_Call { + return &MockIP4Config_GetPropertyWinsServerData_Call{Call: _e.mock.On("GetPropertyWinsServerData")} +} + +func (_c *MockIP4Config_GetPropertyWinsServerData_Call) Run(run func()) *MockIP4Config_GetPropertyWinsServerData_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_GetPropertyWinsServerData_Call) Return(_a0 []string, _a1 error) *MockIP4Config_GetPropertyWinsServerData_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_GetPropertyWinsServerData_Call) RunAndReturn(run func() ([]string, error)) *MockIP4Config_GetPropertyWinsServerData_Call { + _c.Call.Return(run) + return _c +} + +// MarshalJSON provides a mock function with no fields +func (_m *MockIP4Config) MarshalJSON() ([]byte, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MarshalJSON") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func() ([]byte, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []byte); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockIP4Config_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON' +type MockIP4Config_MarshalJSON_Call struct { + *mock.Call +} + +// MarshalJSON is a helper method to define mock.On call +func (_e *MockIP4Config_Expecter) MarshalJSON() *MockIP4Config_MarshalJSON_Call { + return &MockIP4Config_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")} +} + +func (_c *MockIP4Config_MarshalJSON_Call) Run(run func()) *MockIP4Config_MarshalJSON_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockIP4Config_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockIP4Config_MarshalJSON_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockIP4Config_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockIP4Config_MarshalJSON_Call { + _c.Call.Return(run) + return _c +} + +// NewMockIP4Config creates a new instance of MockIP4Config. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockIP4Config(t interface { + mock.TestingT + Cleanup(func()) +}) *MockIP4Config { + mock := &MockIP4Config{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_NetworkManager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_NetworkManager.go new file mode 100644 index 0000000..e74ffb0 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_NetworkManager.go @@ -0,0 +1,2349 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package gonetworkmanager + +import ( + gonetworkmanager "github.com/Wifx/gonetworkmanager/v2" + dbus "github.com/godbus/dbus/v5" + + mock "github.com/stretchr/testify/mock" +) + +// MockNetworkManager is an autogenerated mock type for the NetworkManager type +type MockNetworkManager struct { + mock.Mock +} + +type MockNetworkManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockNetworkManager) EXPECT() *MockNetworkManager_Expecter { + return &MockNetworkManager_Expecter{mock: &_m.Mock} +} + +// ActivateConnection provides a mock function with given fields: connection, device, specificObject +func (_m *MockNetworkManager) ActivateConnection(connection gonetworkmanager.Connection, device gonetworkmanager.Device, specificObject *dbus.Object) (gonetworkmanager.ActiveConnection, error) { + ret := _m.Called(connection, device, specificObject) + + if len(ret) == 0 { + panic("no return value specified for ActivateConnection") + } + + var r0 gonetworkmanager.ActiveConnection + var r1 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, gonetworkmanager.Device, *dbus.Object) (gonetworkmanager.ActiveConnection, error)); ok { + return rf(connection, device, specificObject) + } + if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, gonetworkmanager.Device, *dbus.Object) gonetworkmanager.ActiveConnection); ok { + r0 = rf(connection, device, specificObject) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ActiveConnection) + } + } + + if rf, ok := ret.Get(1).(func(gonetworkmanager.Connection, gonetworkmanager.Device, *dbus.Object) error); ok { + r1 = rf(connection, device, specificObject) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_ActivateConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ActivateConnection' +type MockNetworkManager_ActivateConnection_Call struct { + *mock.Call +} + +// ActivateConnection is a helper method to define mock.On call +// - connection gonetworkmanager.Connection +// - device gonetworkmanager.Device +// - specificObject *dbus.Object +func (_e *MockNetworkManager_Expecter) ActivateConnection(connection interface{}, device interface{}, specificObject interface{}) *MockNetworkManager_ActivateConnection_Call { + return &MockNetworkManager_ActivateConnection_Call{Call: _e.mock.On("ActivateConnection", connection, device, specificObject)} +} + +func (_c *MockNetworkManager_ActivateConnection_Call) Run(run func(connection gonetworkmanager.Connection, device gonetworkmanager.Device, specificObject *dbus.Object)) *MockNetworkManager_ActivateConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.Connection), args[1].(gonetworkmanager.Device), args[2].(*dbus.Object)) + }) + return _c +} + +func (_c *MockNetworkManager_ActivateConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_ActivateConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_ActivateConnection_Call) RunAndReturn(run func(gonetworkmanager.Connection, gonetworkmanager.Device, *dbus.Object) (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_ActivateConnection_Call { + _c.Call.Return(run) + return _c +} + +// ActivateWirelessConnection provides a mock function with given fields: connection, device, accessPoint +func (_m *MockNetworkManager) ActivateWirelessConnection(connection gonetworkmanager.Connection, device gonetworkmanager.Device, accessPoint gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error) { + ret := _m.Called(connection, device, accessPoint) + + if len(ret) == 0 { + panic("no return value specified for ActivateWirelessConnection") + } + + var r0 gonetworkmanager.ActiveConnection + var r1 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, gonetworkmanager.Device, gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error)); ok { + return rf(connection, device, accessPoint) + } + if rf, ok := ret.Get(0).(func(gonetworkmanager.Connection, gonetworkmanager.Device, gonetworkmanager.AccessPoint) gonetworkmanager.ActiveConnection); ok { + r0 = rf(connection, device, accessPoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ActiveConnection) + } + } + + if rf, ok := ret.Get(1).(func(gonetworkmanager.Connection, gonetworkmanager.Device, gonetworkmanager.AccessPoint) error); ok { + r1 = rf(connection, device, accessPoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_ActivateWirelessConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ActivateWirelessConnection' +type MockNetworkManager_ActivateWirelessConnection_Call struct { + *mock.Call +} + +// ActivateWirelessConnection is a helper method to define mock.On call +// - connection gonetworkmanager.Connection +// - device gonetworkmanager.Device +// - accessPoint gonetworkmanager.AccessPoint +func (_e *MockNetworkManager_Expecter) ActivateWirelessConnection(connection interface{}, device interface{}, accessPoint interface{}) *MockNetworkManager_ActivateWirelessConnection_Call { + return &MockNetworkManager_ActivateWirelessConnection_Call{Call: _e.mock.On("ActivateWirelessConnection", connection, device, accessPoint)} +} + +func (_c *MockNetworkManager_ActivateWirelessConnection_Call) Run(run func(connection gonetworkmanager.Connection, device gonetworkmanager.Device, accessPoint gonetworkmanager.AccessPoint)) *MockNetworkManager_ActivateWirelessConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.Connection), args[1].(gonetworkmanager.Device), args[2].(gonetworkmanager.AccessPoint)) + }) + return _c +} + +func (_c *MockNetworkManager_ActivateWirelessConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_ActivateWirelessConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_ActivateWirelessConnection_Call) RunAndReturn(run func(gonetworkmanager.Connection, gonetworkmanager.Device, gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_ActivateWirelessConnection_Call { + _c.Call.Return(run) + return _c +} + +// AddAndActivateConnection provides a mock function with given fields: connection, device +func (_m *MockNetworkManager) AddAndActivateConnection(connection map[string]map[string]interface{}, device gonetworkmanager.Device) (gonetworkmanager.ActiveConnection, error) { + ret := _m.Called(connection, device) + + if len(ret) == 0 { + panic("no return value specified for AddAndActivateConnection") + } + + var r0 gonetworkmanager.ActiveConnection + var r1 error + if rf, ok := ret.Get(0).(func(map[string]map[string]interface{}, gonetworkmanager.Device) (gonetworkmanager.ActiveConnection, error)); ok { + return rf(connection, device) + } + if rf, ok := ret.Get(0).(func(map[string]map[string]interface{}, gonetworkmanager.Device) gonetworkmanager.ActiveConnection); ok { + r0 = rf(connection, device) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ActiveConnection) + } + } + + if rf, ok := ret.Get(1).(func(map[string]map[string]interface{}, gonetworkmanager.Device) error); ok { + r1 = rf(connection, device) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_AddAndActivateConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAndActivateConnection' +type MockNetworkManager_AddAndActivateConnection_Call struct { + *mock.Call +} + +// AddAndActivateConnection is a helper method to define mock.On call +// - connection map[string]map[string]interface{} +// - device gonetworkmanager.Device +func (_e *MockNetworkManager_Expecter) AddAndActivateConnection(connection interface{}, device interface{}) *MockNetworkManager_AddAndActivateConnection_Call { + return &MockNetworkManager_AddAndActivateConnection_Call{Call: _e.mock.On("AddAndActivateConnection", connection, device)} +} + +func (_c *MockNetworkManager_AddAndActivateConnection_Call) Run(run func(connection map[string]map[string]interface{}, device gonetworkmanager.Device)) *MockNetworkManager_AddAndActivateConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(map[string]map[string]interface{}), args[1].(gonetworkmanager.Device)) + }) + return _c +} + +func (_c *MockNetworkManager_AddAndActivateConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_AddAndActivateConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_AddAndActivateConnection_Call) RunAndReturn(run func(map[string]map[string]interface{}, gonetworkmanager.Device) (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_AddAndActivateConnection_Call { + _c.Call.Return(run) + return _c +} + +// AddAndActivateWirelessConnection provides a mock function with given fields: connection, device, accessPoint +func (_m *MockNetworkManager) AddAndActivateWirelessConnection(connection map[string]map[string]interface{}, device gonetworkmanager.Device, accessPoint gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error) { + ret := _m.Called(connection, device, accessPoint) + + if len(ret) == 0 { + panic("no return value specified for AddAndActivateWirelessConnection") + } + + var r0 gonetworkmanager.ActiveConnection + var r1 error + if rf, ok := ret.Get(0).(func(map[string]map[string]interface{}, gonetworkmanager.Device, gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error)); ok { + return rf(connection, device, accessPoint) + } + if rf, ok := ret.Get(0).(func(map[string]map[string]interface{}, gonetworkmanager.Device, gonetworkmanager.AccessPoint) gonetworkmanager.ActiveConnection); ok { + r0 = rf(connection, device, accessPoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ActiveConnection) + } + } + + if rf, ok := ret.Get(1).(func(map[string]map[string]interface{}, gonetworkmanager.Device, gonetworkmanager.AccessPoint) error); ok { + r1 = rf(connection, device, accessPoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_AddAndActivateWirelessConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddAndActivateWirelessConnection' +type MockNetworkManager_AddAndActivateWirelessConnection_Call struct { + *mock.Call +} + +// AddAndActivateWirelessConnection is a helper method to define mock.On call +// - connection map[string]map[string]interface{} +// - device gonetworkmanager.Device +// - accessPoint gonetworkmanager.AccessPoint +func (_e *MockNetworkManager_Expecter) AddAndActivateWirelessConnection(connection interface{}, device interface{}, accessPoint interface{}) *MockNetworkManager_AddAndActivateWirelessConnection_Call { + return &MockNetworkManager_AddAndActivateWirelessConnection_Call{Call: _e.mock.On("AddAndActivateWirelessConnection", connection, device, accessPoint)} +} + +func (_c *MockNetworkManager_AddAndActivateWirelessConnection_Call) Run(run func(connection map[string]map[string]interface{}, device gonetworkmanager.Device, accessPoint gonetworkmanager.AccessPoint)) *MockNetworkManager_AddAndActivateWirelessConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(map[string]map[string]interface{}), args[1].(gonetworkmanager.Device), args[2].(gonetworkmanager.AccessPoint)) + }) + return _c +} + +func (_c *MockNetworkManager_AddAndActivateWirelessConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_AddAndActivateWirelessConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_AddAndActivateWirelessConnection_Call) RunAndReturn(run func(map[string]map[string]interface{}, gonetworkmanager.Device, gonetworkmanager.AccessPoint) (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_AddAndActivateWirelessConnection_Call { + _c.Call.Return(run) + return _c +} + +// CheckConnectivity provides a mock function with no fields +func (_m *MockNetworkManager) CheckConnectivity() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for CheckConnectivity") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockNetworkManager_CheckConnectivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckConnectivity' +type MockNetworkManager_CheckConnectivity_Call struct { + *mock.Call +} + +// CheckConnectivity is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) CheckConnectivity() *MockNetworkManager_CheckConnectivity_Call { + return &MockNetworkManager_CheckConnectivity_Call{Call: _e.mock.On("CheckConnectivity")} +} + +func (_c *MockNetworkManager_CheckConnectivity_Call) Run(run func()) *MockNetworkManager_CheckConnectivity_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_CheckConnectivity_Call) Return(_a0 error) *MockNetworkManager_CheckConnectivity_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockNetworkManager_CheckConnectivity_Call) RunAndReturn(run func() error) *MockNetworkManager_CheckConnectivity_Call { + _c.Call.Return(run) + return _c +} + +// CheckpointAdjustRollbackTimeout provides a mock function with given fields: checkpoint, addTimeout +func (_m *MockNetworkManager) CheckpointAdjustRollbackTimeout(checkpoint gonetworkmanager.Checkpoint, addTimeout uint32) error { + ret := _m.Called(checkpoint, addTimeout) + + if len(ret) == 0 { + panic("no return value specified for CheckpointAdjustRollbackTimeout") + } + + var r0 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.Checkpoint, uint32) error); ok { + r0 = rf(checkpoint, addTimeout) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockNetworkManager_CheckpointAdjustRollbackTimeout_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckpointAdjustRollbackTimeout' +type MockNetworkManager_CheckpointAdjustRollbackTimeout_Call struct { + *mock.Call +} + +// CheckpointAdjustRollbackTimeout is a helper method to define mock.On call +// - checkpoint gonetworkmanager.Checkpoint +// - addTimeout uint32 +func (_e *MockNetworkManager_Expecter) CheckpointAdjustRollbackTimeout(checkpoint interface{}, addTimeout interface{}) *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call { + return &MockNetworkManager_CheckpointAdjustRollbackTimeout_Call{Call: _e.mock.On("CheckpointAdjustRollbackTimeout", checkpoint, addTimeout)} +} + +func (_c *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call) Run(run func(checkpoint gonetworkmanager.Checkpoint, addTimeout uint32)) *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.Checkpoint), args[1].(uint32)) + }) + return _c +} + +func (_c *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call) Return(_a0 error) *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call) RunAndReturn(run func(gonetworkmanager.Checkpoint, uint32) error) *MockNetworkManager_CheckpointAdjustRollbackTimeout_Call { + _c.Call.Return(run) + return _c +} + +// CheckpointCreate provides a mock function with given fields: devices, rollbackTimeout, flags +func (_m *MockNetworkManager) CheckpointCreate(devices []gonetworkmanager.Device, rollbackTimeout uint32, flags uint32) (gonetworkmanager.Checkpoint, error) { + ret := _m.Called(devices, rollbackTimeout, flags) + + if len(ret) == 0 { + panic("no return value specified for CheckpointCreate") + } + + var r0 gonetworkmanager.Checkpoint + var r1 error + if rf, ok := ret.Get(0).(func([]gonetworkmanager.Device, uint32, uint32) (gonetworkmanager.Checkpoint, error)); ok { + return rf(devices, rollbackTimeout, flags) + } + if rf, ok := ret.Get(0).(func([]gonetworkmanager.Device, uint32, uint32) gonetworkmanager.Checkpoint); ok { + r0 = rf(devices, rollbackTimeout, flags) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.Checkpoint) + } + } + + if rf, ok := ret.Get(1).(func([]gonetworkmanager.Device, uint32, uint32) error); ok { + r1 = rf(devices, rollbackTimeout, flags) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_CheckpointCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckpointCreate' +type MockNetworkManager_CheckpointCreate_Call struct { + *mock.Call +} + +// CheckpointCreate is a helper method to define mock.On call +// - devices []gonetworkmanager.Device +// - rollbackTimeout uint32 +// - flags uint32 +func (_e *MockNetworkManager_Expecter) CheckpointCreate(devices interface{}, rollbackTimeout interface{}, flags interface{}) *MockNetworkManager_CheckpointCreate_Call { + return &MockNetworkManager_CheckpointCreate_Call{Call: _e.mock.On("CheckpointCreate", devices, rollbackTimeout, flags)} +} + +func (_c *MockNetworkManager_CheckpointCreate_Call) Run(run func(devices []gonetworkmanager.Device, rollbackTimeout uint32, flags uint32)) *MockNetworkManager_CheckpointCreate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([]gonetworkmanager.Device), args[1].(uint32), args[2].(uint32)) + }) + return _c +} + +func (_c *MockNetworkManager_CheckpointCreate_Call) Return(_a0 gonetworkmanager.Checkpoint, _a1 error) *MockNetworkManager_CheckpointCreate_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_CheckpointCreate_Call) RunAndReturn(run func([]gonetworkmanager.Device, uint32, uint32) (gonetworkmanager.Checkpoint, error)) *MockNetworkManager_CheckpointCreate_Call { + _c.Call.Return(run) + return _c +} + +// CheckpointDestroy provides a mock function with given fields: checkpoint +func (_m *MockNetworkManager) CheckpointDestroy(checkpoint gonetworkmanager.Checkpoint) error { + ret := _m.Called(checkpoint) + + if len(ret) == 0 { + panic("no return value specified for CheckpointDestroy") + } + + var r0 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.Checkpoint) error); ok { + r0 = rf(checkpoint) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockNetworkManager_CheckpointDestroy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckpointDestroy' +type MockNetworkManager_CheckpointDestroy_Call struct { + *mock.Call +} + +// CheckpointDestroy is a helper method to define mock.On call +// - checkpoint gonetworkmanager.Checkpoint +func (_e *MockNetworkManager_Expecter) CheckpointDestroy(checkpoint interface{}) *MockNetworkManager_CheckpointDestroy_Call { + return &MockNetworkManager_CheckpointDestroy_Call{Call: _e.mock.On("CheckpointDestroy", checkpoint)} +} + +func (_c *MockNetworkManager_CheckpointDestroy_Call) Run(run func(checkpoint gonetworkmanager.Checkpoint)) *MockNetworkManager_CheckpointDestroy_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.Checkpoint)) + }) + return _c +} + +func (_c *MockNetworkManager_CheckpointDestroy_Call) Return(_a0 error) *MockNetworkManager_CheckpointDestroy_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockNetworkManager_CheckpointDestroy_Call) RunAndReturn(run func(gonetworkmanager.Checkpoint) error) *MockNetworkManager_CheckpointDestroy_Call { + _c.Call.Return(run) + return _c +} + +// CheckpointRollback provides a mock function with given fields: checkpoint +func (_m *MockNetworkManager) CheckpointRollback(checkpoint gonetworkmanager.Checkpoint) (map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult, error) { + ret := _m.Called(checkpoint) + + if len(ret) == 0 { + panic("no return value specified for CheckpointRollback") + } + + var r0 map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult + var r1 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.Checkpoint) (map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult, error)); ok { + return rf(checkpoint) + } + if rf, ok := ret.Get(0).(func(gonetworkmanager.Checkpoint) map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult); ok { + r0 = rf(checkpoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult) + } + } + + if rf, ok := ret.Get(1).(func(gonetworkmanager.Checkpoint) error); ok { + r1 = rf(checkpoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_CheckpointRollback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckpointRollback' +type MockNetworkManager_CheckpointRollback_Call struct { + *mock.Call +} + +// CheckpointRollback is a helper method to define mock.On call +// - checkpoint gonetworkmanager.Checkpoint +func (_e *MockNetworkManager_Expecter) CheckpointRollback(checkpoint interface{}) *MockNetworkManager_CheckpointRollback_Call { + return &MockNetworkManager_CheckpointRollback_Call{Call: _e.mock.On("CheckpointRollback", checkpoint)} +} + +func (_c *MockNetworkManager_CheckpointRollback_Call) Run(run func(checkpoint gonetworkmanager.Checkpoint)) *MockNetworkManager_CheckpointRollback_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.Checkpoint)) + }) + return _c +} + +func (_c *MockNetworkManager_CheckpointRollback_Call) Return(result map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult, err error) *MockNetworkManager_CheckpointRollback_Call { + _c.Call.Return(result, err) + return _c +} + +func (_c *MockNetworkManager_CheckpointRollback_Call) RunAndReturn(run func(gonetworkmanager.Checkpoint) (map[dbus.ObjectPath]gonetworkmanager.NmRollbackResult, error)) *MockNetworkManager_CheckpointRollback_Call { + _c.Call.Return(run) + return _c +} + +// DeactivateConnection provides a mock function with given fields: connection +func (_m *MockNetworkManager) DeactivateConnection(connection gonetworkmanager.ActiveConnection) error { + ret := _m.Called(connection) + + if len(ret) == 0 { + panic("no return value specified for DeactivateConnection") + } + + var r0 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.ActiveConnection) error); ok { + r0 = rf(connection) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockNetworkManager_DeactivateConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeactivateConnection' +type MockNetworkManager_DeactivateConnection_Call struct { + *mock.Call +} + +// DeactivateConnection is a helper method to define mock.On call +// - connection gonetworkmanager.ActiveConnection +func (_e *MockNetworkManager_Expecter) DeactivateConnection(connection interface{}) *MockNetworkManager_DeactivateConnection_Call { + return &MockNetworkManager_DeactivateConnection_Call{Call: _e.mock.On("DeactivateConnection", connection)} +} + +func (_c *MockNetworkManager_DeactivateConnection_Call) Run(run func(connection gonetworkmanager.ActiveConnection)) *MockNetworkManager_DeactivateConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.ActiveConnection)) + }) + return _c +} + +func (_c *MockNetworkManager_DeactivateConnection_Call) Return(_a0 error) *MockNetworkManager_DeactivateConnection_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockNetworkManager_DeactivateConnection_Call) RunAndReturn(run func(gonetworkmanager.ActiveConnection) error) *MockNetworkManager_DeactivateConnection_Call { + _c.Call.Return(run) + return _c +} + +// Enable provides a mock function with given fields: enableNDisable +func (_m *MockNetworkManager) Enable(enableNDisable bool) error { + ret := _m.Called(enableNDisable) + + if len(ret) == 0 { + panic("no return value specified for Enable") + } + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(enableNDisable) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockNetworkManager_Enable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enable' +type MockNetworkManager_Enable_Call struct { + *mock.Call +} + +// Enable is a helper method to define mock.On call +// - enableNDisable bool +func (_e *MockNetworkManager_Expecter) Enable(enableNDisable interface{}) *MockNetworkManager_Enable_Call { + return &MockNetworkManager_Enable_Call{Call: _e.mock.On("Enable", enableNDisable)} +} + +func (_c *MockNetworkManager_Enable_Call) Run(run func(enableNDisable bool)) *MockNetworkManager_Enable_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool)) + }) + return _c +} + +func (_c *MockNetworkManager_Enable_Call) Return(_a0 error) *MockNetworkManager_Enable_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockNetworkManager_Enable_Call) RunAndReturn(run func(bool) error) *MockNetworkManager_Enable_Call { + _c.Call.Return(run) + return _c +} + +// GetAllDevices provides a mock function with no fields +func (_m *MockNetworkManager) GetAllDevices() ([]gonetworkmanager.Device, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetAllDevices") + } + + var r0 []gonetworkmanager.Device + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.Device) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetAllDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllDevices' +type MockNetworkManager_GetAllDevices_Call struct { + *mock.Call +} + +// GetAllDevices is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetAllDevices() *MockNetworkManager_GetAllDevices_Call { + return &MockNetworkManager_GetAllDevices_Call{Call: _e.mock.On("GetAllDevices")} +} + +func (_c *MockNetworkManager_GetAllDevices_Call) Run(run func()) *MockNetworkManager_GetAllDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetAllDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetAllDevices_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetAllDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockNetworkManager_GetAllDevices_Call { + _c.Call.Return(run) + return _c +} + +// GetDeviceByIpIface provides a mock function with given fields: interfaceId +func (_m *MockNetworkManager) GetDeviceByIpIface(interfaceId string) (gonetworkmanager.Device, error) { + ret := _m.Called(interfaceId) + + if len(ret) == 0 { + panic("no return value specified for GetDeviceByIpIface") + } + + var r0 gonetworkmanager.Device + var r1 error + if rf, ok := ret.Get(0).(func(string) (gonetworkmanager.Device, error)); ok { + return rf(interfaceId) + } + if rf, ok := ret.Get(0).(func(string) gonetworkmanager.Device); ok { + r0 = rf(interfaceId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.Device) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(interfaceId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetDeviceByIpIface_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDeviceByIpIface' +type MockNetworkManager_GetDeviceByIpIface_Call struct { + *mock.Call +} + +// GetDeviceByIpIface is a helper method to define mock.On call +// - interfaceId string +func (_e *MockNetworkManager_Expecter) GetDeviceByIpIface(interfaceId interface{}) *MockNetworkManager_GetDeviceByIpIface_Call { + return &MockNetworkManager_GetDeviceByIpIface_Call{Call: _e.mock.On("GetDeviceByIpIface", interfaceId)} +} + +func (_c *MockNetworkManager_GetDeviceByIpIface_Call) Run(run func(interfaceId string)) *MockNetworkManager_GetDeviceByIpIface_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockNetworkManager_GetDeviceByIpIface_Call) Return(_a0 gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetDeviceByIpIface_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetDeviceByIpIface_Call) RunAndReturn(run func(string) (gonetworkmanager.Device, error)) *MockNetworkManager_GetDeviceByIpIface_Call { + _c.Call.Return(run) + return _c +} + +// GetDevices provides a mock function with no fields +func (_m *MockNetworkManager) GetDevices() ([]gonetworkmanager.Device, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetDevices") + } + + var r0 []gonetworkmanager.Device + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.Device) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDevices' +type MockNetworkManager_GetDevices_Call struct { + *mock.Call +} + +// GetDevices is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetDevices() *MockNetworkManager_GetDevices_Call { + return &MockNetworkManager_GetDevices_Call{Call: _e.mock.On("GetDevices")} +} + +func (_c *MockNetworkManager_GetDevices_Call) Run(run func()) *MockNetworkManager_GetDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetDevices_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockNetworkManager_GetDevices_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyActivatingConnection provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyActivatingConnection() (gonetworkmanager.ActiveConnection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyActivatingConnection") + } + + var r0 gonetworkmanager.ActiveConnection + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.ActiveConnection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.ActiveConnection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ActiveConnection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyActivatingConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActivatingConnection' +type MockNetworkManager_GetPropertyActivatingConnection_Call struct { + *mock.Call +} + +// GetPropertyActivatingConnection is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyActivatingConnection() *MockNetworkManager_GetPropertyActivatingConnection_Call { + return &MockNetworkManager_GetPropertyActivatingConnection_Call{Call: _e.mock.On("GetPropertyActivatingConnection")} +} + +func (_c *MockNetworkManager_GetPropertyActivatingConnection_Call) Run(run func()) *MockNetworkManager_GetPropertyActivatingConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyActivatingConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_GetPropertyActivatingConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyActivatingConnection_Call) RunAndReturn(run func() (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_GetPropertyActivatingConnection_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyActiveConnections provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyActiveConnections() ([]gonetworkmanager.ActiveConnection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyActiveConnections") + } + + var r0 []gonetworkmanager.ActiveConnection + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.ActiveConnection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.ActiveConnection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.ActiveConnection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyActiveConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyActiveConnections' +type MockNetworkManager_GetPropertyActiveConnections_Call struct { + *mock.Call +} + +// GetPropertyActiveConnections is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyActiveConnections() *MockNetworkManager_GetPropertyActiveConnections_Call { + return &MockNetworkManager_GetPropertyActiveConnections_Call{Call: _e.mock.On("GetPropertyActiveConnections")} +} + +func (_c *MockNetworkManager_GetPropertyActiveConnections_Call) Run(run func()) *MockNetworkManager_GetPropertyActiveConnections_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyActiveConnections_Call) Return(_a0 []gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_GetPropertyActiveConnections_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyActiveConnections_Call) RunAndReturn(run func() ([]gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_GetPropertyActiveConnections_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyAllDevices provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyAllDevices() ([]gonetworkmanager.Device, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyAllDevices") + } + + var r0 []gonetworkmanager.Device + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.Device) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyAllDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyAllDevices' +type MockNetworkManager_GetPropertyAllDevices_Call struct { + *mock.Call +} + +// GetPropertyAllDevices is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyAllDevices() *MockNetworkManager_GetPropertyAllDevices_Call { + return &MockNetworkManager_GetPropertyAllDevices_Call{Call: _e.mock.On("GetPropertyAllDevices")} +} + +func (_c *MockNetworkManager_GetPropertyAllDevices_Call) Run(run func()) *MockNetworkManager_GetPropertyAllDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyAllDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetPropertyAllDevices_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyAllDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockNetworkManager_GetPropertyAllDevices_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyCapabilities provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyCapabilities() ([]gonetworkmanager.NmCapability, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyCapabilities") + } + + var r0 []gonetworkmanager.NmCapability + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.NmCapability, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.NmCapability); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.NmCapability) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyCapabilities_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyCapabilities' +type MockNetworkManager_GetPropertyCapabilities_Call struct { + *mock.Call +} + +// GetPropertyCapabilities is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyCapabilities() *MockNetworkManager_GetPropertyCapabilities_Call { + return &MockNetworkManager_GetPropertyCapabilities_Call{Call: _e.mock.On("GetPropertyCapabilities")} +} + +func (_c *MockNetworkManager_GetPropertyCapabilities_Call) Run(run func()) *MockNetworkManager_GetPropertyCapabilities_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyCapabilities_Call) Return(_a0 []gonetworkmanager.NmCapability, _a1 error) *MockNetworkManager_GetPropertyCapabilities_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyCapabilities_Call) RunAndReturn(run func() ([]gonetworkmanager.NmCapability, error)) *MockNetworkManager_GetPropertyCapabilities_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyCheckpoints provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyCheckpoints() ([]gonetworkmanager.Checkpoint, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyCheckpoints") + } + + var r0 []gonetworkmanager.Checkpoint + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Checkpoint, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.Checkpoint); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.Checkpoint) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyCheckpoints_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyCheckpoints' +type MockNetworkManager_GetPropertyCheckpoints_Call struct { + *mock.Call +} + +// GetPropertyCheckpoints is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyCheckpoints() *MockNetworkManager_GetPropertyCheckpoints_Call { + return &MockNetworkManager_GetPropertyCheckpoints_Call{Call: _e.mock.On("GetPropertyCheckpoints")} +} + +func (_c *MockNetworkManager_GetPropertyCheckpoints_Call) Run(run func()) *MockNetworkManager_GetPropertyCheckpoints_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyCheckpoints_Call) Return(_a0 []gonetworkmanager.Checkpoint, _a1 error) *MockNetworkManager_GetPropertyCheckpoints_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyCheckpoints_Call) RunAndReturn(run func() ([]gonetworkmanager.Checkpoint, error)) *MockNetworkManager_GetPropertyCheckpoints_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyConnectivity provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyConnectivity() (gonetworkmanager.NmConnectivity, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyConnectivity") + } + + var r0 gonetworkmanager.NmConnectivity + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmConnectivity, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmConnectivity); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmConnectivity) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyConnectivity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyConnectivity' +type MockNetworkManager_GetPropertyConnectivity_Call struct { + *mock.Call +} + +// GetPropertyConnectivity is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyConnectivity() *MockNetworkManager_GetPropertyConnectivity_Call { + return &MockNetworkManager_GetPropertyConnectivity_Call{Call: _e.mock.On("GetPropertyConnectivity")} +} + +func (_c *MockNetworkManager_GetPropertyConnectivity_Call) Run(run func()) *MockNetworkManager_GetPropertyConnectivity_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyConnectivity_Call) Return(_a0 gonetworkmanager.NmConnectivity, _a1 error) *MockNetworkManager_GetPropertyConnectivity_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyConnectivity_Call) RunAndReturn(run func() (gonetworkmanager.NmConnectivity, error)) *MockNetworkManager_GetPropertyConnectivity_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyConnectivityCheckAvailable provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyConnectivityCheckAvailable() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyConnectivityCheckAvailable") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyConnectivityCheckAvailable' +type MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call struct { + *mock.Call +} + +// GetPropertyConnectivityCheckAvailable is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyConnectivityCheckAvailable() *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call { + return &MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call{Call: _e.mock.On("GetPropertyConnectivityCheckAvailable")} +} + +func (_c *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call) Run(run func()) *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyConnectivityCheckAvailable_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyConnectivityCheckEnabled provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyConnectivityCheckEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyConnectivityCheckEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyConnectivityCheckEnabled' +type MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call struct { + *mock.Call +} + +// GetPropertyConnectivityCheckEnabled is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyConnectivityCheckEnabled() *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call { + return &MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call{Call: _e.mock.On("GetPropertyConnectivityCheckEnabled")} +} + +func (_c *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyConnectivityCheckEnabled_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyDevices provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyDevices() ([]gonetworkmanager.Device, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyDevices") + } + + var r0 []gonetworkmanager.Device + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Device, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.Device); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.Device) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyDevices' +type MockNetworkManager_GetPropertyDevices_Call struct { + *mock.Call +} + +// GetPropertyDevices is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyDevices() *MockNetworkManager_GetPropertyDevices_Call { + return &MockNetworkManager_GetPropertyDevices_Call{Call: _e.mock.On("GetPropertyDevices")} +} + +func (_c *MockNetworkManager_GetPropertyDevices_Call) Run(run func()) *MockNetworkManager_GetPropertyDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyDevices_Call) Return(_a0 []gonetworkmanager.Device, _a1 error) *MockNetworkManager_GetPropertyDevices_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyDevices_Call) RunAndReturn(run func() ([]gonetworkmanager.Device, error)) *MockNetworkManager_GetPropertyDevices_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyMetered provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyMetered() (gonetworkmanager.NmMetered, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyMetered") + } + + var r0 gonetworkmanager.NmMetered + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmMetered, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmMetered); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmMetered) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyMetered_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyMetered' +type MockNetworkManager_GetPropertyMetered_Call struct { + *mock.Call +} + +// GetPropertyMetered is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyMetered() *MockNetworkManager_GetPropertyMetered_Call { + return &MockNetworkManager_GetPropertyMetered_Call{Call: _e.mock.On("GetPropertyMetered")} +} + +func (_c *MockNetworkManager_GetPropertyMetered_Call) Run(run func()) *MockNetworkManager_GetPropertyMetered_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyMetered_Call) Return(_a0 gonetworkmanager.NmMetered, _a1 error) *MockNetworkManager_GetPropertyMetered_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyMetered_Call) RunAndReturn(run func() (gonetworkmanager.NmMetered, error)) *MockNetworkManager_GetPropertyMetered_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyNetworkingEnabled provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyNetworkingEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyNetworkingEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyNetworkingEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyNetworkingEnabled' +type MockNetworkManager_GetPropertyNetworkingEnabled_Call struct { + *mock.Call +} + +// GetPropertyNetworkingEnabled is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyNetworkingEnabled() *MockNetworkManager_GetPropertyNetworkingEnabled_Call { + return &MockNetworkManager_GetPropertyNetworkingEnabled_Call{Call: _e.mock.On("GetPropertyNetworkingEnabled")} +} + +func (_c *MockNetworkManager_GetPropertyNetworkingEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyNetworkingEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyNetworkingEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyNetworkingEnabled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyNetworkingEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyNetworkingEnabled_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyPrimaryConnection provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyPrimaryConnection() (gonetworkmanager.ActiveConnection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyPrimaryConnection") + } + + var r0 gonetworkmanager.ActiveConnection + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.ActiveConnection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.ActiveConnection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.ActiveConnection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyPrimaryConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPrimaryConnection' +type MockNetworkManager_GetPropertyPrimaryConnection_Call struct { + *mock.Call +} + +// GetPropertyPrimaryConnection is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyPrimaryConnection() *MockNetworkManager_GetPropertyPrimaryConnection_Call { + return &MockNetworkManager_GetPropertyPrimaryConnection_Call{Call: _e.mock.On("GetPropertyPrimaryConnection")} +} + +func (_c *MockNetworkManager_GetPropertyPrimaryConnection_Call) Run(run func()) *MockNetworkManager_GetPropertyPrimaryConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyPrimaryConnection_Call) Return(_a0 gonetworkmanager.ActiveConnection, _a1 error) *MockNetworkManager_GetPropertyPrimaryConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyPrimaryConnection_Call) RunAndReturn(run func() (gonetworkmanager.ActiveConnection, error)) *MockNetworkManager_GetPropertyPrimaryConnection_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyPrimaryConnectionType provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyPrimaryConnectionType() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyPrimaryConnectionType") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyPrimaryConnectionType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyPrimaryConnectionType' +type MockNetworkManager_GetPropertyPrimaryConnectionType_Call struct { + *mock.Call +} + +// GetPropertyPrimaryConnectionType is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyPrimaryConnectionType() *MockNetworkManager_GetPropertyPrimaryConnectionType_Call { + return &MockNetworkManager_GetPropertyPrimaryConnectionType_Call{Call: _e.mock.On("GetPropertyPrimaryConnectionType")} +} + +func (_c *MockNetworkManager_GetPropertyPrimaryConnectionType_Call) Run(run func()) *MockNetworkManager_GetPropertyPrimaryConnectionType_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyPrimaryConnectionType_Call) Return(_a0 string, _a1 error) *MockNetworkManager_GetPropertyPrimaryConnectionType_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyPrimaryConnectionType_Call) RunAndReturn(run func() (string, error)) *MockNetworkManager_GetPropertyPrimaryConnectionType_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyStartup provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyStartup() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyStartup") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyStartup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyStartup' +type MockNetworkManager_GetPropertyStartup_Call struct { + *mock.Call +} + +// GetPropertyStartup is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyStartup() *MockNetworkManager_GetPropertyStartup_Call { + return &MockNetworkManager_GetPropertyStartup_Call{Call: _e.mock.On("GetPropertyStartup")} +} + +func (_c *MockNetworkManager_GetPropertyStartup_Call) Run(run func()) *MockNetworkManager_GetPropertyStartup_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyStartup_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyStartup_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyStartup_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyStartup_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyState provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyState() (gonetworkmanager.NmState, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyState") + } + + var r0 gonetworkmanager.NmState + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmState, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmState); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmState) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyState' +type MockNetworkManager_GetPropertyState_Call struct { + *mock.Call +} + +// GetPropertyState is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyState() *MockNetworkManager_GetPropertyState_Call { + return &MockNetworkManager_GetPropertyState_Call{Call: _e.mock.On("GetPropertyState")} +} + +func (_c *MockNetworkManager_GetPropertyState_Call) Run(run func()) *MockNetworkManager_GetPropertyState_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyState_Call) Return(_a0 gonetworkmanager.NmState, _a1 error) *MockNetworkManager_GetPropertyState_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyState_Call) RunAndReturn(run func() (gonetworkmanager.NmState, error)) *MockNetworkManager_GetPropertyState_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyVersion provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyVersion() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyVersion") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyVersion' +type MockNetworkManager_GetPropertyVersion_Call struct { + *mock.Call +} + +// GetPropertyVersion is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyVersion() *MockNetworkManager_GetPropertyVersion_Call { + return &MockNetworkManager_GetPropertyVersion_Call{Call: _e.mock.On("GetPropertyVersion")} +} + +func (_c *MockNetworkManager_GetPropertyVersion_Call) Run(run func()) *MockNetworkManager_GetPropertyVersion_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyVersion_Call) Return(_a0 string, _a1 error) *MockNetworkManager_GetPropertyVersion_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyVersion_Call) RunAndReturn(run func() (string, error)) *MockNetworkManager_GetPropertyVersion_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyWimaxEnabled provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyWimaxEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyWimaxEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyWimaxEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWimaxEnabled' +type MockNetworkManager_GetPropertyWimaxEnabled_Call struct { + *mock.Call +} + +// GetPropertyWimaxEnabled is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyWimaxEnabled() *MockNetworkManager_GetPropertyWimaxEnabled_Call { + return &MockNetworkManager_GetPropertyWimaxEnabled_Call{Call: _e.mock.On("GetPropertyWimaxEnabled")} +} + +func (_c *MockNetworkManager_GetPropertyWimaxEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWimaxEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWimaxEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWimaxEnabled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWimaxEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWimaxEnabled_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyWimaxHardwareEnabled provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyWimaxHardwareEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyWimaxHardwareEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWimaxHardwareEnabled' +type MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call struct { + *mock.Call +} + +// GetPropertyWimaxHardwareEnabled is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyWimaxHardwareEnabled() *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call { + return &MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call{Call: _e.mock.On("GetPropertyWimaxHardwareEnabled")} +} + +func (_c *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWimaxHardwareEnabled_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyWirelessEnabled provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyWirelessEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyWirelessEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyWirelessEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWirelessEnabled' +type MockNetworkManager_GetPropertyWirelessEnabled_Call struct { + *mock.Call +} + +// GetPropertyWirelessEnabled is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyWirelessEnabled() *MockNetworkManager_GetPropertyWirelessEnabled_Call { + return &MockNetworkManager_GetPropertyWirelessEnabled_Call{Call: _e.mock.On("GetPropertyWirelessEnabled")} +} + +func (_c *MockNetworkManager_GetPropertyWirelessEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWirelessEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWirelessEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWirelessEnabled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWirelessEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWirelessEnabled_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyWirelessHardwareEnabled provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyWirelessHardwareEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyWirelessHardwareEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWirelessHardwareEnabled' +type MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call struct { + *mock.Call +} + +// GetPropertyWirelessHardwareEnabled is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyWirelessHardwareEnabled() *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call { + return &MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call{Call: _e.mock.On("GetPropertyWirelessHardwareEnabled")} +} + +func (_c *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWirelessHardwareEnabled_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyWwanEnabled provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyWwanEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyWwanEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyWwanEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWwanEnabled' +type MockNetworkManager_GetPropertyWwanEnabled_Call struct { + *mock.Call +} + +// GetPropertyWwanEnabled is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyWwanEnabled() *MockNetworkManager_GetPropertyWwanEnabled_Call { + return &MockNetworkManager_GetPropertyWwanEnabled_Call{Call: _e.mock.On("GetPropertyWwanEnabled")} +} + +func (_c *MockNetworkManager_GetPropertyWwanEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWwanEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWwanEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWwanEnabled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWwanEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWwanEnabled_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyWwanHardwareEnabled provides a mock function with no fields +func (_m *MockNetworkManager) GetPropertyWwanHardwareEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyWwanHardwareEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_GetPropertyWwanHardwareEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyWwanHardwareEnabled' +type MockNetworkManager_GetPropertyWwanHardwareEnabled_Call struct { + *mock.Call +} + +// GetPropertyWwanHardwareEnabled is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) GetPropertyWwanHardwareEnabled() *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call { + return &MockNetworkManager_GetPropertyWwanHardwareEnabled_Call{Call: _e.mock.On("GetPropertyWwanHardwareEnabled")} +} + +func (_c *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call) Run(run func()) *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call) Return(_a0 bool, _a1 error) *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call) RunAndReturn(run func() (bool, error)) *MockNetworkManager_GetPropertyWwanHardwareEnabled_Call { + _c.Call.Return(run) + return _c +} + +// MarshalJSON provides a mock function with no fields +func (_m *MockNetworkManager) MarshalJSON() ([]byte, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MarshalJSON") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func() ([]byte, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []byte); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_MarshalJSON_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarshalJSON' +type MockNetworkManager_MarshalJSON_Call struct { + *mock.Call +} + +// MarshalJSON is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) MarshalJSON() *MockNetworkManager_MarshalJSON_Call { + return &MockNetworkManager_MarshalJSON_Call{Call: _e.mock.On("MarshalJSON")} +} + +func (_c *MockNetworkManager_MarshalJSON_Call) Run(run func()) *MockNetworkManager_MarshalJSON_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_MarshalJSON_Call) Return(_a0 []byte, _a1 error) *MockNetworkManager_MarshalJSON_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_MarshalJSON_Call) RunAndReturn(run func() ([]byte, error)) *MockNetworkManager_MarshalJSON_Call { + _c.Call.Return(run) + return _c +} + +// Reload provides a mock function with given fields: flags +func (_m *MockNetworkManager) Reload(flags uint32) error { + ret := _m.Called(flags) + + if len(ret) == 0 { + panic("no return value specified for Reload") + } + + var r0 error + if rf, ok := ret.Get(0).(func(uint32) error); ok { + r0 = rf(flags) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockNetworkManager_Reload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reload' +type MockNetworkManager_Reload_Call struct { + *mock.Call +} + +// Reload is a helper method to define mock.On call +// - flags uint32 +func (_e *MockNetworkManager_Expecter) Reload(flags interface{}) *MockNetworkManager_Reload_Call { + return &MockNetworkManager_Reload_Call{Call: _e.mock.On("Reload", flags)} +} + +func (_c *MockNetworkManager_Reload_Call) Run(run func(flags uint32)) *MockNetworkManager_Reload_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(uint32)) + }) + return _c +} + +func (_c *MockNetworkManager_Reload_Call) Return(_a0 error) *MockNetworkManager_Reload_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockNetworkManager_Reload_Call) RunAndReturn(run func(uint32) error) *MockNetworkManager_Reload_Call { + _c.Call.Return(run) + return _c +} + +// SetPropertyWirelessEnabled provides a mock function with given fields: _a0 +func (_m *MockNetworkManager) SetPropertyWirelessEnabled(_a0 bool) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for SetPropertyWirelessEnabled") + } + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockNetworkManager_SetPropertyWirelessEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPropertyWirelessEnabled' +type MockNetworkManager_SetPropertyWirelessEnabled_Call struct { + *mock.Call +} + +// SetPropertyWirelessEnabled is a helper method to define mock.On call +// - _a0 bool +func (_e *MockNetworkManager_Expecter) SetPropertyWirelessEnabled(_a0 interface{}) *MockNetworkManager_SetPropertyWirelessEnabled_Call { + return &MockNetworkManager_SetPropertyWirelessEnabled_Call{Call: _e.mock.On("SetPropertyWirelessEnabled", _a0)} +} + +func (_c *MockNetworkManager_SetPropertyWirelessEnabled_Call) Run(run func(_a0 bool)) *MockNetworkManager_SetPropertyWirelessEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool)) + }) + return _c +} + +func (_c *MockNetworkManager_SetPropertyWirelessEnabled_Call) Return(_a0 error) *MockNetworkManager_SetPropertyWirelessEnabled_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockNetworkManager_SetPropertyWirelessEnabled_Call) RunAndReturn(run func(bool) error) *MockNetworkManager_SetPropertyWirelessEnabled_Call { + _c.Call.Return(run) + return _c +} + +// Sleep provides a mock function with given fields: sleepNWake +func (_m *MockNetworkManager) Sleep(sleepNWake bool) error { + ret := _m.Called(sleepNWake) + + if len(ret) == 0 { + panic("no return value specified for Sleep") + } + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(sleepNWake) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockNetworkManager_Sleep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sleep' +type MockNetworkManager_Sleep_Call struct { + *mock.Call +} + +// Sleep is a helper method to define mock.On call +// - sleepNWake bool +func (_e *MockNetworkManager_Expecter) Sleep(sleepNWake interface{}) *MockNetworkManager_Sleep_Call { + return &MockNetworkManager_Sleep_Call{Call: _e.mock.On("Sleep", sleepNWake)} +} + +func (_c *MockNetworkManager_Sleep_Call) Run(run func(sleepNWake bool)) *MockNetworkManager_Sleep_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool)) + }) + return _c +} + +func (_c *MockNetworkManager_Sleep_Call) Return(_a0 error) *MockNetworkManager_Sleep_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockNetworkManager_Sleep_Call) RunAndReturn(run func(bool) error) *MockNetworkManager_Sleep_Call { + _c.Call.Return(run) + return _c +} + +// State provides a mock function with no fields +func (_m *MockNetworkManager) State() (gonetworkmanager.NmState, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for State") + } + + var r0 gonetworkmanager.NmState + var r1 error + if rf, ok := ret.Get(0).(func() (gonetworkmanager.NmState, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() gonetworkmanager.NmState); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(gonetworkmanager.NmState) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockNetworkManager_State_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'State' +type MockNetworkManager_State_Call struct { + *mock.Call +} + +// State is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) State() *MockNetworkManager_State_Call { + return &MockNetworkManager_State_Call{Call: _e.mock.On("State")} +} + +func (_c *MockNetworkManager_State_Call) Run(run func()) *MockNetworkManager_State_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_State_Call) Return(_a0 gonetworkmanager.NmState, _a1 error) *MockNetworkManager_State_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockNetworkManager_State_Call) RunAndReturn(run func() (gonetworkmanager.NmState, error)) *MockNetworkManager_State_Call { + _c.Call.Return(run) + return _c +} + +// Subscribe provides a mock function with no fields +func (_m *MockNetworkManager) Subscribe() <-chan *dbus.Signal { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Subscribe") + } + + var r0 <-chan *dbus.Signal + if rf, ok := ret.Get(0).(func() <-chan *dbus.Signal); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan *dbus.Signal) + } + } + + return r0 +} + +// MockNetworkManager_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe' +type MockNetworkManager_Subscribe_Call struct { + *mock.Call +} + +// Subscribe is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) Subscribe() *MockNetworkManager_Subscribe_Call { + return &MockNetworkManager_Subscribe_Call{Call: _e.mock.On("Subscribe")} +} + +func (_c *MockNetworkManager_Subscribe_Call) Run(run func()) *MockNetworkManager_Subscribe_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_Subscribe_Call) Return(_a0 <-chan *dbus.Signal) *MockNetworkManager_Subscribe_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockNetworkManager_Subscribe_Call) RunAndReturn(run func() <-chan *dbus.Signal) *MockNetworkManager_Subscribe_Call { + _c.Call.Return(run) + return _c +} + +// Unsubscribe provides a mock function with no fields +func (_m *MockNetworkManager) Unsubscribe() { + _m.Called() +} + +// MockNetworkManager_Unsubscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unsubscribe' +type MockNetworkManager_Unsubscribe_Call struct { + *mock.Call +} + +// Unsubscribe is a helper method to define mock.On call +func (_e *MockNetworkManager_Expecter) Unsubscribe() *MockNetworkManager_Unsubscribe_Call { + return &MockNetworkManager_Unsubscribe_Call{Call: _e.mock.On("Unsubscribe")} +} + +func (_c *MockNetworkManager_Unsubscribe_Call) Run(run func()) *MockNetworkManager_Unsubscribe_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockNetworkManager_Unsubscribe_Call) Return() *MockNetworkManager_Unsubscribe_Call { + _c.Call.Return() + return _c +} + +func (_c *MockNetworkManager_Unsubscribe_Call) RunAndReturn(run func()) *MockNetworkManager_Unsubscribe_Call { + _c.Run(run) + return _c +} + +// NewMockNetworkManager creates a new instance of MockNetworkManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockNetworkManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockNetworkManager { + mock := &MockNetworkManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Settings.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Settings.go new file mode 100644 index 0000000..20f488e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2/mock_Settings.go @@ -0,0 +1,467 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package gonetworkmanager + +import ( + gonetworkmanager "github.com/Wifx/gonetworkmanager/v2" + mock "github.com/stretchr/testify/mock" +) + +// MockSettings is an autogenerated mock type for the Settings type +type MockSettings struct { + mock.Mock +} + +type MockSettings_Expecter struct { + mock *mock.Mock +} + +func (_m *MockSettings) EXPECT() *MockSettings_Expecter { + return &MockSettings_Expecter{mock: &_m.Mock} +} + +// AddConnection provides a mock function with given fields: settings +func (_m *MockSettings) AddConnection(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) { + ret := _m.Called(settings) + + if len(ret) == 0 { + panic("no return value specified for AddConnection") + } + + var r0 gonetworkmanager.Connection + var r1 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)); ok { + return rf(settings) + } + if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) gonetworkmanager.Connection); ok { + r0 = rf(settings) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.Connection) + } + } + + if rf, ok := ret.Get(1).(func(gonetworkmanager.ConnectionSettings) error); ok { + r1 = rf(settings) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockSettings_AddConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddConnection' +type MockSettings_AddConnection_Call struct { + *mock.Call +} + +// AddConnection is a helper method to define mock.On call +// - settings gonetworkmanager.ConnectionSettings +func (_e *MockSettings_Expecter) AddConnection(settings interface{}) *MockSettings_AddConnection_Call { + return &MockSettings_AddConnection_Call{Call: _e.mock.On("AddConnection", settings)} +} + +func (_c *MockSettings_AddConnection_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockSettings_AddConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.ConnectionSettings)) + }) + return _c +} + +func (_c *MockSettings_AddConnection_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockSettings_AddConnection_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockSettings_AddConnection_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)) *MockSettings_AddConnection_Call { + _c.Call.Return(run) + return _c +} + +// AddConnectionUnsaved provides a mock function with given fields: settings +func (_m *MockSettings) AddConnectionUnsaved(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) { + ret := _m.Called(settings) + + if len(ret) == 0 { + panic("no return value specified for AddConnectionUnsaved") + } + + var r0 gonetworkmanager.Connection + var r1 error + if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)); ok { + return rf(settings) + } + if rf, ok := ret.Get(0).(func(gonetworkmanager.ConnectionSettings) gonetworkmanager.Connection); ok { + r0 = rf(settings) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.Connection) + } + } + + if rf, ok := ret.Get(1).(func(gonetworkmanager.ConnectionSettings) error); ok { + r1 = rf(settings) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockSettings_AddConnectionUnsaved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddConnectionUnsaved' +type MockSettings_AddConnectionUnsaved_Call struct { + *mock.Call +} + +// AddConnectionUnsaved is a helper method to define mock.On call +// - settings gonetworkmanager.ConnectionSettings +func (_e *MockSettings_Expecter) AddConnectionUnsaved(settings interface{}) *MockSettings_AddConnectionUnsaved_Call { + return &MockSettings_AddConnectionUnsaved_Call{Call: _e.mock.On("AddConnectionUnsaved", settings)} +} + +func (_c *MockSettings_AddConnectionUnsaved_Call) Run(run func(settings gonetworkmanager.ConnectionSettings)) *MockSettings_AddConnectionUnsaved_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(gonetworkmanager.ConnectionSettings)) + }) + return _c +} + +func (_c *MockSettings_AddConnectionUnsaved_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockSettings_AddConnectionUnsaved_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockSettings_AddConnectionUnsaved_Call) RunAndReturn(run func(gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error)) *MockSettings_AddConnectionUnsaved_Call { + _c.Call.Return(run) + return _c +} + +// GetConnectionByUUID provides a mock function with given fields: uuid +func (_m *MockSettings) GetConnectionByUUID(uuid string) (gonetworkmanager.Connection, error) { + ret := _m.Called(uuid) + + if len(ret) == 0 { + panic("no return value specified for GetConnectionByUUID") + } + + var r0 gonetworkmanager.Connection + var r1 error + if rf, ok := ret.Get(0).(func(string) (gonetworkmanager.Connection, error)); ok { + return rf(uuid) + } + if rf, ok := ret.Get(0).(func(string) gonetworkmanager.Connection); ok { + r0 = rf(uuid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(gonetworkmanager.Connection) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(uuid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockSettings_GetConnectionByUUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConnectionByUUID' +type MockSettings_GetConnectionByUUID_Call struct { + *mock.Call +} + +// GetConnectionByUUID is a helper method to define mock.On call +// - uuid string +func (_e *MockSettings_Expecter) GetConnectionByUUID(uuid interface{}) *MockSettings_GetConnectionByUUID_Call { + return &MockSettings_GetConnectionByUUID_Call{Call: _e.mock.On("GetConnectionByUUID", uuid)} +} + +func (_c *MockSettings_GetConnectionByUUID_Call) Run(run func(uuid string)) *MockSettings_GetConnectionByUUID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockSettings_GetConnectionByUUID_Call) Return(_a0 gonetworkmanager.Connection, _a1 error) *MockSettings_GetConnectionByUUID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockSettings_GetConnectionByUUID_Call) RunAndReturn(run func(string) (gonetworkmanager.Connection, error)) *MockSettings_GetConnectionByUUID_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyCanModify provides a mock function with no fields +func (_m *MockSettings) GetPropertyCanModify() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyCanModify") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockSettings_GetPropertyCanModify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyCanModify' +type MockSettings_GetPropertyCanModify_Call struct { + *mock.Call +} + +// GetPropertyCanModify is a helper method to define mock.On call +func (_e *MockSettings_Expecter) GetPropertyCanModify() *MockSettings_GetPropertyCanModify_Call { + return &MockSettings_GetPropertyCanModify_Call{Call: _e.mock.On("GetPropertyCanModify")} +} + +func (_c *MockSettings_GetPropertyCanModify_Call) Run(run func()) *MockSettings_GetPropertyCanModify_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockSettings_GetPropertyCanModify_Call) Return(_a0 bool, _a1 error) *MockSettings_GetPropertyCanModify_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockSettings_GetPropertyCanModify_Call) RunAndReturn(run func() (bool, error)) *MockSettings_GetPropertyCanModify_Call { + _c.Call.Return(run) + return _c +} + +// GetPropertyHostname provides a mock function with no fields +func (_m *MockSettings) GetPropertyHostname() (string, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPropertyHostname") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockSettings_GetPropertyHostname_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPropertyHostname' +type MockSettings_GetPropertyHostname_Call struct { + *mock.Call +} + +// GetPropertyHostname is a helper method to define mock.On call +func (_e *MockSettings_Expecter) GetPropertyHostname() *MockSettings_GetPropertyHostname_Call { + return &MockSettings_GetPropertyHostname_Call{Call: _e.mock.On("GetPropertyHostname")} +} + +func (_c *MockSettings_GetPropertyHostname_Call) Run(run func()) *MockSettings_GetPropertyHostname_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockSettings_GetPropertyHostname_Call) Return(_a0 string, _a1 error) *MockSettings_GetPropertyHostname_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockSettings_GetPropertyHostname_Call) RunAndReturn(run func() (string, error)) *MockSettings_GetPropertyHostname_Call { + _c.Call.Return(run) + return _c +} + +// ListConnections provides a mock function with no fields +func (_m *MockSettings) ListConnections() ([]gonetworkmanager.Connection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ListConnections") + } + + var r0 []gonetworkmanager.Connection + var r1 error + if rf, ok := ret.Get(0).(func() ([]gonetworkmanager.Connection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []gonetworkmanager.Connection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]gonetworkmanager.Connection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockSettings_ListConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListConnections' +type MockSettings_ListConnections_Call struct { + *mock.Call +} + +// ListConnections is a helper method to define mock.On call +func (_e *MockSettings_Expecter) ListConnections() *MockSettings_ListConnections_Call { + return &MockSettings_ListConnections_Call{Call: _e.mock.On("ListConnections")} +} + +func (_c *MockSettings_ListConnections_Call) Run(run func()) *MockSettings_ListConnections_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockSettings_ListConnections_Call) Return(_a0 []gonetworkmanager.Connection, _a1 error) *MockSettings_ListConnections_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockSettings_ListConnections_Call) RunAndReturn(run func() ([]gonetworkmanager.Connection, error)) *MockSettings_ListConnections_Call { + _c.Call.Return(run) + return _c +} + +// ReloadConnections provides a mock function with no fields +func (_m *MockSettings) ReloadConnections() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ReloadConnections") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockSettings_ReloadConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReloadConnections' +type MockSettings_ReloadConnections_Call struct { + *mock.Call +} + +// ReloadConnections is a helper method to define mock.On call +func (_e *MockSettings_Expecter) ReloadConnections() *MockSettings_ReloadConnections_Call { + return &MockSettings_ReloadConnections_Call{Call: _e.mock.On("ReloadConnections")} +} + +func (_c *MockSettings_ReloadConnections_Call) Run(run func()) *MockSettings_ReloadConnections_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockSettings_ReloadConnections_Call) Return(_a0 error) *MockSettings_ReloadConnections_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockSettings_ReloadConnections_Call) RunAndReturn(run func() error) *MockSettings_ReloadConnections_Call { + _c.Call.Return(run) + return _c +} + +// SaveHostname provides a mock function with given fields: hostname +func (_m *MockSettings) SaveHostname(hostname string) error { + ret := _m.Called(hostname) + + if len(ret) == 0 { + panic("no return value specified for SaveHostname") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(hostname) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockSettings_SaveHostname_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveHostname' +type MockSettings_SaveHostname_Call struct { + *mock.Call +} + +// SaveHostname is a helper method to define mock.On call +// - hostname string +func (_e *MockSettings_Expecter) SaveHostname(hostname interface{}) *MockSettings_SaveHostname_Call { + return &MockSettings_SaveHostname_Call{Call: _e.mock.On("SaveHostname", hostname)} +} + +func (_c *MockSettings_SaveHostname_Call) Run(run func(hostname string)) *MockSettings_SaveHostname_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockSettings_SaveHostname_Call) Return(_a0 error) *MockSettings_SaveHostname_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockSettings_SaveHostname_Call) RunAndReturn(run func(string) error) *MockSettings_SaveHostname_Call { + _c.Call.Return(run) + return _c +} + +// NewMockSettings creates a new instance of MockSettings. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockSettings(t interface { + mock.TestingT + Cleanup(func()) +}) *MockSettings { + mock := &MockSettings{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5/mock_BusObject.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5/mock_BusObject.go new file mode 100644 index 0000000..c0d1a3f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/github.com/godbus/dbus/v5/mock_BusObject.go @@ -0,0 +1,649 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package dbus + +import ( + context "context" + + dbus "github.com/godbus/dbus/v5" + mock "github.com/stretchr/testify/mock" +) + +// MockBusObject is an autogenerated mock type for the BusObject type +type MockBusObject struct { + mock.Mock +} + +type MockBusObject_Expecter struct { + mock *mock.Mock +} + +func (_m *MockBusObject) EXPECT() *MockBusObject_Expecter { + return &MockBusObject_Expecter{mock: &_m.Mock} +} + +// AddMatchSignal provides a mock function with given fields: iface, member, options +func (_m *MockBusObject) AddMatchSignal(iface string, member string, options ...dbus.MatchOption) *dbus.Call { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, iface, member) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for AddMatchSignal") + } + + var r0 *dbus.Call + if rf, ok := ret.Get(0).(func(string, string, ...dbus.MatchOption) *dbus.Call); ok { + r0 = rf(iface, member, options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*dbus.Call) + } + } + + return r0 +} + +// MockBusObject_AddMatchSignal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddMatchSignal' +type MockBusObject_AddMatchSignal_Call struct { + *mock.Call +} + +// AddMatchSignal is a helper method to define mock.On call +// - iface string +// - member string +// - options ...dbus.MatchOption +func (_e *MockBusObject_Expecter) AddMatchSignal(iface interface{}, member interface{}, options ...interface{}) *MockBusObject_AddMatchSignal_Call { + return &MockBusObject_AddMatchSignal_Call{Call: _e.mock.On("AddMatchSignal", + append([]interface{}{iface, member}, options...)...)} +} + +func (_c *MockBusObject_AddMatchSignal_Call) Run(run func(iface string, member string, options ...dbus.MatchOption)) *MockBusObject_AddMatchSignal_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]dbus.MatchOption, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(dbus.MatchOption) + } + } + run(args[0].(string), args[1].(string), variadicArgs...) + }) + return _c +} + +func (_c *MockBusObject_AddMatchSignal_Call) Return(_a0 *dbus.Call) *MockBusObject_AddMatchSignal_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_AddMatchSignal_Call) RunAndReturn(run func(string, string, ...dbus.MatchOption) *dbus.Call) *MockBusObject_AddMatchSignal_Call { + _c.Call.Return(run) + return _c +} + +// Call provides a mock function with given fields: method, flags, args +func (_m *MockBusObject) Call(method string, flags dbus.Flags, args ...interface{}) *dbus.Call { + var _ca []interface{} + _ca = append(_ca, method, flags) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Call") + } + + var r0 *dbus.Call + if rf, ok := ret.Get(0).(func(string, dbus.Flags, ...interface{}) *dbus.Call); ok { + r0 = rf(method, flags, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*dbus.Call) + } + } + + return r0 +} + +// MockBusObject_Call_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Call' +type MockBusObject_Call_Call struct { + *mock.Call +} + +// Call is a helper method to define mock.On call +// - method string +// - flags dbus.Flags +// - args ...interface{} +func (_e *MockBusObject_Expecter) Call(method interface{}, flags interface{}, args ...interface{}) *MockBusObject_Call_Call { + return &MockBusObject_Call_Call{Call: _e.mock.On("Call", + append([]interface{}{method, flags}, args...)...)} +} + +func (_c *MockBusObject_Call_Call) Run(run func(method string, flags dbus.Flags, args ...interface{})) *MockBusObject_Call_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].(string), args[1].(dbus.Flags), variadicArgs...) + }) + return _c +} + +func (_c *MockBusObject_Call_Call) Return(_a0 *dbus.Call) *MockBusObject_Call_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_Call_Call) RunAndReturn(run func(string, dbus.Flags, ...interface{}) *dbus.Call) *MockBusObject_Call_Call { + _c.Call.Return(run) + return _c +} + +// CallWithContext provides a mock function with given fields: ctx, method, flags, args +func (_m *MockBusObject) CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...interface{}) *dbus.Call { + var _ca []interface{} + _ca = append(_ca, ctx, method, flags) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for CallWithContext") + } + + var r0 *dbus.Call + if rf, ok := ret.Get(0).(func(context.Context, string, dbus.Flags, ...interface{}) *dbus.Call); ok { + r0 = rf(ctx, method, flags, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*dbus.Call) + } + } + + return r0 +} + +// MockBusObject_CallWithContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CallWithContext' +type MockBusObject_CallWithContext_Call struct { + *mock.Call +} + +// CallWithContext is a helper method to define mock.On call +// - ctx context.Context +// - method string +// - flags dbus.Flags +// - args ...interface{} +func (_e *MockBusObject_Expecter) CallWithContext(ctx interface{}, method interface{}, flags interface{}, args ...interface{}) *MockBusObject_CallWithContext_Call { + return &MockBusObject_CallWithContext_Call{Call: _e.mock.On("CallWithContext", + append([]interface{}{ctx, method, flags}, args...)...)} +} + +func (_c *MockBusObject_CallWithContext_Call) Run(run func(ctx context.Context, method string, flags dbus.Flags, args ...interface{})) *MockBusObject_CallWithContext_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-3) + for i, a := range args[3:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].(context.Context), args[1].(string), args[2].(dbus.Flags), variadicArgs...) + }) + return _c +} + +func (_c *MockBusObject_CallWithContext_Call) Return(_a0 *dbus.Call) *MockBusObject_CallWithContext_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_CallWithContext_Call) RunAndReturn(run func(context.Context, string, dbus.Flags, ...interface{}) *dbus.Call) *MockBusObject_CallWithContext_Call { + _c.Call.Return(run) + return _c +} + +// Destination provides a mock function with no fields +func (_m *MockBusObject) Destination() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Destination") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// MockBusObject_Destination_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Destination' +type MockBusObject_Destination_Call struct { + *mock.Call +} + +// Destination is a helper method to define mock.On call +func (_e *MockBusObject_Expecter) Destination() *MockBusObject_Destination_Call { + return &MockBusObject_Destination_Call{Call: _e.mock.On("Destination")} +} + +func (_c *MockBusObject_Destination_Call) Run(run func()) *MockBusObject_Destination_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBusObject_Destination_Call) Return(_a0 string) *MockBusObject_Destination_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_Destination_Call) RunAndReturn(run func() string) *MockBusObject_Destination_Call { + _c.Call.Return(run) + return _c +} + +// GetProperty provides a mock function with given fields: p +func (_m *MockBusObject) GetProperty(p string) (dbus.Variant, error) { + ret := _m.Called(p) + + if len(ret) == 0 { + panic("no return value specified for GetProperty") + } + + var r0 dbus.Variant + var r1 error + if rf, ok := ret.Get(0).(func(string) (dbus.Variant, error)); ok { + return rf(p) + } + if rf, ok := ret.Get(0).(func(string) dbus.Variant); ok { + r0 = rf(p) + } else { + r0 = ret.Get(0).(dbus.Variant) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(p) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBusObject_GetProperty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProperty' +type MockBusObject_GetProperty_Call struct { + *mock.Call +} + +// GetProperty is a helper method to define mock.On call +// - p string +func (_e *MockBusObject_Expecter) GetProperty(p interface{}) *MockBusObject_GetProperty_Call { + return &MockBusObject_GetProperty_Call{Call: _e.mock.On("GetProperty", p)} +} + +func (_c *MockBusObject_GetProperty_Call) Run(run func(p string)) *MockBusObject_GetProperty_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBusObject_GetProperty_Call) Return(_a0 dbus.Variant, _a1 error) *MockBusObject_GetProperty_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBusObject_GetProperty_Call) RunAndReturn(run func(string) (dbus.Variant, error)) *MockBusObject_GetProperty_Call { + _c.Call.Return(run) + return _c +} + +// Go provides a mock function with given fields: method, flags, ch, args +func (_m *MockBusObject) Go(method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{}) *dbus.Call { + var _ca []interface{} + _ca = append(_ca, method, flags, ch) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Go") + } + + var r0 *dbus.Call + if rf, ok := ret.Get(0).(func(string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call); ok { + r0 = rf(method, flags, ch, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*dbus.Call) + } + } + + return r0 +} + +// MockBusObject_Go_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Go' +type MockBusObject_Go_Call struct { + *mock.Call +} + +// Go is a helper method to define mock.On call +// - method string +// - flags dbus.Flags +// - ch chan *dbus.Call +// - args ...interface{} +func (_e *MockBusObject_Expecter) Go(method interface{}, flags interface{}, ch interface{}, args ...interface{}) *MockBusObject_Go_Call { + return &MockBusObject_Go_Call{Call: _e.mock.On("Go", + append([]interface{}{method, flags, ch}, args...)...)} +} + +func (_c *MockBusObject_Go_Call) Run(run func(method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{})) *MockBusObject_Go_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-3) + for i, a := range args[3:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].(string), args[1].(dbus.Flags), args[2].(chan *dbus.Call), variadicArgs...) + }) + return _c +} + +func (_c *MockBusObject_Go_Call) Return(_a0 *dbus.Call) *MockBusObject_Go_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_Go_Call) RunAndReturn(run func(string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call) *MockBusObject_Go_Call { + _c.Call.Return(run) + return _c +} + +// GoWithContext provides a mock function with given fields: ctx, method, flags, ch, args +func (_m *MockBusObject) GoWithContext(ctx context.Context, method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{}) *dbus.Call { + var _ca []interface{} + _ca = append(_ca, ctx, method, flags, ch) + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for GoWithContext") + } + + var r0 *dbus.Call + if rf, ok := ret.Get(0).(func(context.Context, string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call); ok { + r0 = rf(ctx, method, flags, ch, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*dbus.Call) + } + } + + return r0 +} + +// MockBusObject_GoWithContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GoWithContext' +type MockBusObject_GoWithContext_Call struct { + *mock.Call +} + +// GoWithContext is a helper method to define mock.On call +// - ctx context.Context +// - method string +// - flags dbus.Flags +// - ch chan *dbus.Call +// - args ...interface{} +func (_e *MockBusObject_Expecter) GoWithContext(ctx interface{}, method interface{}, flags interface{}, ch interface{}, args ...interface{}) *MockBusObject_GoWithContext_Call { + return &MockBusObject_GoWithContext_Call{Call: _e.mock.On("GoWithContext", + append([]interface{}{ctx, method, flags, ch}, args...)...)} +} + +func (_c *MockBusObject_GoWithContext_Call) Run(run func(ctx context.Context, method string, flags dbus.Flags, ch chan *dbus.Call, args ...interface{})) *MockBusObject_GoWithContext_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-4) + for i, a := range args[4:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].(context.Context), args[1].(string), args[2].(dbus.Flags), args[3].(chan *dbus.Call), variadicArgs...) + }) + return _c +} + +func (_c *MockBusObject_GoWithContext_Call) Return(_a0 *dbus.Call) *MockBusObject_GoWithContext_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_GoWithContext_Call) RunAndReturn(run func(context.Context, string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call) *MockBusObject_GoWithContext_Call { + _c.Call.Return(run) + return _c +} + +// Path provides a mock function with no fields +func (_m *MockBusObject) Path() dbus.ObjectPath { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Path") + } + + var r0 dbus.ObjectPath + if rf, ok := ret.Get(0).(func() dbus.ObjectPath); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(dbus.ObjectPath) + } + + return r0 +} + +// MockBusObject_Path_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Path' +type MockBusObject_Path_Call struct { + *mock.Call +} + +// Path is a helper method to define mock.On call +func (_e *MockBusObject_Expecter) Path() *MockBusObject_Path_Call { + return &MockBusObject_Path_Call{Call: _e.mock.On("Path")} +} + +func (_c *MockBusObject_Path_Call) Run(run func()) *MockBusObject_Path_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBusObject_Path_Call) Return(_a0 dbus.ObjectPath) *MockBusObject_Path_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_Path_Call) RunAndReturn(run func() dbus.ObjectPath) *MockBusObject_Path_Call { + _c.Call.Return(run) + return _c +} + +// RemoveMatchSignal provides a mock function with given fields: iface, member, options +func (_m *MockBusObject) RemoveMatchSignal(iface string, member string, options ...dbus.MatchOption) *dbus.Call { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, iface, member) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for RemoveMatchSignal") + } + + var r0 *dbus.Call + if rf, ok := ret.Get(0).(func(string, string, ...dbus.MatchOption) *dbus.Call); ok { + r0 = rf(iface, member, options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*dbus.Call) + } + } + + return r0 +} + +// MockBusObject_RemoveMatchSignal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveMatchSignal' +type MockBusObject_RemoveMatchSignal_Call struct { + *mock.Call +} + +// RemoveMatchSignal is a helper method to define mock.On call +// - iface string +// - member string +// - options ...dbus.MatchOption +func (_e *MockBusObject_Expecter) RemoveMatchSignal(iface interface{}, member interface{}, options ...interface{}) *MockBusObject_RemoveMatchSignal_Call { + return &MockBusObject_RemoveMatchSignal_Call{Call: _e.mock.On("RemoveMatchSignal", + append([]interface{}{iface, member}, options...)...)} +} + +func (_c *MockBusObject_RemoveMatchSignal_Call) Run(run func(iface string, member string, options ...dbus.MatchOption)) *MockBusObject_RemoveMatchSignal_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]dbus.MatchOption, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(dbus.MatchOption) + } + } + run(args[0].(string), args[1].(string), variadicArgs...) + }) + return _c +} + +func (_c *MockBusObject_RemoveMatchSignal_Call) Return(_a0 *dbus.Call) *MockBusObject_RemoveMatchSignal_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_RemoveMatchSignal_Call) RunAndReturn(run func(string, string, ...dbus.MatchOption) *dbus.Call) *MockBusObject_RemoveMatchSignal_Call { + _c.Call.Return(run) + return _c +} + +// SetProperty provides a mock function with given fields: p, v +func (_m *MockBusObject) SetProperty(p string, v interface{}) error { + ret := _m.Called(p, v) + + if len(ret) == 0 { + panic("no return value specified for SetProperty") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { + r0 = rf(p, v) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBusObject_SetProperty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetProperty' +type MockBusObject_SetProperty_Call struct { + *mock.Call +} + +// SetProperty is a helper method to define mock.On call +// - p string +// - v interface{} +func (_e *MockBusObject_Expecter) SetProperty(p interface{}, v interface{}) *MockBusObject_SetProperty_Call { + return &MockBusObject_SetProperty_Call{Call: _e.mock.On("SetProperty", p, v)} +} + +func (_c *MockBusObject_SetProperty_Call) Run(run func(p string, v interface{})) *MockBusObject_SetProperty_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(interface{})) + }) + return _c +} + +func (_c *MockBusObject_SetProperty_Call) Return(_a0 error) *MockBusObject_SetProperty_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_SetProperty_Call) RunAndReturn(run func(string, interface{}) error) *MockBusObject_SetProperty_Call { + _c.Call.Return(run) + return _c +} + +// StoreProperty provides a mock function with given fields: p, value +func (_m *MockBusObject) StoreProperty(p string, value interface{}) error { + ret := _m.Called(p, value) + + if len(ret) == 0 { + panic("no return value specified for StoreProperty") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, interface{}) error); ok { + r0 = rf(p, value) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBusObject_StoreProperty_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StoreProperty' +type MockBusObject_StoreProperty_Call struct { + *mock.Call +} + +// StoreProperty is a helper method to define mock.On call +// - p string +// - value interface{} +func (_e *MockBusObject_Expecter) StoreProperty(p interface{}, value interface{}) *MockBusObject_StoreProperty_Call { + return &MockBusObject_StoreProperty_Call{Call: _e.mock.On("StoreProperty", p, value)} +} + +func (_c *MockBusObject_StoreProperty_Call) Run(run func(p string, value interface{})) *MockBusObject_StoreProperty_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(interface{})) + }) + return _c +} + +func (_c *MockBusObject_StoreProperty_Call) Return(_a0 error) *MockBusObject_StoreProperty_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBusObject_StoreProperty_Call) RunAndReturn(run func(string, interface{}) error) *MockBusObject_StoreProperty_Call { + _c.Call.Return(run) + return _c +} + +// NewMockBusObject creates a new instance of MockBusObject. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockBusObject(t interface { + mock.TestingT + Cleanup(func()) +}) *MockBusObject { + mock := &MockBusObject{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/internal/plugins/mock_GitClient.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/internal/plugins/mock_GitClient.go new file mode 100644 index 0000000..23e34f3 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/internal/plugins/mock_GitClient.go @@ -0,0 +1,181 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package plugins + +import mock "github.com/stretchr/testify/mock" + +// MockGitClient is an autogenerated mock type for the GitClient type +type MockGitClient struct { + mock.Mock +} + +type MockGitClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGitClient) EXPECT() *MockGitClient_Expecter { + return &MockGitClient_Expecter{mock: &_m.Mock} +} + +// HasUpdates provides a mock function with given fields: path +func (_m *MockGitClient) HasUpdates(path string) (bool, error) { + ret := _m.Called(path) + + if len(ret) == 0 { + panic("no return value specified for HasUpdates") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(string) (bool, error)); ok { + return rf(path) + } + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(path) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(path) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGitClient_HasUpdates_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasUpdates' +type MockGitClient_HasUpdates_Call struct { + *mock.Call +} + +// HasUpdates is a helper method to define mock.On call +// - path string +func (_e *MockGitClient_Expecter) HasUpdates(path any) *MockGitClient_HasUpdates_Call { + return &MockGitClient_HasUpdates_Call{Call: _e.mock.On("HasUpdates", path)} +} + +func (_c *MockGitClient_HasUpdates_Call) Run(run func(path string)) *MockGitClient_HasUpdates_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockGitClient_HasUpdates_Call) Return(_a0 bool, _a1 error) *MockGitClient_HasUpdates_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGitClient_HasUpdates_Call) RunAndReturn(run func(string) (bool, error)) *MockGitClient_HasUpdates_Call { + _c.Call.Return(run) + return _c +} + +// PlainClone provides a mock function with given fields: path, url +func (_m *MockGitClient) PlainClone(path string, url string) error { + ret := _m.Called(path, url) + + if len(ret) == 0 { + panic("no return value specified for PlainClone") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(path, url) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGitClient_PlainClone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PlainClone' +type MockGitClient_PlainClone_Call struct { + *mock.Call +} + +// PlainClone is a helper method to define mock.On call +// - path string +// - url string +func (_e *MockGitClient_Expecter) PlainClone(path any, url any) *MockGitClient_PlainClone_Call { + return &MockGitClient_PlainClone_Call{Call: _e.mock.On("PlainClone", path, url)} +} + +func (_c *MockGitClient_PlainClone_Call) Run(run func(path string, url string)) *MockGitClient_PlainClone_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockGitClient_PlainClone_Call) Return(_a0 error) *MockGitClient_PlainClone_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitClient_PlainClone_Call) RunAndReturn(run func(string, string) error) *MockGitClient_PlainClone_Call { + _c.Call.Return(run) + return _c +} + +// Pull provides a mock function with given fields: path +func (_m *MockGitClient) Pull(path string) error { + ret := _m.Called(path) + + if len(ret) == 0 { + panic("no return value specified for Pull") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(path) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGitClient_Pull_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pull' +type MockGitClient_Pull_Call struct { + *mock.Call +} + +// Pull is a helper method to define mock.On call +// - path string +func (_e *MockGitClient_Expecter) Pull(path any) *MockGitClient_Pull_Call { + return &MockGitClient_Pull_Call{Call: _e.mock.On("Pull", path)} +} + +func (_c *MockGitClient_Pull_Call) Run(run func(path string)) *MockGitClient_Pull_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockGitClient_Pull_Call) Return(_a0 error) *MockGitClient_Pull_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitClient_Pull_Call) RunAndReturn(run func(string) error) *MockGitClient_Pull_Call { + _c.Call.Return(run) + return _c +} + +// NewMockGitClient creates a new instance of MockGitClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGitClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGitClient { + mock := &MockGitClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/net/mock_Conn.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/net/mock_Conn.go new file mode 100644 index 0000000..248a0d8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/net/mock_Conn.go @@ -0,0 +1,427 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package net + +import ( + net "net" + + mock "github.com/stretchr/testify/mock" + + time "time" +) + +// MockConn is an autogenerated mock type for the Conn type +type MockConn struct { + mock.Mock +} + +type MockConn_Expecter struct { + mock *mock.Mock +} + +func (_m *MockConn) EXPECT() *MockConn_Expecter { + return &MockConn_Expecter{mock: &_m.Mock} +} + +// Close provides a mock function with no fields +func (_m *MockConn) Close() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Close") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConn_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type MockConn_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *MockConn_Expecter) Close() *MockConn_Close_Call { + return &MockConn_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *MockConn_Close_Call) Run(run func()) *MockConn_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConn_Close_Call) Return(_a0 error) *MockConn_Close_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConn_Close_Call) RunAndReturn(run func() error) *MockConn_Close_Call { + _c.Call.Return(run) + return _c +} + +// LocalAddr provides a mock function with no fields +func (_m *MockConn) LocalAddr() net.Addr { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for LocalAddr") + } + + var r0 net.Addr + if rf, ok := ret.Get(0).(func() net.Addr); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(net.Addr) + } + } + + return r0 +} + +// MockConn_LocalAddr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LocalAddr' +type MockConn_LocalAddr_Call struct { + *mock.Call +} + +// LocalAddr is a helper method to define mock.On call +func (_e *MockConn_Expecter) LocalAddr() *MockConn_LocalAddr_Call { + return &MockConn_LocalAddr_Call{Call: _e.mock.On("LocalAddr")} +} + +func (_c *MockConn_LocalAddr_Call) Run(run func()) *MockConn_LocalAddr_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConn_LocalAddr_Call) Return(_a0 net.Addr) *MockConn_LocalAddr_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConn_LocalAddr_Call) RunAndReturn(run func() net.Addr) *MockConn_LocalAddr_Call { + _c.Call.Return(run) + return _c +} + +// Read provides a mock function with given fields: b +func (_m *MockConn) Read(b []byte) (int, error) { + ret := _m.Called(b) + + if len(ret) == 0 { + panic("no return value specified for Read") + } + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func([]byte) (int, error)); ok { + return rf(b) + } + if rf, ok := ret.Get(0).(func([]byte) int); ok { + r0 = rf(b) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func([]byte) error); ok { + r1 = rf(b) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockConn_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' +type MockConn_Read_Call struct { + *mock.Call +} + +// Read is a helper method to define mock.On call +// - b []byte +func (_e *MockConn_Expecter) Read(b interface{}) *MockConn_Read_Call { + return &MockConn_Read_Call{Call: _e.mock.On("Read", b)} +} + +func (_c *MockConn_Read_Call) Run(run func(b []byte)) *MockConn_Read_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([]byte)) + }) + return _c +} + +func (_c *MockConn_Read_Call) Return(n int, err error) *MockConn_Read_Call { + _c.Call.Return(n, err) + return _c +} + +func (_c *MockConn_Read_Call) RunAndReturn(run func([]byte) (int, error)) *MockConn_Read_Call { + _c.Call.Return(run) + return _c +} + +// RemoteAddr provides a mock function with no fields +func (_m *MockConn) RemoteAddr() net.Addr { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for RemoteAddr") + } + + var r0 net.Addr + if rf, ok := ret.Get(0).(func() net.Addr); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(net.Addr) + } + } + + return r0 +} + +// MockConn_RemoteAddr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoteAddr' +type MockConn_RemoteAddr_Call struct { + *mock.Call +} + +// RemoteAddr is a helper method to define mock.On call +func (_e *MockConn_Expecter) RemoteAddr() *MockConn_RemoteAddr_Call { + return &MockConn_RemoteAddr_Call{Call: _e.mock.On("RemoteAddr")} +} + +func (_c *MockConn_RemoteAddr_Call) Run(run func()) *MockConn_RemoteAddr_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConn_RemoteAddr_Call) Return(_a0 net.Addr) *MockConn_RemoteAddr_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConn_RemoteAddr_Call) RunAndReturn(run func() net.Addr) *MockConn_RemoteAddr_Call { + _c.Call.Return(run) + return _c +} + +// SetDeadline provides a mock function with given fields: t +func (_m *MockConn) SetDeadline(t time.Time) error { + ret := _m.Called(t) + + if len(ret) == 0 { + panic("no return value specified for SetDeadline") + } + + var r0 error + if rf, ok := ret.Get(0).(func(time.Time) error); ok { + r0 = rf(t) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConn_SetDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDeadline' +type MockConn_SetDeadline_Call struct { + *mock.Call +} + +// SetDeadline is a helper method to define mock.On call +// - t time.Time +func (_e *MockConn_Expecter) SetDeadline(t interface{}) *MockConn_SetDeadline_Call { + return &MockConn_SetDeadline_Call{Call: _e.mock.On("SetDeadline", t)} +} + +func (_c *MockConn_SetDeadline_Call) Run(run func(t time.Time)) *MockConn_SetDeadline_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(time.Time)) + }) + return _c +} + +func (_c *MockConn_SetDeadline_Call) Return(_a0 error) *MockConn_SetDeadline_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConn_SetDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetDeadline_Call { + _c.Call.Return(run) + return _c +} + +// SetReadDeadline provides a mock function with given fields: t +func (_m *MockConn) SetReadDeadline(t time.Time) error { + ret := _m.Called(t) + + if len(ret) == 0 { + panic("no return value specified for SetReadDeadline") + } + + var r0 error + if rf, ok := ret.Get(0).(func(time.Time) error); ok { + r0 = rf(t) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConn_SetReadDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetReadDeadline' +type MockConn_SetReadDeadline_Call struct { + *mock.Call +} + +// SetReadDeadline is a helper method to define mock.On call +// - t time.Time +func (_e *MockConn_Expecter) SetReadDeadline(t interface{}) *MockConn_SetReadDeadline_Call { + return &MockConn_SetReadDeadline_Call{Call: _e.mock.On("SetReadDeadline", t)} +} + +func (_c *MockConn_SetReadDeadline_Call) Run(run func(t time.Time)) *MockConn_SetReadDeadline_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(time.Time)) + }) + return _c +} + +func (_c *MockConn_SetReadDeadline_Call) Return(_a0 error) *MockConn_SetReadDeadline_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConn_SetReadDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetReadDeadline_Call { + _c.Call.Return(run) + return _c +} + +// SetWriteDeadline provides a mock function with given fields: t +func (_m *MockConn) SetWriteDeadline(t time.Time) error { + ret := _m.Called(t) + + if len(ret) == 0 { + panic("no return value specified for SetWriteDeadline") + } + + var r0 error + if rf, ok := ret.Get(0).(func(time.Time) error); ok { + r0 = rf(t) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConn_SetWriteDeadline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWriteDeadline' +type MockConn_SetWriteDeadline_Call struct { + *mock.Call +} + +// SetWriteDeadline is a helper method to define mock.On call +// - t time.Time +func (_e *MockConn_Expecter) SetWriteDeadline(t interface{}) *MockConn_SetWriteDeadline_Call { + return &MockConn_SetWriteDeadline_Call{Call: _e.mock.On("SetWriteDeadline", t)} +} + +func (_c *MockConn_SetWriteDeadline_Call) Run(run func(t time.Time)) *MockConn_SetWriteDeadline_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(time.Time)) + }) + return _c +} + +func (_c *MockConn_SetWriteDeadline_Call) Return(_a0 error) *MockConn_SetWriteDeadline_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConn_SetWriteDeadline_Call) RunAndReturn(run func(time.Time) error) *MockConn_SetWriteDeadline_Call { + _c.Call.Return(run) + return _c +} + +// Write provides a mock function with given fields: b +func (_m *MockConn) Write(b []byte) (int, error) { + ret := _m.Called(b) + + if len(ret) == 0 { + panic("no return value specified for Write") + } + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func([]byte) (int, error)); ok { + return rf(b) + } + if rf, ok := ret.Get(0).(func([]byte) int); ok { + r0 = rf(b) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func([]byte) error); ok { + r1 = rf(b) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockConn_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' +type MockConn_Write_Call struct { + *mock.Call +} + +// Write is a helper method to define mock.On call +// - b []byte +func (_e *MockConn_Expecter) Write(b interface{}) *MockConn_Write_Call { + return &MockConn_Write_Call{Call: _e.mock.On("Write", b)} +} + +func (_c *MockConn_Write_Call) Run(run func(b []byte)) *MockConn_Write_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([]byte)) + }) + return _c +} + +func (_c *MockConn_Write_Call) Return(n int, err error) *MockConn_Write_Call { + _c.Call.Return(n, err) + return _c +} + +func (_c *MockConn_Write_Call) RunAndReturn(run func([]byte) (int, error)) *MockConn_Write_Call { + _c.Call.Return(run) + return _c +} + +// NewMockConn creates a new instance of MockConn. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockConn(t interface { + mock.TestingT + Cleanup(func()) +}) *MockConn { + mock := &MockConn{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/network/mock_Backend.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/network/mock_Backend.go new file mode 100644 index 0000000..d7eb2df --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/network/mock_Backend.go @@ -0,0 +1,1975 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_network + +import ( + network "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" + mock "github.com/stretchr/testify/mock" +) + +// MockBackend is an autogenerated mock type for the Backend type +type MockBackend struct { + mock.Mock +} + +type MockBackend_Expecter struct { + mock *mock.Mock +} + +func (_m *MockBackend) EXPECT() *MockBackend_Expecter { + return &MockBackend_Expecter{mock: &_m.Mock} +} + +// ActivateWiredConnection provides a mock function with given fields: uuid +func (_m *MockBackend) ActivateWiredConnection(uuid string) error { + ret := _m.Called(uuid) + + if len(ret) == 0 { + panic("no return value specified for ActivateWiredConnection") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(uuid) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_ActivateWiredConnection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ActivateWiredConnection' +type MockBackend_ActivateWiredConnection_Call struct { + *mock.Call +} + +// ActivateWiredConnection is a helper method to define mock.On call +// - uuid string +func (_e *MockBackend_Expecter) ActivateWiredConnection(uuid interface{}) *MockBackend_ActivateWiredConnection_Call { + return &MockBackend_ActivateWiredConnection_Call{Call: _e.mock.On("ActivateWiredConnection", uuid)} +} + +func (_c *MockBackend_ActivateWiredConnection_Call) Run(run func(uuid string)) *MockBackend_ActivateWiredConnection_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_ActivateWiredConnection_Call) Return(_a0 error) *MockBackend_ActivateWiredConnection_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_ActivateWiredConnection_Call) RunAndReturn(run func(string) error) *MockBackend_ActivateWiredConnection_Call { + _c.Call.Return(run) + return _c +} + +// CancelCredentials provides a mock function with given fields: token +func (_m *MockBackend) CancelCredentials(token string) error { + ret := _m.Called(token) + + if len(ret) == 0 { + panic("no return value specified for CancelCredentials") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(token) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_CancelCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelCredentials' +type MockBackend_CancelCredentials_Call struct { + *mock.Call +} + +// CancelCredentials is a helper method to define mock.On call +// - token string +func (_e *MockBackend_Expecter) CancelCredentials(token interface{}) *MockBackend_CancelCredentials_Call { + return &MockBackend_CancelCredentials_Call{Call: _e.mock.On("CancelCredentials", token)} +} + +func (_c *MockBackend_CancelCredentials_Call) Run(run func(token string)) *MockBackend_CancelCredentials_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_CancelCredentials_Call) Return(_a0 error) *MockBackend_CancelCredentials_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_CancelCredentials_Call) RunAndReturn(run func(string) error) *MockBackend_CancelCredentials_Call { + _c.Call.Return(run) + return _c +} + +// ClearVPNCredentials provides a mock function with given fields: uuidOrName +func (_m *MockBackend) ClearVPNCredentials(uuidOrName string) error { + ret := _m.Called(uuidOrName) + + if len(ret) == 0 { + panic("no return value specified for ClearVPNCredentials") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(uuidOrName) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_ClearVPNCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearVPNCredentials' +type MockBackend_ClearVPNCredentials_Call struct { + *mock.Call +} + +// ClearVPNCredentials is a helper method to define mock.On call +// - uuidOrName string +func (_e *MockBackend_Expecter) ClearVPNCredentials(uuidOrName interface{}) *MockBackend_ClearVPNCredentials_Call { + return &MockBackend_ClearVPNCredentials_Call{Call: _e.mock.On("ClearVPNCredentials", uuidOrName)} +} + +func (_c *MockBackend_ClearVPNCredentials_Call) Run(run func(uuidOrName string)) *MockBackend_ClearVPNCredentials_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_ClearVPNCredentials_Call) Return(_a0 error) *MockBackend_ClearVPNCredentials_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_ClearVPNCredentials_Call) RunAndReturn(run func(string) error) *MockBackend_ClearVPNCredentials_Call { + _c.Call.Return(run) + return _c +} + +// Close provides a mock function with no fields +func (_m *MockBackend) Close() { + _m.Called() +} + +// MockBackend_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type MockBackend_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *MockBackend_Expecter) Close() *MockBackend_Close_Call { + return &MockBackend_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *MockBackend_Close_Call) Run(run func()) *MockBackend_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_Close_Call) Return() *MockBackend_Close_Call { + _c.Call.Return() + return _c +} + +func (_c *MockBackend_Close_Call) RunAndReturn(run func()) *MockBackend_Close_Call { + _c.Run(run) + return _c +} + +// ConnectEthernet provides a mock function with no fields +func (_m *MockBackend) ConnectEthernet() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ConnectEthernet") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_ConnectEthernet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectEthernet' +type MockBackend_ConnectEthernet_Call struct { + *mock.Call +} + +// ConnectEthernet is a helper method to define mock.On call +func (_e *MockBackend_Expecter) ConnectEthernet() *MockBackend_ConnectEthernet_Call { + return &MockBackend_ConnectEthernet_Call{Call: _e.mock.On("ConnectEthernet")} +} + +func (_c *MockBackend_ConnectEthernet_Call) Run(run func()) *MockBackend_ConnectEthernet_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_ConnectEthernet_Call) Return(_a0 error) *MockBackend_ConnectEthernet_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_ConnectEthernet_Call) RunAndReturn(run func() error) *MockBackend_ConnectEthernet_Call { + _c.Call.Return(run) + return _c +} + +// ConnectVPN provides a mock function with given fields: uuidOrName, singleActive +func (_m *MockBackend) ConnectVPN(uuidOrName string, singleActive bool) error { + ret := _m.Called(uuidOrName, singleActive) + + if len(ret) == 0 { + panic("no return value specified for ConnectVPN") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(uuidOrName, singleActive) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_ConnectVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectVPN' +type MockBackend_ConnectVPN_Call struct { + *mock.Call +} + +// ConnectVPN is a helper method to define mock.On call +// - uuidOrName string +// - singleActive bool +func (_e *MockBackend_Expecter) ConnectVPN(uuidOrName interface{}, singleActive interface{}) *MockBackend_ConnectVPN_Call { + return &MockBackend_ConnectVPN_Call{Call: _e.mock.On("ConnectVPN", uuidOrName, singleActive)} +} + +func (_c *MockBackend_ConnectVPN_Call) Run(run func(uuidOrName string, singleActive bool)) *MockBackend_ConnectVPN_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockBackend_ConnectVPN_Call) Return(_a0 error) *MockBackend_ConnectVPN_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_ConnectVPN_Call) RunAndReturn(run func(string, bool) error) *MockBackend_ConnectVPN_Call { + _c.Call.Return(run) + return _c +} + +// ConnectWiFi provides a mock function with given fields: req +func (_m *MockBackend) ConnectWiFi(req network.ConnectionRequest) error { + ret := _m.Called(req) + + if len(ret) == 0 { + panic("no return value specified for ConnectWiFi") + } + + var r0 error + if rf, ok := ret.Get(0).(func(network.ConnectionRequest) error); ok { + r0 = rf(req) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_ConnectWiFi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectWiFi' +type MockBackend_ConnectWiFi_Call struct { + *mock.Call +} + +// ConnectWiFi is a helper method to define mock.On call +// - req network.ConnectionRequest +func (_e *MockBackend_Expecter) ConnectWiFi(req interface{}) *MockBackend_ConnectWiFi_Call { + return &MockBackend_ConnectWiFi_Call{Call: _e.mock.On("ConnectWiFi", req)} +} + +func (_c *MockBackend_ConnectWiFi_Call) Run(run func(req network.ConnectionRequest)) *MockBackend_ConnectWiFi_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(network.ConnectionRequest)) + }) + return _c +} + +func (_c *MockBackend_ConnectWiFi_Call) Return(_a0 error) *MockBackend_ConnectWiFi_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_ConnectWiFi_Call) RunAndReturn(run func(network.ConnectionRequest) error) *MockBackend_ConnectWiFi_Call { + _c.Call.Return(run) + return _c +} + +// DeleteVPN provides a mock function with given fields: uuidOrName +func (_m *MockBackend) DeleteVPN(uuidOrName string) error { + ret := _m.Called(uuidOrName) + + if len(ret) == 0 { + panic("no return value specified for DeleteVPN") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(uuidOrName) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_DeleteVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVPN' +type MockBackend_DeleteVPN_Call struct { + *mock.Call +} + +// DeleteVPN is a helper method to define mock.On call +// - uuidOrName string +func (_e *MockBackend_Expecter) DeleteVPN(uuidOrName interface{}) *MockBackend_DeleteVPN_Call { + return &MockBackend_DeleteVPN_Call{Call: _e.mock.On("DeleteVPN", uuidOrName)} +} + +func (_c *MockBackend_DeleteVPN_Call) Run(run func(uuidOrName string)) *MockBackend_DeleteVPN_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_DeleteVPN_Call) Return(_a0 error) *MockBackend_DeleteVPN_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_DeleteVPN_Call) RunAndReturn(run func(string) error) *MockBackend_DeleteVPN_Call { + _c.Call.Return(run) + return _c +} + +// DisconnectAllVPN provides a mock function with no fields +func (_m *MockBackend) DisconnectAllVPN() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for DisconnectAllVPN") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_DisconnectAllVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectAllVPN' +type MockBackend_DisconnectAllVPN_Call struct { + *mock.Call +} + +// DisconnectAllVPN is a helper method to define mock.On call +func (_e *MockBackend_Expecter) DisconnectAllVPN() *MockBackend_DisconnectAllVPN_Call { + return &MockBackend_DisconnectAllVPN_Call{Call: _e.mock.On("DisconnectAllVPN")} +} + +func (_c *MockBackend_DisconnectAllVPN_Call) Run(run func()) *MockBackend_DisconnectAllVPN_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_DisconnectAllVPN_Call) Return(_a0 error) *MockBackend_DisconnectAllVPN_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_DisconnectAllVPN_Call) RunAndReturn(run func() error) *MockBackend_DisconnectAllVPN_Call { + _c.Call.Return(run) + return _c +} + +// DisconnectEthernet provides a mock function with no fields +func (_m *MockBackend) DisconnectEthernet() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for DisconnectEthernet") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_DisconnectEthernet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectEthernet' +type MockBackend_DisconnectEthernet_Call struct { + *mock.Call +} + +// DisconnectEthernet is a helper method to define mock.On call +func (_e *MockBackend_Expecter) DisconnectEthernet() *MockBackend_DisconnectEthernet_Call { + return &MockBackend_DisconnectEthernet_Call{Call: _e.mock.On("DisconnectEthernet")} +} + +func (_c *MockBackend_DisconnectEthernet_Call) Run(run func()) *MockBackend_DisconnectEthernet_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_DisconnectEthernet_Call) Return(_a0 error) *MockBackend_DisconnectEthernet_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_DisconnectEthernet_Call) RunAndReturn(run func() error) *MockBackend_DisconnectEthernet_Call { + _c.Call.Return(run) + return _c +} + +// DisconnectEthernetDevice provides a mock function with given fields: device +func (_m *MockBackend) DisconnectEthernetDevice(device string) error { + ret := _m.Called(device) + + if len(ret) == 0 { + panic("no return value specified for DisconnectEthernetDevice") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(device) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_DisconnectEthernetDevice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectEthernetDevice' +type MockBackend_DisconnectEthernetDevice_Call struct { + *mock.Call +} + +// DisconnectEthernetDevice is a helper method to define mock.On call +// - device string +func (_e *MockBackend_Expecter) DisconnectEthernetDevice(device interface{}) *MockBackend_DisconnectEthernetDevice_Call { + return &MockBackend_DisconnectEthernetDevice_Call{Call: _e.mock.On("DisconnectEthernetDevice", device)} +} + +func (_c *MockBackend_DisconnectEthernetDevice_Call) Run(run func(device string)) *MockBackend_DisconnectEthernetDevice_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_DisconnectEthernetDevice_Call) Return(_a0 error) *MockBackend_DisconnectEthernetDevice_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_DisconnectEthernetDevice_Call) RunAndReturn(run func(string) error) *MockBackend_DisconnectEthernetDevice_Call { + _c.Call.Return(run) + return _c +} + +// DisconnectVPN provides a mock function with given fields: uuidOrName +func (_m *MockBackend) DisconnectVPN(uuidOrName string) error { + ret := _m.Called(uuidOrName) + + if len(ret) == 0 { + panic("no return value specified for DisconnectVPN") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(uuidOrName) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_DisconnectVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectVPN' +type MockBackend_DisconnectVPN_Call struct { + *mock.Call +} + +// DisconnectVPN is a helper method to define mock.On call +// - uuidOrName string +func (_e *MockBackend_Expecter) DisconnectVPN(uuidOrName interface{}) *MockBackend_DisconnectVPN_Call { + return &MockBackend_DisconnectVPN_Call{Call: _e.mock.On("DisconnectVPN", uuidOrName)} +} + +func (_c *MockBackend_DisconnectVPN_Call) Run(run func(uuidOrName string)) *MockBackend_DisconnectVPN_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_DisconnectVPN_Call) Return(_a0 error) *MockBackend_DisconnectVPN_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_DisconnectVPN_Call) RunAndReturn(run func(string) error) *MockBackend_DisconnectVPN_Call { + _c.Call.Return(run) + return _c +} + +// DisconnectWiFi provides a mock function with no fields +func (_m *MockBackend) DisconnectWiFi() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for DisconnectWiFi") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_DisconnectWiFi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectWiFi' +type MockBackend_DisconnectWiFi_Call struct { + *mock.Call +} + +// DisconnectWiFi is a helper method to define mock.On call +func (_e *MockBackend_Expecter) DisconnectWiFi() *MockBackend_DisconnectWiFi_Call { + return &MockBackend_DisconnectWiFi_Call{Call: _e.mock.On("DisconnectWiFi")} +} + +func (_c *MockBackend_DisconnectWiFi_Call) Run(run func()) *MockBackend_DisconnectWiFi_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_DisconnectWiFi_Call) Return(_a0 error) *MockBackend_DisconnectWiFi_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_DisconnectWiFi_Call) RunAndReturn(run func() error) *MockBackend_DisconnectWiFi_Call { + _c.Call.Return(run) + return _c +} + +// DisconnectWiFiDevice provides a mock function with given fields: device +func (_m *MockBackend) DisconnectWiFiDevice(device string) error { + ret := _m.Called(device) + + if len(ret) == 0 { + panic("no return value specified for DisconnectWiFiDevice") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(device) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_DisconnectWiFiDevice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisconnectWiFiDevice' +type MockBackend_DisconnectWiFiDevice_Call struct { + *mock.Call +} + +// DisconnectWiFiDevice is a helper method to define mock.On call +// - device string +func (_e *MockBackend_Expecter) DisconnectWiFiDevice(device interface{}) *MockBackend_DisconnectWiFiDevice_Call { + return &MockBackend_DisconnectWiFiDevice_Call{Call: _e.mock.On("DisconnectWiFiDevice", device)} +} + +func (_c *MockBackend_DisconnectWiFiDevice_Call) Run(run func(device string)) *MockBackend_DisconnectWiFiDevice_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_DisconnectWiFiDevice_Call) Return(_a0 error) *MockBackend_DisconnectWiFiDevice_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_DisconnectWiFiDevice_Call) RunAndReturn(run func(string) error) *MockBackend_DisconnectWiFiDevice_Call { + _c.Call.Return(run) + return _c +} + +// ForgetWiFiNetwork provides a mock function with given fields: ssid +func (_m *MockBackend) ForgetWiFiNetwork(ssid string) error { + ret := _m.Called(ssid) + + if len(ret) == 0 { + panic("no return value specified for ForgetWiFiNetwork") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(ssid) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_ForgetWiFiNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgetWiFiNetwork' +type MockBackend_ForgetWiFiNetwork_Call struct { + *mock.Call +} + +// ForgetWiFiNetwork is a helper method to define mock.On call +// - ssid string +func (_e *MockBackend_Expecter) ForgetWiFiNetwork(ssid interface{}) *MockBackend_ForgetWiFiNetwork_Call { + return &MockBackend_ForgetWiFiNetwork_Call{Call: _e.mock.On("ForgetWiFiNetwork", ssid)} +} + +func (_c *MockBackend_ForgetWiFiNetwork_Call) Run(run func(ssid string)) *MockBackend_ForgetWiFiNetwork_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_ForgetWiFiNetwork_Call) Return(_a0 error) *MockBackend_ForgetWiFiNetwork_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_ForgetWiFiNetwork_Call) RunAndReturn(run func(string) error) *MockBackend_ForgetWiFiNetwork_Call { + _c.Call.Return(run) + return _c +} + +// GetCurrentState provides a mock function with no fields +func (_m *MockBackend) GetCurrentState() (*network.BackendState, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCurrentState") + } + + var r0 *network.BackendState + var r1 error + if rf, ok := ret.Get(0).(func() (*network.BackendState, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() *network.BackendState); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*network.BackendState) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_GetCurrentState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCurrentState' +type MockBackend_GetCurrentState_Call struct { + *mock.Call +} + +// GetCurrentState is a helper method to define mock.On call +func (_e *MockBackend_Expecter) GetCurrentState() *MockBackend_GetCurrentState_Call { + return &MockBackend_GetCurrentState_Call{Call: _e.mock.On("GetCurrentState")} +} + +func (_c *MockBackend_GetCurrentState_Call) Run(run func()) *MockBackend_GetCurrentState_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_GetCurrentState_Call) Return(_a0 *network.BackendState, _a1 error) *MockBackend_GetCurrentState_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_GetCurrentState_Call) RunAndReturn(run func() (*network.BackendState, error)) *MockBackend_GetCurrentState_Call { + _c.Call.Return(run) + return _c +} + +// GetEthernetDevices provides a mock function with no fields +func (_m *MockBackend) GetEthernetDevices() []network.EthernetDevice { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetEthernetDevices") + } + + var r0 []network.EthernetDevice + if rf, ok := ret.Get(0).(func() []network.EthernetDevice); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.EthernetDevice) + } + } + + return r0 +} + +// MockBackend_GetEthernetDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetEthernetDevices' +type MockBackend_GetEthernetDevices_Call struct { + *mock.Call +} + +// GetEthernetDevices is a helper method to define mock.On call +func (_e *MockBackend_Expecter) GetEthernetDevices() *MockBackend_GetEthernetDevices_Call { + return &MockBackend_GetEthernetDevices_Call{Call: _e.mock.On("GetEthernetDevices")} +} + +func (_c *MockBackend_GetEthernetDevices_Call) Run(run func()) *MockBackend_GetEthernetDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_GetEthernetDevices_Call) Return(_a0 []network.EthernetDevice) *MockBackend_GetEthernetDevices_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_GetEthernetDevices_Call) RunAndReturn(run func() []network.EthernetDevice) *MockBackend_GetEthernetDevices_Call { + _c.Call.Return(run) + return _c +} + +// GetPromptBroker provides a mock function with no fields +func (_m *MockBackend) GetPromptBroker() network.PromptBroker { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetPromptBroker") + } + + var r0 network.PromptBroker + if rf, ok := ret.Get(0).(func() network.PromptBroker); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(network.PromptBroker) + } + } + + return r0 +} + +// MockBackend_GetPromptBroker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPromptBroker' +type MockBackend_GetPromptBroker_Call struct { + *mock.Call +} + +// GetPromptBroker is a helper method to define mock.On call +func (_e *MockBackend_Expecter) GetPromptBroker() *MockBackend_GetPromptBroker_Call { + return &MockBackend_GetPromptBroker_Call{Call: _e.mock.On("GetPromptBroker")} +} + +func (_c *MockBackend_GetPromptBroker_Call) Run(run func()) *MockBackend_GetPromptBroker_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_GetPromptBroker_Call) Return(_a0 network.PromptBroker) *MockBackend_GetPromptBroker_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_GetPromptBroker_Call) RunAndReturn(run func() network.PromptBroker) *MockBackend_GetPromptBroker_Call { + _c.Call.Return(run) + return _c +} + +// GetVPNConfig provides a mock function with given fields: uuidOrName +func (_m *MockBackend) GetVPNConfig(uuidOrName string) (*network.VPNConfig, error) { + ret := _m.Called(uuidOrName) + + if len(ret) == 0 { + panic("no return value specified for GetVPNConfig") + } + + var r0 *network.VPNConfig + var r1 error + if rf, ok := ret.Get(0).(func(string) (*network.VPNConfig, error)); ok { + return rf(uuidOrName) + } + if rf, ok := ret.Get(0).(func(string) *network.VPNConfig); ok { + r0 = rf(uuidOrName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*network.VPNConfig) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(uuidOrName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_GetVPNConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetVPNConfig' +type MockBackend_GetVPNConfig_Call struct { + *mock.Call +} + +// GetVPNConfig is a helper method to define mock.On call +// - uuidOrName string +func (_e *MockBackend_Expecter) GetVPNConfig(uuidOrName interface{}) *MockBackend_GetVPNConfig_Call { + return &MockBackend_GetVPNConfig_Call{Call: _e.mock.On("GetVPNConfig", uuidOrName)} +} + +func (_c *MockBackend_GetVPNConfig_Call) Run(run func(uuidOrName string)) *MockBackend_GetVPNConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_GetVPNConfig_Call) Return(_a0 *network.VPNConfig, _a1 error) *MockBackend_GetVPNConfig_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_GetVPNConfig_Call) RunAndReturn(run func(string) (*network.VPNConfig, error)) *MockBackend_GetVPNConfig_Call { + _c.Call.Return(run) + return _c +} + +// GetWiFiDevices provides a mock function with no fields +func (_m *MockBackend) GetWiFiDevices() []network.WiFiDevice { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetWiFiDevices") + } + + var r0 []network.WiFiDevice + if rf, ok := ret.Get(0).(func() []network.WiFiDevice); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.WiFiDevice) + } + } + + return r0 +} + +// MockBackend_GetWiFiDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiFiDevices' +type MockBackend_GetWiFiDevices_Call struct { + *mock.Call +} + +// GetWiFiDevices is a helper method to define mock.On call +func (_e *MockBackend_Expecter) GetWiFiDevices() *MockBackend_GetWiFiDevices_Call { + return &MockBackend_GetWiFiDevices_Call{Call: _e.mock.On("GetWiFiDevices")} +} + +func (_c *MockBackend_GetWiFiDevices_Call) Run(run func()) *MockBackend_GetWiFiDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_GetWiFiDevices_Call) Return(_a0 []network.WiFiDevice) *MockBackend_GetWiFiDevices_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_GetWiFiDevices_Call) RunAndReturn(run func() []network.WiFiDevice) *MockBackend_GetWiFiDevices_Call { + _c.Call.Return(run) + return _c +} + +// GetWiFiEnabled provides a mock function with no fields +func (_m *MockBackend) GetWiFiEnabled() (bool, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetWiFiEnabled") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func() (bool, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_GetWiFiEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiFiEnabled' +type MockBackend_GetWiFiEnabled_Call struct { + *mock.Call +} + +// GetWiFiEnabled is a helper method to define mock.On call +func (_e *MockBackend_Expecter) GetWiFiEnabled() *MockBackend_GetWiFiEnabled_Call { + return &MockBackend_GetWiFiEnabled_Call{Call: _e.mock.On("GetWiFiEnabled")} +} + +func (_c *MockBackend_GetWiFiEnabled_Call) Run(run func()) *MockBackend_GetWiFiEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_GetWiFiEnabled_Call) Return(_a0 bool, _a1 error) *MockBackend_GetWiFiEnabled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_GetWiFiEnabled_Call) RunAndReturn(run func() (bool, error)) *MockBackend_GetWiFiEnabled_Call { + _c.Call.Return(run) + return _c +} + +// GetWiFiNetworkDetails provides a mock function with given fields: ssid +func (_m *MockBackend) GetWiFiNetworkDetails(ssid string) (*network.NetworkInfoResponse, error) { + ret := _m.Called(ssid) + + if len(ret) == 0 { + panic("no return value specified for GetWiFiNetworkDetails") + } + + var r0 *network.NetworkInfoResponse + var r1 error + if rf, ok := ret.Get(0).(func(string) (*network.NetworkInfoResponse, error)); ok { + return rf(ssid) + } + if rf, ok := ret.Get(0).(func(string) *network.NetworkInfoResponse); ok { + r0 = rf(ssid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*network.NetworkInfoResponse) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(ssid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_GetWiFiNetworkDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiFiNetworkDetails' +type MockBackend_GetWiFiNetworkDetails_Call struct { + *mock.Call +} + +// GetWiFiNetworkDetails is a helper method to define mock.On call +// - ssid string +func (_e *MockBackend_Expecter) GetWiFiNetworkDetails(ssid interface{}) *MockBackend_GetWiFiNetworkDetails_Call { + return &MockBackend_GetWiFiNetworkDetails_Call{Call: _e.mock.On("GetWiFiNetworkDetails", ssid)} +} + +func (_c *MockBackend_GetWiFiNetworkDetails_Call) Run(run func(ssid string)) *MockBackend_GetWiFiNetworkDetails_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_GetWiFiNetworkDetails_Call) Return(_a0 *network.NetworkInfoResponse, _a1 error) *MockBackend_GetWiFiNetworkDetails_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_GetWiFiNetworkDetails_Call) RunAndReturn(run func(string) (*network.NetworkInfoResponse, error)) *MockBackend_GetWiFiNetworkDetails_Call { + _c.Call.Return(run) + return _c +} + +// GetWiFiQRCodeContent provides a mock function with given fields: ssid +func (_m *MockBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + ret := _m.Called(ssid) + + if len(ret) == 0 { + panic("no return value specified for GetWiFiQRCodeContent") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(ssid) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(ssid) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(ssid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_GetWiFiQRCodeContent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiFiQRCodeContent' +type MockBackend_GetWiFiQRCodeContent_Call struct { + *mock.Call +} + +// GetWiFiQRCodeContent is a helper method to define mock.On call +// - ssid string +func (_e *MockBackend_Expecter) GetWiFiQRCodeContent(ssid interface{}) *MockBackend_GetWiFiQRCodeContent_Call { + return &MockBackend_GetWiFiQRCodeContent_Call{Call: _e.mock.On("GetWiFiQRCodeContent", ssid)} +} + +func (_c *MockBackend_GetWiFiQRCodeContent_Call) Run(run func(ssid string)) *MockBackend_GetWiFiQRCodeContent_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_GetWiFiQRCodeContent_Call) Return(_a0 string, _a1 error) *MockBackend_GetWiFiQRCodeContent_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_GetWiFiQRCodeContent_Call) RunAndReturn(run func(string) (string, error)) *MockBackend_GetWiFiQRCodeContent_Call { + _c.Call.Return(run) + return _c +} + +// GetWiredConnections provides a mock function with no fields +func (_m *MockBackend) GetWiredConnections() ([]network.WiredConnection, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetWiredConnections") + } + + var r0 []network.WiredConnection + var r1 error + if rf, ok := ret.Get(0).(func() ([]network.WiredConnection, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []network.WiredConnection); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.WiredConnection) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_GetWiredConnections_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiredConnections' +type MockBackend_GetWiredConnections_Call struct { + *mock.Call +} + +// GetWiredConnections is a helper method to define mock.On call +func (_e *MockBackend_Expecter) GetWiredConnections() *MockBackend_GetWiredConnections_Call { + return &MockBackend_GetWiredConnections_Call{Call: _e.mock.On("GetWiredConnections")} +} + +func (_c *MockBackend_GetWiredConnections_Call) Run(run func()) *MockBackend_GetWiredConnections_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_GetWiredConnections_Call) Return(_a0 []network.WiredConnection, _a1 error) *MockBackend_GetWiredConnections_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_GetWiredConnections_Call) RunAndReturn(run func() ([]network.WiredConnection, error)) *MockBackend_GetWiredConnections_Call { + _c.Call.Return(run) + return _c +} + +// GetWiredNetworkDetails provides a mock function with given fields: uuid +func (_m *MockBackend) GetWiredNetworkDetails(uuid string) (*network.WiredNetworkInfoResponse, error) { + ret := _m.Called(uuid) + + if len(ret) == 0 { + panic("no return value specified for GetWiredNetworkDetails") + } + + var r0 *network.WiredNetworkInfoResponse + var r1 error + if rf, ok := ret.Get(0).(func(string) (*network.WiredNetworkInfoResponse, error)); ok { + return rf(uuid) + } + if rf, ok := ret.Get(0).(func(string) *network.WiredNetworkInfoResponse); ok { + r0 = rf(uuid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*network.WiredNetworkInfoResponse) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(uuid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_GetWiredNetworkDetails_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiredNetworkDetails' +type MockBackend_GetWiredNetworkDetails_Call struct { + *mock.Call +} + +// GetWiredNetworkDetails is a helper method to define mock.On call +// - uuid string +func (_e *MockBackend_Expecter) GetWiredNetworkDetails(uuid interface{}) *MockBackend_GetWiredNetworkDetails_Call { + return &MockBackend_GetWiredNetworkDetails_Call{Call: _e.mock.On("GetWiredNetworkDetails", uuid)} +} + +func (_c *MockBackend_GetWiredNetworkDetails_Call) Run(run func(uuid string)) *MockBackend_GetWiredNetworkDetails_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_GetWiredNetworkDetails_Call) Return(_a0 *network.WiredNetworkInfoResponse, _a1 error) *MockBackend_GetWiredNetworkDetails_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_GetWiredNetworkDetails_Call) RunAndReturn(run func(string) (*network.WiredNetworkInfoResponse, error)) *MockBackend_GetWiredNetworkDetails_Call { + _c.Call.Return(run) + return _c +} + +// ImportVPN provides a mock function with given fields: filePath, name +func (_m *MockBackend) ImportVPN(filePath string, name string) (*network.VPNImportResult, error) { + ret := _m.Called(filePath, name) + + if len(ret) == 0 { + panic("no return value specified for ImportVPN") + } + + var r0 *network.VPNImportResult + var r1 error + if rf, ok := ret.Get(0).(func(string, string) (*network.VPNImportResult, error)); ok { + return rf(filePath, name) + } + if rf, ok := ret.Get(0).(func(string, string) *network.VPNImportResult); ok { + r0 = rf(filePath, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*network.VPNImportResult) + } + } + + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(filePath, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_ImportVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ImportVPN' +type MockBackend_ImportVPN_Call struct { + *mock.Call +} + +// ImportVPN is a helper method to define mock.On call +// - filePath string +// - name string +func (_e *MockBackend_Expecter) ImportVPN(filePath interface{}, name interface{}) *MockBackend_ImportVPN_Call { + return &MockBackend_ImportVPN_Call{Call: _e.mock.On("ImportVPN", filePath, name)} +} + +func (_c *MockBackend_ImportVPN_Call) Run(run func(filePath string, name string)) *MockBackend_ImportVPN_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *MockBackend_ImportVPN_Call) Return(_a0 *network.VPNImportResult, _a1 error) *MockBackend_ImportVPN_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_ImportVPN_Call) RunAndReturn(run func(string, string) (*network.VPNImportResult, error)) *MockBackend_ImportVPN_Call { + _c.Call.Return(run) + return _c +} + +// Initialize provides a mock function with no fields +func (_m *MockBackend) Initialize() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Initialize") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_Initialize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Initialize' +type MockBackend_Initialize_Call struct { + *mock.Call +} + +// Initialize is a helper method to define mock.On call +func (_e *MockBackend_Expecter) Initialize() *MockBackend_Initialize_Call { + return &MockBackend_Initialize_Call{Call: _e.mock.On("Initialize")} +} + +func (_c *MockBackend_Initialize_Call) Run(run func()) *MockBackend_Initialize_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_Initialize_Call) Return(_a0 error) *MockBackend_Initialize_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_Initialize_Call) RunAndReturn(run func() error) *MockBackend_Initialize_Call { + _c.Call.Return(run) + return _c +} + +// ListActiveVPN provides a mock function with no fields +func (_m *MockBackend) ListActiveVPN() ([]network.VPNActive, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ListActiveVPN") + } + + var r0 []network.VPNActive + var r1 error + if rf, ok := ret.Get(0).(func() ([]network.VPNActive, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []network.VPNActive); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.VPNActive) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_ListActiveVPN_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListActiveVPN' +type MockBackend_ListActiveVPN_Call struct { + *mock.Call +} + +// ListActiveVPN is a helper method to define mock.On call +func (_e *MockBackend_Expecter) ListActiveVPN() *MockBackend_ListActiveVPN_Call { + return &MockBackend_ListActiveVPN_Call{Call: _e.mock.On("ListActiveVPN")} +} + +func (_c *MockBackend_ListActiveVPN_Call) Run(run func()) *MockBackend_ListActiveVPN_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_ListActiveVPN_Call) Return(_a0 []network.VPNActive, _a1 error) *MockBackend_ListActiveVPN_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_ListActiveVPN_Call) RunAndReturn(run func() ([]network.VPNActive, error)) *MockBackend_ListActiveVPN_Call { + _c.Call.Return(run) + return _c +} + +// ListVPNPlugins provides a mock function with no fields +func (_m *MockBackend) ListVPNPlugins() ([]network.VPNPlugin, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ListVPNPlugins") + } + + var r0 []network.VPNPlugin + var r1 error + if rf, ok := ret.Get(0).(func() ([]network.VPNPlugin, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []network.VPNPlugin); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.VPNPlugin) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_ListVPNPlugins_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVPNPlugins' +type MockBackend_ListVPNPlugins_Call struct { + *mock.Call +} + +// ListVPNPlugins is a helper method to define mock.On call +func (_e *MockBackend_Expecter) ListVPNPlugins() *MockBackend_ListVPNPlugins_Call { + return &MockBackend_ListVPNPlugins_Call{Call: _e.mock.On("ListVPNPlugins")} +} + +func (_c *MockBackend_ListVPNPlugins_Call) Run(run func()) *MockBackend_ListVPNPlugins_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_ListVPNPlugins_Call) Return(_a0 []network.VPNPlugin, _a1 error) *MockBackend_ListVPNPlugins_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_ListVPNPlugins_Call) RunAndReturn(run func() ([]network.VPNPlugin, error)) *MockBackend_ListVPNPlugins_Call { + _c.Call.Return(run) + return _c +} + +// ListVPNProfiles provides a mock function with no fields +func (_m *MockBackend) ListVPNProfiles() ([]network.VPNProfile, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ListVPNProfiles") + } + + var r0 []network.VPNProfile + var r1 error + if rf, ok := ret.Get(0).(func() ([]network.VPNProfile, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []network.VPNProfile); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]network.VPNProfile) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_ListVPNProfiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVPNProfiles' +type MockBackend_ListVPNProfiles_Call struct { + *mock.Call +} + +// ListVPNProfiles is a helper method to define mock.On call +func (_e *MockBackend_Expecter) ListVPNProfiles() *MockBackend_ListVPNProfiles_Call { + return &MockBackend_ListVPNProfiles_Call{Call: _e.mock.On("ListVPNProfiles")} +} + +func (_c *MockBackend_ListVPNProfiles_Call) Run(run func()) *MockBackend_ListVPNProfiles_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_ListVPNProfiles_Call) Return(_a0 []network.VPNProfile, _a1 error) *MockBackend_ListVPNProfiles_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_ListVPNProfiles_Call) RunAndReturn(run func() ([]network.VPNProfile, error)) *MockBackend_ListVPNProfiles_Call { + _c.Call.Return(run) + return _c +} + +// ScanWiFi provides a mock function with no fields +func (_m *MockBackend) ScanWiFi() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ScanWiFi") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_ScanWiFi_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ScanWiFi' +type MockBackend_ScanWiFi_Call struct { + *mock.Call +} + +// ScanWiFi is a helper method to define mock.On call +func (_e *MockBackend_Expecter) ScanWiFi() *MockBackend_ScanWiFi_Call { + return &MockBackend_ScanWiFi_Call{Call: _e.mock.On("ScanWiFi")} +} + +func (_c *MockBackend_ScanWiFi_Call) Run(run func()) *MockBackend_ScanWiFi_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_ScanWiFi_Call) Return(_a0 error) *MockBackend_ScanWiFi_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_ScanWiFi_Call) RunAndReturn(run func() error) *MockBackend_ScanWiFi_Call { + _c.Call.Return(run) + return _c +} + +// ScanWiFiDevice provides a mock function with given fields: device +func (_m *MockBackend) ScanWiFiDevice(device string) error { + ret := _m.Called(device) + + if len(ret) == 0 { + panic("no return value specified for ScanWiFiDevice") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(device) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_ScanWiFiDevice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ScanWiFiDevice' +type MockBackend_ScanWiFiDevice_Call struct { + *mock.Call +} + +// ScanWiFiDevice is a helper method to define mock.On call +// - device string +func (_e *MockBackend_Expecter) ScanWiFiDevice(device interface{}) *MockBackend_ScanWiFiDevice_Call { + return &MockBackend_ScanWiFiDevice_Call{Call: _e.mock.On("ScanWiFiDevice", device)} +} + +func (_c *MockBackend_ScanWiFiDevice_Call) Run(run func(device string)) *MockBackend_ScanWiFiDevice_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_ScanWiFiDevice_Call) Return(_a0 error) *MockBackend_ScanWiFiDevice_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_ScanWiFiDevice_Call) RunAndReturn(run func(string) error) *MockBackend_ScanWiFiDevice_Call { + _c.Call.Return(run) + return _c +} + +// SetPromptBroker provides a mock function with given fields: broker +func (_m *MockBackend) SetPromptBroker(broker network.PromptBroker) error { + ret := _m.Called(broker) + + if len(ret) == 0 { + panic("no return value specified for SetPromptBroker") + } + + var r0 error + if rf, ok := ret.Get(0).(func(network.PromptBroker) error); ok { + r0 = rf(broker) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_SetPromptBroker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetPromptBroker' +type MockBackend_SetPromptBroker_Call struct { + *mock.Call +} + +// SetPromptBroker is a helper method to define mock.On call +// - broker network.PromptBroker +func (_e *MockBackend_Expecter) SetPromptBroker(broker interface{}) *MockBackend_SetPromptBroker_Call { + return &MockBackend_SetPromptBroker_Call{Call: _e.mock.On("SetPromptBroker", broker)} +} + +func (_c *MockBackend_SetPromptBroker_Call) Run(run func(broker network.PromptBroker)) *MockBackend_SetPromptBroker_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(network.PromptBroker)) + }) + return _c +} + +func (_c *MockBackend_SetPromptBroker_Call) Return(_a0 error) *MockBackend_SetPromptBroker_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_SetPromptBroker_Call) RunAndReturn(run func(network.PromptBroker) error) *MockBackend_SetPromptBroker_Call { + _c.Call.Return(run) + return _c +} + +// SetVPNCredentials provides a mock function with given fields: uuid, username, password, save +func (_m *MockBackend) SetVPNCredentials(uuid string, username string, password string, save bool) error { + ret := _m.Called(uuid, username, password, save) + + if len(ret) == 0 { + panic("no return value specified for SetVPNCredentials") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, string, bool) error); ok { + r0 = rf(uuid, username, password, save) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_SetVPNCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetVPNCredentials' +type MockBackend_SetVPNCredentials_Call struct { + *mock.Call +} + +// SetVPNCredentials is a helper method to define mock.On call +// - uuid string +// - username string +// - password string +// - save bool +func (_e *MockBackend_Expecter) SetVPNCredentials(uuid interface{}, username interface{}, password interface{}, save interface{}) *MockBackend_SetVPNCredentials_Call { + return &MockBackend_SetVPNCredentials_Call{Call: _e.mock.On("SetVPNCredentials", uuid, username, password, save)} +} + +func (_c *MockBackend_SetVPNCredentials_Call) Run(run func(uuid string, username string, password string, save bool)) *MockBackend_SetVPNCredentials_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(string), args[3].(bool)) + }) + return _c +} + +func (_c *MockBackend_SetVPNCredentials_Call) Return(_a0 error) *MockBackend_SetVPNCredentials_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_SetVPNCredentials_Call) RunAndReturn(run func(string, string, string, bool) error) *MockBackend_SetVPNCredentials_Call { + _c.Call.Return(run) + return _c +} + +// SetWiFiAutoconnect provides a mock function with given fields: ssid, autoconnect +func (_m *MockBackend) SetWiFiAutoconnect(ssid string, autoconnect bool) error { + ret := _m.Called(ssid, autoconnect) + + if len(ret) == 0 { + panic("no return value specified for SetWiFiAutoconnect") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(ssid, autoconnect) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_SetWiFiAutoconnect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWiFiAutoconnect' +type MockBackend_SetWiFiAutoconnect_Call struct { + *mock.Call +} + +// SetWiFiAutoconnect is a helper method to define mock.On call +// - ssid string +// - autoconnect bool +func (_e *MockBackend_Expecter) SetWiFiAutoconnect(ssid interface{}, autoconnect interface{}) *MockBackend_SetWiFiAutoconnect_Call { + return &MockBackend_SetWiFiAutoconnect_Call{Call: _e.mock.On("SetWiFiAutoconnect", ssid, autoconnect)} +} + +func (_c *MockBackend_SetWiFiAutoconnect_Call) Run(run func(ssid string, autoconnect bool)) *MockBackend_SetWiFiAutoconnect_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockBackend_SetWiFiAutoconnect_Call) Return(_a0 error) *MockBackend_SetWiFiAutoconnect_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_SetWiFiAutoconnect_Call) RunAndReturn(run func(string, bool) error) *MockBackend_SetWiFiAutoconnect_Call { + _c.Call.Return(run) + return _c +} + +// SetWiFiEnabled provides a mock function with given fields: enabled +func (_m *MockBackend) SetWiFiEnabled(enabled bool) error { + ret := _m.Called(enabled) + + if len(ret) == 0 { + panic("no return value specified for SetWiFiEnabled") + } + + var r0 error + if rf, ok := ret.Get(0).(func(bool) error); ok { + r0 = rf(enabled) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_SetWiFiEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWiFiEnabled' +type MockBackend_SetWiFiEnabled_Call struct { + *mock.Call +} + +// SetWiFiEnabled is a helper method to define mock.On call +// - enabled bool +func (_e *MockBackend_Expecter) SetWiFiEnabled(enabled interface{}) *MockBackend_SetWiFiEnabled_Call { + return &MockBackend_SetWiFiEnabled_Call{Call: _e.mock.On("SetWiFiEnabled", enabled)} +} + +func (_c *MockBackend_SetWiFiEnabled_Call) Run(run func(enabled bool)) *MockBackend_SetWiFiEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool)) + }) + return _c +} + +func (_c *MockBackend_SetWiFiEnabled_Call) Return(_a0 error) *MockBackend_SetWiFiEnabled_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_SetWiFiEnabled_Call) RunAndReturn(run func(bool) error) *MockBackend_SetWiFiEnabled_Call { + _c.Call.Return(run) + return _c +} + +// StartMonitoring provides a mock function with given fields: onStateChange +func (_m *MockBackend) StartMonitoring(onStateChange func()) error { + ret := _m.Called(onStateChange) + + if len(ret) == 0 { + panic("no return value specified for StartMonitoring") + } + + var r0 error + if rf, ok := ret.Get(0).(func(func()) error); ok { + r0 = rf(onStateChange) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_StartMonitoring_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartMonitoring' +type MockBackend_StartMonitoring_Call struct { + *mock.Call +} + +// StartMonitoring is a helper method to define mock.On call +// - onStateChange func() +func (_e *MockBackend_Expecter) StartMonitoring(onStateChange interface{}) *MockBackend_StartMonitoring_Call { + return &MockBackend_StartMonitoring_Call{Call: _e.mock.On("StartMonitoring", onStateChange)} +} + +func (_c *MockBackend_StartMonitoring_Call) Run(run func(onStateChange func())) *MockBackend_StartMonitoring_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(func())) + }) + return _c +} + +func (_c *MockBackend_StartMonitoring_Call) Return(_a0 error) *MockBackend_StartMonitoring_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_StartMonitoring_Call) RunAndReturn(run func(func()) error) *MockBackend_StartMonitoring_Call { + _c.Call.Return(run) + return _c +} + +// StopMonitoring provides a mock function with no fields +func (_m *MockBackend) StopMonitoring() { + _m.Called() +} + +// MockBackend_StopMonitoring_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopMonitoring' +type MockBackend_StopMonitoring_Call struct { + *mock.Call +} + +// StopMonitoring is a helper method to define mock.On call +func (_e *MockBackend_Expecter) StopMonitoring() *MockBackend_StopMonitoring_Call { + return &MockBackend_StopMonitoring_Call{Call: _e.mock.On("StopMonitoring")} +} + +func (_c *MockBackend_StopMonitoring_Call) Run(run func()) *MockBackend_StopMonitoring_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBackend_StopMonitoring_Call) Return() *MockBackend_StopMonitoring_Call { + _c.Call.Return() + return _c +} + +func (_c *MockBackend_StopMonitoring_Call) RunAndReturn(run func()) *MockBackend_StopMonitoring_Call { + _c.Run(run) + return _c +} + +// SubmitCredentials provides a mock function with given fields: token, secrets, save +func (_m *MockBackend) SubmitCredentials(token string, secrets map[string]string, save bool) error { + ret := _m.Called(token, secrets, save) + + if len(ret) == 0 { + panic("no return value specified for SubmitCredentials") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, map[string]string, bool) error); ok { + r0 = rf(token, secrets, save) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_SubmitCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubmitCredentials' +type MockBackend_SubmitCredentials_Call struct { + *mock.Call +} + +// SubmitCredentials is a helper method to define mock.On call +// - token string +// - secrets map[string]string +// - save bool +func (_e *MockBackend_Expecter) SubmitCredentials(token interface{}, secrets interface{}, save interface{}) *MockBackend_SubmitCredentials_Call { + return &MockBackend_SubmitCredentials_Call{Call: _e.mock.On("SubmitCredentials", token, secrets, save)} +} + +func (_c *MockBackend_SubmitCredentials_Call) Run(run func(token string, secrets map[string]string, save bool)) *MockBackend_SubmitCredentials_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(map[string]string), args[2].(bool)) + }) + return _c +} + +func (_c *MockBackend_SubmitCredentials_Call) Return(_a0 error) *MockBackend_SubmitCredentials_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_SubmitCredentials_Call) RunAndReturn(run func(string, map[string]string, bool) error) *MockBackend_SubmitCredentials_Call { + _c.Call.Return(run) + return _c +} + +// UpdateVPNConfig provides a mock function with given fields: uuid, updates +func (_m *MockBackend) UpdateVPNConfig(uuid string, updates map[string]interface{}) error { + ret := _m.Called(uuid, updates) + + if len(ret) == 0 { + panic("no return value specified for UpdateVPNConfig") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, map[string]interface{}) error); ok { + r0 = rf(uuid, updates) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockBackend_UpdateVPNConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateVPNConfig' +type MockBackend_UpdateVPNConfig_Call struct { + *mock.Call +} + +// UpdateVPNConfig is a helper method to define mock.On call +// - uuid string +// - updates map[string]interface{} +func (_e *MockBackend_Expecter) UpdateVPNConfig(uuid interface{}, updates interface{}) *MockBackend_UpdateVPNConfig_Call { + return &MockBackend_UpdateVPNConfig_Call{Call: _e.mock.On("UpdateVPNConfig", uuid, updates)} +} + +func (_c *MockBackend_UpdateVPNConfig_Call) Run(run func(uuid string, updates map[string]interface{})) *MockBackend_UpdateVPNConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(map[string]interface{})) + }) + return _c +} + +func (_c *MockBackend_UpdateVPNConfig_Call) Return(_a0 error) *MockBackend_UpdateVPNConfig_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockBackend_UpdateVPNConfig_Call) RunAndReturn(run func(string, map[string]interface{}) error) *MockBackend_UpdateVPNConfig_Call { + _c.Call.Return(run) + return _c +} + +// NewMockBackend creates a new instance of MockBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockBackend(t interface { + mock.TestingT + Cleanup(func()) +}) *MockBackend { + mock := &MockBackend{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/utils/mock_AppChecker.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/utils/mock_AppChecker.go new file mode 100644 index 0000000..be220c4 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/utils/mock_AppChecker.go @@ -0,0 +1,242 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_utils + +import mock "github.com/stretchr/testify/mock" + +// MockAppChecker is an autogenerated mock type for the AppChecker type +type MockAppChecker struct { + mock.Mock +} + +type MockAppChecker_Expecter struct { + mock *mock.Mock +} + +func (_m *MockAppChecker) EXPECT() *MockAppChecker_Expecter { + return &MockAppChecker_Expecter{mock: &_m.Mock} +} + +// AnyCommandExists provides a mock function with given fields: cmds +func (_m *MockAppChecker) AnyCommandExists(cmds ...string) bool { + _va := make([]interface{}, len(cmds)) + for _i := range cmds { + _va[_i] = cmds[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for AnyCommandExists") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(...string) bool); ok { + r0 = rf(cmds...) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockAppChecker_AnyCommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyCommandExists' +type MockAppChecker_AnyCommandExists_Call struct { + *mock.Call +} + +// AnyCommandExists is a helper method to define mock.On call +// - cmds ...string +func (_e *MockAppChecker_Expecter) AnyCommandExists(cmds ...interface{}) *MockAppChecker_AnyCommandExists_Call { + return &MockAppChecker_AnyCommandExists_Call{Call: _e.mock.On("AnyCommandExists", + append([]interface{}{}, cmds...)...)} +} + +func (_c *MockAppChecker_AnyCommandExists_Call) Run(run func(cmds ...string)) *MockAppChecker_AnyCommandExists_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]string, len(args)-0) + for i, a := range args[0:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(variadicArgs...) + }) + return _c +} + +func (_c *MockAppChecker_AnyCommandExists_Call) Return(_a0 bool) *MockAppChecker_AnyCommandExists_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockAppChecker_AnyCommandExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyCommandExists_Call { + _c.Call.Return(run) + return _c +} + +// AnyFlatpakExists provides a mock function with given fields: flatpaks +func (_m *MockAppChecker) AnyFlatpakExists(flatpaks ...string) bool { + _va := make([]interface{}, len(flatpaks)) + for _i := range flatpaks { + _va[_i] = flatpaks[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for AnyFlatpakExists") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(...string) bool); ok { + r0 = rf(flatpaks...) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockAppChecker_AnyFlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AnyFlatpakExists' +type MockAppChecker_AnyFlatpakExists_Call struct { + *mock.Call +} + +// AnyFlatpakExists is a helper method to define mock.On call +// - flatpaks ...string +func (_e *MockAppChecker_Expecter) AnyFlatpakExists(flatpaks ...interface{}) *MockAppChecker_AnyFlatpakExists_Call { + return &MockAppChecker_AnyFlatpakExists_Call{Call: _e.mock.On("AnyFlatpakExists", + append([]interface{}{}, flatpaks...)...)} +} + +func (_c *MockAppChecker_AnyFlatpakExists_Call) Run(run func(flatpaks ...string)) *MockAppChecker_AnyFlatpakExists_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]string, len(args)-0) + for i, a := range args[0:] { + if a != nil { + variadicArgs[i] = a.(string) + } + } + run(variadicArgs...) + }) + return _c +} + +func (_c *MockAppChecker_AnyFlatpakExists_Call) Return(_a0 bool) *MockAppChecker_AnyFlatpakExists_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockAppChecker_AnyFlatpakExists_Call) RunAndReturn(run func(...string) bool) *MockAppChecker_AnyFlatpakExists_Call { + _c.Call.Return(run) + return _c +} + +// CommandExists provides a mock function with given fields: cmd +func (_m *MockAppChecker) CommandExists(cmd string) bool { + ret := _m.Called(cmd) + + if len(ret) == 0 { + panic("no return value specified for CommandExists") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(cmd) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockAppChecker_CommandExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommandExists' +type MockAppChecker_CommandExists_Call struct { + *mock.Call +} + +// CommandExists is a helper method to define mock.On call +// - cmd string +func (_e *MockAppChecker_Expecter) CommandExists(cmd interface{}) *MockAppChecker_CommandExists_Call { + return &MockAppChecker_CommandExists_Call{Call: _e.mock.On("CommandExists", cmd)} +} + +func (_c *MockAppChecker_CommandExists_Call) Run(run func(cmd string)) *MockAppChecker_CommandExists_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockAppChecker_CommandExists_Call) Return(_a0 bool) *MockAppChecker_CommandExists_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockAppChecker_CommandExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_CommandExists_Call { + _c.Call.Return(run) + return _c +} + +// FlatpakExists provides a mock function with given fields: name +func (_m *MockAppChecker) FlatpakExists(name string) bool { + ret := _m.Called(name) + + if len(ret) == 0 { + panic("no return value specified for FlatpakExists") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(name) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockAppChecker_FlatpakExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FlatpakExists' +type MockAppChecker_FlatpakExists_Call struct { + *mock.Call +} + +// FlatpakExists is a helper method to define mock.On call +// - name string +func (_e *MockAppChecker_Expecter) FlatpakExists(name interface{}) *MockAppChecker_FlatpakExists_Call { + return &MockAppChecker_FlatpakExists_Call{Call: _e.mock.On("FlatpakExists", name)} +} + +func (_c *MockAppChecker_FlatpakExists_Call) Run(run func(name string)) *MockAppChecker_FlatpakExists_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockAppChecker_FlatpakExists_Call) Return(_a0 bool) *MockAppChecker_FlatpakExists_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockAppChecker_FlatpakExists_Call) RunAndReturn(run func(string) bool) *MockAppChecker_FlatpakExists_Call { + _c.Call.Return(run) + return _c +} + +// NewMockAppChecker creates a new instance of MockAppChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAppChecker(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAppChecker { + mock := &MockAppChecker{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/version/mock_VersionFetcher.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/version/mock_VersionFetcher.go new file mode 100644 index 0000000..a6fa27c --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/version/mock_VersionFetcher.go @@ -0,0 +1,144 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_version + +import mock "github.com/stretchr/testify/mock" + +// MockVersionFetcher is an autogenerated mock type for the VersionFetcher type +type MockVersionFetcher struct { + mock.Mock +} + +type MockVersionFetcher_Expecter struct { + mock *mock.Mock +} + +func (_m *MockVersionFetcher) EXPECT() *MockVersionFetcher_Expecter { + return &MockVersionFetcher_Expecter{mock: &_m.Mock} +} + +// GetCurrentVersion provides a mock function with given fields: dmsPath +func (_m *MockVersionFetcher) GetCurrentVersion(dmsPath string) (string, error) { + ret := _m.Called(dmsPath) + + if len(ret) == 0 { + panic("no return value specified for GetCurrentVersion") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(dmsPath) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(dmsPath) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(dmsPath) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockVersionFetcher_GetCurrentVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCurrentVersion' +type MockVersionFetcher_GetCurrentVersion_Call struct { + *mock.Call +} + +// GetCurrentVersion is a helper method to define mock.On call +// - dmsPath string +func (_e *MockVersionFetcher_Expecter) GetCurrentVersion(dmsPath interface{}) *MockVersionFetcher_GetCurrentVersion_Call { + return &MockVersionFetcher_GetCurrentVersion_Call{Call: _e.mock.On("GetCurrentVersion", dmsPath)} +} + +func (_c *MockVersionFetcher_GetCurrentVersion_Call) Run(run func(dmsPath string)) *MockVersionFetcher_GetCurrentVersion_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockVersionFetcher_GetCurrentVersion_Call) Return(_a0 string, _a1 error) *MockVersionFetcher_GetCurrentVersion_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockVersionFetcher_GetCurrentVersion_Call) RunAndReturn(run func(string) (string, error)) *MockVersionFetcher_GetCurrentVersion_Call { + _c.Call.Return(run) + return _c +} + +// GetLatestVersion provides a mock function with given fields: dmsPath +func (_m *MockVersionFetcher) GetLatestVersion(dmsPath string) (string, error) { + ret := _m.Called(dmsPath) + + if len(ret) == 0 { + panic("no return value specified for GetLatestVersion") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(dmsPath) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(dmsPath) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(dmsPath) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockVersionFetcher_GetLatestVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestVersion' +type MockVersionFetcher_GetLatestVersion_Call struct { + *mock.Call +} + +// GetLatestVersion is a helper method to define mock.On call +// - dmsPath string +func (_e *MockVersionFetcher_Expecter) GetLatestVersion(dmsPath interface{}) *MockVersionFetcher_GetLatestVersion_Call { + return &MockVersionFetcher_GetLatestVersion_Call{Call: _e.mock.On("GetLatestVersion", dmsPath)} +} + +func (_c *MockVersionFetcher_GetLatestVersion_Call) Run(run func(dmsPath string)) *MockVersionFetcher_GetLatestVersion_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockVersionFetcher_GetLatestVersion_Call) Return(_a0 string, _a1 error) *MockVersionFetcher_GetLatestVersion_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockVersionFetcher_GetLatestVersion_Call) RunAndReturn(run func(string) (string, error)) *MockVersionFetcher_GetLatestVersion_Call { + _c.Call.Return(run) + return _c +} + +// NewMockVersionFetcher creates a new instance of MockVersionFetcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockVersionFetcher(t interface { + mock.TestingT + Cleanup(func()) +}) *MockVersionFetcher { + mock := &MockVersionFetcher{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/wlclient/mock_WaylandDisplay.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/wlclient/mock_WaylandDisplay.go new file mode 100644 index 0000000..45d944b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/wlclient/mock_WaylandDisplay.go @@ -0,0 +1,229 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_wlclient + +import ( + client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + mock "github.com/stretchr/testify/mock" +) + +// MockWaylandDisplay is an autogenerated mock type for the WaylandDisplay type +type MockWaylandDisplay struct { + mock.Mock +} + +type MockWaylandDisplay_Expecter struct { + mock *mock.Mock +} + +func (_m *MockWaylandDisplay) EXPECT() *MockWaylandDisplay_Expecter { + return &MockWaylandDisplay_Expecter{mock: &_m.Mock} +} + +// Context provides a mock function with no fields +func (_m *MockWaylandDisplay) Context() *client.Context { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Context") + } + + var r0 *client.Context + if rf, ok := ret.Get(0).(func() *client.Context); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.Context) + } + } + + return r0 +} + +// MockWaylandDisplay_Context_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Context' +type MockWaylandDisplay_Context_Call struct { + *mock.Call +} + +// Context is a helper method to define mock.On call +func (_e *MockWaylandDisplay_Expecter) Context() *MockWaylandDisplay_Context_Call { + return &MockWaylandDisplay_Context_Call{Call: _e.mock.On("Context")} +} + +func (_c *MockWaylandDisplay_Context_Call) Run(run func()) *MockWaylandDisplay_Context_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockWaylandDisplay_Context_Call) Return(_a0 *client.Context) *MockWaylandDisplay_Context_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockWaylandDisplay_Context_Call) RunAndReturn(run func() *client.Context) *MockWaylandDisplay_Context_Call { + _c.Call.Return(run) + return _c +} + +// Destroy provides a mock function with no fields +func (_m *MockWaylandDisplay) Destroy() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Destroy") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockWaylandDisplay_Destroy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Destroy' +type MockWaylandDisplay_Destroy_Call struct { + *mock.Call +} + +// Destroy is a helper method to define mock.On call +func (_e *MockWaylandDisplay_Expecter) Destroy() *MockWaylandDisplay_Destroy_Call { + return &MockWaylandDisplay_Destroy_Call{Call: _e.mock.On("Destroy")} +} + +func (_c *MockWaylandDisplay_Destroy_Call) Run(run func()) *MockWaylandDisplay_Destroy_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockWaylandDisplay_Destroy_Call) Return(_a0 error) *MockWaylandDisplay_Destroy_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockWaylandDisplay_Destroy_Call) RunAndReturn(run func() error) *MockWaylandDisplay_Destroy_Call { + _c.Call.Return(run) + return _c +} + +// GetRegistry provides a mock function with no fields +func (_m *MockWaylandDisplay) GetRegistry() (*client.Registry, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetRegistry") + } + + var r0 *client.Registry + var r1 error + if rf, ok := ret.Get(0).(func() (*client.Registry, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() *client.Registry); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.Registry) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockWaylandDisplay_GetRegistry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRegistry' +type MockWaylandDisplay_GetRegistry_Call struct { + *mock.Call +} + +// GetRegistry is a helper method to define mock.On call +func (_e *MockWaylandDisplay_Expecter) GetRegistry() *MockWaylandDisplay_GetRegistry_Call { + return &MockWaylandDisplay_GetRegistry_Call{Call: _e.mock.On("GetRegistry")} +} + +func (_c *MockWaylandDisplay_GetRegistry_Call) Run(run func()) *MockWaylandDisplay_GetRegistry_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockWaylandDisplay_GetRegistry_Call) Return(_a0 *client.Registry, _a1 error) *MockWaylandDisplay_GetRegistry_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockWaylandDisplay_GetRegistry_Call) RunAndReturn(run func() (*client.Registry, error)) *MockWaylandDisplay_GetRegistry_Call { + _c.Call.Return(run) + return _c +} + +// Roundtrip provides a mock function with no fields +func (_m *MockWaylandDisplay) Roundtrip() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Roundtrip") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockWaylandDisplay_Roundtrip_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Roundtrip' +type MockWaylandDisplay_Roundtrip_Call struct { + *mock.Call +} + +// Roundtrip is a helper method to define mock.On call +func (_e *MockWaylandDisplay_Expecter) Roundtrip() *MockWaylandDisplay_Roundtrip_Call { + return &MockWaylandDisplay_Roundtrip_Call{Call: _e.mock.On("Roundtrip")} +} + +func (_c *MockWaylandDisplay_Roundtrip_Call) Run(run func()) *MockWaylandDisplay_Roundtrip_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockWaylandDisplay_Roundtrip_Call) Return(_a0 error) *MockWaylandDisplay_Roundtrip_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockWaylandDisplay_Roundtrip_Call) RunAndReturn(run func() error) *MockWaylandDisplay_Roundtrip_Call { + _c.Call.Return(run) + return _c +} + +// NewMockWaylandDisplay creates a new instance of MockWaylandDisplay. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockWaylandDisplay(t interface { + mock.TestingT + Cleanup(func()) +}) *MockWaylandDisplay { + mock := &MockWaylandDisplay{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/wlcontext/mock_WaylandContext.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/wlcontext/mock_WaylandContext.go new file mode 100644 index 0000000..f5b0c35 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/mocks/wlcontext/mock_WaylandContext.go @@ -0,0 +1,226 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks_wlcontext + +import ( + client "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + mock "github.com/stretchr/testify/mock" +) + +// MockWaylandContext is an autogenerated mock type for the WaylandContext type +type MockWaylandContext struct { + mock.Mock +} + +type MockWaylandContext_Expecter struct { + mock *mock.Mock +} + +func (_m *MockWaylandContext) EXPECT() *MockWaylandContext_Expecter { + return &MockWaylandContext_Expecter{mock: &_m.Mock} +} + +// Close provides a mock function with no fields +func (_m *MockWaylandContext) Close() { + _m.Called() +} + +// MockWaylandContext_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' +type MockWaylandContext_Close_Call struct { + *mock.Call +} + +// Close is a helper method to define mock.On call +func (_e *MockWaylandContext_Expecter) Close() *MockWaylandContext_Close_Call { + return &MockWaylandContext_Close_Call{Call: _e.mock.On("Close")} +} + +func (_c *MockWaylandContext_Close_Call) Run(run func()) *MockWaylandContext_Close_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockWaylandContext_Close_Call) Return() *MockWaylandContext_Close_Call { + _c.Call.Return() + return _c +} + +func (_c *MockWaylandContext_Close_Call) RunAndReturn(run func()) *MockWaylandContext_Close_Call { + _c.Run(run) + return _c +} + +// Display provides a mock function with no fields +func (_m *MockWaylandContext) Display() *client.Display { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Display") + } + + var r0 *client.Display + if rf, ok := ret.Get(0).(func() *client.Display); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.Display) + } + } + + return r0 +} + +// MockWaylandContext_Display_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Display' +type MockWaylandContext_Display_Call struct { + *mock.Call +} + +// Display is a helper method to define mock.On call +func (_e *MockWaylandContext_Expecter) Display() *MockWaylandContext_Display_Call { + return &MockWaylandContext_Display_Call{Call: _e.mock.On("Display")} +} + +func (_c *MockWaylandContext_Display_Call) Run(run func()) *MockWaylandContext_Display_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockWaylandContext_Display_Call) Return(_a0 *client.Display) *MockWaylandContext_Display_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockWaylandContext_Display_Call) RunAndReturn(run func() *client.Display) *MockWaylandContext_Display_Call { + _c.Call.Return(run) + return _c +} + +// FatalError provides a mock function with no fields +func (_m *MockWaylandContext) FatalError() <-chan error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for FatalError") + } + + var r0 <-chan error + if rf, ok := ret.Get(0).(func() <-chan error); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan error) + } + } + + return r0 +} + +// MockWaylandContext_FatalError_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FatalError' +type MockWaylandContext_FatalError_Call struct { + *mock.Call +} + +// FatalError is a helper method to define mock.On call +func (_e *MockWaylandContext_Expecter) FatalError() *MockWaylandContext_FatalError_Call { + return &MockWaylandContext_FatalError_Call{Call: _e.mock.On("FatalError")} +} + +func (_c *MockWaylandContext_FatalError_Call) Run(run func()) *MockWaylandContext_FatalError_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockWaylandContext_FatalError_Call) Return(_a0 <-chan error) *MockWaylandContext_FatalError_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockWaylandContext_FatalError_Call) RunAndReturn(run func() <-chan error) *MockWaylandContext_FatalError_Call { + _c.Call.Return(run) + return _c +} + +// Post provides a mock function with given fields: fn +func (_m *MockWaylandContext) Post(fn func()) { + _m.Called(fn) +} + +// MockWaylandContext_Post_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Post' +type MockWaylandContext_Post_Call struct { + *mock.Call +} + +// Post is a helper method to define mock.On call +// - fn func() +func (_e *MockWaylandContext_Expecter) Post(fn interface{}) *MockWaylandContext_Post_Call { + return &MockWaylandContext_Post_Call{Call: _e.mock.On("Post", fn)} +} + +func (_c *MockWaylandContext_Post_Call) Run(run func(fn func())) *MockWaylandContext_Post_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(func())) + }) + return _c +} + +func (_c *MockWaylandContext_Post_Call) Return() *MockWaylandContext_Post_Call { + _c.Call.Return() + return _c +} + +func (_c *MockWaylandContext_Post_Call) RunAndReturn(run func(func())) *MockWaylandContext_Post_Call { + _c.Run(run) + return _c +} + +// Start provides a mock function with no fields +func (_m *MockWaylandContext) Start() { + _m.Called() +} + +// MockWaylandContext_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start' +type MockWaylandContext_Start_Call struct { + *mock.Call +} + +// Start is a helper method to define mock.On call +func (_e *MockWaylandContext_Expecter) Start() *MockWaylandContext_Start_Call { + return &MockWaylandContext_Start_Call{Call: _e.mock.On("Start")} +} + +func (_c *MockWaylandContext_Start_Call) Run(run func()) *MockWaylandContext_Start_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockWaylandContext_Start_Call) Return() *MockWaylandContext_Start_Call { + _c.Call.Return() + return _c +} + +func (_c *MockWaylandContext_Start_Call) RunAndReturn(run func()) *MockWaylandContext_Start_Call { + _c.Run(run) + return _c +} + +// NewMockWaylandContext creates a new instance of MockWaylandContext. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockWaylandContext(t interface { + mock.TestingT + Cleanup(func()) +}) *MockWaylandContext { + mock := &MockWaylandContext{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/notify/notify.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/notify/notify.go new file mode 100644 index 0000000..1e42b16 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/notify/notify.go @@ -0,0 +1,185 @@ +package notify + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/godbus/dbus/v5" +) + +const ( + notifyDest = "org.freedesktop.Notifications" + notifyPath = "/org/freedesktop/Notifications" + notifyInterface = "org.freedesktop.Notifications" + + maxSummaryLen = 29 + maxBodyLen = 80 +) + +type Notification struct { + AppName string + Icon string + Summary string + Body string + FilePath string + Timeout int32 +} + +func Send(n Notification) error { + conn, err := dbus.SessionBus() + if err != nil { + return fmt.Errorf("dbus session failed: %w", err) + } + + if n.AppName == "" { + n.AppName = "DMS" + } + if n.Timeout == 0 { + n.Timeout = 5000 + } + + if len(n.Summary) > maxSummaryLen { + n.Summary = n.Summary[:maxSummaryLen-3] + "..." + } + if len(n.Body) > maxBodyLen { + n.Body = n.Body[:maxBodyLen-3] + "..." + } + + var actions []string + if n.FilePath != "" { + actions = []string{ + "open", "Open", + "folder", "Open Folder", + } + } + + hints := map[string]dbus.Variant{} + if n.FilePath != "" { + imgPath := n.FilePath + if !strings.HasPrefix(imgPath, "file://") { + imgPath = "file://" + imgPath + } + hints["image_path"] = dbus.MakeVariant(imgPath) + } + + obj := conn.Object(notifyDest, notifyPath) + call := obj.Call( + notifyInterface+".Notify", + 0, + n.AppName, + uint32(0), + n.Icon, + n.Summary, + n.Body, + actions, + hints, + n.Timeout, + ) + + if call.Err != nil { + return fmt.Errorf("notify call failed: %w", call.Err) + } + + var notificationID uint32 + if err := call.Store(¬ificationID); err != nil { + return fmt.Errorf("failed to get notification id: %w", err) + } + + if len(actions) > 0 && n.FilePath != "" { + spawnActionListener(notificationID, n.FilePath) + } + + return nil +} + +func spawnActionListener(notificationID uint32, filePath string) { + exe, err := os.Executable() + if err != nil { + return + } + + cmd := exec.Command(exe, "notify-action-generic", fmt.Sprintf("%d", notificationID), filePath) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + cmd.Start() +} + +func RunActionListener(args []string) { + if len(args) < 2 { + return + } + + notificationID, err := strconv.ParseUint(args[0], 10, 32) + if err != nil { + return + } + + filePath := args[1] + + conn, err := dbus.SessionBus() + if err != nil { + return + } + + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(notifyPath), + dbus.WithMatchInterface(notifyInterface), + ); err != nil { + return + } + + signals := make(chan *dbus.Signal, 10) + conn.Signal(signals) + + for sig := range signals { + switch sig.Name { + case notifyInterface + ".ActionInvoked": + if len(sig.Body) < 2 { + continue + } + id, ok := sig.Body[0].(uint32) + if !ok || id != uint32(notificationID) { + continue + } + action, ok := sig.Body[1].(string) + if !ok { + continue + } + handleAction(action, filePath) + return + + case notifyInterface + ".NotificationClosed": + if len(sig.Body) < 1 { + continue + } + id, ok := sig.Body[0].(uint32) + if !ok || id != uint32(notificationID) { + continue + } + return + } + } +} + +func handleAction(action, filePath string) { + switch action { + case "open", "default": + openPath(filePath) + case "folder": + openPath(filepath.Dir(filePath)) + } +} + +func openPath(path string) { + cmd := exec.Command("xdg-open", path) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + cmd.Start() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/pam/pam.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/pam/pam.go new file mode 100644 index 0000000..16ca3f9 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/pam/pam.go @@ -0,0 +1,892 @@ +package pam + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" +) + +const ( + GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)" + GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH" + + LockscreenPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN AUTH (managed by dms greeter sync)" + LockscreenPamManagedBlockEnd = "# END DMS LOCKSCREEN AUTH" + + LockscreenU2FPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN U2F AUTH (managed by dms auth sync)" + LockscreenU2FPamManagedBlockEnd = "# END DMS LOCKSCREEN U2F AUTH" + + legacyGreeterPamFprintComment = "# DMS greeter fingerprint" + legacyGreeterPamU2FComment = "# DMS greeter U2F" + + GreetdPamPath = "/etc/pam.d/greetd" + DankshellPamPath = "/etc/pam.d/dankshell" + DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f" +) + +var includedPamAuthFiles = []string{ + "system-auth", + "common-auth", + "password-auth", + "system-login", + "system-local-login", + "common-auth-pc", + "login", +} + +type AuthSettings struct { + EnableFprint bool `json:"enableFprint"` + EnableU2f bool `json:"enableU2f"` + GreeterEnableFprint bool `json:"greeterEnableFprint"` + GreeterEnableU2f bool `json:"greeterEnableU2f"` +} + +type SyncAuthOptions struct { + HomeDir string + ForceGreeterAuth bool +} + +type syncDeps struct { + pamDir string + greetdPath string + dankshellPath string + dankshellU2fPath string + isNixOS func() bool + readFile func(string) ([]byte, error) + stat func(string) (os.FileInfo, error) + createTemp func(string, string) (*os.File, error) + removeFile func(string) error + runSudoCmd func(string, string, ...string) error + pamModuleExists func(string) bool + fingerprintAvailableForCurrentUser func() bool +} + +type lockscreenPamIncludeDirective struct { + target string + filterType string +} + +type lockscreenPamResolver struct { + pamDir string + readFile func(string) ([]byte, error) +} + +func defaultSyncDeps() syncDeps { + return syncDeps{ + pamDir: "/etc/pam.d", + greetdPath: GreetdPamPath, + dankshellPath: DankshellPamPath, + dankshellU2fPath: DankshellU2FPamPath, + isNixOS: IsNixOS, + readFile: os.ReadFile, + stat: os.Stat, + createTemp: os.CreateTemp, + removeFile: os.Remove, + runSudoCmd: runSudoCmd, + pamModuleExists: pamModuleExists, + fingerprintAvailableForCurrentUser: FingerprintAuthAvailableForCurrentUser, + } +} + +func IsNixOS() bool { + _, err := os.Stat("/etc/NIXOS") + return err == nil +} + +func ReadAuthSettings(homeDir string) (AuthSettings, error) { + settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return AuthSettings{}, nil + } + return AuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return AuthSettings{}, nil + } + + var settings AuthSettings + if err := json.Unmarshal(data, &settings); err != nil { + return AuthSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err) + } + return settings, nil +} + +func ReadGreeterAuthToggles(homeDir string) (enableFprint bool, enableU2f bool, err error) { + settings, err := ReadAuthSettings(homeDir) + if err != nil { + return false, false, err + } + return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil +} + +func SyncAuthConfig(logFunc func(string), sudoPassword string, options SyncAuthOptions) error { + return syncAuthConfigWithDeps(logFunc, sudoPassword, options, defaultSyncDeps()) +} + +func RemoveManagedGreeterPamBlock(logFunc func(string), sudoPassword string) error { + return removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, defaultSyncDeps()) +} + +func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options SyncAuthOptions, deps syncDeps) error { + homeDir := strings.TrimSpace(options.HomeDir) + if homeDir == "" { + var err error + homeDir, err = os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + } + + settings, err := ReadAuthSettings(homeDir) + if err != nil { + return err + } + + if err := syncLockscreenPamConfigWithDeps(logFunc, sudoPassword, deps); err != nil { + return err + } + if err := syncLockscreenU2FPamConfigWithDeps(logFunc, sudoPassword, settings.EnableU2f, deps); err != nil { + return err + } + + if _, err := deps.stat(deps.greetdPath); err != nil { + if os.IsNotExist(err) { + logFunc("ℹ /etc/pam.d/greetd not found. Skipping greeter PAM sync.") + return nil + } + return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err) + } + + if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil { + return err + } + + return nil +} + +func removeManagedGreeterPamBlockWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error { + if deps.isNixOS() { + return nil + } + + data, err := deps.readFile(deps.greetdPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err) + } + + originalContent := string(data) + stripped, removed := stripManagedGreeterPamBlock(originalContent) + strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped) + if !removed && !removedLegacy { + return nil + } + + if err := writeManagedPamFile(strippedAgain, deps.greetdPath, sudoPassword, deps); err != nil { + return fmt.Errorf("failed to write %s: %w", deps.greetdPath, err) + } + + logFunc("✓ Removed DMS managed PAM block from " + deps.greetdPath) + return nil +} + +func ParseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) { + if pamText == "" { + return false, false, false, false + } + + lines := strings.Split(pamText, "\n") + inManaged := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + switch trimmed { + case GreeterPamManagedBlockStart: + managed = true + inManaged = true + continue + case GreeterPamManagedBlockEnd: + inManaged = false + continue + } + + if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) { + legacy = true + } + if !inManaged { + continue + } + if strings.Contains(trimmed, "pam_fprintd") { + fingerprint = true + } + if strings.Contains(trimmed, "pam_u2f") { + u2f = true + } + } + + return managed, fingerprint, u2f, legacy +} + +func StripManagedGreeterPamContent(pamText string) (string, bool) { + stripped, removed := stripManagedGreeterPamBlock(pamText) + stripped, removedLegacy := stripLegacyGreeterPamLines(stripped) + return stripped, removed || removedLegacy +} + +func PamTextIncludesFile(pamText, filename string) bool { + lines := strings.Split(pamText, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, filename) && + (strings.Contains(trimmed, "include") || strings.Contains(trimmed, "substack") || strings.HasPrefix(trimmed, "@include")) { + return true + } + } + return false +} + +func PamFileHasModule(pamFilePath, module string) bool { + data, err := os.ReadFile(pamFilePath) + if err != nil { + return false + } + return pamContentHasModule(string(data), module) +} + +func DetectIncludedPamModule(pamText, module string) string { + return detectIncludedPamModule(pamText, module, defaultSyncDeps()) +} + +func detectIncludedPamModule(pamText, module string, deps syncDeps) string { + for _, includedFile := range includedPamAuthFiles { + if !PamTextIncludesFile(pamText, includedFile) { + continue + } + path := filepath.Join(deps.pamDir, includedFile) + data, err := deps.readFile(path) + if err != nil { + continue + } + if pamContentHasModule(string(data), module) { + return includedFile + } + } + return "" +} + +func pamContentHasModule(content, module string) bool { + lines := strings.Split(content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, module) { + return true + } + } + return false +} + +func hasManagedLockscreenPamFile(content string) bool { + return strings.Contains(content, LockscreenPamManagedBlockStart) && + strings.Contains(content, LockscreenPamManagedBlockEnd) +} + +func hasManagedLockscreenU2FPamFile(content string) bool { + return strings.Contains(content, LockscreenU2FPamManagedBlockStart) && + strings.Contains(content, LockscreenU2FPamManagedBlockEnd) +} + +func pamDirectiveType(line string) string { + fields := strings.Fields(line) + if len(fields) == 0 { + return "" + } + + directiveType := strings.TrimPrefix(fields[0], "-") + switch directiveType { + case "auth", "account", "password", "session": + return directiveType + default: + return "" + } +} + +func isExcludedLockscreenPamLine(line string) bool { + for _, field := range strings.Fields(line) { + if strings.HasPrefix(field, "#") { + break + } + if strings.Contains(field, "pam_u2f") || strings.Contains(field, "pam_fprintd") { + return true + } + } + return false +} + +func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string) (lockscreenPamIncludeDirective, bool) { + fields := strings.Fields(trimmed) + if len(fields) >= 2 && fields[0] == "@include" { + return lockscreenPamIncludeDirective{ + target: fields[1], + filterType: inheritedFilter, + }, true + } + + if len(fields) >= 3 && (fields[1] == "include" || fields[1] == "substack") { + lineType := pamDirectiveType(trimmed) + if lineType == "" { + return lockscreenPamIncludeDirective{}, false + } + return lockscreenPamIncludeDirective{ + target: fields[2], + filterType: lineType, + }, true + } + + if len(fields) >= 3 && fields[1] == "@include" { + lineType := pamDirectiveType(trimmed) + if lineType == "" { + return lockscreenPamIncludeDirective{}, false + } + return lockscreenPamIncludeDirective{ + target: fields[2], + filterType: lineType, + }, true + } + + return lockscreenPamIncludeDirective{}, false +} + +func resolveLockscreenPamIncludePath(pamDir, target string) (string, error) { + if strings.TrimSpace(target) == "" { + return "", fmt.Errorf("empty PAM include target") + } + + cleanPamDir := filepath.Clean(pamDir) + if filepath.IsAbs(target) { + cleanTarget := filepath.Clean(target) + if filepath.Dir(cleanTarget) != cleanPamDir { + return "", fmt.Errorf("unsupported PAM include outside %s: %s", cleanPamDir, target) + } + return cleanTarget, nil + } + + cleanTarget := filepath.Clean(target) + if cleanTarget == "." || cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid PAM include target: %s", target) + } + + return filepath.Join(cleanPamDir, cleanTarget), nil +} + +func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) { + path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName) + if err != nil { + return nil, err + } + + for _, seen := range stack { + if seen == path { + chain := append(append([]string{}, stack...), path) + display := make([]string, 0, len(chain)) + for _, item := range chain { + display = append(display, filepath.Base(item)) + } + return nil, fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> ")) + } + } + + data, err := r.readFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read PAM file %s: %w", path, err) + } + + var resolved []string + for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") { + rawLine = strings.TrimRight(rawLine, "\r") + trimmed := strings.TrimSpace(rawLine) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || trimmed == "#%PAM-1.0" { + continue + } + + if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok { + lineType := pamDirectiveType(trimmed) + if filterType != "" && lineType != "" && lineType != filterType { + continue + } + + nested, err := r.resolveService(include.target, include.filterType, append(stack, path)) + if err != nil { + return nil, err + } + resolved = append(resolved, nested...) + continue + } + + lineType := pamDirectiveType(trimmed) + if lineType == "" { + return nil, fmt.Errorf("unsupported PAM directive in %s: %s", filepath.Base(path), trimmed) + } + if filterType != "" && lineType != filterType { + continue + } + if isExcludedLockscreenPamLine(trimmed) { + continue + } + + resolved = append(resolved, rawLine) + } + + return resolved, nil +} + +func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) { + resolver := lockscreenPamResolver{ + pamDir: pamDir, + readFile: readFile, + } + + resolvedLines, err := resolver.resolveService("login", "", nil) + if err != nil { + return "", err + } + if len(resolvedLines) == 0 { + return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login")) + } + + hasAuth := false + for _, line := range resolvedLines { + if pamDirectiveType(strings.TrimSpace(line)) == "auth" { + hasAuth = true + break + } + } + if !hasAuth { + return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login")) + } + + var b strings.Builder + b.WriteString("#%PAM-1.0\n") + b.WriteString(LockscreenPamManagedBlockStart + "\n") + for _, line := range resolvedLines { + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteString(LockscreenPamManagedBlockEnd + "\n") + return b.String(), nil +} + +func buildManagedLockscreenU2FPamContent() string { + var b strings.Builder + b.WriteString("#%PAM-1.0\n") + b.WriteString(LockscreenU2FPamManagedBlockStart + "\n") + b.WriteString("auth required pam_u2f.so cue nouserok timeout=10\n") + b.WriteString(LockscreenU2FPamManagedBlockEnd + "\n") + return b.String() +} + +func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error { + if deps.isNixOS() { + logFunc("ℹ NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.") + return nil + } + + existingData, err := deps.readFile(deps.dankshellPath) + if err == nil { + if !hasManagedLockscreenPamFile(string(existingData)) { + logFunc("ℹ Custom /etc/pam.d/dankshell found (no DMS block). Skipping.") + return nil + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err) + } + + content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile) + if err != nil { + return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err) + } + + if err := writeManagedPamFile(content, deps.dankshellPath, sudoPassword, deps); err != nil { + return fmt.Errorf("failed to write %s: %w", deps.dankshellPath, err) + } + + logFunc("✓ Created or updated /etc/pam.d/dankshell for lock screen authentication") + return nil +} + +func syncLockscreenU2FPamConfigWithDeps(logFunc func(string), sudoPassword string, enabled bool, deps syncDeps) error { + if deps.isNixOS() { + logFunc("ℹ NixOS detected. DMS does not manage /etc/pam.d/dankshell-u2f on NixOS. Keep using the bundled U2F helper or configure a custom PAM service yourself.") + return nil + } + + existingData, err := deps.readFile(deps.dankshellU2fPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", deps.dankshellU2fPath, err) + } + + if enabled { + if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) { + logFunc("ℹ Custom /etc/pam.d/dankshell-u2f found (no DMS block). Skipping.") + return nil + } + if err := writeManagedPamFile(buildManagedLockscreenU2FPamContent(), deps.dankshellU2fPath, sudoPassword, deps); err != nil { + return fmt.Errorf("failed to write %s: %w", deps.dankshellU2fPath, err) + } + logFunc("✓ Created or updated /etc/pam.d/dankshell-u2f for lock screen security-key authentication") + return nil + } + + if os.IsNotExist(err) { + return nil + } + if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) { + logFunc("ℹ Custom /etc/pam.d/dankshell-u2f found (no DMS block). Leaving it untouched.") + return nil + } + + if err := deps.runSudoCmd(sudoPassword, "rm", "-f", deps.dankshellU2fPath); err != nil { + return fmt.Errorf("failed to remove %s: %w", deps.dankshellU2fPath, err) + } + logFunc("✓ Removed DMS-managed /etc/pam.d/dankshell-u2f") + return nil +} + +func stripManagedGreeterPamBlock(content string) (string, bool) { + lines := strings.Split(content, "\n") + filtered := make([]string, 0, len(lines)) + inManagedBlock := false + removed := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == GreeterPamManagedBlockStart { + inManagedBlock = true + removed = true + continue + } + if trimmed == GreeterPamManagedBlockEnd { + inManagedBlock = false + removed = true + continue + } + if inManagedBlock { + removed = true + continue + } + filtered = append(filtered, line) + } + + return strings.Join(filtered, "\n"), removed +} + +func stripLegacyGreeterPamLines(content string) (string, bool) { + lines := strings.Split(content, "\n") + filtered := make([]string, 0, len(lines)) + removed := false + + for i := 0; i < len(lines); i++ { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) { + removed = true + if i+1 < len(lines) { + nextLine := strings.TrimSpace(lines[i+1]) + if strings.HasPrefix(nextLine, "auth") && + (strings.Contains(nextLine, "pam_fprintd") || strings.Contains(nextLine, "pam_u2f")) { + i++ + } + } + continue + } + filtered = append(filtered, lines[i]) + } + + return strings.Join(filtered, "\n"), removed +} + +func insertManagedGreeterPamBlock(content string, blockLines []string, greetdPamPath string) (string, error) { + lines := strings.Split(content, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "auth") { + block := strings.Join(blockLines, "\n") + prefix := strings.Join(lines[:i], "\n") + suffix := strings.Join(lines[i:], "\n") + switch { + case prefix == "": + return block + "\n" + suffix, nil + case suffix == "": + return prefix + "\n" + block, nil + default: + return prefix + "\n" + block + "\n" + suffix, nil + } + } + } + return "", fmt.Errorf("no auth directive found in %s", greetdPamPath) +} + +func syncGreeterPamConfigWithDeps(logFunc func(string), sudoPassword string, settings AuthSettings, forceAuth bool, deps syncDeps) error { + var wantFprint, wantU2f bool + fprintToggleEnabled := forceAuth + u2fToggleEnabled := forceAuth + if forceAuth { + wantFprint = deps.pamModuleExists("pam_fprintd.so") + wantU2f = deps.pamModuleExists("pam_u2f.so") + } else { + fprintToggleEnabled = settings.GreeterEnableFprint + u2fToggleEnabled = settings.GreeterEnableU2f + fprintModule := deps.pamModuleExists("pam_fprintd.so") + u2fModule := deps.pamModuleExists("pam_u2f.so") + wantFprint = settings.GreeterEnableFprint && fprintModule + wantU2f = settings.GreeterEnableU2f && u2fModule + if settings.GreeterEnableFprint && !fprintModule { + logFunc("⚠ Warning: greeter fingerprint toggle is enabled, but pam_fprintd.so was not found.") + } + if settings.GreeterEnableU2f && !u2fModule { + logFunc("⚠ Warning: greeter security key toggle is enabled, but pam_u2f.so was not found.") + } + } + + if deps.isNixOS() { + logFunc("ℹ NixOS detected: PAM config is managed by NixOS modules. Skipping DMS PAM block write.") + logFunc(" Configure fingerprint/U2F auth via your greetd NixOS module options (e.g. security.pam.services.greetd).") + return nil + } + + pamData, err := deps.readFile(deps.greetdPath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err) + } + originalContent := string(pamData) + content, _ := stripManagedGreeterPamBlock(originalContent) + content, _ = stripLegacyGreeterPamLines(content) + + includedFprintFile := detectIncludedPamModule(content, "pam_fprintd.so", deps) + includedU2fFile := detectIncludedPamModule(content, "pam_u2f.so", deps) + fprintAvailableForCurrentUser := deps.fingerprintAvailableForCurrentUser() + if wantFprint && includedFprintFile != "" { + logFunc("⚠ pam_fprintd already present in included " + includedFprintFile + " (managed by authselect/pam-auth-update). Skipping DMS fprint block to avoid double-fingerprint auth.") + wantFprint = false + } + if wantU2f && includedU2fFile != "" { + logFunc("⚠ pam_u2f already present in included " + includedU2fFile + " (managed by authselect/pam-auth-update). Skipping DMS U2F block to avoid double security-key auth.") + wantU2f = false + } + if !wantFprint && includedFprintFile != "" { + if fprintToggleEnabled { + logFunc("ℹ Fingerprint auth is still enabled via included " + includedFprintFile + ".") + if fprintAvailableForCurrentUser { + logFunc(" DMS toggle is enabled, and effective auth is provided by the included PAM stack.") + } else { + logFunc(" No enrolled fingerprints detected for the current user; password auth remains the effective path.") + } + } else { + if fprintAvailableForCurrentUser { + logFunc("ℹ Fingerprint auth is active via included " + includedFprintFile + " while DMS fingerprint toggle is off.") + logFunc(" Password login will work but may be delayed while the fingerprint module runs first.") + logFunc(" To eliminate the delay, " + pamManagerHintForCurrentDistro()) + } else { + logFunc("ℹ pam_fprintd is present via included " + includedFprintFile + ", but no enrolled fingerprints were detected for the current user.") + logFunc(" Password auth remains the effective login path.") + } + } + } + if !wantU2f && includedU2fFile != "" { + if u2fToggleEnabled { + logFunc("ℹ Security-key auth is still enabled via included " + includedU2fFile + ".") + logFunc(" DMS toggle is enabled, but effective auth is provided by the included PAM stack.") + } else { + logFunc("⚠ Security-key auth is active via included " + includedU2fFile + " while DMS security-key toggle is off.") + logFunc(" " + pamManagerHintForCurrentDistro()) + } + } + + if wantFprint || wantU2f { + blockLines := []string{GreeterPamManagedBlockStart} + if wantFprint { + blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") + } + if wantU2f { + blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10") + } + blockLines = append(blockLines, GreeterPamManagedBlockEnd) + + content, err = insertManagedGreeterPamBlock(content, blockLines, deps.greetdPath) + if err != nil { + return err + } + } + + if content == originalContent { + return nil + } + + if err := writeManagedPamFile(content, deps.greetdPath, sudoPassword, deps); err != nil { + return fmt.Errorf("failed to install updated PAM config at %s: %w", deps.greetdPath, err) + } + if wantFprint || wantU2f { + logFunc("✓ Configured greetd PAM for fingerprint/U2F") + } else { + logFunc("✓ Cleared DMS-managed greeter PAM auth block") + } + return nil +} + +func writeManagedPamFile(content string, destPath string, sudoPassword string, deps syncDeps) error { + tmpFile, err := deps.createTemp("", "dms-pam-*.conf") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + defer func() { + _ = deps.removeFile(tmpPath) + }() + + if _, err := tmpFile.WriteString(content); err != nil { + tmpFile.Close() + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + if err := deps.runSudoCmd(sudoPassword, "cp", tmpPath, destPath); err != nil { + return err + } + if err := deps.runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil { + return fmt.Errorf("failed to set permissions on %s: %w", destPath, err) + } + return nil +} + +func pamManagerHintForCurrentDistro() string { + osInfo, err := distros.GetOSInfo() + if err != nil { + return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } + + switch config.Family { + case distros.FamilyFedora: + return "Disable it in authselect to force password-only greeter login." + case distros.FamilyDebian, distros.FamilyUbuntu: + return "Disable it in pam-auth-update to force password-only greeter login." + default: + return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } +} + +func pamModuleExists(module string) bool { + for _, libDir := range []string{ + "/usr/lib64/security", + "/usr/lib/security", + "/lib64/security", + "/lib/security", + "/lib/x86_64-linux-gnu/security", + "/usr/lib/x86_64-linux-gnu/security", + "/lib/aarch64-linux-gnu/security", + "/usr/lib/aarch64-linux-gnu/security", + "/run/current-system/sw/lib64/security", + "/run/current-system/sw/lib/security", + } { + if _, err := os.Stat(filepath.Join(libDir, module)); err == nil { + return true + } + } + return false +} + +func hasEnrolledFingerprintOutput(output string) bool { + lower := strings.ToLower(output) + if strings.Contains(lower, "no fingers enrolled") || + strings.Contains(lower, "no fingerprints enrolled") || + strings.Contains(lower, "no prints enrolled") { + return false + } + if strings.Contains(lower, "has fingers enrolled") || + strings.Contains(lower, "has fingerprints enrolled") { + return true + } + for _, line := range strings.Split(lower, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "finger:") { + return true + } + if strings.HasPrefix(trimmed, "- ") && strings.Contains(trimmed, "finger") { + return true + } + } + return false +} + +func FingerprintAuthAvailableForCurrentUser() bool { + username := strings.TrimSpace(os.Getenv("SUDO_USER")) + if username == "" { + username = strings.TrimSpace(os.Getenv("USER")) + } + if username == "" { + out, err := exec.Command("id", "-un").Output() + if err == nil { + username = strings.TrimSpace(string(out)) + } + } + return fingerprintAuthAvailableForUser(username) +} + +func fingerprintAuthAvailableForUser(username string) bool { + username = strings.TrimSpace(username) + if username == "" { + return false + } + if !pamModuleExists("pam_fprintd.so") { + return false + } + if _, err := exec.LookPath("fprintd-list"); err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "fprintd-list", username).CombinedOutput() + if err != nil { + return false + } + return hasEnrolledFingerprintOutput(string(out)) +} + +func runSudoCmd(sudoPassword string, command string, args ...string) error { + var cmd *exec.Cmd + + if sudoPassword != "" { + fullArgs := append([]string{command}, args...) + quotedArgs := make([]string, len(fullArgs)) + for i, arg := range fullArgs { + quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" + } + cmdStr := strings.Join(quotedArgs, " ") + + cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr) + } else { + cmd = exec.Command("sudo", append([]string{command}, args...)...) + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/pam/pam_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/pam/pam_test.go new file mode 100644 index 0000000..113bf10 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/pam/pam_test.go @@ -0,0 +1,671 @@ +package pam + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("failed to create parent dir for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} + +type pamTestEnv struct { + pamDir string + greetdPath string + dankshellPath string + dankshellU2fPath string + tmpDir string + homeDir string + availableModules map[string]bool + fingerprintAvailable bool +} + +func newPamTestEnv(t *testing.T) *pamTestEnv { + t.Helper() + + root := t.TempDir() + pamDir := filepath.Join(root, "pam.d") + tmpDir := filepath.Join(root, "tmp") + homeDir := filepath.Join(root, "home") + + for _, dir := range []string{pamDir, tmpDir, homeDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("failed to create %s: %v", dir, err) + } + } + + return &pamTestEnv{ + pamDir: pamDir, + greetdPath: filepath.Join(pamDir, "greetd"), + dankshellPath: filepath.Join(pamDir, "dankshell"), + dankshellU2fPath: filepath.Join(pamDir, "dankshell-u2f"), + tmpDir: tmpDir, + homeDir: homeDir, + availableModules: map[string]bool{}, + } +} + +func (e *pamTestEnv) writePamFile(t *testing.T, name string, content string) { + t.Helper() + writeTestFile(t, filepath.Join(e.pamDir, name), content) +} + +func (e *pamTestEnv) writeSettings(t *testing.T, content string) { + t.Helper() + writeTestFile(t, filepath.Join(e.homeDir, ".config", "DankMaterialShell", "settings.json"), content) +} + +func (e *pamTestEnv) deps(isNixOS bool) syncDeps { + return syncDeps{ + pamDir: e.pamDir, + greetdPath: e.greetdPath, + dankshellPath: e.dankshellPath, + dankshellU2fPath: e.dankshellU2fPath, + isNixOS: func() bool { return isNixOS }, + readFile: os.ReadFile, + stat: os.Stat, + createTemp: func(_ string, pattern string) (*os.File, error) { + return os.CreateTemp(e.tmpDir, pattern) + }, + removeFile: os.Remove, + runSudoCmd: func(_ string, command string, args ...string) error { + switch command { + case "cp": + if len(args) != 2 { + return fmt.Errorf("unexpected cp args: %v", args) + } + data, err := os.ReadFile(args[0]) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(args[1]), 0o755); err != nil { + return err + } + return os.WriteFile(args[1], data, 0o644) + case "chmod": + if len(args) != 2 { + return fmt.Errorf("unexpected chmod args: %v", args) + } + return nil + case "rm": + if len(args) != 2 || args[0] != "-f" { + return fmt.Errorf("unexpected rm args: %v", args) + } + if err := os.Remove(args[1]); err != nil && !os.IsNotExist(err) { + return err + } + return nil + default: + return fmt.Errorf("unexpected sudo command: %s %v", command, args) + } + }, + pamModuleExists: func(module string) bool { + return e.availableModules[module] + }, + fingerprintAvailableForCurrentUser: func() bool { + return e.fingerprintAvailable + }, + } +} + +func readFileString(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + return string(data) +} + +func TestHasManagedLockscreenPamFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want bool + }{ + { + name: "both markers present", + content: "#%PAM-1.0\n" + + LockscreenPamManagedBlockStart + "\n" + + "auth sufficient pam_unix.so\n" + + LockscreenPamManagedBlockEnd + "\n", + want: true, + }, + { + name: "missing end marker is not managed", + content: "#%PAM-1.0\n" + + LockscreenPamManagedBlockStart + "\n" + + "auth sufficient pam_unix.so\n", + want: false, + }, + { + name: "custom file is not managed", + content: "#%PAM-1.0\nauth sufficient pam_unix.so\n", + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := hasManagedLockscreenPamFile(tt.content); got != tt.want { + t.Fatalf("hasManagedLockscreenPamFile() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBuildManagedLockscreenPamContent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files map[string]string + wantContains []string + wantNotContains []string + wantCounts map[string]int + wantErr string + }{ + { + name: "preserves custom modules and strips direct u2f and fprint directives", + files: map[string]string{ + "login": "#%PAM-1.0\n" + + "auth include system-auth\n" + + "account include system-auth\n" + + "session include system-auth\n", + "system-auth": "auth requisite pam_nologin.so\n" + + "auth sufficient pam_unix.so try_first_pass nullok\n" + + "auth sufficient pam_u2f.so cue\n" + + "auth sufficient pam_fprintd.so max-tries=1\n" + + "auth required pam_radius_auth.so conf=/etc/raddb/server\n" + + "account required pam_access.so\n" + + "session optional pam_lastlog.so silent\n", + }, + wantContains: []string{ + "#%PAM-1.0", + LockscreenPamManagedBlockStart, + LockscreenPamManagedBlockEnd, + "auth requisite pam_nologin.so", + "auth sufficient pam_unix.so try_first_pass nullok", + "auth required pam_radius_auth.so conf=/etc/raddb/server", + "account required pam_access.so", + "session optional pam_lastlog.so silent", + }, + wantNotContains: []string{ + "pam_u2f", + "pam_fprintd", + }, + wantCounts: map[string]int{ + "auth required pam_radius_auth.so conf=/etc/raddb/server": 1, + "account required pam_access.so": 1, + }, + }, + { + name: "resolves nested include substack and @include transitively", + files: map[string]string{ + "login": "#%PAM-1.0\n" + + "auth include system-auth\n" + + "account include system-auth\n" + + "password include system-auth\n" + + "session include system-auth\n", + "system-auth": "auth substack custom-auth\n" + + "account include custom-auth\n" + + "password include custom-auth\n" + + "session @include common-session\n", + "custom-auth": "auth required pam_custom.so one=two\n" + + "account required pam_custom_account.so\n" + + "password required pam_custom_password.so\n", + "common-session": "session optional pam_fprintd.so max-tries=1\n" + + "session optional pam_lastlog.so silent\n", + }, + wantContains: []string{ + "auth required pam_custom.so one=two", + "account required pam_custom_account.so", + "password required pam_custom_password.so", + "session optional pam_lastlog.so silent", + }, + wantNotContains: []string{ + "pam_fprintd", + }, + wantCounts: map[string]int{ + "auth required pam_custom.so one=two": 1, + "account required pam_custom_account.so": 1, + "password required pam_custom_password.so": 1, + "session optional pam_lastlog.so silent": 1, + }, + }, + { + name: "missing include fails", + files: map[string]string{ + "login": "#%PAM-1.0\nauth include missing-auth\n", + }, + wantErr: "failed to read PAM file", + }, + { + name: "cyclic include fails", + files: map[string]string{ + "login": "#%PAM-1.0\nauth include system-auth\n", + "system-auth": "auth include login\n", + }, + wantErr: "cyclic PAM include detected", + }, + { + name: "no auth directives remain after filtering fails", + files: map[string]string{ + "login": "#%PAM-1.0\nauth include system-auth\n", + "system-auth": "auth sufficient pam_u2f.so cue\n", + }, + wantErr: "no auth directives remained after filtering", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + for name, content := range tt.files { + env.writePamFile(t, name, content) + } + + content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err) + } + + for _, want := range tt.wantContains { + if !strings.Contains(content, want) { + t.Errorf("missing expected string %q in output:\n%s", want, content) + } + } + for _, notWant := range tt.wantNotContains { + if strings.Contains(content, notWant) { + t.Errorf("unexpected string %q found in output:\n%s", notWant, content) + } + } + for want, wantCount := range tt.wantCounts { + if gotCount := strings.Count(content, want); gotCount != wantCount { + t.Errorf("count for %q = %d, want %d\noutput:\n%s", want, gotCount, wantCount, content) + } + } + }) + } +} + +func TestSyncLockscreenPamConfigWithDeps(t *testing.T) { + t.Parallel() + + t.Run("custom dankshell file is skipped untouched", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + customContent := "#%PAM-1.0\nauth required pam_unix.so\n" + env.writePamFile(t, "dankshell", customContent) + + var logs []string + err := syncLockscreenPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err) + } + + if got := readFileString(t, env.dankshellPath); got != customContent { + t.Fatalf("custom dankshell content changed\ngot:\n%s\nwant:\n%s", got, customContent) + } + if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell found") { + t.Fatalf("expected custom-file skip log, got %v", logs) + } + }) + + t.Run("managed dankshell file is rewritten from resolved login stack", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\nauth sufficient pam_u2f.so cue\naccount required pam_access.so\n") + env.writePamFile(t, "dankshell", "#%PAM-1.0\n"+LockscreenPamManagedBlockStart+"\nauth required pam_env.so\n"+LockscreenPamManagedBlockEnd+"\n") + + var logs []string + err := syncLockscreenPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err) + } + + output := readFileString(t, env.dankshellPath) + for _, want := range []string{ + LockscreenPamManagedBlockStart, + "auth sufficient pam_unix.so try_first_pass nullok", + "account required pam_access.so", + LockscreenPamManagedBlockEnd, + } { + if !strings.Contains(output, want) { + t.Errorf("missing expected string %q in rewritten dankshell:\n%s", want, output) + } + } + if strings.Contains(output, "pam_u2f") { + t.Errorf("rewritten dankshell still contains pam_u2f:\n%s", output) + } + if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell") { + t.Fatalf("expected success log, got %v", logs) + } + }) + + t.Run("mutable systems fail when login stack cannot be converted safely", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + err := syncLockscreenPamConfigWithDeps(func(string) {}, "", env.deps(false)) + if err == nil { + t.Fatal("expected error when login PAM file is missing, got nil") + } + if !strings.Contains(err.Error(), "failed to build") { + t.Fatalf("error = %q, want substring %q", err.Error(), "failed to build") + } + }) + + t.Run("NixOS remains informational and does not write dankshell", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + var logs []string + + err := syncLockscreenPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", env.deps(true)) + if err != nil { + t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err) + } + if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") { + t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs) + } + if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) { + t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err) + } + }) +} + +func TestSyncLockscreenU2FPamConfigWithDeps(t *testing.T) { + t.Parallel() + + t.Run("enabled creates managed file", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + var logs []string + + err := syncLockscreenU2FPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", true, env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err) + } + + got := readFileString(t, env.dankshellU2fPath) + if got != buildManagedLockscreenU2FPamContent() { + t.Fatalf("unexpected managed dankshell-u2f content:\n%s", got) + } + if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell-u2f") { + t.Fatalf("expected create log, got %v", logs) + } + }) + + t.Run("enabled rewrites existing managed file", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\n"+LockscreenU2FPamManagedBlockStart+"\nauth required pam_u2f.so old\n"+LockscreenU2FPamManagedBlockEnd+"\n") + + if err := syncLockscreenU2FPamConfigWithDeps(func(string) {}, "", true, env.deps(false)); err != nil { + t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err) + } + if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() { + t.Fatalf("managed dankshell-u2f was not rewritten:\n%s", got) + } + }) + + t.Run("disabled removes DMS-managed file", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "dankshell-u2f", buildManagedLockscreenU2FPamContent()) + + var logs []string + err := syncLockscreenU2FPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", false, env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err) + } + if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) { + t.Fatalf("expected managed dankshell-u2f to be removed, stat err = %v", err) + } + if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Removed DMS-managed /etc/pam.d/dankshell-u2f") { + t.Fatalf("expected removal log, got %v", logs) + } + }) + + t.Run("disabled preserves custom file", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + customContent := "#%PAM-1.0\nauth required pam_u2f.so cue\n" + env.writePamFile(t, "dankshell-u2f", customContent) + + var logs []string + err := syncLockscreenU2FPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", false, env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err) + } + if got := readFileString(t, env.dankshellU2fPath); got != customContent { + t.Fatalf("custom dankshell-u2f content changed\ngot:\n%s\nwant:\n%s", got, customContent) + } + if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell-u2f found") { + t.Fatalf("expected custom-file log, got %v", logs) + } + }) +} + +func TestSyncGreeterPamConfigWithDeps(t *testing.T) { + t.Parallel() + + t.Run("adds managed block for enabled auth modules", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_fprintd.so"] = true + env.availableModules["pam_u2f.so"] = true + env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n") + + settings := AuthSettings{GreeterEnableFprint: true, GreeterEnableU2f: true} + if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil { + t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err) + } + + got := readFileString(t, env.greetdPath) + for _, want := range []string{ + GreeterPamManagedBlockStart, + "auth sufficient pam_fprintd.so max-tries=1 timeout=5", + "auth sufficient pam_u2f.so cue nouserok timeout=10", + GreeterPamManagedBlockEnd, + } { + if !strings.Contains(got, want) { + t.Errorf("missing expected string %q in greetd PAM:\n%s", want, got) + } + } + if strings.Index(got, GreeterPamManagedBlockStart) > strings.Index(got, "auth include system-auth") { + t.Fatalf("managed block was not inserted before first auth line:\n%s", got) + } + }) + + t.Run("avoids duplicate fingerprint when included stack already provides it", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_fprintd.so"] = true + env.fingerprintAvailable = true + original := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n" + env.writePamFile(t, "greetd", original) + env.writePamFile(t, "system-auth", "auth sufficient pam_fprintd.so max-tries=1\nauth sufficient pam_unix.so\n") + + settings := AuthSettings{GreeterEnableFprint: true} + if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil { + t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err) + } + + got := readFileString(t, env.greetdPath) + if got != original { + t.Fatalf("greetd PAM changed despite included pam_fprintd stack\ngot:\n%s\nwant:\n%s", got, original) + } + if strings.Contains(got, GreeterPamManagedBlockStart) { + t.Fatalf("managed block should not be inserted when included stack already has pam_fprintd:\n%s", got) + } + }) +} + +func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "greetd", "#%PAM-1.0\n"+ + legacyGreeterPamFprintComment+"\n"+ + "auth sufficient pam_fprintd.so max-tries=1\n"+ + GreeterPamManagedBlockStart+"\n"+ + "auth sufficient pam_u2f.so cue nouserok timeout=10\n"+ + GreeterPamManagedBlockEnd+"\n"+ + "auth include system-auth\n") + + if err := removeManagedGreeterPamBlockWithDeps(func(string) {}, "", env.deps(false)); err != nil { + t.Fatalf("removeManagedGreeterPamBlockWithDeps returned error: %v", err) + } + + got := readFileString(t, env.greetdPath) + if strings.Contains(got, GreeterPamManagedBlockStart) || strings.Contains(got, legacyGreeterPamFprintComment) { + t.Fatalf("managed or legacy DMS auth lines remained in greetd PAM:\n%s", got) + } + if !strings.Contains(got, "auth include system-auth") { + t.Fatalf("expected non-DMS greetd auth lines to remain:\n%s", got) + } +} + +func TestSyncAuthConfigWithDeps(t *testing.T) { + t.Parallel() + + t.Run("creates lockscreen targets and skips greetd when greeter is not installed", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writeSettings(t, `{"enableU2f":true}`) + env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n") + + var logs []string + err := syncAuthConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false)) + if err != nil { + t.Fatalf("syncAuthConfigWithDeps returned error: %v", err) + } + + if _, err := os.Stat(env.dankshellPath); err != nil { + t.Fatalf("expected dankshell to be created: %v", err) + } + if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() { + t.Fatalf("unexpected dankshell-u2f content:\n%s", got) + } + if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "greetd not found") { + t.Fatalf("expected greetd skip log, got %v", logs) + } + }) + + t.Run("separate greeter and lockscreen toggles are respected", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_fprintd.so"] = true + env.writeSettings(t, `{"enableU2f":false,"greeterEnableFprint":true,"greeterEnableU2f":false}`) + env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n") + env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + + err := syncAuthConfigWithDeps(func(string) {}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false)) + if err != nil { + t.Fatalf("syncAuthConfigWithDeps returned error: %v", err) + } + + dankshell := readFileString(t, env.dankshellPath) + if strings.Contains(dankshell, "pam_fprintd") || strings.Contains(dankshell, "pam_u2f") { + t.Fatalf("lockscreen PAM should strip fingerprint and U2F modules:\n%s", dankshell) + } + if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) { + t.Fatalf("expected dankshell-u2f to remain absent when enableU2f is false, stat err = %v", err) + } + + greetd := readFileString(t, env.greetdPath) + if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") { + t.Fatalf("expected greetd PAM to receive fingerprint auth block:\n%s", greetd) + } + if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") { + t.Fatalf("did not expect greetd PAM to receive U2F auth block:\n%s", greetd) + } + }) + + t.Run("NixOS remains informational and non-mutating", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_fprintd.so"] = true + env.availableModules["pam_u2f.so"] = true + env.writeSettings(t, `{"enableU2f":true,"greeterEnableFprint":true,"greeterEnableU2f":true}`) + originalGreetd := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n" + env.writePamFile(t, "greetd", originalGreetd) + + var logs []string + err := syncAuthConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(true)) + if err != nil { + t.Fatalf("syncAuthConfigWithDeps returned error: %v", err) + } + + if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) { + t.Fatalf("expected dankshell to remain absent on NixOS path, stat err = %v", err) + } + if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) { + t.Fatalf("expected dankshell-u2f to remain absent on NixOS path, stat err = %v", err) + } + if got := readFileString(t, env.greetdPath); got != originalGreetd { + t.Fatalf("expected greetd PAM to remain unchanged on NixOS path\ngot:\n%s\nwant:\n%s", got, originalGreetd) + } + if len(logs) < 2 || !strings.Contains(strings.Join(logs, "\n"), "NixOS detected") { + t.Fatalf("expected informational NixOS logs, got %v", logs) + } + }) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/manager.go new file mode 100644 index 0000000..1c0ddf5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/manager.go @@ -0,0 +1,588 @@ +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/afero" +) + +type Manager struct { + fs afero.Fs + pluginsDir string + gitClient GitClient +} + +func NewManager() (*Manager, error) { + return NewManagerWithFs(afero.NewOsFs()) +} + +func NewManagerWithFs(fs afero.Fs) (*Manager, error) { + pluginsDir := getPluginsDir() + return &Manager{ + fs: fs, + pluginsDir: pluginsDir, + gitClient: &realGitClient{}, + }, nil +} + +func getPluginsDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + log.Error("failed to get user config dir", "err", err) + return "" + } + return filepath.Join(configDir, "DankMaterialShell", "plugins") +} + +func (m *Manager) IsInstalled(plugin Plugin) (bool, error) { + path, err := m.findInstalledPath(plugin.ID) + if err != nil { + return false, err + } + return path != "", nil +} + +func (m *Manager) findInstalledPath(pluginID string) (string, error) { + // Check user plugins directory + path, err := m.findInDir(m.pluginsDir, pluginID) + if err != nil { + return "", err + } + if path != "" { + return path, nil + } + + // Check system plugins directory + systemDir := "/etc/xdg/quickshell/dms-plugins" + return m.findInDir(systemDir, pluginID) +} + +func (m *Manager) findInDir(dir, pluginID string) (string, error) { + // First, check if folder with exact ID name exists + exactPath := filepath.Join(dir, pluginID) + if exists, _ := afero.DirExists(m.fs, exactPath); exists { + return exactPath, nil + } + + // Scan all folders and check plugin.json for matching ID + exists, err := afero.DirExists(m.fs, dir) + if err != nil || !exists { + return "", nil + } + + entries, err := afero.ReadDir(m.fs, dir) + if err != nil { + return "", nil + } + + for _, entry := range entries { + name := entry.Name() + if name == ".repos" || strings.HasSuffix(name, ".meta") { + continue + } + + fullPath := filepath.Join(dir, name) + isPlugin := entry.IsDir() || entry.Mode()&os.ModeSymlink != 0 + if !isPlugin { + if info, err := m.fs.Stat(fullPath); err == nil && info.IsDir() { + isPlugin = true + } + } + + if isPlugin && m.getPluginID(fullPath) == pluginID { + return fullPath, nil + } + } + + return "", nil +} + +func (m *Manager) Install(plugin Plugin) error { + pluginPath := filepath.Join(m.pluginsDir, plugin.ID) + + exists, err := afero.DirExists(m.fs, pluginPath) + if err != nil { + return fmt.Errorf("failed to check if plugin exists: %w", err) + } + + if exists { + return fmt.Errorf("plugin already installed: %s", plugin.Name) + } + + if err := m.fs.MkdirAll(m.pluginsDir, 0o755); err != nil { + return fmt.Errorf("failed to create plugins directory: %w", err) + } + + reposDir := filepath.Join(m.pluginsDir, ".repos") + if err := m.fs.MkdirAll(reposDir, 0o755); err != nil { + return fmt.Errorf("failed to create repos directory: %w", err) + } + + if plugin.Path != "" { + repoName := m.getRepoName(plugin.Repo) + repoPath := filepath.Join(reposDir, repoName) + + repoExists, err := afero.DirExists(m.fs, repoPath) + if err != nil { + return fmt.Errorf("failed to check if repo exists: %w", err) + } + + if !repoExists { + if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil { + m.fs.RemoveAll(repoPath) //nolint:errcheck + return fmt.Errorf("failed to clone repository: %w", err) + } + } else { + // Pull latest changes if repo already exists + if err := m.gitClient.Pull(repoPath); err != nil { + // If pull fails (e.g., corrupted shallow clone), delete and re-clone + if err := m.fs.RemoveAll(repoPath); err != nil { + return fmt.Errorf("failed to remove corrupted repository: %w", err) + } + + if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil { + return fmt.Errorf("failed to re-clone repository: %w", err) + } + } + } + + sourcePath := filepath.Join(repoPath, plugin.Path) + sourceExists, err := afero.DirExists(m.fs, sourcePath) + if err != nil { + return fmt.Errorf("failed to check plugin path: %w", err) + } + if !sourceExists { + return fmt.Errorf("plugin path does not exist in repository: %s", plugin.Path) + } + + if err := m.createSymlink(sourcePath, pluginPath); err != nil { + return fmt.Errorf("failed to create symlink: %w", err) + } + + metaPath := pluginPath + ".meta" + metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName) + if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0o644); err != nil { + return fmt.Errorf("failed to write metadata: %w", err) + } + } else { + if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil { + m.fs.RemoveAll(pluginPath) //nolint:errcheck + return fmt.Errorf("failed to clone plugin: %w", err) + } + } + + return nil +} + +func (m *Manager) getRepoName(repoURL string) string { + hash := sha256.Sum256([]byte(repoURL)) + return hex.EncodeToString(hash[:])[:16] +} + +func (m *Manager) createSymlink(source, dest string) error { + if symlinkFs, ok := m.fs.(afero.Symlinker); ok { + return symlinkFs.SymlinkIfPossible(source, dest) + } + return os.Symlink(source, dest) +} + +func (m *Manager) Update(plugin Plugin) error { + pluginPath, err := m.findInstalledPath(plugin.ID) + if err != nil { + return fmt.Errorf("failed to find plugin: %w", err) + } + + if pluginPath == "" { + return fmt.Errorf("plugin not installed: %s", plugin.Name) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return fmt.Errorf("cannot update system plugin: %s", plugin.Name) + } + + metaPath := pluginPath + ".meta" + metaExists, err := afero.Exists(m.fs, metaPath) + if err != nil { + return fmt.Errorf("failed to check metadata: %w", err) + } + + if metaExists { + reposDir := filepath.Join(m.pluginsDir, ".repos") + repoName := m.getRepoName(plugin.Repo) + repoPath := filepath.Join(reposDir, repoName) + + // Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone + if err := m.gitClient.Pull(repoPath); err != nil { + // Repository is likely corrupted or has issues, delete and re-clone + if err := m.fs.RemoveAll(repoPath); err != nil { + return fmt.Errorf("failed to remove corrupted repository: %w", err) + } + + if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil { + return fmt.Errorf("failed to re-clone repository: %w", err) + } + } + } else { + // Try to pull, if it fails, delete and re-clone + if err := m.gitClient.Pull(pluginPath); err != nil { + if err := m.fs.RemoveAll(pluginPath); err != nil { + return fmt.Errorf("failed to remove corrupted plugin: %w", err) + } + + if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil { + return fmt.Errorf("failed to re-clone plugin: %w", err) + } + } + } + + return nil +} + +func (m *Manager) Uninstall(plugin Plugin) error { + pluginPath, err := m.findInstalledPath(plugin.ID) + if err != nil { + return fmt.Errorf("failed to find plugin: %w", err) + } + + if pluginPath == "" { + return fmt.Errorf("plugin not installed: %s", plugin.Name) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name) + } + + metaPath := pluginPath + ".meta" + metaExists, err := afero.Exists(m.fs, metaPath) + if err != nil { + return fmt.Errorf("failed to check metadata: %w", err) + } + + if metaExists { + reposDir := filepath.Join(m.pluginsDir, ".repos") + repoName := m.getRepoName(plugin.Repo) + repoPath := filepath.Join(reposDir, repoName) + + shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID) + if err != nil { + return fmt.Errorf("failed to check repo cleanup: %w", err) + } + + if err := m.fs.Remove(pluginPath); err != nil { + return fmt.Errorf("failed to remove symlink: %w", err) + } + + if err := m.fs.Remove(metaPath); err != nil { + return fmt.Errorf("failed to remove metadata: %w", err) + } + + if shouldCleanup { + if err := m.fs.RemoveAll(repoPath); err != nil { + return fmt.Errorf("failed to cleanup repository: %w", err) + } + } + } else { + if err := m.fs.RemoveAll(pluginPath); err != nil { + return fmt.Errorf("failed to remove plugin: %w", err) + } + } + + return nil +} + +func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) { + installed, err := m.ListInstalled() + if err != nil { + return false, err + } + + registry, err := NewRegistry() + if err != nil { + return false, err + } + + allPlugins, err := registry.List() + if err != nil { + return false, err + } + + for _, id := range installed { + if id == excludePlugin { + continue + } + + for _, p := range allPlugins { + if p.ID == id && p.Repo == repoURL && p.Path != "" { + return false, nil + } + } + } + + return true, nil +} + +func (m *Manager) ListInstalled() ([]string, error) { + installedMap := make(map[string]bool) + + exists, err := afero.DirExists(m.fs, m.pluginsDir) + if err != nil { + return nil, err + } + + if exists { + entries, err := afero.ReadDir(m.fs, m.pluginsDir) + if err != nil { + return nil, fmt.Errorf("failed to read plugins directory: %w", err) + } + + for _, entry := range entries { + name := entry.Name() + if name == ".repos" || strings.HasSuffix(name, ".meta") { + continue + } + + fullPath := filepath.Join(m.pluginsDir, name) + isPlugin := false + + if entry.IsDir() { + isPlugin = true + } else if entry.Mode()&os.ModeSymlink != 0 { + isPlugin = true + } else { + info, err := m.fs.Stat(fullPath) + if err == nil && info.IsDir() { + isPlugin = true + } + } + + if isPlugin { + // Read plugin.json to get the actual plugin ID + pluginID := m.getPluginID(fullPath) + if pluginID != "" { + installedMap[pluginID] = true + } + } + } + } + + systemPluginsDir := "/etc/xdg/quickshell/dms-plugins" + systemExists, err := afero.DirExists(m.fs, systemPluginsDir) + if err == nil && systemExists { + entries, err := afero.ReadDir(m.fs, systemPluginsDir) + if err == nil { + for _, entry := range entries { + if entry.IsDir() { + fullPath := filepath.Join(systemPluginsDir, entry.Name()) + // Read plugin.json to get the actual plugin ID + pluginID := m.getPluginID(fullPath) + if pluginID != "" { + installedMap[pluginID] = true + } + } + } + } + } + + var installed []string + for name := range installedMap { + installed = append(installed, name) + } + + return installed, nil +} + +// getPluginID reads the plugin.json file and returns the plugin ID +func (m *Manager) getPluginID(pluginPath string) string { + manifest := m.getPluginManifest(pluginPath) + if manifest == nil { + return "" + } + return manifest.ID +} + +func (m *Manager) getPluginManifest(pluginPath string) *pluginManifest { + manifestPath := filepath.Join(pluginPath, "plugin.json") + data, err := afero.ReadFile(m.fs, manifestPath) + if err != nil { + return nil + } + + var manifest pluginManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil + } + + return &manifest +} + +type pluginManifest struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (m *Manager) GetPluginsDir() string { + return m.pluginsDir +} + +func (m *Manager) UninstallByIDOrName(idOrName string) error { + pluginPath, err := m.findInstalledPathByIDOrName(idOrName) + if err != nil { + return err + } + if pluginPath == "" { + return fmt.Errorf("plugin not found: %s", idOrName) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return fmt.Errorf("cannot uninstall system plugin: %s", idOrName) + } + + metaPath := pluginPath + ".meta" + metaExists, _ := afero.Exists(m.fs, metaPath) + + if metaExists { + if err := m.fs.Remove(pluginPath); err != nil { + return fmt.Errorf("failed to remove symlink: %w", err) + } + if err := m.fs.Remove(metaPath); err != nil { + return fmt.Errorf("failed to remove metadata: %w", err) + } + } else { + if err := m.fs.RemoveAll(pluginPath); err != nil { + return fmt.Errorf("failed to remove plugin: %w", err) + } + } + + return nil +} + +func (m *Manager) UpdateByIDOrName(idOrName string) error { + pluginPath, err := m.findInstalledPathByIDOrName(idOrName) + if err != nil { + return err + } + if pluginPath == "" { + return fmt.Errorf("plugin not found: %s", idOrName) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return fmt.Errorf("cannot update system plugin: %s", idOrName) + } + + metaPath := pluginPath + ".meta" + metaExists, _ := afero.Exists(m.fs, metaPath) + + if metaExists { + // Plugin is from monorepo, but we don't know the repo URL without registry + // Just try to pull from existing .git in the symlink target + return fmt.Errorf("cannot update monorepo plugin without registry info: %s", idOrName) + } + + // Standalone plugin - just pull + if err := m.gitClient.Pull(pluginPath); err != nil { + return fmt.Errorf("failed to update plugin: %w", err) + } + + return nil +} + +func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) { + path, err := m.findInDirByIDOrName(m.pluginsDir, idOrName) + if err != nil { + return "", err + } + if path != "" { + return path, nil + } + + systemDir := "/etc/xdg/quickshell/dms-plugins" + return m.findInDirByIDOrName(systemDir, idOrName) +} + +func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) { + // Check exact folder name match first + exactPath := filepath.Join(dir, idOrName) + if exists, _ := afero.DirExists(m.fs, exactPath); exists { + return exactPath, nil + } + + exists, err := afero.DirExists(m.fs, dir) + if err != nil || !exists { + return "", nil + } + + entries, err := afero.ReadDir(m.fs, dir) + if err != nil { + return "", nil + } + + for _, entry := range entries { + name := entry.Name() + if name == ".repos" || strings.HasSuffix(name, ".meta") { + continue + } + + fullPath := filepath.Join(dir, name) + isPlugin := entry.IsDir() || entry.Mode()&os.ModeSymlink != 0 + if !isPlugin { + if info, err := m.fs.Stat(fullPath); err == nil && info.IsDir() { + isPlugin = true + } + } + + if !isPlugin { + continue + } + + manifest := m.getPluginManifest(fullPath) + if manifest == nil { + continue + } + + if manifest.ID == idOrName || manifest.Name == idOrName { + return fullPath, nil + } + } + + return "", nil +} + +func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) { + pluginPath, err := m.findInstalledPath(pluginID) + if err != nil { + return false, fmt.Errorf("failed to find plugin: %w", err) + } + + if pluginPath == "" { + return false, fmt.Errorf("plugin not installed: %s", pluginID) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return false, nil + } + + metaPath := pluginPath + ".meta" + metaExists, err := afero.Exists(m.fs, metaPath) + if err != nil { + return false, fmt.Errorf("failed to check metadata: %w", err) + } + + if metaExists { + // Plugin is from a monorepo, check the repo directory + reposDir := filepath.Join(m.pluginsDir, ".repos") + repoName := m.getRepoName(plugin.Repo) + repoPath := filepath.Join(reposDir, repoName) + + return m.gitClient.HasUpdates(repoPath) + } + + // Plugin is a standalone repo + return m.gitClient.HasUpdates(pluginPath) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/manager_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/manager_test.go new file mode 100644 index 0000000..485479e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/manager_test.go @@ -0,0 +1,247 @@ +package plugins + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) { + fs := afero.NewMemMapFs() + pluginsDir := "/test-plugins" + manager := &Manager{ + fs: fs, + pluginsDir: pluginsDir, + gitClient: &mockGitClient{}, + } + return manager, fs, pluginsDir +} + +func TestNewManager(t *testing.T) { + manager, err := NewManager() + assert.NoError(t, err) + assert.NotNil(t, manager) + assert.NotEmpty(t, manager.pluginsDir) +} + +func TestGetPluginsDir(t *testing.T) { + t.Run("uses XDG_CONFIG_HOME when set", func(t *testing.T) { + oldConfig := os.Getenv("XDG_CONFIG_HOME") + defer func() { + if oldConfig != "" { + os.Setenv("XDG_CONFIG_HOME", oldConfig) + } else { + os.Unsetenv("XDG_CONFIG_HOME") + } + }() + + os.Setenv("XDG_CONFIG_HOME", "/tmp/test-config") + dir := getPluginsDir() + assert.Equal(t, "/tmp/test-config/DankMaterialShell/plugins", dir) + }) + + t.Run("falls back to home directory", func(t *testing.T) { + oldConfig := os.Getenv("XDG_CONFIG_HOME") + defer func() { + if oldConfig != "" { + os.Setenv("XDG_CONFIG_HOME", oldConfig) + } else { + os.Unsetenv("XDG_CONFIG_HOME") + } + }() + + os.Unsetenv("XDG_CONFIG_HOME") + dir := getPluginsDir() + assert.Contains(t, dir, ".config/DankMaterialShell/plugins") + }) +} + +func TestIsInstalled(t *testing.T) { + t.Run("returns true when plugin is installed", func(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + + plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"} + pluginPath := filepath.Join(pluginsDir, plugin.ID) + err := fs.MkdirAll(pluginPath, 0o755) + require.NoError(t, err) + + installed, err := manager.IsInstalled(plugin) + assert.NoError(t, err) + assert.True(t, installed) + }) + + t.Run("returns false when plugin is not installed", func(t *testing.T) { + manager, _, _ := setupTestManager(t) + + plugin := Plugin{ID: "non-existent", Name: "NonExistent"} + installed, err := manager.IsInstalled(plugin) + assert.NoError(t, err) + assert.False(t, installed) + }) +} + +func TestInstall(t *testing.T) { + t.Run("installs plugin successfully", func(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + + plugin := Plugin{ + ID: "test-plugin", + Name: "TestPlugin", + Repo: "https://github.com/test/plugin", + } + + cloneCalled := false + mockGit := &mockGitClient{ + cloneFunc: func(path string, url string) error { + cloneCalled = true + assert.Equal(t, filepath.Join(pluginsDir, plugin.ID), path) + assert.Equal(t, plugin.Repo, url) + return fs.MkdirAll(path, 0o755) + }, + } + manager.gitClient = mockGit + + err := manager.Install(plugin) + assert.NoError(t, err) + assert.True(t, cloneCalled) + + exists, _ := afero.DirExists(fs, filepath.Join(pluginsDir, plugin.ID)) + assert.True(t, exists) + }) + + t.Run("returns error when plugin already installed", func(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + + plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"} + pluginPath := filepath.Join(pluginsDir, plugin.ID) + err := fs.MkdirAll(pluginPath, 0o755) + require.NoError(t, err) + + err = manager.Install(plugin) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already installed") + }) + + t.Run("installs monorepo plugin with symlink", func(t *testing.T) { + t.Skip("Skipping symlink test as MemMapFs doesn't support symlinks") + }) +} + +func TestManagerUpdate(t *testing.T) { + t.Run("updates plugin successfully", func(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + + plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"} + pluginPath := filepath.Join(pluginsDir, plugin.ID) + err := fs.MkdirAll(pluginPath, 0o755) + require.NoError(t, err) + + pullCalled := false + mockGit := &mockGitClient{ + pullFunc: func(path string) error { + pullCalled = true + assert.Equal(t, pluginPath, path) + return nil + }, + } + manager.gitClient = mockGit + + err = manager.Update(plugin) + assert.NoError(t, err) + assert.True(t, pullCalled) + }) + + t.Run("returns error when plugin not installed", func(t *testing.T) { + manager, _, _ := setupTestManager(t) + + plugin := Plugin{ID: "non-existent", Name: "NonExistent"} + err := manager.Update(plugin) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not installed") + }) +} + +func TestUninstall(t *testing.T) { + t.Run("uninstalls plugin successfully", func(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + + plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"} + pluginPath := filepath.Join(pluginsDir, plugin.ID) + err := fs.MkdirAll(pluginPath, 0o755) + require.NoError(t, err) + + err = manager.Uninstall(plugin) + assert.NoError(t, err) + + exists, _ := afero.DirExists(fs, pluginPath) + assert.False(t, exists) + }) + + t.Run("returns error when plugin not installed", func(t *testing.T) { + manager, _, _ := setupTestManager(t) + + plugin := Plugin{ID: "non-existent", Name: "NonExistent"} + err := manager.Uninstall(plugin) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not installed") + }) +} + +func TestListInstalled(t *testing.T) { + t.Run("lists installed plugins", func(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + + err := fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0o755) + require.NoError(t, err) + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0o644) + require.NoError(t, err) + + err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin2"), 0o755) + require.NoError(t, err) + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin2", "plugin.json"), []byte(`{"id":"Plugin2"}`), 0o644) + require.NoError(t, err) + + installed, err := manager.ListInstalled() + assert.NoError(t, err) + assert.Len(t, installed, 2) + assert.Contains(t, installed, "Plugin1") + assert.Contains(t, installed, "Plugin2") + }) + + t.Run("returns empty list when no plugins installed", func(t *testing.T) { + manager, _, _ := setupTestManager(t) + + installed, err := manager.ListInstalled() + assert.NoError(t, err) + assert.Empty(t, installed) + }) + + t.Run("ignores files and .repos directory", func(t *testing.T) { + manager, fs, pluginsDir := setupTestManager(t) + + err := fs.MkdirAll(pluginsDir, 0o755) + require.NoError(t, err) + err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0o755) + require.NoError(t, err) + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0o644) + require.NoError(t, err) + err = fs.MkdirAll(filepath.Join(pluginsDir, ".repos"), 0o755) + require.NoError(t, err) + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("test"), 0o644) + require.NoError(t, err) + + installed, err := manager.ListInstalled() + assert.NoError(t, err) + assert.Len(t, installed, 1) + assert.Equal(t, "Plugin1", installed[0]) + }) +} + +func TestManagerGetPluginsDir(t *testing.T) { + manager, _, pluginsDir := setupTestManager(t) + assert.Equal(t, pluginsDir, manager.GetPluginsDir()) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/registry.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/registry.go new file mode 100644 index 0000000..1728e72 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/registry.go @@ -0,0 +1,258 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/go-git/go-git/v6" + "github.com/spf13/afero" +) + +const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git" + +type Plugin struct { + ID string `json:"id"` + Name string `json:"name"` + Capabilities []string `json:"capabilities"` + Category string `json:"category"` + Repo string `json:"repo"` + Path string `json:"path,omitempty"` + Author string `json:"author"` + Description string `json:"description"` + Dependencies []string `json:"dependencies,omitempty"` + Compositors []string `json:"compositors"` + Distro []string `json:"distro"` + Screenshot string `json:"screenshot,omitempty"` + RequiresDMS string `json:"requires_dms,omitempty"` + Featured bool `json:"featured,omitempty"` +} + +type GitClient interface { + PlainClone(path string, url string) error + Pull(path string) error + HasUpdates(path string) (bool, error) +} + +type realGitClient struct{} + +func (g *realGitClient) PlainClone(path string, url string) error { + _, err := git.PlainClone(path, &git.CloneOptions{ + URL: url, + Progress: os.Stdout, + }) + return err +} + +func (g *realGitClient) Pull(path string) error { + repo, err := git.PlainOpen(path) + if err != nil { + return err + } + + worktree, err := repo.Worktree() + if err != nil { + return err + } + + err = worktree.Pull(&git.PullOptions{}) + if err != nil && err.Error() != "already up-to-date" { + return err + } + + return nil +} + +func (g *realGitClient) HasUpdates(path string) (bool, error) { + repo, err := git.PlainOpen(path) + if err != nil { + return false, err + } + + // Fetch remote changes + err = repo.Fetch(&git.FetchOptions{}) + if err != nil && err.Error() != "already up-to-date" { + // If fetch fails, we can't determine if there are updates + // Return false and the error + return false, err + } + + // Get the HEAD reference + head, err := repo.Head() + if err != nil { + return false, err + } + + // Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master) + remote, err := repo.Remote("origin") + if err != nil { + return false, err + } + + refs, err := remote.List(&git.ListOptions{}) + if err != nil { + return false, err + } + + // Find the default branch remote ref + var remoteHead string + for _, ref := range refs { + if ref.Name().IsBranch() { + // Try common branch names + if ref.Name().Short() == "main" || ref.Name().Short() == "master" { + remoteHead = ref.Hash().String() + break + } + } + } + + // If we couldn't find a remote HEAD, assume no updates + if remoteHead == "" { + return false, nil + } + + // Compare local HEAD with remote HEAD + return head.Hash().String() != remoteHead, nil +} + +type Registry struct { + fs afero.Fs + cacheDir string + plugins []Plugin + git GitClient +} + +func NewRegistry() (*Registry, error) { + return NewRegistryWithFs(afero.NewOsFs()) +} + +func NewRegistryWithFs(fs afero.Fs) (*Registry, error) { + cacheDir := getCacheDir() + return &Registry{ + fs: fs, + cacheDir: cacheDir, + git: &realGitClient{}, + }, nil +} + +func getCacheDir() string { + return filepath.Join(os.TempDir(), "dankdots-plugin-registry") +} + +func (r *Registry) Update() error { + exists, err := afero.DirExists(r.fs, r.cacheDir) + if err != nil { + return fmt.Errorf("failed to check cache directory: %w", err) + } + + if !exists { + if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil { + return fmt.Errorf("failed to clone registry: %w", err) + } + } else { + // Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone + if err := r.git.Pull(r.cacheDir); err != nil { + // Repository is likely corrupted or has issues, delete and re-clone + if err := r.fs.RemoveAll(r.cacheDir); err != nil { + return fmt.Errorf("failed to remove corrupted registry: %w", err) + } + + if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil { + return fmt.Errorf("failed to re-clone registry: %w", err) + } + } + } + + return r.loadPlugins() +} + +func (r *Registry) loadPlugins() error { + pluginsDir := filepath.Join(r.cacheDir, "plugins") + + entries, err := afero.ReadDir(r.fs, pluginsDir) + if err != nil { + return fmt.Errorf("failed to read plugins directory: %w", err) + } + + r.plugins = []Plugin{} + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + + data, err := afero.ReadFile(r.fs, filepath.Join(pluginsDir, entry.Name())) + if err != nil { + continue + } + + var plugin Plugin + if err := json.Unmarshal(data, &plugin); err != nil { + continue + } + + if plugin.ID == "" { + plugin.ID = strings.TrimSuffix(entry.Name(), ".json") + } + + r.plugins = append(r.plugins, plugin) + } + + return nil +} + +func (r *Registry) List() ([]Plugin, error) { + if len(r.plugins) == 0 { + if err := r.Update(); err != nil { + return nil, err + } + } + + return SortByFirstParty(r.plugins), nil +} + +func (r *Registry) Search(query string) ([]Plugin, error) { + allPlugins, err := r.List() + if err != nil { + return nil, err + } + + if query == "" { + return allPlugins, nil + } + + return SortByFirstParty(FuzzySearch(query, allPlugins)), nil +} + +func (r *Registry) Get(idOrName string) (*Plugin, error) { + plugins, err := r.List() + if err != nil { + return nil, err + } + + // First, try to find by ID (preferred method) + for _, p := range plugins { + if p.ID == idOrName { + return &p, nil + } + } + + // Fallback to name for backward compatibility + for _, p := range plugins { + if p.Name == idOrName { + return &p, nil + } + } + + return nil, fmt.Errorf("plugin not found: %s", idOrName) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/registry_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/registry_test.go new file mode 100644 index 0000000..e535e6f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/registry_test.go @@ -0,0 +1,326 @@ +package plugins + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockGitClient struct { + cloneFunc func(path string, url string) error + pullFunc func(path string) error + hasUpdatesFunc func(path string) (bool, error) +} + +func (m *mockGitClient) PlainClone(path string, url string) error { + if m.cloneFunc != nil { + return m.cloneFunc(path, url) + } + return nil +} + +func (m *mockGitClient) Pull(path string) error { + if m.pullFunc != nil { + return m.pullFunc(path) + } + return nil +} + +func (m *mockGitClient) HasUpdates(path string) (bool, error) { + if m.hasUpdatesFunc != nil { + return m.hasUpdatesFunc(path) + } + return false, nil +} + +func TestNewRegistry(t *testing.T) { + registry, err := NewRegistry() + assert.NoError(t, err) + assert.NotNil(t, registry) + assert.NotEmpty(t, registry.cacheDir) +} + +func TestGetCacheDir(t *testing.T) { + cacheDir := getCacheDir() + assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry") +} + +func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) { + fs := afero.NewMemMapFs() + tmpDir := "/test-cache" + registry := &Registry{ + fs: fs, + cacheDir: tmpDir, + plugins: []Plugin{}, + git: &mockGitClient{}, + } + return registry, fs, tmpDir +} + +func createTestPlugin(t *testing.T, fs afero.Fs, dir string, filename string, plugin Plugin) { + pluginsDir := filepath.Join(dir, "plugins") + err := fs.MkdirAll(pluginsDir, 0o755) + require.NoError(t, err) + + data, err := json.Marshal(plugin) + require.NoError(t, err) + + err = afero.WriteFile(fs, filepath.Join(pluginsDir, filename), data, 0o644) + require.NoError(t, err) +} + +func TestLoadPlugins(t *testing.T) { + t.Run("loads valid plugin files", func(t *testing.T) { + registry, fs, tmpDir := setupTestRegistry(t) + + plugin1 := Plugin{ + Name: "TestPlugin1", + Capabilities: []string{"dankbar-widget"}, + Category: "monitoring", + Repo: "https://github.com/test/plugin1", + Author: "Test Author", + Description: "Test plugin 1", + Compositors: []string{"niri"}, + Distro: []string{"any"}, + } + + plugin2 := Plugin{ + Name: "TestPlugin2", + Capabilities: []string{"system-tray"}, + Category: "utilities", + Repo: "https://github.com/test/plugin2", + Author: "Another Author", + Description: "Test plugin 2", + Dependencies: []string{"dep1", "dep2"}, + Compositors: []string{"hyprland", "niri"}, + Distro: []string{"arch"}, + Screenshot: "https://example.com/screenshot.png", + } + + createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1) + createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2) + + err := registry.loadPlugins() + assert.NoError(t, err) + assert.Len(t, registry.plugins, 2) + + assert.Equal(t, "TestPlugin1", registry.plugins[0].Name) + assert.Equal(t, "TestPlugin2", registry.plugins[1].Name) + assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities) + assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies) + }) + + t.Run("skips non-json files", func(t *testing.T) { + registry, fs, tmpDir := setupTestRegistry(t) + + pluginsDir := filepath.Join(tmpDir, "plugins") + err := fs.MkdirAll(pluginsDir, 0o755) + require.NoError(t, err) + + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("# Test"), 0o644) + require.NoError(t, err) + + plugin := Plugin{ + Name: "ValidPlugin", + Capabilities: []string{"test"}, + Category: "test", + Repo: "https://github.com/test/test", + Author: "Test", + Description: "Test", + Compositors: []string{"niri"}, + Distro: []string{"any"}, + } + createTestPlugin(t, fs, tmpDir, "valid.json", plugin) + + err = registry.loadPlugins() + assert.NoError(t, err) + assert.Len(t, registry.plugins, 1) + assert.Equal(t, "ValidPlugin", registry.plugins[0].Name) + }) + + t.Run("skips directories", func(t *testing.T) { + registry, fs, tmpDir := setupTestRegistry(t) + + pluginsDir := filepath.Join(tmpDir, "plugins") + err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0o755) + require.NoError(t, err) + + plugin := Plugin{ + Name: "ValidPlugin", + Capabilities: []string{"test"}, + Category: "test", + Repo: "https://github.com/test/test", + Author: "Test", + Description: "Test", + Compositors: []string{"niri"}, + Distro: []string{"any"}, + } + createTestPlugin(t, fs, tmpDir, "valid.json", plugin) + + err = registry.loadPlugins() + assert.NoError(t, err) + assert.Len(t, registry.plugins, 1) + }) + + t.Run("skips invalid json files", func(t *testing.T) { + registry, fs, tmpDir := setupTestRegistry(t) + + pluginsDir := filepath.Join(tmpDir, "plugins") + err := fs.MkdirAll(pluginsDir, 0o755) + require.NoError(t, err) + + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "invalid.json"), []byte("{invalid json}"), 0o644) + require.NoError(t, err) + + plugin := Plugin{ + Name: "ValidPlugin", + Capabilities: []string{"test"}, + Category: "test", + Repo: "https://github.com/test/test", + Author: "Test", + Description: "Test", + Compositors: []string{"niri"}, + Distro: []string{"any"}, + } + createTestPlugin(t, fs, tmpDir, "valid.json", plugin) + + err = registry.loadPlugins() + assert.NoError(t, err) + assert.Len(t, registry.plugins, 1) + assert.Equal(t, "ValidPlugin", registry.plugins[0].Name) + }) + + t.Run("returns error when plugins directory missing", func(t *testing.T) { + registry, _, _ := setupTestRegistry(t) + + err := registry.loadPlugins() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read plugins directory") + }) +} + +func TestList(t *testing.T) { + t.Run("returns cached plugins if available", func(t *testing.T) { + registry, _, _ := setupTestRegistry(t) + + plugin := Plugin{ + Name: "CachedPlugin", + Capabilities: []string{"test"}, + Category: "test", + Repo: "https://github.com/test/test", + Author: "Test", + Description: "Test", + Compositors: []string{"niri"}, + Distro: []string{"any"}, + } + + registry.plugins = []Plugin{plugin} + + plugins, err := registry.List() + assert.NoError(t, err) + assert.Len(t, plugins, 1) + assert.Equal(t, "CachedPlugin", plugins[0].Name) + }) + + t.Run("updates and loads plugins when cache is empty", func(t *testing.T) { + registry, fs, _ := setupTestRegistry(t) + + plugin := Plugin{ + Name: "NewPlugin", + Capabilities: []string{"test"}, + Category: "test", + Repo: "https://github.com/test/test", + Author: "Test", + Description: "Test", + Compositors: []string{"niri"}, + Distro: []string{"any"}, + } + + mockGit := &mockGitClient{ + cloneFunc: func(path string, url string) error { + createTestPlugin(t, fs, path, "plugin.json", plugin) + return nil + }, + } + registry.git = mockGit + + plugins, err := registry.List() + assert.NoError(t, err) + assert.Len(t, plugins, 1) + assert.Equal(t, "NewPlugin", plugins[0].Name) + }) +} + +func TestUpdate(t *testing.T) { + t.Run("clones repository when cache doesn't exist", func(t *testing.T) { + registry, fs, tmpDir := setupTestRegistry(t) + + plugin := Plugin{ + Name: "RepoPlugin", + Capabilities: []string{"test"}, + Category: "test", + Repo: "https://github.com/test/test", + Author: "Test", + Description: "Test", + Compositors: []string{"niri"}, + Distro: []string{"any"}, + } + + cloneCalled := false + mockGit := &mockGitClient{ + cloneFunc: func(path string, url string) error { + cloneCalled = true + assert.Equal(t, registryRepo, url) + assert.Equal(t, tmpDir, path) + createTestPlugin(t, fs, path, "plugin.json", plugin) + return nil + }, + } + registry.git = mockGit + + err := registry.Update() + assert.NoError(t, err) + assert.True(t, cloneCalled) + assert.Len(t, registry.plugins, 1) + assert.Equal(t, "RepoPlugin", registry.plugins[0].Name) + }) + + t.Run("pulls updates when cache exists", func(t *testing.T) { + registry, fs, tmpDir := setupTestRegistry(t) + + plugin := Plugin{ + Name: "UpdatedPlugin", + Capabilities: []string{"test"}, + Category: "test", + Repo: "https://github.com/test/test", + Author: "Test", + Description: "Test", + Compositors: []string{"niri"}, + Distro: []string{"any"}, + } + + err := fs.MkdirAll(tmpDir, 0o755) + require.NoError(t, err) + + pullCalled := false + mockGit := &mockGitClient{ + pullFunc: func(path string) error { + pullCalled = true + assert.Equal(t, tmpDir, path) + createTestPlugin(t, fs, path, "plugin.json", plugin) + return nil + }, + } + registry.git = mockGit + + err = registry.Update() + assert.NoError(t, err) + assert.True(t, pullCalled) + assert.Len(t, registry.plugins, 1) + assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name) + }) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/search.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/search.go new file mode 100644 index 0000000..833158f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/plugins/search.go @@ -0,0 +1,91 @@ +package plugins + +import ( + "sort" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func FuzzySearch(query string, plugins []Plugin) []Plugin { + if query == "" { + return plugins + } + + queryLower := strings.ToLower(query) + return utils.Filter(plugins, func(p Plugin) bool { + return fuzzyMatch(queryLower, strings.ToLower(p.Name)) || + fuzzyMatch(queryLower, strings.ToLower(p.Category)) || + fuzzyMatch(queryLower, strings.ToLower(p.Description)) || + fuzzyMatch(queryLower, strings.ToLower(p.Author)) + }) +} + +func fuzzyMatch(query, text string) bool { + queryIdx := 0 + for _, char := range text { + if queryIdx < len(query) && char == rune(query[queryIdx]) { + queryIdx++ + } + } + return queryIdx == len(query) +} + +func FilterByCategory(category string, plugins []Plugin) []Plugin { + if category == "" { + return plugins + } + categoryLower := strings.ToLower(category) + return utils.Filter(plugins, func(p Plugin) bool { + return strings.ToLower(p.Category) == categoryLower + }) +} + +func FilterByCompositor(compositor string, plugins []Plugin) []Plugin { + if compositor == "" { + return plugins + } + compositorLower := strings.ToLower(compositor) + return utils.Filter(plugins, func(p Plugin) bool { + return utils.Any(p.Compositors, func(c string) bool { + return strings.ToLower(c) == compositorLower + }) + }) +} + +func FilterByCapability(capability string, plugins []Plugin) []Plugin { + if capability == "" { + return plugins + } + capabilityLower := strings.ToLower(capability) + return utils.Filter(plugins, func(p Plugin) bool { + return utils.Any(p.Capabilities, func(c string) bool { + return strings.ToLower(c) == capabilityLower + }) + }) +} + +func SortByFirstParty(plugins []Plugin) []Plugin { + sort.SliceStable(plugins, func(i, j int) bool { + if plugins[i].Featured != plugins[j].Featured { + return plugins[i].Featured + } + isFirstPartyI := strings.HasPrefix(plugins[i].Repo, "https://github.com/AvengeMedia") + isFirstPartyJ := strings.HasPrefix(plugins[j].Repo, "https://github.com/AvengeMedia") + if isFirstPartyI != isFirstPartyJ { + return isFirstPartyI + } + return false + }) + return plugins +} + +func FindByIDOrName(idOrName string, plugins []Plugin) *Plugin { + if p, found := utils.Find(plugins, func(p Plugin) bool { return p.ID == idOrName }); found { + return &p + } + if p, found := utils.Find(plugins, func(p Plugin) bool { return p.Name == idOrName }); found { + return &p + } + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/dwl_ipc/dwl_ipc.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/dwl_ipc/dwl_ipc.go new file mode 100644 index 0000000..fc0e50b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/dwl_ipc/dwl_ipc.go @@ -0,0 +1,791 @@ +// Generated by go-wayland-scanner +// https://github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/cmd/go-wayland-scanner +// XML file : internal/proto/xml/dwl-ipc-unstable-v2.xml +// +// dwl_ipc_unstable_v2 Protocol Copyright: + +package dwl_ipc + +import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + +// ZdwlIpcManagerV2InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZdwlIpcManagerV2InterfaceName = "zdwl_ipc_manager_v2" + +// ZdwlIpcManagerV2 : manage dwl state +// +// This interface is exposed as a global in wl_registry. +// +// Clients can use this interface to get a dwl_ipc_output. +// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events. +// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client. +type ZdwlIpcManagerV2 struct { + client.BaseProxy + tagsHandler ZdwlIpcManagerV2TagsHandlerFunc + layoutHandler ZdwlIpcManagerV2LayoutHandlerFunc +} + +// NewZdwlIpcManagerV2 : manage dwl state +// +// This interface is exposed as a global in wl_registry. +// +// Clients can use this interface to get a dwl_ipc_output. +// After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events. +// The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client. +func NewZdwlIpcManagerV2(ctx *client.Context) *ZdwlIpcManagerV2 { + zdwlIpcManagerV2 := &ZdwlIpcManagerV2{} + ctx.Register(zdwlIpcManagerV2) + return zdwlIpcManagerV2 +} + +// Release : release dwl_ipc_manager +// +// Indicates that the client will not the dwl_ipc_manager object anymore. +// Objects created through this instance are not affected. +func (i *ZdwlIpcManagerV2) Release() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// GetOutput : get a dwl_ipc_outout for a wl_output +// +// Get a dwl_ipc_outout for the specified wl_output. +func (i *ZdwlIpcManagerV2) GetOutput(output *client.Output) (*ZdwlIpcOutputV2, error) { + id := NewZdwlIpcOutputV2(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], output.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// ZdwlIpcManagerV2TagsEvent : Announces tag amount +// +// This event is sent after binding. +// A roundtrip after binding guarantees the client recieved all tags. +type ZdwlIpcManagerV2TagsEvent struct { + Amount uint32 +} +type ZdwlIpcManagerV2TagsHandlerFunc func(ZdwlIpcManagerV2TagsEvent) + +// SetTagsHandler : sets handler for ZdwlIpcManagerV2TagsEvent +func (i *ZdwlIpcManagerV2) SetTagsHandler(f ZdwlIpcManagerV2TagsHandlerFunc) { + i.tagsHandler = f +} + +// ZdwlIpcManagerV2LayoutEvent : Announces a layout +// +// This event is sent after binding. +// A roundtrip after binding guarantees the client recieved all layouts. +type ZdwlIpcManagerV2LayoutEvent struct { + Name string +} +type ZdwlIpcManagerV2LayoutHandlerFunc func(ZdwlIpcManagerV2LayoutEvent) + +// SetLayoutHandler : sets handler for ZdwlIpcManagerV2LayoutEvent +func (i *ZdwlIpcManagerV2) SetLayoutHandler(f ZdwlIpcManagerV2LayoutHandlerFunc) { + i.layoutHandler = f +} + +func (i *ZdwlIpcManagerV2) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.tagsHandler == nil { + return + } + var e ZdwlIpcManagerV2TagsEvent + l := 0 + e.Amount = client.Uint32(data[l : l+4]) + l += 4 + + i.tagsHandler(e) + case 1: + if i.layoutHandler == nil { + return + } + var e ZdwlIpcManagerV2LayoutEvent + l := 0 + nameLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Name = client.String(data[l : l+nameLen]) + l += nameLen + + i.layoutHandler(e) + } +} + +// ZdwlIpcOutputV2InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZdwlIpcOutputV2InterfaceName = "zdwl_ipc_output_v2" + +// ZdwlIpcOutputV2 : control dwl output +// +// Observe and control a dwl output. +// +// Events are double-buffered: +// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent. +// +// Request are not double-buffered: +// The compositor will update immediately upon request. +type ZdwlIpcOutputV2 struct { + client.BaseProxy + toggleVisibilityHandler ZdwlIpcOutputV2ToggleVisibilityHandlerFunc + activeHandler ZdwlIpcOutputV2ActiveHandlerFunc + tagHandler ZdwlIpcOutputV2TagHandlerFunc + layoutHandler ZdwlIpcOutputV2LayoutHandlerFunc + titleHandler ZdwlIpcOutputV2TitleHandlerFunc + appidHandler ZdwlIpcOutputV2AppidHandlerFunc + layoutSymbolHandler ZdwlIpcOutputV2LayoutSymbolHandlerFunc + frameHandler ZdwlIpcOutputV2FrameHandlerFunc + fullscreenHandler ZdwlIpcOutputV2FullscreenHandlerFunc + floatingHandler ZdwlIpcOutputV2FloatingHandlerFunc + xHandler ZdwlIpcOutputV2XHandlerFunc + yHandler ZdwlIpcOutputV2YHandlerFunc + widthHandler ZdwlIpcOutputV2WidthHandlerFunc + heightHandler ZdwlIpcOutputV2HeightHandlerFunc + lastLayerHandler ZdwlIpcOutputV2LastLayerHandlerFunc + kbLayoutHandler ZdwlIpcOutputV2KbLayoutHandlerFunc + keymodeHandler ZdwlIpcOutputV2KeymodeHandlerFunc + scalefactorHandler ZdwlIpcOutputV2ScalefactorHandlerFunc +} + +// NewZdwlIpcOutputV2 : control dwl output +// +// Observe and control a dwl output. +// +// Events are double-buffered: +// Clients should cache events and redraw when a dwl_ipc_output.frame event is sent. +// +// Request are not double-buffered: +// The compositor will update immediately upon request. +func NewZdwlIpcOutputV2(ctx *client.Context) *ZdwlIpcOutputV2 { + zdwlIpcOutputV2 := &ZdwlIpcOutputV2{} + ctx.Register(zdwlIpcOutputV2) + return zdwlIpcOutputV2 +} + +// Release : release dwl_ipc_outout +// +// Indicates to that the client no longer needs this dwl_ipc_output. +func (i *ZdwlIpcOutputV2) Release() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetTags : Set the active tags of this output +// +// tagmask: bitmask of the tags that should be set. +// toggleTagset: toggle the selected tagset, zero for invalid, nonzero for valid. +func (i *ZdwlIpcOutputV2) SetTags(tagmask, toggleTagset uint32) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(tagmask)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(toggleTagset)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetClientTags : Set the tags of the focused client. +// +// The tags are updated as follows: +// new_tags = (current_tags AND and_tags) XOR xor_tags +func (i *ZdwlIpcOutputV2) SetClientTags(andTags, xorTags uint32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(andTags)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(xorTags)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetLayout : Set the layout of this output +// +// index: index of a layout recieved by dwl_ipc_manager.layout +func (i *ZdwlIpcOutputV2) SetLayout(index uint32) error { + const opcode = 3 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(index)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Quit : Quit mango +// This request allows clients to instruct the compositor to quit mango. +func (i *ZdwlIpcOutputV2) Quit() error { + const opcode = 4 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SendDispatch : Set the active tags of this output +// +// dispatch: dispatch name. +// arg1: arg1. +// arg2: arg2. +// arg3: arg3. +// arg4: arg4. +// arg5: arg5. +func (i *ZdwlIpcOutputV2) SendDispatch(dispatch, arg1, arg2, arg3, arg4, arg5 string) error { + const opcode = 5 + dispatchLen := client.PaddedLen(len(dispatch) + 1) + arg1Len := client.PaddedLen(len(arg1) + 1) + arg2Len := client.PaddedLen(len(arg2) + 1) + arg3Len := client.PaddedLen(len(arg3) + 1) + arg4Len := client.PaddedLen(len(arg4) + 1) + arg5Len := client.PaddedLen(len(arg5) + 1) + _reqBufLen := 8 + (4 + dispatchLen) + (4 + arg1Len) + (4 + arg2Len) + (4 + arg3Len) + (4 + arg4Len) + (4 + arg5Len) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutString(_reqBuf[l:l+(4+dispatchLen)], dispatch) + l += (4 + dispatchLen) + client.PutString(_reqBuf[l:l+(4+arg1Len)], arg1) + l += (4 + arg1Len) + client.PutString(_reqBuf[l:l+(4+arg2Len)], arg2) + l += (4 + arg2Len) + client.PutString(_reqBuf[l:l+(4+arg3Len)], arg3) + l += (4 + arg3Len) + client.PutString(_reqBuf[l:l+(4+arg4Len)], arg4) + l += (4 + arg4Len) + client.PutString(_reqBuf[l:l+(4+arg5Len)], arg5) + l += (4 + arg5Len) + err := i.Context().WriteMsg(_reqBuf, nil) + return err +} + +type ZdwlIpcOutputV2TagState uint32 + +// ZdwlIpcOutputV2TagState : +const ( + // ZdwlIpcOutputV2TagStateNone : no state + ZdwlIpcOutputV2TagStateNone ZdwlIpcOutputV2TagState = 0 + // ZdwlIpcOutputV2TagStateActive : tag is active + ZdwlIpcOutputV2TagStateActive ZdwlIpcOutputV2TagState = 1 + // ZdwlIpcOutputV2TagStateUrgent : tag has at least one urgent client + ZdwlIpcOutputV2TagStateUrgent ZdwlIpcOutputV2TagState = 2 +) + +func (e ZdwlIpcOutputV2TagState) Name() string { + switch e { + case ZdwlIpcOutputV2TagStateNone: + return "none" + case ZdwlIpcOutputV2TagStateActive: + return "active" + case ZdwlIpcOutputV2TagStateUrgent: + return "urgent" + default: + return "" + } +} + +func (e ZdwlIpcOutputV2TagState) Value() string { + switch e { + case ZdwlIpcOutputV2TagStateNone: + return "0" + case ZdwlIpcOutputV2TagStateActive: + return "1" + case ZdwlIpcOutputV2TagStateUrgent: + return "2" + default: + return "" + } +} + +func (e ZdwlIpcOutputV2TagState) String() string { + return e.Name() + "=" + e.Value() +} + +// ZdwlIpcOutputV2ToggleVisibilityEvent : Toggle client visibilty +// +// Indicates the client should hide or show themselves. +// If the client is visible then hide, if hidden then show. +type ZdwlIpcOutputV2ToggleVisibilityEvent struct{} +type ZdwlIpcOutputV2ToggleVisibilityHandlerFunc func(ZdwlIpcOutputV2ToggleVisibilityEvent) + +// SetToggleVisibilityHandler : sets handler for ZdwlIpcOutputV2ToggleVisibilityEvent +func (i *ZdwlIpcOutputV2) SetToggleVisibilityHandler(f ZdwlIpcOutputV2ToggleVisibilityHandlerFunc) { + i.toggleVisibilityHandler = f +} + +// ZdwlIpcOutputV2ActiveEvent : Update the selected output. +// +// Indicates if the output is active. Zero is invalid, nonzero is valid. +type ZdwlIpcOutputV2ActiveEvent struct { + Active uint32 +} +type ZdwlIpcOutputV2ActiveHandlerFunc func(ZdwlIpcOutputV2ActiveEvent) + +// SetActiveHandler : sets handler for ZdwlIpcOutputV2ActiveEvent +func (i *ZdwlIpcOutputV2) SetActiveHandler(f ZdwlIpcOutputV2ActiveHandlerFunc) { + i.activeHandler = f +} + +// ZdwlIpcOutputV2TagEvent : Update the state of a tag. +// +// Indicates that a tag has been updated. +type ZdwlIpcOutputV2TagEvent struct { + Tag uint32 + State uint32 + Clients uint32 + Focused uint32 +} +type ZdwlIpcOutputV2TagHandlerFunc func(ZdwlIpcOutputV2TagEvent) + +// SetTagHandler : sets handler for ZdwlIpcOutputV2TagEvent +func (i *ZdwlIpcOutputV2) SetTagHandler(f ZdwlIpcOutputV2TagHandlerFunc) { + i.tagHandler = f +} + +// ZdwlIpcOutputV2LayoutEvent : Update the layout. +// +// Indicates a new layout is selected. +type ZdwlIpcOutputV2LayoutEvent struct { + Layout uint32 +} +type ZdwlIpcOutputV2LayoutHandlerFunc func(ZdwlIpcOutputV2LayoutEvent) + +// SetLayoutHandler : sets handler for ZdwlIpcOutputV2LayoutEvent +func (i *ZdwlIpcOutputV2) SetLayoutHandler(f ZdwlIpcOutputV2LayoutHandlerFunc) { + i.layoutHandler = f +} + +// ZdwlIpcOutputV2TitleEvent : Update the title. +// +// Indicates the title has changed. +type ZdwlIpcOutputV2TitleEvent struct { + Title string +} +type ZdwlIpcOutputV2TitleHandlerFunc func(ZdwlIpcOutputV2TitleEvent) + +// SetTitleHandler : sets handler for ZdwlIpcOutputV2TitleEvent +func (i *ZdwlIpcOutputV2) SetTitleHandler(f ZdwlIpcOutputV2TitleHandlerFunc) { + i.titleHandler = f +} + +// ZdwlIpcOutputV2AppidEvent : Update the appid. +// +// Indicates the appid has changed. +type ZdwlIpcOutputV2AppidEvent struct { + Appid string +} +type ZdwlIpcOutputV2AppidHandlerFunc func(ZdwlIpcOutputV2AppidEvent) + +// SetAppidHandler : sets handler for ZdwlIpcOutputV2AppidEvent +func (i *ZdwlIpcOutputV2) SetAppidHandler(f ZdwlIpcOutputV2AppidHandlerFunc) { + i.appidHandler = f +} + +// ZdwlIpcOutputV2LayoutSymbolEvent : Update the current layout symbol +// +// Indicates the layout has changed. Since layout symbols are dynamic. +// As opposed to the zdwl_ipc_manager.layout event, this should take precendence when displaying. +// You can ignore the zdwl_ipc_output.layout event. +type ZdwlIpcOutputV2LayoutSymbolEvent struct { + Layout string +} +type ZdwlIpcOutputV2LayoutSymbolHandlerFunc func(ZdwlIpcOutputV2LayoutSymbolEvent) + +// SetLayoutSymbolHandler : sets handler for ZdwlIpcOutputV2LayoutSymbolEvent +func (i *ZdwlIpcOutputV2) SetLayoutSymbolHandler(f ZdwlIpcOutputV2LayoutSymbolHandlerFunc) { + i.layoutSymbolHandler = f +} + +// ZdwlIpcOutputV2FrameEvent : The update sequence is done. +// +// Indicates that a sequence of status updates have finished and the client should redraw. +type ZdwlIpcOutputV2FrameEvent struct{} +type ZdwlIpcOutputV2FrameHandlerFunc func(ZdwlIpcOutputV2FrameEvent) + +// SetFrameHandler : sets handler for ZdwlIpcOutputV2FrameEvent +func (i *ZdwlIpcOutputV2) SetFrameHandler(f ZdwlIpcOutputV2FrameHandlerFunc) { + i.frameHandler = f +} + +// ZdwlIpcOutputV2FullscreenEvent : Update fullscreen status +// +// Indicates if the selected client on this output is fullscreen. +type ZdwlIpcOutputV2FullscreenEvent struct { + IsFullscreen uint32 +} +type ZdwlIpcOutputV2FullscreenHandlerFunc func(ZdwlIpcOutputV2FullscreenEvent) + +// SetFullscreenHandler : sets handler for ZdwlIpcOutputV2FullscreenEvent +func (i *ZdwlIpcOutputV2) SetFullscreenHandler(f ZdwlIpcOutputV2FullscreenHandlerFunc) { + i.fullscreenHandler = f +} + +// ZdwlIpcOutputV2FloatingEvent : Update the floating status +// +// Indicates if the selected client on this output is floating. +type ZdwlIpcOutputV2FloatingEvent struct { + IsFloating uint32 +} +type ZdwlIpcOutputV2FloatingHandlerFunc func(ZdwlIpcOutputV2FloatingEvent) + +// SetFloatingHandler : sets handler for ZdwlIpcOutputV2FloatingEvent +func (i *ZdwlIpcOutputV2) SetFloatingHandler(f ZdwlIpcOutputV2FloatingHandlerFunc) { + i.floatingHandler = f +} + +// ZdwlIpcOutputV2XEvent : Update the x coordinates +// +// Indicates if x coordinates of the selected client. +type ZdwlIpcOutputV2XEvent struct { + X int32 +} +type ZdwlIpcOutputV2XHandlerFunc func(ZdwlIpcOutputV2XEvent) + +// SetXHandler : sets handler for ZdwlIpcOutputV2XEvent +func (i *ZdwlIpcOutputV2) SetXHandler(f ZdwlIpcOutputV2XHandlerFunc) { + i.xHandler = f +} + +// ZdwlIpcOutputV2YEvent : Update the y coordinates +// +// Indicates if y coordinates of the selected client. +type ZdwlIpcOutputV2YEvent struct { + Y int32 +} +type ZdwlIpcOutputV2YHandlerFunc func(ZdwlIpcOutputV2YEvent) + +// SetYHandler : sets handler for ZdwlIpcOutputV2YEvent +func (i *ZdwlIpcOutputV2) SetYHandler(f ZdwlIpcOutputV2YHandlerFunc) { + i.yHandler = f +} + +// ZdwlIpcOutputV2WidthEvent : Update the width +// +// Indicates if width of the selected client. +type ZdwlIpcOutputV2WidthEvent struct { + Width int32 +} +type ZdwlIpcOutputV2WidthHandlerFunc func(ZdwlIpcOutputV2WidthEvent) + +// SetWidthHandler : sets handler for ZdwlIpcOutputV2WidthEvent +func (i *ZdwlIpcOutputV2) SetWidthHandler(f ZdwlIpcOutputV2WidthHandlerFunc) { + i.widthHandler = f +} + +// ZdwlIpcOutputV2HeightEvent : Update the height +// +// Indicates if height of the selected client. +type ZdwlIpcOutputV2HeightEvent struct { + Height int32 +} +type ZdwlIpcOutputV2HeightHandlerFunc func(ZdwlIpcOutputV2HeightEvent) + +// SetHeightHandler : sets handler for ZdwlIpcOutputV2HeightEvent +func (i *ZdwlIpcOutputV2) SetHeightHandler(f ZdwlIpcOutputV2HeightHandlerFunc) { + i.heightHandler = f +} + +// ZdwlIpcOutputV2LastLayerEvent : last map layer. +// +// last map layer. +type ZdwlIpcOutputV2LastLayerEvent struct { + LastLayer string +} +type ZdwlIpcOutputV2LastLayerHandlerFunc func(ZdwlIpcOutputV2LastLayerEvent) + +// SetLastLayerHandler : sets handler for ZdwlIpcOutputV2LastLayerEvent +func (i *ZdwlIpcOutputV2) SetLastLayerHandler(f ZdwlIpcOutputV2LastLayerHandlerFunc) { + i.lastLayerHandler = f +} + +// ZdwlIpcOutputV2KbLayoutEvent : current keyboard layout. +// +// current keyboard layout. +type ZdwlIpcOutputV2KbLayoutEvent struct { + KbLayout string +} +type ZdwlIpcOutputV2KbLayoutHandlerFunc func(ZdwlIpcOutputV2KbLayoutEvent) + +// SetKbLayoutHandler : sets handler for ZdwlIpcOutputV2KbLayoutEvent +func (i *ZdwlIpcOutputV2) SetKbLayoutHandler(f ZdwlIpcOutputV2KbLayoutHandlerFunc) { + i.kbLayoutHandler = f +} + +// ZdwlIpcOutputV2KeymodeEvent : current keybind mode. +// +// current keybind mode. +type ZdwlIpcOutputV2KeymodeEvent struct { + Keymode string +} +type ZdwlIpcOutputV2KeymodeHandlerFunc func(ZdwlIpcOutputV2KeymodeEvent) + +// SetKeymodeHandler : sets handler for ZdwlIpcOutputV2KeymodeEvent +func (i *ZdwlIpcOutputV2) SetKeymodeHandler(f ZdwlIpcOutputV2KeymodeHandlerFunc) { + i.keymodeHandler = f +} + +// ZdwlIpcOutputV2ScalefactorEvent : scale factor of monitor. +// +// scale factor of monitor. +type ZdwlIpcOutputV2ScalefactorEvent struct { + Scalefactor uint32 +} +type ZdwlIpcOutputV2ScalefactorHandlerFunc func(ZdwlIpcOutputV2ScalefactorEvent) + +// SetScalefactorHandler : sets handler for ZdwlIpcOutputV2ScalefactorEvent +func (i *ZdwlIpcOutputV2) SetScalefactorHandler(f ZdwlIpcOutputV2ScalefactorHandlerFunc) { + i.scalefactorHandler = f +} + +func (i *ZdwlIpcOutputV2) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.toggleVisibilityHandler == nil { + return + } + var e ZdwlIpcOutputV2ToggleVisibilityEvent + + i.toggleVisibilityHandler(e) + case 1: + if i.activeHandler == nil { + return + } + var e ZdwlIpcOutputV2ActiveEvent + l := 0 + e.Active = client.Uint32(data[l : l+4]) + l += 4 + + i.activeHandler(e) + case 2: + if i.tagHandler == nil { + return + } + var e ZdwlIpcOutputV2TagEvent + l := 0 + e.Tag = client.Uint32(data[l : l+4]) + l += 4 + e.State = client.Uint32(data[l : l+4]) + l += 4 + e.Clients = client.Uint32(data[l : l+4]) + l += 4 + e.Focused = client.Uint32(data[l : l+4]) + l += 4 + + i.tagHandler(e) + case 3: + if i.layoutHandler == nil { + return + } + var e ZdwlIpcOutputV2LayoutEvent + l := 0 + e.Layout = client.Uint32(data[l : l+4]) + l += 4 + + i.layoutHandler(e) + case 4: + if i.titleHandler == nil { + return + } + var e ZdwlIpcOutputV2TitleEvent + l := 0 + titleLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Title = client.String(data[l : l+titleLen]) + l += titleLen + + i.titleHandler(e) + case 5: + if i.appidHandler == nil { + return + } + var e ZdwlIpcOutputV2AppidEvent + l := 0 + appidLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Appid = client.String(data[l : l+appidLen]) + l += appidLen + + i.appidHandler(e) + case 6: + if i.layoutSymbolHandler == nil { + return + } + var e ZdwlIpcOutputV2LayoutSymbolEvent + l := 0 + layoutLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Layout = client.String(data[l : l+layoutLen]) + l += layoutLen + + i.layoutSymbolHandler(e) + case 7: + if i.frameHandler == nil { + return + } + var e ZdwlIpcOutputV2FrameEvent + + i.frameHandler(e) + case 8: + if i.fullscreenHandler == nil { + return + } + var e ZdwlIpcOutputV2FullscreenEvent + l := 0 + e.IsFullscreen = client.Uint32(data[l : l+4]) + l += 4 + + i.fullscreenHandler(e) + case 9: + if i.floatingHandler == nil { + return + } + var e ZdwlIpcOutputV2FloatingEvent + l := 0 + e.IsFloating = client.Uint32(data[l : l+4]) + l += 4 + + i.floatingHandler(e) + case 10: + if i.xHandler == nil { + return + } + var e ZdwlIpcOutputV2XEvent + l := 0 + e.X = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.xHandler(e) + case 11: + if i.yHandler == nil { + return + } + var e ZdwlIpcOutputV2YEvent + l := 0 + e.Y = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.yHandler(e) + case 12: + if i.widthHandler == nil { + return + } + var e ZdwlIpcOutputV2WidthEvent + l := 0 + e.Width = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.widthHandler(e) + case 13: + if i.heightHandler == nil { + return + } + var e ZdwlIpcOutputV2HeightEvent + l := 0 + e.Height = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.heightHandler(e) + case 14: + if i.lastLayerHandler == nil { + return + } + var e ZdwlIpcOutputV2LastLayerEvent + l := 0 + lastLayerLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.LastLayer = client.String(data[l : l+lastLayerLen]) + l += lastLayerLen + + i.lastLayerHandler(e) + case 15: + if i.kbLayoutHandler == nil { + return + } + var e ZdwlIpcOutputV2KbLayoutEvent + l := 0 + kbLayoutLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.KbLayout = client.String(data[l : l+kbLayoutLen]) + l += kbLayoutLen + + i.kbLayoutHandler(e) + case 16: + if i.keymodeHandler == nil { + return + } + var e ZdwlIpcOutputV2KeymodeEvent + l := 0 + keymodeLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Keymode = client.String(data[l : l+keymodeLen]) + l += keymodeLen + + i.keymodeHandler(e) + case 17: + if i.scalefactorHandler == nil { + return + } + var e ZdwlIpcOutputV2ScalefactorEvent + l := 0 + e.Scalefactor = client.Uint32(data[l : l+4]) + l += 4 + + i.scalefactorHandler(e) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/ext_data_control/data_control.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/ext_data_control/data_control.go new file mode 100644 index 0000000..3a4de9f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/ext_data_control/data_control.go @@ -0,0 +1,695 @@ +// Generated by go-wayland-scanner +// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner +// XML file : internal/proto/xml/ext-data-control-v1.xml +// +// ext_data_control_v1 Protocol Copyright: +// +// Copyright © 2018 Simon Ser +// Copyright © 2019 Ivan Molodetskikh +// Copyright © 2024 Neal Gompa +// +// Permission to use, copy, modify, distribute, and sell this +// software and its documentation for any purpose is hereby granted +// without fee, provided that the above copyright notice appear in +// all copies and that both that copyright notice and this permission +// notice appear in supporting documentation, and that the name of +// the copyright holders not be used in advertising or publicity +// pertaining to distribution of the software without specific, +// written prior permission. The copyright holders make no +// representations about the suitability of this software for any +// purpose. It is provided "as is" without express or implied +// warranty. +// +// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS +// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY +// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +// THIS SOFTWARE. + +package ext_data_control + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "golang.org/x/sys/unix" +) + +// ExtDataControlManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ExtDataControlManagerV1InterfaceName = "ext_data_control_manager_v1" + +// ExtDataControlManagerV1 : manager to control data devices +// +// This interface is a manager that allows creating per-seat data device +// controls. +type ExtDataControlManagerV1 struct { + client.BaseProxy +} + +// NewExtDataControlManagerV1 : manager to control data devices +// +// This interface is a manager that allows creating per-seat data device +// controls. +func NewExtDataControlManagerV1(ctx *client.Context) *ExtDataControlManagerV1 { + extDataControlManagerV1 := &ExtDataControlManagerV1{} + ctx.Register(extDataControlManagerV1) + return extDataControlManagerV1 +} + +// CreateDataSource : create a new data source +// +// Create a new data source. +func (i *ExtDataControlManagerV1) CreateDataSource() (*ExtDataControlSourceV1, error) { + id := NewExtDataControlSourceV1(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// GetDataDevice : get a data device for a seat +// +// Create a data device that can be used to manage a seat's selection. +func (i *ExtDataControlManagerV1) GetDataDevice(seat *client.Seat) (*ExtDataControlDeviceV1, error) { + id := NewExtDataControlDeviceV1(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], seat.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// GetDataDeviceWithProxy : get a data device for a seat using a pre-created proxy +// +// Like GetDataDevice, but uses a pre-created ExtDataControlDeviceV1 proxy. +// This allows setting up event handlers before the request is sent. +func (i *ExtDataControlManagerV1) GetDataDeviceWithProxy(device *ExtDataControlDeviceV1, seat *client.Seat) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], device.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], seat.ID()) + l += 4 + return i.Context().WriteMsg(_reqBuf[:], nil) +} + +// Destroy : destroy the manager +// +// All objects created by the manager will still remain valid, until their +// appropriate destroy request has been called. +func (i *ExtDataControlManagerV1) Destroy() error { + defer i.MarkZombie() + const opcode = 2 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// ExtDataControlDeviceV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ExtDataControlDeviceV1InterfaceName = "ext_data_control_device_v1" + +// ExtDataControlDeviceV1 : manage a data device for a seat +// +// This interface allows a client to manage a seat's selection. +// +// When the seat is destroyed, this object becomes inert. +type ExtDataControlDeviceV1 struct { + client.BaseProxy + dataOfferHandler ExtDataControlDeviceV1DataOfferHandlerFunc + selectionHandler ExtDataControlDeviceV1SelectionHandlerFunc + finishedHandler ExtDataControlDeviceV1FinishedHandlerFunc + primarySelectionHandler ExtDataControlDeviceV1PrimarySelectionHandlerFunc +} + +// NewExtDataControlDeviceV1 : manage a data device for a seat +// +// This interface allows a client to manage a seat's selection. +// +// When the seat is destroyed, this object becomes inert. +func NewExtDataControlDeviceV1(ctx *client.Context) *ExtDataControlDeviceV1 { + extDataControlDeviceV1 := &ExtDataControlDeviceV1{} + ctx.Register(extDataControlDeviceV1) + return extDataControlDeviceV1 +} + +// SetSelection : copy data to the selection +// +// This request asks the compositor to set the selection to the data from +// the source on behalf of the client. +// +// The given source may not be used in any further set_selection or +// set_primary_selection requests. Attempting to use a previously used +// source triggers the used_source protocol error. +// +// To unset the selection, set the source to NULL. +func (i *ExtDataControlDeviceV1) SetSelection(source *ExtDataControlSourceV1) error { + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if source == nil { + client.PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + client.PutUint32(_reqBuf[l:l+4], source.ID()) + l += 4 + } + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Destroy : destroy this data device +// +// Destroys the data device object. +func (i *ExtDataControlDeviceV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetPrimarySelection : copy data to the primary selection +// +// This request asks the compositor to set the primary selection to the +// data from the source on behalf of the client. +// +// The given source may not be used in any further set_selection or +// set_primary_selection requests. Attempting to use a previously used +// source triggers the used_source protocol error. +// +// To unset the primary selection, set the source to NULL. +// +// The compositor will ignore this request if it does not support primary +// selection. +func (i *ExtDataControlDeviceV1) SetPrimarySelection(source *ExtDataControlSourceV1) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if source == nil { + client.PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + client.PutUint32(_reqBuf[l:l+4], source.ID()) + l += 4 + } + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ExtDataControlDeviceV1Error uint32 + +// ExtDataControlDeviceV1Error : +const ( + // ExtDataControlDeviceV1ErrorUsedSource : source given to set_selection or set_primary_selection was already used before + ExtDataControlDeviceV1ErrorUsedSource ExtDataControlDeviceV1Error = 1 +) + +func (e ExtDataControlDeviceV1Error) Name() string { + switch e { + case ExtDataControlDeviceV1ErrorUsedSource: + return "used_source" + default: + return "" + } +} + +func (e ExtDataControlDeviceV1Error) Value() string { + switch e { + case ExtDataControlDeviceV1ErrorUsedSource: + return "1" + default: + return "" + } +} + +func (e ExtDataControlDeviceV1Error) String() string { + return e.Name() + "=" + e.Value() +} + +// ExtDataControlDeviceV1DataOfferEvent : introduce a new ext_data_control_offer +// +// The data_offer event introduces a new ext_data_control_offer object, +// which will subsequently be used in either the +// ext_data_control_device.selection event (for the regular clipboard +// selections) or the ext_data_control_device.primary_selection event (for +// the primary clipboard selections). Immediately following the +// ext_data_control_device.data_offer event, the new data_offer object +// will send out ext_data_control_offer.offer events to describe the MIME +// types it offers. +type ExtDataControlDeviceV1DataOfferEvent struct { + Id *ExtDataControlOfferV1 +} +type ExtDataControlDeviceV1DataOfferHandlerFunc func(ExtDataControlDeviceV1DataOfferEvent) + +// SetDataOfferHandler : sets handler for ExtDataControlDeviceV1DataOfferEvent +func (i *ExtDataControlDeviceV1) SetDataOfferHandler(f ExtDataControlDeviceV1DataOfferHandlerFunc) { + i.dataOfferHandler = f +} + +// ExtDataControlDeviceV1SelectionEvent : advertise new selection +// +// The selection event is sent out to notify the client of a new +// ext_data_control_offer for the selection for this device. The +// ext_data_control_device.data_offer and the ext_data_control_offer.offer +// events are sent out immediately before this event to introduce the data +// offer object. The selection event is sent to a client when a new +// selection is set. The ext_data_control_offer is valid until a new +// ext_data_control_offer or NULL is received. The client must destroy the +// previous selection ext_data_control_offer, if any, upon receiving this +// event. Regardless, the previous selection will be ignored once a new +// selection ext_data_control_offer is received. +// +// The first selection event is sent upon binding the +// ext_data_control_device object. +type ExtDataControlDeviceV1SelectionEvent struct { + Id *ExtDataControlOfferV1 + OfferId uint32 // Raw object ID for external registry lookups +} +type ExtDataControlDeviceV1SelectionHandlerFunc func(ExtDataControlDeviceV1SelectionEvent) + +// SetSelectionHandler : sets handler for ExtDataControlDeviceV1SelectionEvent +func (i *ExtDataControlDeviceV1) SetSelectionHandler(f ExtDataControlDeviceV1SelectionHandlerFunc) { + i.selectionHandler = f +} + +// ExtDataControlDeviceV1FinishedEvent : this data control is no longer valid +// +// This data control object is no longer valid and should be destroyed by +// the client. +type ExtDataControlDeviceV1FinishedEvent struct{} +type ExtDataControlDeviceV1FinishedHandlerFunc func(ExtDataControlDeviceV1FinishedEvent) + +// SetFinishedHandler : sets handler for ExtDataControlDeviceV1FinishedEvent +func (i *ExtDataControlDeviceV1) SetFinishedHandler(f ExtDataControlDeviceV1FinishedHandlerFunc) { + i.finishedHandler = f +} + +// ExtDataControlDeviceV1PrimarySelectionEvent : advertise new primary selection +// +// The primary_selection event is sent out to notify the client of a new +// ext_data_control_offer for the primary selection for this device. The +// ext_data_control_device.data_offer and the ext_data_control_offer.offer +// events are sent out immediately before this event to introduce the data +// offer object. The primary_selection event is sent to a client when a +// new primary selection is set. The ext_data_control_offer is valid until +// a new ext_data_control_offer or NULL is received. The client must +// destroy the previous primary selection ext_data_control_offer, if any, +// upon receiving this event. Regardless, the previous primary selection +// will be ignored once a new primary selection ext_data_control_offer is +// received. +// +// If the compositor supports primary selection, the first +// primary_selection event is sent upon binding the +// ext_data_control_device object. +type ExtDataControlDeviceV1PrimarySelectionEvent struct { + Id *ExtDataControlOfferV1 + OfferId uint32 // Raw object ID for external registry lookups +} +type ExtDataControlDeviceV1PrimarySelectionHandlerFunc func(ExtDataControlDeviceV1PrimarySelectionEvent) + +// SetPrimarySelectionHandler : sets handler for ExtDataControlDeviceV1PrimarySelectionEvent +func (i *ExtDataControlDeviceV1) SetPrimarySelectionHandler(f ExtDataControlDeviceV1PrimarySelectionHandlerFunc) { + i.primarySelectionHandler = f +} + +func (i *ExtDataControlDeviceV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + // data_offer event: server creates a new object (new_id) + if i.dataOfferHandler == nil { + return + } + var e ExtDataControlDeviceV1DataOfferEvent + l := 0 + newID := client.Uint32(data[l : l+4]) + l += 4 + + ctx := i.Context() + offer := &ExtDataControlOfferV1{} + offer.SetContext(ctx) + offer.SetID(newID) + ctx.RegisterWithID(offer, newID) + e.Id = offer + + i.dataOfferHandler(e) + case 1: + // selection event: nullable object reference + if i.selectionHandler == nil { + return + } + var e ExtDataControlDeviceV1SelectionEvent + l := 0 + objID := client.Uint32(data[l : l+4]) + l += 4 + + e.OfferId = objID + if objID != 0 { + if p := i.Context().GetProxy(objID); p != nil { + e.Id = p.(*ExtDataControlOfferV1) + } + } + + i.selectionHandler(e) + case 2: + if i.finishedHandler == nil { + return + } + var e ExtDataControlDeviceV1FinishedEvent + + i.finishedHandler(e) + case 3: + // primary_selection event: nullable object reference + if i.primarySelectionHandler == nil { + return + } + var e ExtDataControlDeviceV1PrimarySelectionEvent + l := 0 + objID := client.Uint32(data[l : l+4]) + l += 4 + + e.OfferId = objID + if objID != 0 { + if p := i.Context().GetProxy(objID); p != nil { + e.Id = p.(*ExtDataControlOfferV1) + } + } + + i.primarySelectionHandler(e) + } +} + +// ExtDataControlSourceV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ExtDataControlSourceV1InterfaceName = "ext_data_control_source_v1" + +// ExtDataControlSourceV1 : offer to transfer data +// +// The ext_data_control_source object is the source side of a +// ext_data_control_offer. It is created by the source client in a data +// transfer and provides a way to describe the offered data and a way to +// respond to requests to transfer the data. +type ExtDataControlSourceV1 struct { + client.BaseProxy + sendHandler ExtDataControlSourceV1SendHandlerFunc + cancelledHandler ExtDataControlSourceV1CancelledHandlerFunc +} + +// NewExtDataControlSourceV1 : offer to transfer data +// +// The ext_data_control_source object is the source side of a +// ext_data_control_offer. It is created by the source client in a data +// transfer and provides a way to describe the offered data and a way to +// respond to requests to transfer the data. +func NewExtDataControlSourceV1(ctx *client.Context) *ExtDataControlSourceV1 { + extDataControlSourceV1 := &ExtDataControlSourceV1{} + ctx.Register(extDataControlSourceV1) + return extDataControlSourceV1 +} + +// Offer : add an offered MIME type +// +// This request adds a MIME type to the set of MIME types advertised to +// targets. Can be called several times to offer multiple types. +// +// Calling this after ext_data_control_device.set_selection is a protocol +// error. +// +// mimeType: MIME type offered by the data source +func (i *ExtDataControlSourceV1) Offer(mimeType string) error { + const opcode = 0 + mimeTypeLen := client.PaddedLen(len(mimeType) + 1) + _reqBufLen := 8 + (4 + mimeTypeLen) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutString(_reqBuf[l:l+(4+mimeTypeLen)], mimeType) + l += (4 + mimeTypeLen) + err := i.Context().WriteMsg(_reqBuf, nil) + return err +} + +// Destroy : destroy this source +// +// Destroys the data source object. +func (i *ExtDataControlSourceV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ExtDataControlSourceV1Error uint32 + +// ExtDataControlSourceV1Error : +const ( + // ExtDataControlSourceV1ErrorInvalidOffer : offer sent after ext_data_control_device.set_selection + ExtDataControlSourceV1ErrorInvalidOffer ExtDataControlSourceV1Error = 1 +) + +func (e ExtDataControlSourceV1Error) Name() string { + switch e { + case ExtDataControlSourceV1ErrorInvalidOffer: + return "invalid_offer" + default: + return "" + } +} + +func (e ExtDataControlSourceV1Error) Value() string { + switch e { + case ExtDataControlSourceV1ErrorInvalidOffer: + return "1" + default: + return "" + } +} + +func (e ExtDataControlSourceV1Error) String() string { + return e.Name() + "=" + e.Value() +} + +// ExtDataControlSourceV1SendEvent : send the data +// +// Request for data from the client. Send the data as the specified MIME +// type over the passed file descriptor, then close it. +type ExtDataControlSourceV1SendEvent struct { + MimeType string + Fd int +} +type ExtDataControlSourceV1SendHandlerFunc func(ExtDataControlSourceV1SendEvent) + +// SetSendHandler : sets handler for ExtDataControlSourceV1SendEvent +func (i *ExtDataControlSourceV1) SetSendHandler(f ExtDataControlSourceV1SendHandlerFunc) { + i.sendHandler = f +} + +// ExtDataControlSourceV1CancelledEvent : selection was cancelled +// +// This data source is no longer valid. The data source has been replaced +// by another data source. +// +// The client should clean up and destroy this data source. +type ExtDataControlSourceV1CancelledEvent struct{} +type ExtDataControlSourceV1CancelledHandlerFunc func(ExtDataControlSourceV1CancelledEvent) + +// SetCancelledHandler : sets handler for ExtDataControlSourceV1CancelledEvent +func (i *ExtDataControlSourceV1) SetCancelledHandler(f ExtDataControlSourceV1CancelledHandlerFunc) { + i.cancelledHandler = f +} + +func (i *ExtDataControlSourceV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.sendHandler == nil { + if fd != -1 { + unix.Close(fd) + } + return + } + var e ExtDataControlSourceV1SendEvent + l := 0 + mimeTypeLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.MimeType = client.String(data[l : l+mimeTypeLen]) + l += mimeTypeLen + e.Fd = fd + + i.sendHandler(e) + case 1: + if i.cancelledHandler == nil { + return + } + var e ExtDataControlSourceV1CancelledEvent + + i.cancelledHandler(e) + } +} + +// ExtDataControlOfferV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ExtDataControlOfferV1InterfaceName = "ext_data_control_offer_v1" + +// ExtDataControlOfferV1 : offer to transfer data +// +// A ext_data_control_offer represents a piece of data offered for transfer +// by another client (the source client). The offer describes the different +// MIME types that the data can be converted to and provides the mechanism +// for transferring the data directly from the source client. +type ExtDataControlOfferV1 struct { + client.BaseProxy + offerHandler ExtDataControlOfferV1OfferHandlerFunc +} + +// NewExtDataControlOfferV1 : offer to transfer data +// +// A ext_data_control_offer represents a piece of data offered for transfer +// by another client (the source client). The offer describes the different +// MIME types that the data can be converted to and provides the mechanism +// for transferring the data directly from the source client. +func NewExtDataControlOfferV1(ctx *client.Context) *ExtDataControlOfferV1 { + extDataControlOfferV1 := &ExtDataControlOfferV1{} + ctx.Register(extDataControlOfferV1) + return extDataControlOfferV1 +} + +// Receive : request that the data is transferred +// +// To transfer the offered data, the client issues this request and +// indicates the MIME type it wants to receive. The transfer happens +// through the passed file descriptor (typically created with the pipe +// system call). The source client writes the data in the MIME type +// representation requested and then closes the file descriptor. +// +// The receiving client reads from the read end of the pipe until EOF and +// then closes its end, at which point the transfer is complete. +// +// This request may happen multiple times for different MIME types. +// +// mimeType: MIME type desired by receiver +// fd: file descriptor for data transfer +func (i *ExtDataControlOfferV1) Receive(mimeType string, fd int) error { + const opcode = 0 + mimeTypeLen := client.PaddedLen(len(mimeType) + 1) + _reqBufLen := 8 + (4 + mimeTypeLen) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutString(_reqBuf[l:l+(4+mimeTypeLen)], mimeType) + l += (4 + mimeTypeLen) + oob := unix.UnixRights(int(fd)) + err := i.Context().WriteMsg(_reqBuf, oob) + return err +} + +// Destroy : destroy this offer +// +// Destroys the data offer object. +func (i *ExtDataControlOfferV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// ExtDataControlOfferV1OfferEvent : advertise offered MIME type +// +// Sent immediately after creating the ext_data_control_offer object. +// One event per offered MIME type. +type ExtDataControlOfferV1OfferEvent struct { + MimeType string +} +type ExtDataControlOfferV1OfferHandlerFunc func(ExtDataControlOfferV1OfferEvent) + +// SetOfferHandler : sets handler for ExtDataControlOfferV1OfferEvent +func (i *ExtDataControlOfferV1) SetOfferHandler(f ExtDataControlOfferV1OfferHandlerFunc) { + i.offerHandler = f +} + +func (i *ExtDataControlOfferV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.offerHandler == nil { + return + } + var e ExtDataControlOfferV1OfferEvent + l := 0 + mimeTypeLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.MimeType = client.String(data[l : l+mimeTypeLen]) + l += mimeTypeLen + + i.offerHandler(e) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/ext_workspace/workspace.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/ext_workspace/workspace.go new file mode 100644 index 0000000..c76ab98 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/ext_workspace/workspace.go @@ -0,0 +1,1040 @@ +// Generated by go-wayland-scanner +// https://github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/cmd/go-wayland-scanner +// XML file : ext-workspace-v1.xml +// +// ext_workspace_v1 Protocol Copyright: +// +// Copyright © 2019 Christopher Billington +// Copyright © 2020 Ilia Bozhinov +// Copyright © 2022 Victoria Brekenfeld +// +// Permission to use, copy, modify, distribute, and sell this +// software and its documentation for any purpose is hereby granted +// without fee, provided that the above copyright notice appear in +// all copies and that both that copyright notice and this permission +// notice appear in supporting documentation, and that the name of +// the copyright holders not be used in advertising or publicity +// pertaining to distribution of the software without specific, +// written prior permission. The copyright holders make no +// representations about the suitability of this software for any +// purpose. It is provided "as is" without express or implied +// warranty. +// +// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS +// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY +// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +// THIS SOFTWARE. + +package ext_workspace + +import ( + "reflect" + "unsafe" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +// registerServerProxy registers a proxy with a server-assigned ID. +// This is necessary because go-wayland-scanner doesn't properly handle new_id arguments in events. +// In requests (like DWL), the client creates the ID via NewXxx(ctx) which calls ctx.Register(). +// In events (like ext-workspace), the server creates the ID and sends it, requiring manual registration. +// The Context.objects map is private with no public API for server IDs, requiring reflection. +func registerServerProxy(ctx *client.Context, proxy client.Proxy, serverID uint32) { + defer func() { + if r := recover(); r != nil { + return + } + }() + + ctxVal := reflect.ValueOf(ctx) + if ctxVal.Kind() != reflect.Ptr || ctxVal.IsNil() { + return + } + + ctxElem := ctxVal.Elem() + objectsField := ctxElem.FieldByName("objects") + if !objectsField.IsValid() { + return + } + + objectsMapPtr := unsafe.Pointer(objectsField.UnsafeAddr()) + objectsMap := (*syncmap.Map[uint32, client.Proxy])(objectsMapPtr) + objectsMap.Store(serverID, proxy) +} + +// ExtWorkspaceManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ExtWorkspaceManagerV1InterfaceName = "ext_workspace_manager_v1" + +// ExtWorkspaceManagerV1 : list and control workspaces +// +// Workspaces, also called virtual desktops, are groups of surfaces. A +// compositor with a concept of workspaces may only show some such groups of +// surfaces (those of 'active' workspaces) at a time. 'Activating' a +// workspace is a request for the compositor to display that workspace's +// surfaces as normal, whereas the compositor may hide or otherwise +// de-emphasise surfaces that are associated only with 'inactive' workspaces. +// Workspaces are grouped by which sets of outputs they correspond to, and +// may contain surfaces only from those outputs. In this way, it is possible +// for each output to have its own set of workspaces, or for all outputs (or +// any other arbitrary grouping) to share workspaces. Compositors may +// optionally conceptually arrange each group of workspaces in an +// N-dimensional grid. +// +// The purpose of this protocol is to enable the creation of taskbars and +// docks by providing them with a list of workspaces and their properties, +// and allowing them to activate and deactivate workspaces. +// +// After a client binds the ext_workspace_manager_v1, each workspace will be +// sent via the workspace event. +type ExtWorkspaceManagerV1 struct { + client.BaseProxy + workspaceGroupHandler ExtWorkspaceManagerV1WorkspaceGroupHandlerFunc + workspaceHandler ExtWorkspaceManagerV1WorkspaceHandlerFunc + doneHandler ExtWorkspaceManagerV1DoneHandlerFunc + finishedHandler ExtWorkspaceManagerV1FinishedHandlerFunc +} + +// NewExtWorkspaceManagerV1 : list and control workspaces +// +// Workspaces, also called virtual desktops, are groups of surfaces. A +// compositor with a concept of workspaces may only show some such groups of +// surfaces (those of 'active' workspaces) at a time. 'Activating' a +// workspace is a request for the compositor to display that workspace's +// surfaces as normal, whereas the compositor may hide or otherwise +// de-emphasise surfaces that are associated only with 'inactive' workspaces. +// Workspaces are grouped by which sets of outputs they correspond to, and +// may contain surfaces only from those outputs. In this way, it is possible +// for each output to have its own set of workspaces, or for all outputs (or +// any other arbitrary grouping) to share workspaces. Compositors may +// optionally conceptually arrange each group of workspaces in an +// N-dimensional grid. +// +// The purpose of this protocol is to enable the creation of taskbars and +// docks by providing them with a list of workspaces and their properties, +// and allowing them to activate and deactivate workspaces. +// +// After a client binds the ext_workspace_manager_v1, each workspace will be +// sent via the workspace event. +func NewExtWorkspaceManagerV1(ctx *client.Context) *ExtWorkspaceManagerV1 { + extWorkspaceManagerV1 := &ExtWorkspaceManagerV1{} + ctx.Register(extWorkspaceManagerV1) + return extWorkspaceManagerV1 +} + +// Commit : all requests about the workspaces have been sent +// +// The client must send this request after it has finished sending other +// requests. The compositor must process a series of requests preceding a +// commit request atomically. +// +// This allows changes to the workspace properties to be seen as atomic, +// even if they happen via multiple events, and even if they involve +// multiple ext_workspace_handle_v1 objects, for example, deactivating one +// workspace and activating another. +func (i *ExtWorkspaceManagerV1) Commit() error { + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Stop : stop sending events +// +// Indicates the client no longer wishes to receive events for new +// workspace groups. However the compositor may emit further workspace +// events, until the finished event is emitted. The compositor is expected +// to send the finished event eventually once the stop request has been processed. +// +// The client must not send any requests after this one, doing so will raise a wl_display +// invalid_object error. +func (i *ExtWorkspaceManagerV1) Stop() error { + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +func (i *ExtWorkspaceManagerV1) Destroy() error { + i.MarkZombie() + return nil +} + +// ExtWorkspaceManagerV1WorkspaceGroupEvent : a workspace group has been created +// +// This event is emitted whenever a new workspace group has been created. +// +// All initial details of the workspace group (outputs) will be +// sent immediately after this event via the corresponding events in +// ext_workspace_group_handle_v1 and ext_workspace_handle_v1. +type ExtWorkspaceManagerV1WorkspaceGroupEvent struct { + WorkspaceGroup *ExtWorkspaceGroupHandleV1 +} +type ExtWorkspaceManagerV1WorkspaceGroupHandlerFunc func(ExtWorkspaceManagerV1WorkspaceGroupEvent) + +// SetWorkspaceGroupHandler : sets handler for ExtWorkspaceManagerV1WorkspaceGroupEvent +func (i *ExtWorkspaceManagerV1) SetWorkspaceGroupHandler(f ExtWorkspaceManagerV1WorkspaceGroupHandlerFunc) { + i.workspaceGroupHandler = f +} + +// ExtWorkspaceManagerV1WorkspaceEvent : workspace has been created +// +// This event is emitted whenever a new workspace has been created. +// +// All initial details of the workspace (name, coordinates, state) will +// be sent immediately after this event via the corresponding events in +// ext_workspace_handle_v1. +// +// Workspaces start off unassigned to any workspace group. +type ExtWorkspaceManagerV1WorkspaceEvent struct { + Workspace *ExtWorkspaceHandleV1 +} +type ExtWorkspaceManagerV1WorkspaceHandlerFunc func(ExtWorkspaceManagerV1WorkspaceEvent) + +// SetWorkspaceHandler : sets handler for ExtWorkspaceManagerV1WorkspaceEvent +func (i *ExtWorkspaceManagerV1) SetWorkspaceHandler(f ExtWorkspaceManagerV1WorkspaceHandlerFunc) { + i.workspaceHandler = f +} + +// ExtWorkspaceManagerV1DoneEvent : all information about the workspaces and workspace groups has been sent +// +// This event is sent after all changes in all workspaces and workspace groups have been +// sent. +// +// This allows changes to one or more ext_workspace_group_handle_v1 +// properties and ext_workspace_handle_v1 properties +// to be seen as atomic, even if they happen via multiple events. +// In particular, an output moving from one workspace group to +// another sends an output_enter event and an output_leave event to the two +// ext_workspace_group_handle_v1 objects in question. The compositor sends +// the done event only after updating the output information in both +// workspace groups. +type ExtWorkspaceManagerV1DoneEvent struct{} +type ExtWorkspaceManagerV1DoneHandlerFunc func(ExtWorkspaceManagerV1DoneEvent) + +// SetDoneHandler : sets handler for ExtWorkspaceManagerV1DoneEvent +func (i *ExtWorkspaceManagerV1) SetDoneHandler(f ExtWorkspaceManagerV1DoneHandlerFunc) { + i.doneHandler = f +} + +// ExtWorkspaceManagerV1FinishedEvent : the compositor has finished with the workspace_manager +// +// This event indicates that the compositor is done sending events to the +// ext_workspace_manager_v1. The server will destroy the object +// immediately after sending this request. +type ExtWorkspaceManagerV1FinishedEvent struct{} +type ExtWorkspaceManagerV1FinishedHandlerFunc func(ExtWorkspaceManagerV1FinishedEvent) + +// SetFinishedHandler : sets handler for ExtWorkspaceManagerV1FinishedEvent +func (i *ExtWorkspaceManagerV1) SetFinishedHandler(f ExtWorkspaceManagerV1FinishedHandlerFunc) { + i.finishedHandler = f +} + +func (i *ExtWorkspaceManagerV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.workspaceGroupHandler == nil { + return + } + var e ExtWorkspaceManagerV1WorkspaceGroupEvent + l := 0 + objectID := client.Uint32(data[l : l+4]) + proxy := i.Context().GetProxy(objectID) + if proxy != nil && !proxy.IsZombie() { + e.WorkspaceGroup = proxy.(*ExtWorkspaceGroupHandleV1) + } else { + groupHandle := &ExtWorkspaceGroupHandleV1{} + groupHandle.SetContext(i.Context()) + groupHandle.SetID(objectID) + registerServerProxy(i.Context(), groupHandle, objectID) + e.WorkspaceGroup = groupHandle + } + l += 4 + + i.workspaceGroupHandler(e) + case 1: + if i.workspaceHandler == nil { + return + } + var e ExtWorkspaceManagerV1WorkspaceEvent + l := 0 + objectID := client.Uint32(data[l : l+4]) + proxy := i.Context().GetProxy(objectID) + if proxy != nil && !proxy.IsZombie() { + e.Workspace = proxy.(*ExtWorkspaceHandleV1) + } else { + wsHandle := &ExtWorkspaceHandleV1{} + wsHandle.SetContext(i.Context()) + wsHandle.SetID(objectID) + registerServerProxy(i.Context(), wsHandle, objectID) + e.Workspace = wsHandle + } + l += 4 + + i.workspaceHandler(e) + case 2: + if i.doneHandler == nil { + return + } + var e ExtWorkspaceManagerV1DoneEvent + + i.doneHandler(e) + case 3: + if i.finishedHandler == nil { + return + } + var e ExtWorkspaceManagerV1FinishedEvent + + i.finishedHandler(e) + } +} + +// ExtWorkspaceGroupHandleV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ExtWorkspaceGroupHandleV1InterfaceName = "ext_workspace_group_handle_v1" + +// ExtWorkspaceGroupHandleV1 : a workspace group assigned to a set of outputs +// +// A ext_workspace_group_handle_v1 object represents a workspace group +// that is assigned a set of outputs and contains a number of workspaces. +// +// The set of outputs assigned to the workspace group is conveyed to the client via +// output_enter and output_leave events, and its workspaces are conveyed with +// workspace events. +// +// For example, a compositor which has a set of workspaces for each output may +// advertise a workspace group (and its workspaces) per output, whereas a compositor +// where a workspace spans all outputs may advertise a single workspace group for all +// outputs. +type ExtWorkspaceGroupHandleV1 struct { + client.BaseProxy + capabilitiesHandler ExtWorkspaceGroupHandleV1CapabilitiesHandlerFunc + outputEnterHandler ExtWorkspaceGroupHandleV1OutputEnterHandlerFunc + outputLeaveHandler ExtWorkspaceGroupHandleV1OutputLeaveHandlerFunc + workspaceEnterHandler ExtWorkspaceGroupHandleV1WorkspaceEnterHandlerFunc + workspaceLeaveHandler ExtWorkspaceGroupHandleV1WorkspaceLeaveHandlerFunc + removedHandler ExtWorkspaceGroupHandleV1RemovedHandlerFunc +} + +// NewExtWorkspaceGroupHandleV1 : a workspace group assigned to a set of outputs +// +// A ext_workspace_group_handle_v1 object represents a workspace group +// that is assigned a set of outputs and contains a number of workspaces. +// +// The set of outputs assigned to the workspace group is conveyed to the client via +// output_enter and output_leave events, and its workspaces are conveyed with +// workspace events. +// +// For example, a compositor which has a set of workspaces for each output may +// advertise a workspace group (and its workspaces) per output, whereas a compositor +// where a workspace spans all outputs may advertise a single workspace group for all +// outputs. +func NewExtWorkspaceGroupHandleV1(ctx *client.Context) *ExtWorkspaceGroupHandleV1 { + extWorkspaceGroupHandleV1 := &ExtWorkspaceGroupHandleV1{} + ctx.Register(extWorkspaceGroupHandleV1) + return extWorkspaceGroupHandleV1 +} + +// CreateWorkspace : create a new workspace +// +// Request that the compositor create a new workspace with the given name +// and assign it to this group. +// +// There is no guarantee that the compositor will create a new workspace, +// or that the created workspace will have the provided name. +func (i *ExtWorkspaceGroupHandleV1) CreateWorkspace(workspace string) error { + const opcode = 0 + workspaceLen := client.PaddedLen(len(workspace) + 1) + _reqBufLen := 8 + (4 + workspaceLen) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutString(_reqBuf[l:l+(4+workspaceLen)], workspace) + l += (4 + workspaceLen) + err := i.Context().WriteMsg(_reqBuf, nil) + return err +} + +// Destroy : destroy the ext_workspace_group_handle_v1 object +// +// Destroys the ext_workspace_group_handle_v1 object. +// +// This request should be send either when the client does not want to +// use the workspace group object any more or after the removed event to finalize +// the destruction of the object. +func (i *ExtWorkspaceGroupHandleV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ExtWorkspaceGroupHandleV1GroupCapabilities uint32 + +// ExtWorkspaceGroupHandleV1GroupCapabilities : +const ( + // ExtWorkspaceGroupHandleV1GroupCapabilitiesCreateWorkspace : create_workspace request is available + ExtWorkspaceGroupHandleV1GroupCapabilitiesCreateWorkspace ExtWorkspaceGroupHandleV1GroupCapabilities = 1 +) + +func (e ExtWorkspaceGroupHandleV1GroupCapabilities) Name() string { + switch e { + case ExtWorkspaceGroupHandleV1GroupCapabilitiesCreateWorkspace: + return "create_workspace" + default: + return "" + } +} + +func (e ExtWorkspaceGroupHandleV1GroupCapabilities) Value() string { + switch e { + case ExtWorkspaceGroupHandleV1GroupCapabilitiesCreateWorkspace: + return "1" + default: + return "" + } +} + +func (e ExtWorkspaceGroupHandleV1GroupCapabilities) String() string { + return e.Name() + "=" + e.Value() +} + +// ExtWorkspaceGroupHandleV1CapabilitiesEvent : compositor capabilities +// +// This event advertises the capabilities supported by the compositor. If +// a capability isn't supported, clients should hide or disable the UI +// elements that expose this functionality. For instance, if the +// compositor doesn't advertise support for creating workspaces, a button +// triggering the create_workspace request should not be displayed. +// +// The compositor will ignore requests it doesn't support. For instance, +// a compositor which doesn't advertise support for creating workspaces will ignore +// create_workspace requests. +// +// Compositors must send this event once after creation of an +// ext_workspace_group_handle_v1. When the capabilities change, compositors +// must send this event again. +type ExtWorkspaceGroupHandleV1CapabilitiesEvent struct { + Capabilities uint32 +} +type ExtWorkspaceGroupHandleV1CapabilitiesHandlerFunc func(ExtWorkspaceGroupHandleV1CapabilitiesEvent) + +// SetCapabilitiesHandler : sets handler for ExtWorkspaceGroupHandleV1CapabilitiesEvent +func (i *ExtWorkspaceGroupHandleV1) SetCapabilitiesHandler(f ExtWorkspaceGroupHandleV1CapabilitiesHandlerFunc) { + i.capabilitiesHandler = f +} + +// ExtWorkspaceGroupHandleV1OutputEnterEvent : output assigned to workspace group +// +// This event is emitted whenever an output is assigned to the workspace +// group or a new `wl_output` object is bound by the client, which was already +// assigned to this workspace_group. +type ExtWorkspaceGroupHandleV1OutputEnterEvent struct { + Output *client.Output +} +type ExtWorkspaceGroupHandleV1OutputEnterHandlerFunc func(ExtWorkspaceGroupHandleV1OutputEnterEvent) + +// SetOutputEnterHandler : sets handler for ExtWorkspaceGroupHandleV1OutputEnterEvent +func (i *ExtWorkspaceGroupHandleV1) SetOutputEnterHandler(f ExtWorkspaceGroupHandleV1OutputEnterHandlerFunc) { + i.outputEnterHandler = f +} + +// ExtWorkspaceGroupHandleV1OutputLeaveEvent : output removed from workspace group +// +// This event is emitted whenever an output is removed from the workspace +// group. +type ExtWorkspaceGroupHandleV1OutputLeaveEvent struct { + Output *client.Output +} +type ExtWorkspaceGroupHandleV1OutputLeaveHandlerFunc func(ExtWorkspaceGroupHandleV1OutputLeaveEvent) + +// SetOutputLeaveHandler : sets handler for ExtWorkspaceGroupHandleV1OutputLeaveEvent +func (i *ExtWorkspaceGroupHandleV1) SetOutputLeaveHandler(f ExtWorkspaceGroupHandleV1OutputLeaveHandlerFunc) { + i.outputLeaveHandler = f +} + +// ExtWorkspaceGroupHandleV1WorkspaceEnterEvent : workspace added to workspace group +// +// This event is emitted whenever a workspace is assigned to this group. +// A workspace may only ever be assigned to a single group at a single point +// in time, but can be re-assigned during it's lifetime. +type ExtWorkspaceGroupHandleV1WorkspaceEnterEvent struct { + Workspace *ExtWorkspaceHandleV1 +} +type ExtWorkspaceGroupHandleV1WorkspaceEnterHandlerFunc func(ExtWorkspaceGroupHandleV1WorkspaceEnterEvent) + +// SetWorkspaceEnterHandler : sets handler for ExtWorkspaceGroupHandleV1WorkspaceEnterEvent +func (i *ExtWorkspaceGroupHandleV1) SetWorkspaceEnterHandler(f ExtWorkspaceGroupHandleV1WorkspaceEnterHandlerFunc) { + i.workspaceEnterHandler = f +} + +// ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent : workspace removed from workspace group +// +// This event is emitted whenever a workspace is removed from this group. +type ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent struct { + Workspace *ExtWorkspaceHandleV1 +} +type ExtWorkspaceGroupHandleV1WorkspaceLeaveHandlerFunc func(ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent) + +// SetWorkspaceLeaveHandler : sets handler for ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent +func (i *ExtWorkspaceGroupHandleV1) SetWorkspaceLeaveHandler(f ExtWorkspaceGroupHandleV1WorkspaceLeaveHandlerFunc) { + i.workspaceLeaveHandler = f +} + +// ExtWorkspaceGroupHandleV1RemovedEvent : this workspace group has been removed +// +// This event is send when the group associated with the ext_workspace_group_handle_v1 +// has been removed. After sending this request the compositor will immediately consider +// the object inert. Any requests will be ignored except the destroy request. +// It is guaranteed there won't be any more events referencing this +// ext_workspace_group_handle_v1. +// +// The compositor must remove all workspaces belonging to a workspace group +// via a workspace_leave event before removing the workspace group. +type ExtWorkspaceGroupHandleV1RemovedEvent struct{} +type ExtWorkspaceGroupHandleV1RemovedHandlerFunc func(ExtWorkspaceGroupHandleV1RemovedEvent) + +// SetRemovedHandler : sets handler for ExtWorkspaceGroupHandleV1RemovedEvent +func (i *ExtWorkspaceGroupHandleV1) SetRemovedHandler(f ExtWorkspaceGroupHandleV1RemovedHandlerFunc) { + i.removedHandler = f +} + +func (i *ExtWorkspaceGroupHandleV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.capabilitiesHandler == nil { + return + } + var e ExtWorkspaceGroupHandleV1CapabilitiesEvent + l := 0 + e.Capabilities = client.Uint32(data[l : l+4]) + l += 4 + + i.capabilitiesHandler(e) + case 1: + if i.outputEnterHandler == nil { + return + } + var e ExtWorkspaceGroupHandleV1OutputEnterEvent + l := 0 + e.Output = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*client.Output) + l += 4 + + i.outputEnterHandler(e) + case 2: + if i.outputLeaveHandler == nil { + return + } + var e ExtWorkspaceGroupHandleV1OutputLeaveEvent + l := 0 + e.Output = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*client.Output) + l += 4 + + i.outputLeaveHandler(e) + case 3: + if i.workspaceEnterHandler == nil { + return + } + var e ExtWorkspaceGroupHandleV1WorkspaceEnterEvent + l := 0 + e.Workspace = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*ExtWorkspaceHandleV1) + l += 4 + + i.workspaceEnterHandler(e) + case 4: + if i.workspaceLeaveHandler == nil { + return + } + var e ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent + l := 0 + e.Workspace = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*ExtWorkspaceHandleV1) + l += 4 + + i.workspaceLeaveHandler(e) + case 5: + if i.removedHandler == nil { + return + } + var e ExtWorkspaceGroupHandleV1RemovedEvent + + i.removedHandler(e) + } +} + +// ExtWorkspaceHandleV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ExtWorkspaceHandleV1InterfaceName = "ext_workspace_handle_v1" + +// ExtWorkspaceHandleV1 : a workspace handing a group of surfaces +// +// A ext_workspace_handle_v1 object represents a workspace that handles a +// group of surfaces. +// +// Each workspace has: +// - a name, conveyed to the client with the name event +// - potentially an id conveyed with the id event +// - a list of states, conveyed to the client with the state event +// - and optionally a set of coordinates, conveyed to the client with the +// coordinates event +// +// The client may request that the compositor activate or deactivate the workspace. +// +// Each workspace can belong to only a single workspace group. +// Depending on the compositor policy, there might be workspaces with +// the same name in different workspace groups, but these workspaces are still +// separate (e.g. one of them might be active while the other is not). +type ExtWorkspaceHandleV1 struct { + client.BaseProxy + idHandler ExtWorkspaceHandleV1IdHandlerFunc + nameHandler ExtWorkspaceHandleV1NameHandlerFunc + coordinatesHandler ExtWorkspaceHandleV1CoordinatesHandlerFunc + stateHandler ExtWorkspaceHandleV1StateHandlerFunc + capabilitiesHandler ExtWorkspaceHandleV1CapabilitiesHandlerFunc + removedHandler ExtWorkspaceHandleV1RemovedHandlerFunc +} + +// NewExtWorkspaceHandleV1 : a workspace handing a group of surfaces +// +// A ext_workspace_handle_v1 object represents a workspace that handles a +// group of surfaces. +// +// Each workspace has: +// - a name, conveyed to the client with the name event +// - potentially an id conveyed with the id event +// - a list of states, conveyed to the client with the state event +// - and optionally a set of coordinates, conveyed to the client with the +// coordinates event +// +// The client may request that the compositor activate or deactivate the workspace. +// +// Each workspace can belong to only a single workspace group. +// Depending on the compositor policy, there might be workspaces with +// the same name in different workspace groups, but these workspaces are still +// separate (e.g. one of them might be active while the other is not). +func NewExtWorkspaceHandleV1(ctx *client.Context) *ExtWorkspaceHandleV1 { + extWorkspaceHandleV1 := &ExtWorkspaceHandleV1{} + ctx.Register(extWorkspaceHandleV1) + return extWorkspaceHandleV1 +} + +// Destroy : destroy the ext_workspace_handle_v1 object +// +// Destroys the ext_workspace_handle_v1 object. +// +// This request should be made either when the client does not want to +// use the workspace object any more or after the remove event to finalize +// the destruction of the object. +func (i *ExtWorkspaceHandleV1) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Activate : activate the workspace +// +// Request that this workspace be activated. +// +// There is no guarantee the workspace will be actually activated, and +// behaviour may be compositor-dependent. For example, activating a +// workspace may or may not deactivate all other workspaces in the same +// group. +func (i *ExtWorkspaceHandleV1) Activate() error { + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Deactivate : deactivate the workspace +// +// Request that this workspace be deactivated. +// +// There is no guarantee the workspace will be actually deactivated. +func (i *ExtWorkspaceHandleV1) Deactivate() error { + const opcode = 2 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Assign : assign workspace to group +// +// Requests that this workspace is assigned to the given workspace group. +// +// There is no guarantee the workspace will be assigned. +func (i *ExtWorkspaceHandleV1) Assign(workspaceGroup *ExtWorkspaceGroupHandleV1) error { + const opcode = 3 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], workspaceGroup.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Remove : remove the workspace +// +// Request that this workspace be removed. +// +// There is no guarantee the workspace will be actually removed. +func (i *ExtWorkspaceHandleV1) Remove() error { + const opcode = 4 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ExtWorkspaceHandleV1State uint32 + +// ExtWorkspaceHandleV1State : types of states on the workspace +// +// The different states that a workspace can have. +const ( + // ExtWorkspaceHandleV1StateActive : the workspace is active + ExtWorkspaceHandleV1StateActive ExtWorkspaceHandleV1State = 1 + // ExtWorkspaceHandleV1StateUrgent : the workspace requests attention + ExtWorkspaceHandleV1StateUrgent ExtWorkspaceHandleV1State = 2 + ExtWorkspaceHandleV1StateHidden ExtWorkspaceHandleV1State = 4 +) + +func (e ExtWorkspaceHandleV1State) Name() string { + switch e { + case ExtWorkspaceHandleV1StateActive: + return "active" + case ExtWorkspaceHandleV1StateUrgent: + return "urgent" + case ExtWorkspaceHandleV1StateHidden: + return "hidden" + default: + return "" + } +} + +func (e ExtWorkspaceHandleV1State) Value() string { + switch e { + case ExtWorkspaceHandleV1StateActive: + return "1" + case ExtWorkspaceHandleV1StateUrgent: + return "2" + case ExtWorkspaceHandleV1StateHidden: + return "4" + default: + return "" + } +} + +func (e ExtWorkspaceHandleV1State) String() string { + return e.Name() + "=" + e.Value() +} + +type ExtWorkspaceHandleV1WorkspaceCapabilities uint32 + +// ExtWorkspaceHandleV1WorkspaceCapabilities : +const ( + // ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate : activate request is available + ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate ExtWorkspaceHandleV1WorkspaceCapabilities = 1 + // ExtWorkspaceHandleV1WorkspaceCapabilitiesDeactivate : deactivate request is available + ExtWorkspaceHandleV1WorkspaceCapabilitiesDeactivate ExtWorkspaceHandleV1WorkspaceCapabilities = 2 + // ExtWorkspaceHandleV1WorkspaceCapabilitiesRemove : remove request is available + ExtWorkspaceHandleV1WorkspaceCapabilitiesRemove ExtWorkspaceHandleV1WorkspaceCapabilities = 4 + // ExtWorkspaceHandleV1WorkspaceCapabilitiesAssign : assign request is available + ExtWorkspaceHandleV1WorkspaceCapabilitiesAssign ExtWorkspaceHandleV1WorkspaceCapabilities = 8 +) + +func (e ExtWorkspaceHandleV1WorkspaceCapabilities) Name() string { + switch e { + case ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate: + return "activate" + case ExtWorkspaceHandleV1WorkspaceCapabilitiesDeactivate: + return "deactivate" + case ExtWorkspaceHandleV1WorkspaceCapabilitiesRemove: + return "remove" + case ExtWorkspaceHandleV1WorkspaceCapabilitiesAssign: + return "assign" + default: + return "" + } +} + +func (e ExtWorkspaceHandleV1WorkspaceCapabilities) Value() string { + switch e { + case ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate: + return "1" + case ExtWorkspaceHandleV1WorkspaceCapabilitiesDeactivate: + return "2" + case ExtWorkspaceHandleV1WorkspaceCapabilitiesRemove: + return "4" + case ExtWorkspaceHandleV1WorkspaceCapabilitiesAssign: + return "8" + default: + return "" + } +} + +func (e ExtWorkspaceHandleV1WorkspaceCapabilities) String() string { + return e.Name() + "=" + e.Value() +} + +// ExtWorkspaceHandleV1IdEvent : workspace id +// +// If this event is emitted, it will be send immediately after the +// ext_workspace_handle_v1 is created or when an id is assigned to +// a workspace (at most once during it's lifetime). +// +// An id will never change during the lifetime of the `ext_workspace_handle_v1` +// and is guaranteed to be unique during it's lifetime. +// +// Ids are not human-readable and shouldn't be displayed, use `name` for that purpose. +// +// Compositors are expected to only send ids for workspaces likely stable across multiple +// sessions and can be used by clients to store preferences for workspaces. Workspaces without +// ids should be considered temporary and any data associated with them should be deleted once +// the respective object is lost. +type ExtWorkspaceHandleV1IdEvent struct { + Id string +} +type ExtWorkspaceHandleV1IdHandlerFunc func(ExtWorkspaceHandleV1IdEvent) + +// SetIdHandler : sets handler for ExtWorkspaceHandleV1IdEvent +func (i *ExtWorkspaceHandleV1) SetIdHandler(f ExtWorkspaceHandleV1IdHandlerFunc) { + i.idHandler = f +} + +// ExtWorkspaceHandleV1NameEvent : workspace name changed +// +// This event is emitted immediately after the ext_workspace_handle_v1 is +// created and whenever the name of the workspace changes. +// +// A name is meant to be human-readable and can be displayed to a user. +// Unlike the id it is neither stable nor unique. +type ExtWorkspaceHandleV1NameEvent struct { + Name string +} +type ExtWorkspaceHandleV1NameHandlerFunc func(ExtWorkspaceHandleV1NameEvent) + +// SetNameHandler : sets handler for ExtWorkspaceHandleV1NameEvent +func (i *ExtWorkspaceHandleV1) SetNameHandler(f ExtWorkspaceHandleV1NameHandlerFunc) { + i.nameHandler = f +} + +// ExtWorkspaceHandleV1CoordinatesEvent : workspace coordinates changed +// +// This event is used to organize workspaces into an N-dimensional grid +// within a workspace group, and if supported, is emitted immediately after +// the ext_workspace_handle_v1 is created and whenever the coordinates of +// the workspace change. Compositors may not send this event if they do not +// conceptually arrange workspaces in this way. If compositors simply +// number workspaces, without any geometric interpretation, they may send +// 1D coordinates, which clients should not interpret as implying any +// geometry. Sending an empty array means that the compositor no longer +// orders the workspace geometrically. +// +// Coordinates have an arbitrary number of dimensions N with an uint32 +// position along each dimension. By convention if N > 1, the first +// dimension is X, the second Y, the third Z, and so on. The compositor may +// chose to utilize these events for a more novel workspace layout +// convention, however. No guarantee is made about the grid being filled or +// bounded; there may be a workspace at coordinate 1 and another at +// coordinate 1000 and none in between. Within a workspace group, however, +// workspaces must have unique coordinates of equal dimensionality. +type ExtWorkspaceHandleV1CoordinatesEvent struct { + Coordinates []byte +} +type ExtWorkspaceHandleV1CoordinatesHandlerFunc func(ExtWorkspaceHandleV1CoordinatesEvent) + +// SetCoordinatesHandler : sets handler for ExtWorkspaceHandleV1CoordinatesEvent +func (i *ExtWorkspaceHandleV1) SetCoordinatesHandler(f ExtWorkspaceHandleV1CoordinatesHandlerFunc) { + i.coordinatesHandler = f +} + +// ExtWorkspaceHandleV1StateEvent : the state of the workspace changed +// +// This event is emitted immediately after the ext_workspace_handle_v1 is +// created and each time the workspace state changes, either because of a +// compositor action or because of a request in this protocol. +// +// Missing states convey the opposite meaning, e.g. an unset active bit +// means the workspace is currently inactive. +type ExtWorkspaceHandleV1StateEvent struct { + State uint32 +} +type ExtWorkspaceHandleV1StateHandlerFunc func(ExtWorkspaceHandleV1StateEvent) + +// SetStateHandler : sets handler for ExtWorkspaceHandleV1StateEvent +func (i *ExtWorkspaceHandleV1) SetStateHandler(f ExtWorkspaceHandleV1StateHandlerFunc) { + i.stateHandler = f +} + +// ExtWorkspaceHandleV1CapabilitiesEvent : compositor capabilities +// +// This event advertises the capabilities supported by the compositor. If +// a capability isn't supported, clients should hide or disable the UI +// elements that expose this functionality. For instance, if the +// compositor doesn't advertise support for removing workspaces, a button +// triggering the remove request should not be displayed. +// +// The compositor will ignore requests it doesn't support. For instance, +// a compositor which doesn't advertise support for remove will ignore +// remove requests. +// +// Compositors must send this event once after creation of an +// ext_workspace_handle_v1 . When the capabilities change, compositors +// must send this event again. +type ExtWorkspaceHandleV1CapabilitiesEvent struct { + Capabilities uint32 +} +type ExtWorkspaceHandleV1CapabilitiesHandlerFunc func(ExtWorkspaceHandleV1CapabilitiesEvent) + +// SetCapabilitiesHandler : sets handler for ExtWorkspaceHandleV1CapabilitiesEvent +func (i *ExtWorkspaceHandleV1) SetCapabilitiesHandler(f ExtWorkspaceHandleV1CapabilitiesHandlerFunc) { + i.capabilitiesHandler = f +} + +// ExtWorkspaceHandleV1RemovedEvent : this workspace has been removed +// +// This event is send when the workspace associated with the ext_workspace_handle_v1 +// has been removed. After sending this request, the compositor will immediately consider +// the object inert. Any requests will be ignored except the destroy request. +// +// It is guaranteed there won't be any more events referencing this +// ext_workspace_handle_v1. +// +// The compositor must only remove a workspaces not currently belonging to any +// workspace_group. +type ExtWorkspaceHandleV1RemovedEvent struct{} +type ExtWorkspaceHandleV1RemovedHandlerFunc func(ExtWorkspaceHandleV1RemovedEvent) + +// SetRemovedHandler : sets handler for ExtWorkspaceHandleV1RemovedEvent +func (i *ExtWorkspaceHandleV1) SetRemovedHandler(f ExtWorkspaceHandleV1RemovedHandlerFunc) { + i.removedHandler = f +} + +func (i *ExtWorkspaceHandleV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.idHandler == nil { + return + } + var e ExtWorkspaceHandleV1IdEvent + l := 0 + idLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Id = client.String(data[l : l+idLen]) + l += idLen + + i.idHandler(e) + case 1: + if i.nameHandler == nil { + return + } + var e ExtWorkspaceHandleV1NameEvent + l := 0 + nameLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Name = client.String(data[l : l+nameLen]) + l += nameLen + + i.nameHandler(e) + case 2: + if i.coordinatesHandler == nil { + return + } + var e ExtWorkspaceHandleV1CoordinatesEvent + l := 0 + coordinatesLen := int(client.Uint32(data[l : l+4])) + l += 4 + e.Coordinates = make([]byte, coordinatesLen) + copy(e.Coordinates, data[l:l+coordinatesLen]) + l += coordinatesLen + + i.coordinatesHandler(e) + case 3: + if i.stateHandler == nil { + return + } + var e ExtWorkspaceHandleV1StateEvent + l := 0 + e.State = client.Uint32(data[l : l+4]) + l += 4 + + i.stateHandler(e) + case 4: + if i.capabilitiesHandler == nil { + return + } + var e ExtWorkspaceHandleV1CapabilitiesEvent + l := 0 + e.Capabilities = client.Uint32(data[l : l+4]) + l += 4 + + i.capabilitiesHandler(e) + case 5: + if i.removedHandler == nil { + return + } + var e ExtWorkspaceHandleV1RemovedEvent + + i.removedHandler(e) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/keyboard_shortcuts_inhibit/keyboard_shortcuts_inhibit.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/keyboard_shortcuts_inhibit/keyboard_shortcuts_inhibit.go new file mode 100644 index 0000000..0a23bb2 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/keyboard_shortcuts_inhibit/keyboard_shortcuts_inhibit.go @@ -0,0 +1,284 @@ +// Generated by go-wayland-scanner +// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner +// XML file : internal/proto/xml/keyboard-shortcuts-inhibit-unstable-v1.xml +// +// keyboard_shortcuts_inhibit_unstable_v1 Protocol Copyright: +// +// Copyright © 2017 Red Hat Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice (including the next +// paragraph) shall be included in all copies or substantial portions of the +// Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package keyboard_shortcuts_inhibit + +import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + +// ZwpKeyboardShortcutsInhibitManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwpKeyboardShortcutsInhibitManagerV1InterfaceName = "zwp_keyboard_shortcuts_inhibit_manager_v1" + +// ZwpKeyboardShortcutsInhibitManagerV1 : context object for keyboard grab_manager +// +// A global interface used for inhibiting the compositor keyboard shortcuts. +type ZwpKeyboardShortcutsInhibitManagerV1 struct { + client.BaseProxy +} + +// NewZwpKeyboardShortcutsInhibitManagerV1 : context object for keyboard grab_manager +// +// A global interface used for inhibiting the compositor keyboard shortcuts. +func NewZwpKeyboardShortcutsInhibitManagerV1(ctx *client.Context) *ZwpKeyboardShortcutsInhibitManagerV1 { + zwpKeyboardShortcutsInhibitManagerV1 := &ZwpKeyboardShortcutsInhibitManagerV1{} + ctx.Register(zwpKeyboardShortcutsInhibitManagerV1) + return zwpKeyboardShortcutsInhibitManagerV1 +} + +// Destroy : destroy the keyboard shortcuts inhibitor object +// +// Destroy the keyboard shortcuts inhibitor manager. +func (i *ZwpKeyboardShortcutsInhibitManagerV1) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// InhibitShortcuts : create a new keyboard shortcuts inhibitor object +// +// Create a new keyboard shortcuts inhibitor object associated with +// the given surface for the given seat. +// +// If shortcuts are already inhibited for the specified seat and surface, +// a protocol error "already_inhibited" is raised by the compositor. +// +// surface: the surface that inhibits the keyboard shortcuts behavior +// seat: the wl_seat for which keyboard shortcuts should be disabled +func (i *ZwpKeyboardShortcutsInhibitManagerV1) InhibitShortcuts(surface *client.Surface, seat *client.Seat) (*ZwpKeyboardShortcutsInhibitorV1, error) { + id := NewZwpKeyboardShortcutsInhibitorV1(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], surface.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], seat.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +type ZwpKeyboardShortcutsInhibitManagerV1Error uint32 + +// ZwpKeyboardShortcutsInhibitManagerV1Error : +const ( + // ZwpKeyboardShortcutsInhibitManagerV1ErrorAlreadyInhibited : the shortcuts are already inhibited for this surface + ZwpKeyboardShortcutsInhibitManagerV1ErrorAlreadyInhibited ZwpKeyboardShortcutsInhibitManagerV1Error = 0 +) + +func (e ZwpKeyboardShortcutsInhibitManagerV1Error) Name() string { + switch e { + case ZwpKeyboardShortcutsInhibitManagerV1ErrorAlreadyInhibited: + return "already_inhibited" + default: + return "" + } +} + +func (e ZwpKeyboardShortcutsInhibitManagerV1Error) Value() string { + switch e { + case ZwpKeyboardShortcutsInhibitManagerV1ErrorAlreadyInhibited: + return "0" + default: + return "" + } +} + +func (e ZwpKeyboardShortcutsInhibitManagerV1Error) String() string { + return e.Name() + "=" + e.Value() +} + +// ZwpKeyboardShortcutsInhibitorV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwpKeyboardShortcutsInhibitorV1InterfaceName = "zwp_keyboard_shortcuts_inhibitor_v1" + +// ZwpKeyboardShortcutsInhibitorV1 : context object for keyboard shortcuts inhibitor +// +// A keyboard shortcuts inhibitor instructs the compositor to ignore +// its own keyboard shortcuts when the associated surface has keyboard +// focus. As a result, when the surface has keyboard focus on the given +// seat, it will receive all key events originating from the specified +// seat, even those which would normally be caught by the compositor for +// its own shortcuts. +// +// The Wayland compositor is however under no obligation to disable +// all of its shortcuts, and may keep some special key combo for its own +// use, including but not limited to one allowing the user to forcibly +// restore normal keyboard events routing in the case of an unwilling +// client. The compositor may also use the same key combo to reactivate +// an existing shortcut inhibitor that was previously deactivated on +// user request. +// +// When the compositor restores its own keyboard shortcuts, an +// "inactive" event is emitted to notify the client that the keyboard +// shortcuts inhibitor is not effectively active for the surface and +// seat any more, and the client should not expect to receive all +// keyboard events. +// +// When the keyboard shortcuts inhibitor is inactive, the client has +// no way to forcibly reactivate the keyboard shortcuts inhibitor. +// +// The user can chose to re-enable a previously deactivated keyboard +// shortcuts inhibitor using any mechanism the compositor may offer, +// in which case the compositor will send an "active" event to notify +// the client. +// +// If the surface is destroyed, unmapped, or loses the seat's keyboard +// focus, the keyboard shortcuts inhibitor becomes irrelevant and the +// compositor will restore its own keyboard shortcuts but no "inactive" +// event is emitted in this case. +type ZwpKeyboardShortcutsInhibitorV1 struct { + client.BaseProxy + activeHandler ZwpKeyboardShortcutsInhibitorV1ActiveHandlerFunc + inactiveHandler ZwpKeyboardShortcutsInhibitorV1InactiveHandlerFunc +} + +// NewZwpKeyboardShortcutsInhibitorV1 : context object for keyboard shortcuts inhibitor +// +// A keyboard shortcuts inhibitor instructs the compositor to ignore +// its own keyboard shortcuts when the associated surface has keyboard +// focus. As a result, when the surface has keyboard focus on the given +// seat, it will receive all key events originating from the specified +// seat, even those which would normally be caught by the compositor for +// its own shortcuts. +// +// The Wayland compositor is however under no obligation to disable +// all of its shortcuts, and may keep some special key combo for its own +// use, including but not limited to one allowing the user to forcibly +// restore normal keyboard events routing in the case of an unwilling +// client. The compositor may also use the same key combo to reactivate +// an existing shortcut inhibitor that was previously deactivated on +// user request. +// +// When the compositor restores its own keyboard shortcuts, an +// "inactive" event is emitted to notify the client that the keyboard +// shortcuts inhibitor is not effectively active for the surface and +// seat any more, and the client should not expect to receive all +// keyboard events. +// +// When the keyboard shortcuts inhibitor is inactive, the client has +// no way to forcibly reactivate the keyboard shortcuts inhibitor. +// +// The user can chose to re-enable a previously deactivated keyboard +// shortcuts inhibitor using any mechanism the compositor may offer, +// in which case the compositor will send an "active" event to notify +// the client. +// +// If the surface is destroyed, unmapped, or loses the seat's keyboard +// focus, the keyboard shortcuts inhibitor becomes irrelevant and the +// compositor will restore its own keyboard shortcuts but no "inactive" +// event is emitted in this case. +func NewZwpKeyboardShortcutsInhibitorV1(ctx *client.Context) *ZwpKeyboardShortcutsInhibitorV1 { + zwpKeyboardShortcutsInhibitorV1 := &ZwpKeyboardShortcutsInhibitorV1{} + ctx.Register(zwpKeyboardShortcutsInhibitorV1) + return zwpKeyboardShortcutsInhibitorV1 +} + +// Destroy : destroy the keyboard shortcuts inhibitor object +// +// Remove the keyboard shortcuts inhibitor from the associated wl_surface. +func (i *ZwpKeyboardShortcutsInhibitorV1) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// ZwpKeyboardShortcutsInhibitorV1ActiveEvent : shortcuts are inhibited +// +// This event indicates that the shortcut inhibitor is active. +// +// The compositor sends this event every time compositor shortcuts +// are inhibited on behalf of the surface. When active, the client +// may receive input events normally reserved by the compositor +// (see zwp_keyboard_shortcuts_inhibitor_v1). +// +// This occurs typically when the initial request "inhibit_shortcuts" +// first becomes active or when the user instructs the compositor to +// re-enable and existing shortcuts inhibitor using any mechanism +// offered by the compositor. +type ZwpKeyboardShortcutsInhibitorV1ActiveEvent struct{} +type ZwpKeyboardShortcutsInhibitorV1ActiveHandlerFunc func(ZwpKeyboardShortcutsInhibitorV1ActiveEvent) + +// SetActiveHandler : sets handler for ZwpKeyboardShortcutsInhibitorV1ActiveEvent +func (i *ZwpKeyboardShortcutsInhibitorV1) SetActiveHandler(f ZwpKeyboardShortcutsInhibitorV1ActiveHandlerFunc) { + i.activeHandler = f +} + +// ZwpKeyboardShortcutsInhibitorV1InactiveEvent : shortcuts are restored +// +// This event indicates that the shortcuts inhibitor is inactive, +// normal shortcuts processing is restored by the compositor. +type ZwpKeyboardShortcutsInhibitorV1InactiveEvent struct{} +type ZwpKeyboardShortcutsInhibitorV1InactiveHandlerFunc func(ZwpKeyboardShortcutsInhibitorV1InactiveEvent) + +// SetInactiveHandler : sets handler for ZwpKeyboardShortcutsInhibitorV1InactiveEvent +func (i *ZwpKeyboardShortcutsInhibitorV1) SetInactiveHandler(f ZwpKeyboardShortcutsInhibitorV1InactiveHandlerFunc) { + i.inactiveHandler = f +} + +func (i *ZwpKeyboardShortcutsInhibitorV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.activeHandler == nil { + return + } + var e ZwpKeyboardShortcutsInhibitorV1ActiveEvent + + i.activeHandler(e) + case 1: + if i.inactiveHandler == nil { + return + } + var e ZwpKeyboardShortcutsInhibitorV1InactiveEvent + + i.inactiveHandler(e) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_gamma_control/gamma_control.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_gamma_control/gamma_control.go new file mode 100644 index 0000000..a1cb429 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_gamma_control/gamma_control.go @@ -0,0 +1,268 @@ +// Generated by go-wayland-scanner +// https://github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/cmd/go-wayland-scanner +// XML file : wayland-protocols/wlr-gamma-control-unstable-v1.xml +// +// wlr_gamma_control_unstable_v1 Protocol Copyright: +// +// Copyright © 2015 Giulio camuffo +// Copyright © 2018 Simon Ser +// +// Permission to use, copy, modify, distribute, and sell this +// software and its documentation for any purpose is hereby granted +// without fee, provided that the above copyright notice appear in +// all copies and that both that copyright notice and this permission +// notice appear in supporting documentation, and that the name of +// the copyright holders not be used in advertising or publicity +// pertaining to distribution of the software without specific, +// written prior permission. The copyright holders make no +// representations about the suitability of this software for any +// purpose. It is provided "as is" without express or implied +// warranty. +// +// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS +// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY +// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +// THIS SOFTWARE. + +package wlr_gamma_control + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "golang.org/x/sys/unix" +) + +// ZwlrGammaControlManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrGammaControlManagerV1InterfaceName = "zwlr_gamma_control_manager_v1" + +// ZwlrGammaControlManagerV1 : manager to create per-output gamma controls +// +// This interface is a manager that allows creating per-output gamma +// controls. +type ZwlrGammaControlManagerV1 struct { + client.BaseProxy +} + +// NewZwlrGammaControlManagerV1 : manager to create per-output gamma controls +// +// This interface is a manager that allows creating per-output gamma +// controls. +func NewZwlrGammaControlManagerV1(ctx *client.Context) *ZwlrGammaControlManagerV1 { + zwlrGammaControlManagerV1 := &ZwlrGammaControlManagerV1{} + ctx.Register(zwlrGammaControlManagerV1) + return zwlrGammaControlManagerV1 +} + +// GetGammaControl : get a gamma control for an output +// +// Create a gamma control that can be used to adjust gamma tables for the +// provided output. +func (i *ZwlrGammaControlManagerV1) GetGammaControl(output *client.Output) (*ZwlrGammaControlV1, error) { + id := NewZwlrGammaControlV1(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], output.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// Destroy : destroy the manager +// +// All objects created by the manager will still remain valid, until their +// appropriate destroy request has been called. +func (i *ZwlrGammaControlManagerV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// ZwlrGammaControlV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrGammaControlV1InterfaceName = "zwlr_gamma_control_v1" + +// ZwlrGammaControlV1 : adjust gamma tables for an output +// +// This interface allows a client to adjust gamma tables for a particular +// output. +// +// The client will receive the gamma size, and will then be able to set gamma +// tables. At any time the compositor can send a failed event indicating that +// this object is no longer valid. +// +// There can only be at most one gamma control object per output, which +// has exclusive access to this particular output. When the gamma control +// object is destroyed, the gamma table is restored to its original value. +type ZwlrGammaControlV1 struct { + client.BaseProxy + gammaSizeHandler ZwlrGammaControlV1GammaSizeHandlerFunc + failedHandler ZwlrGammaControlV1FailedHandlerFunc +} + +// NewZwlrGammaControlV1 : adjust gamma tables for an output +// +// This interface allows a client to adjust gamma tables for a particular +// output. +// +// The client will receive the gamma size, and will then be able to set gamma +// tables. At any time the compositor can send a failed event indicating that +// this object is no longer valid. +// +// There can only be at most one gamma control object per output, which +// has exclusive access to this particular output. When the gamma control +// object is destroyed, the gamma table is restored to its original value. +func NewZwlrGammaControlV1(ctx *client.Context) *ZwlrGammaControlV1 { + zwlrGammaControlV1 := &ZwlrGammaControlV1{} + ctx.Register(zwlrGammaControlV1) + return zwlrGammaControlV1 +} + +// SetGamma : set the gamma table +// +// Set the gamma table. The file descriptor can be memory-mapped to provide +// the raw gamma table, which contains successive gamma ramps for the red, +// green and blue channels. Each gamma ramp is an array of 16-byte unsigned +// integers which has the same length as the gamma size. +// +// The file descriptor data must have the same length as three times the +// gamma size. +// +// fd: gamma table file descriptor +func (i *ZwlrGammaControlV1) SetGamma(fd int) error { + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + oob := unix.UnixRights(int(fd)) + err := i.Context().WriteMsg(_reqBuf[:], oob) + return err +} + +// Destroy : destroy this control +// +// Destroys the gamma control object. If the object is still valid, this +// restores the original gamma tables. +func (i *ZwlrGammaControlV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ZwlrGammaControlV1Error uint32 + +// ZwlrGammaControlV1Error : +const ( + // ZwlrGammaControlV1ErrorInvalidGamma : invalid gamma tables + ZwlrGammaControlV1ErrorInvalidGamma ZwlrGammaControlV1Error = 1 +) + +func (e ZwlrGammaControlV1Error) Name() string { + switch e { + case ZwlrGammaControlV1ErrorInvalidGamma: + return "invalid_gamma" + default: + return "" + } +} + +func (e ZwlrGammaControlV1Error) Value() string { + switch e { + case ZwlrGammaControlV1ErrorInvalidGamma: + return "1" + default: + return "" + } +} + +func (e ZwlrGammaControlV1Error) String() string { + return e.Name() + "=" + e.Value() +} + +// ZwlrGammaControlV1GammaSizeEvent : size of gamma ramps +// +// Advertise the size of each gamma ramp. +// +// This event is sent immediately when the gamma control object is created. +type ZwlrGammaControlV1GammaSizeEvent struct { + Size uint32 +} +type ZwlrGammaControlV1GammaSizeHandlerFunc func(ZwlrGammaControlV1GammaSizeEvent) + +// SetGammaSizeHandler : sets handler for ZwlrGammaControlV1GammaSizeEvent +func (i *ZwlrGammaControlV1) SetGammaSizeHandler(f ZwlrGammaControlV1GammaSizeHandlerFunc) { + i.gammaSizeHandler = f +} + +// ZwlrGammaControlV1FailedEvent : object no longer valid +// +// This event indicates that the gamma control is no longer valid. This +// can happen for a number of reasons, including: +// - The output doesn't support gamma tables +// - Setting the gamma tables failed +// - Another client already has exclusive gamma control for this output +// - The compositor has transferred gamma control to another client +// +// Upon receiving this event, the client should destroy this object. +type ZwlrGammaControlV1FailedEvent struct{} +type ZwlrGammaControlV1FailedHandlerFunc func(ZwlrGammaControlV1FailedEvent) + +// SetFailedHandler : sets handler for ZwlrGammaControlV1FailedEvent +func (i *ZwlrGammaControlV1) SetFailedHandler(f ZwlrGammaControlV1FailedHandlerFunc) { + i.failedHandler = f +} + +func (i *ZwlrGammaControlV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.gammaSizeHandler == nil { + return + } + var e ZwlrGammaControlV1GammaSizeEvent + l := 0 + e.Size = client.Uint32(data[l : l+4]) + l += 4 + + i.gammaSizeHandler(e) + case 1: + if i.failedHandler == nil { + return + } + var e ZwlrGammaControlV1FailedEvent + + i.failedHandler(e) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_layer_shell/layer_shell.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_layer_shell/layer_shell.go new file mode 100644 index 0000000..6fea165 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_layer_shell/layer_shell.go @@ -0,0 +1,792 @@ +// Generated by go-wayland-scanner +// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner +// XML file : internal/proto/xml/wlr-layer-shell-unstable-v1.xml +// +// wlr_layer_shell_unstable_v1 Protocol Copyright: +// +// Copyright © 2017 Drew DeVault +// +// Permission to use, copy, modify, distribute, and sell this +// software and its documentation for any purpose is hereby granted +// without fee, provided that the above copyright notice appear in +// all copies and that both that copyright notice and this permission +// notice appear in supporting documentation, and that the name of +// the copyright holders not be used in advertising or publicity +// pertaining to distribution of the software without specific, +// written prior permission. The copyright holders make no +// representations about the suitability of this software for any +// purpose. It is provided "as is" without express or implied +// warranty. +// +// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS +// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY +// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +// THIS SOFTWARE. + +package wlr_layer_shell + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + xdg_shell "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/stable/xdg-shell" +) + +// ZwlrLayerShellV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrLayerShellV1InterfaceName = "zwlr_layer_shell_v1" + +// ZwlrLayerShellV1 : create surfaces that are layers of the desktop +// +// Clients can use this interface to assign the surface_layer role to +// wl_surfaces. Such surfaces are assigned to a "layer" of the output and +// rendered with a defined z-depth respective to each other. They may also be +// anchored to the edges and corners of a screen and specify input handling +// semantics. This interface should be suitable for the implementation of +// many desktop shell components, and a broad number of other applications +// that interact with the desktop. +type ZwlrLayerShellV1 struct { + client.BaseProxy +} + +// NewZwlrLayerShellV1 : create surfaces that are layers of the desktop +// +// Clients can use this interface to assign the surface_layer role to +// wl_surfaces. Such surfaces are assigned to a "layer" of the output and +// rendered with a defined z-depth respective to each other. They may also be +// anchored to the edges and corners of a screen and specify input handling +// semantics. This interface should be suitable for the implementation of +// many desktop shell components, and a broad number of other applications +// that interact with the desktop. +func NewZwlrLayerShellV1(ctx *client.Context) *ZwlrLayerShellV1 { + zwlrLayerShellV1 := &ZwlrLayerShellV1{} + ctx.Register(zwlrLayerShellV1) + return zwlrLayerShellV1 +} + +// GetLayerSurface : create a layer_surface from a surface +// +// Create a layer surface for an existing surface. This assigns the role of +// layer_surface, or raises a protocol error if another role is already +// assigned. +// +// Creating a layer surface from a wl_surface which has a buffer attached +// or committed is a client error, and any attempts by a client to attach +// or manipulate a buffer prior to the first layer_surface.configure call +// must also be treated as errors. +// +// After creating a layer_surface object and setting it up, the client +// must perform an initial commit without any buffer attached. +// The compositor will reply with a layer_surface.configure event. +// The client must acknowledge it and is then allowed to attach a buffer +// to map the surface. +// +// You may pass NULL for output to allow the compositor to decide which +// output to use. Generally this will be the one that the user most +// recently interacted with. +// +// Clients can specify a namespace that defines the purpose of the layer +// surface. +// +// layer: layer to add this surface to +// namespace: namespace for the layer surface +func (i *ZwlrLayerShellV1) GetLayerSurface(surface *client.Surface, output *client.Output, layer uint32, namespace string) (*ZwlrLayerSurfaceV1, error) { + id := NewZwlrLayerSurfaceV1(i.Context()) + const opcode = 0 + namespaceLen := client.PaddedLen(len(namespace) + 1) + _reqBufLen := 8 + 4 + 4 + 4 + 4 + (4 + namespaceLen) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], surface.ID()) + l += 4 + if output == nil { + client.PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + client.PutUint32(_reqBuf[l:l+4], output.ID()) + l += 4 + } + client.PutUint32(_reqBuf[l:l+4], uint32(layer)) + l += 4 + client.PutString(_reqBuf[l:l+(4+namespaceLen)], namespace) + l += (4 + namespaceLen) + err := i.Context().WriteMsg(_reqBuf, nil) + return id, err +} + +// Destroy : destroy the layer_shell object +// +// This request indicates that the client will not use the layer_shell +// object any more. Objects that have been created through this instance +// are not affected. +func (i *ZwlrLayerShellV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ZwlrLayerShellV1Error uint32 + +// ZwlrLayerShellV1Error : +const ( + // ZwlrLayerShellV1ErrorRole : wl_surface has another role + ZwlrLayerShellV1ErrorRole ZwlrLayerShellV1Error = 0 + // ZwlrLayerShellV1ErrorInvalidLayer : layer value is invalid + ZwlrLayerShellV1ErrorInvalidLayer ZwlrLayerShellV1Error = 1 + // ZwlrLayerShellV1ErrorAlreadyConstructed : wl_surface has a buffer attached or committed + ZwlrLayerShellV1ErrorAlreadyConstructed ZwlrLayerShellV1Error = 2 +) + +func (e ZwlrLayerShellV1Error) Name() string { + switch e { + case ZwlrLayerShellV1ErrorRole: + return "role" + case ZwlrLayerShellV1ErrorInvalidLayer: + return "invalid_layer" + case ZwlrLayerShellV1ErrorAlreadyConstructed: + return "already_constructed" + default: + return "" + } +} + +func (e ZwlrLayerShellV1Error) Value() string { + switch e { + case ZwlrLayerShellV1ErrorRole: + return "0" + case ZwlrLayerShellV1ErrorInvalidLayer: + return "1" + case ZwlrLayerShellV1ErrorAlreadyConstructed: + return "2" + default: + return "" + } +} + +func (e ZwlrLayerShellV1Error) String() string { + return e.Name() + "=" + e.Value() +} + +type ZwlrLayerShellV1Layer uint32 + +// ZwlrLayerShellV1Layer : available layers for surfaces +// +// These values indicate which layers a surface can be rendered in. They +// are ordered by z depth, bottom-most first. Traditional shell surfaces +// will typically be rendered between the bottom and top layers. +// Fullscreen shell surfaces are typically rendered at the top layer. +// Multiple surfaces can share a single layer, and ordering within a +// single layer is undefined. +const ( + ZwlrLayerShellV1LayerBackground ZwlrLayerShellV1Layer = 0 + ZwlrLayerShellV1LayerBottom ZwlrLayerShellV1Layer = 1 + ZwlrLayerShellV1LayerTop ZwlrLayerShellV1Layer = 2 + ZwlrLayerShellV1LayerOverlay ZwlrLayerShellV1Layer = 3 +) + +func (e ZwlrLayerShellV1Layer) Name() string { + switch e { + case ZwlrLayerShellV1LayerBackground: + return "background" + case ZwlrLayerShellV1LayerBottom: + return "bottom" + case ZwlrLayerShellV1LayerTop: + return "top" + case ZwlrLayerShellV1LayerOverlay: + return "overlay" + default: + return "" + } +} + +func (e ZwlrLayerShellV1Layer) Value() string { + switch e { + case ZwlrLayerShellV1LayerBackground: + return "0" + case ZwlrLayerShellV1LayerBottom: + return "1" + case ZwlrLayerShellV1LayerTop: + return "2" + case ZwlrLayerShellV1LayerOverlay: + return "3" + default: + return "" + } +} + +func (e ZwlrLayerShellV1Layer) String() string { + return e.Name() + "=" + e.Value() +} + +// ZwlrLayerSurfaceV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrLayerSurfaceV1InterfaceName = "zwlr_layer_surface_v1" + +// ZwlrLayerSurfaceV1 : layer metadata interface +// +// An interface that may be implemented by a wl_surface, for surfaces that +// are designed to be rendered as a layer of a stacked desktop-like +// environment. +// +// Layer surface state (layer, size, anchor, exclusive zone, +// margin, interactivity) is double-buffered, and will be applied at the +// time wl_surface.commit of the corresponding wl_surface is called. +// +// Attaching a null buffer to a layer surface unmaps it. +// +// Unmapping a layer_surface means that the surface cannot be shown by the +// compositor until it is explicitly mapped again. The layer_surface +// returns to the state it had right after layer_shell.get_layer_surface. +// The client can re-map the surface by performing a commit without any +// buffer attached, waiting for a configure event and handling it as usual. +type ZwlrLayerSurfaceV1 struct { + client.BaseProxy + configureHandler ZwlrLayerSurfaceV1ConfigureHandlerFunc + closedHandler ZwlrLayerSurfaceV1ClosedHandlerFunc +} + +// NewZwlrLayerSurfaceV1 : layer metadata interface +// +// An interface that may be implemented by a wl_surface, for surfaces that +// are designed to be rendered as a layer of a stacked desktop-like +// environment. +// +// Layer surface state (layer, size, anchor, exclusive zone, +// margin, interactivity) is double-buffered, and will be applied at the +// time wl_surface.commit of the corresponding wl_surface is called. +// +// Attaching a null buffer to a layer surface unmaps it. +// +// Unmapping a layer_surface means that the surface cannot be shown by the +// compositor until it is explicitly mapped again. The layer_surface +// returns to the state it had right after layer_shell.get_layer_surface. +// The client can re-map the surface by performing a commit without any +// buffer attached, waiting for a configure event and handling it as usual. +func NewZwlrLayerSurfaceV1(ctx *client.Context) *ZwlrLayerSurfaceV1 { + zwlrLayerSurfaceV1 := &ZwlrLayerSurfaceV1{} + ctx.Register(zwlrLayerSurfaceV1) + return zwlrLayerSurfaceV1 +} + +// SetSize : sets the size of the surface +// +// Sets the size of the surface in surface-local coordinates. The +// compositor will display the surface centered with respect to its +// anchors. +// +// If you pass 0 for either value, the compositor will assign it and +// inform you of the assignment in the configure event. You must set your +// anchor to opposite edges in the dimensions you omit; not doing so is a +// protocol error. Both values are 0 by default. +// +// Size is double-buffered, see wl_surface.commit. +func (i *ZwlrLayerSurfaceV1) SetSize(width, height uint32) error { + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetAnchor : configures the anchor point of the surface +// +// Requests that the compositor anchor the surface to the specified edges +// and corners. If two orthogonal edges are specified (e.g. 'top' and +// 'left'), then the anchor point will be the intersection of the edges +// (e.g. the top left corner of the output); otherwise the anchor point +// will be centered on that edge, or in the center if none is specified. +// +// Anchor is double-buffered, see wl_surface.commit. +func (i *ZwlrLayerSurfaceV1) SetAnchor(anchor uint32) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(anchor)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetExclusiveZone : configures the exclusive geometry of this surface +// +// Requests that the compositor avoids occluding an area with other +// surfaces. The compositor's use of this information is +// implementation-dependent - do not assume that this region will not +// actually be occluded. +// +// A positive value is only meaningful if the surface is anchored to one +// edge or an edge and both perpendicular edges. If the surface is not +// anchored, anchored to only two perpendicular edges (a corner), anchored +// to only two parallel edges or anchored to all edges, a positive value +// will be treated the same as zero. +// +// A positive zone is the distance from the edge in surface-local +// coordinates to consider exclusive. +// +// Surfaces that do not wish to have an exclusive zone may instead specify +// how they should interact with surfaces that do. If set to zero, the +// surface indicates that it would like to be moved to avoid occluding +// surfaces with a positive exclusive zone. If set to -1, the surface +// indicates that it would not like to be moved to accommodate for other +// surfaces, and the compositor should extend it all the way to the edges +// it is anchored to. +// +// For example, a panel might set its exclusive zone to 10, so that +// maximized shell surfaces are not shown on top of it. A notification +// might set its exclusive zone to 0, so that it is moved to avoid +// occluding the panel, but shell surfaces are shown underneath it. A +// wallpaper or lock screen might set their exclusive zone to -1, so that +// they stretch below or over the panel. +// +// The default value is 0. +// +// Exclusive zone is double-buffered, see wl_surface.commit. +func (i *ZwlrLayerSurfaceV1) SetExclusiveZone(zone int32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(zone)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetMargin : sets a margin from the anchor point +// +// Requests that the surface be placed some distance away from the anchor +// point on the output, in surface-local coordinates. Setting this value +// for edges you are not anchored to has no effect. +// +// The exclusive zone includes the margin. +// +// Margin is double-buffered, see wl_surface.commit. +func (i *ZwlrLayerSurfaceV1) SetMargin(top, right, bottom, left int32) error { + const opcode = 3 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(top)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(right)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(bottom)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(left)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetKeyboardInteractivity : requests keyboard events +// +// Set how keyboard events are delivered to this surface. By default, +// layer shell surfaces do not receive keyboard events; this request can +// be used to change this. +// +// This setting is inherited by child surfaces set by the get_popup +// request. +// +// Layer surfaces receive pointer, touch, and tablet events normally. If +// you do not want to receive them, set the input region on your surface +// to an empty region. +// +// Keyboard interactivity is double-buffered, see wl_surface.commit. +func (i *ZwlrLayerSurfaceV1) SetKeyboardInteractivity(keyboardInteractivity uint32) error { + const opcode = 4 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(keyboardInteractivity)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// GetPopup : assign this layer_surface as an xdg_popup parent +// +// This assigns an xdg_popup's parent to this layer_surface. This popup +// should have been created via xdg_surface::get_popup with the parent set +// to NULL, and this request must be invoked before committing the popup's +// initial state. +// +// See the documentation of xdg_popup for more details about what an +// xdg_popup is and how it is used. +func (i *ZwlrLayerSurfaceV1) GetPopup(popup *xdg_shell.Popup) error { + const opcode = 5 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], popup.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// AckConfigure : ack a configure event +// +// When a configure event is received, if a client commits the +// surface in response to the configure event, then the client +// must make an ack_configure request sometime before the commit +// request, passing along the serial of the configure event. +// +// If the client receives multiple configure events before it +// can respond to one, it only has to ack the last configure event. +// +// A client is not required to commit immediately after sending +// an ack_configure request - it may even ack_configure several times +// before its next surface commit. +// +// A client may send multiple ack_configure requests before committing, but +// only the last request sent before a commit indicates which configure +// event the client really is responding to. +// +// serial: the serial from the configure event +func (i *ZwlrLayerSurfaceV1) AckConfigure(serial uint32) error { + const opcode = 6 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Destroy : destroy the layer_surface +// +// This request destroys the layer surface. +func (i *ZwlrLayerSurfaceV1) Destroy() error { + defer i.MarkZombie() + const opcode = 7 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetLayer : change the layer of the surface +// +// Change the layer that the surface is rendered on. +// +// Layer is double-buffered, see wl_surface.commit. +// +// layer: layer to move this surface to +func (i *ZwlrLayerSurfaceV1) SetLayer(layer uint32) error { + const opcode = 8 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(layer)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetExclusiveEdge : set the edge the exclusive zone will be applied to +// +// Requests an edge for the exclusive zone to apply. The exclusive +// edge will be automatically deduced from anchor points when possible, +// but when the surface is anchored to a corner, it will be necessary +// to set it explicitly to disambiguate, as it is not possible to deduce +// which one of the two corner edges should be used. +// +// The edge must be one the surface is anchored to, otherwise the +// invalid_exclusive_edge protocol error will be raised. +func (i *ZwlrLayerSurfaceV1) SetExclusiveEdge(edge uint32) error { + const opcode = 9 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(edge)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ZwlrLayerSurfaceV1KeyboardInteractivity uint32 + +// ZwlrLayerSurfaceV1KeyboardInteractivity : types of keyboard interaction possible for a layer shell surface +// +// Types of keyboard interaction possible for layer shell surfaces. The +// rationale for this is twofold: (1) some applications are not interested +// in keyboard events and not allowing them to be focused can improve the +// desktop experience; (2) some applications will want to take exclusive +// keyboard focus. +const ( + ZwlrLayerSurfaceV1KeyboardInteractivityNone ZwlrLayerSurfaceV1KeyboardInteractivity = 0 + ZwlrLayerSurfaceV1KeyboardInteractivityExclusive ZwlrLayerSurfaceV1KeyboardInteractivity = 1 + ZwlrLayerSurfaceV1KeyboardInteractivityOnDemand ZwlrLayerSurfaceV1KeyboardInteractivity = 2 +) + +func (e ZwlrLayerSurfaceV1KeyboardInteractivity) Name() string { + switch e { + case ZwlrLayerSurfaceV1KeyboardInteractivityNone: + return "none" + case ZwlrLayerSurfaceV1KeyboardInteractivityExclusive: + return "exclusive" + case ZwlrLayerSurfaceV1KeyboardInteractivityOnDemand: + return "on_demand" + default: + return "" + } +} + +func (e ZwlrLayerSurfaceV1KeyboardInteractivity) Value() string { + switch e { + case ZwlrLayerSurfaceV1KeyboardInteractivityNone: + return "0" + case ZwlrLayerSurfaceV1KeyboardInteractivityExclusive: + return "1" + case ZwlrLayerSurfaceV1KeyboardInteractivityOnDemand: + return "2" + default: + return "" + } +} + +func (e ZwlrLayerSurfaceV1KeyboardInteractivity) String() string { + return e.Name() + "=" + e.Value() +} + +type ZwlrLayerSurfaceV1Error uint32 + +// ZwlrLayerSurfaceV1Error : +const ( + // ZwlrLayerSurfaceV1ErrorInvalidSurfaceState : provided surface state is invalid + ZwlrLayerSurfaceV1ErrorInvalidSurfaceState ZwlrLayerSurfaceV1Error = 0 + // ZwlrLayerSurfaceV1ErrorInvalidSize : size is invalid + ZwlrLayerSurfaceV1ErrorInvalidSize ZwlrLayerSurfaceV1Error = 1 + // ZwlrLayerSurfaceV1ErrorInvalidAnchor : anchor bitfield is invalid + ZwlrLayerSurfaceV1ErrorInvalidAnchor ZwlrLayerSurfaceV1Error = 2 + // ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity : keyboard interactivity is invalid + ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity ZwlrLayerSurfaceV1Error = 3 + // ZwlrLayerSurfaceV1ErrorInvalidExclusiveEdge : exclusive edge is invalid given the surface anchors + ZwlrLayerSurfaceV1ErrorInvalidExclusiveEdge ZwlrLayerSurfaceV1Error = 4 +) + +func (e ZwlrLayerSurfaceV1Error) Name() string { + switch e { + case ZwlrLayerSurfaceV1ErrorInvalidSurfaceState: + return "invalid_surface_state" + case ZwlrLayerSurfaceV1ErrorInvalidSize: + return "invalid_size" + case ZwlrLayerSurfaceV1ErrorInvalidAnchor: + return "invalid_anchor" + case ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity: + return "invalid_keyboard_interactivity" + case ZwlrLayerSurfaceV1ErrorInvalidExclusiveEdge: + return "invalid_exclusive_edge" + default: + return "" + } +} + +func (e ZwlrLayerSurfaceV1Error) Value() string { + switch e { + case ZwlrLayerSurfaceV1ErrorInvalidSurfaceState: + return "0" + case ZwlrLayerSurfaceV1ErrorInvalidSize: + return "1" + case ZwlrLayerSurfaceV1ErrorInvalidAnchor: + return "2" + case ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity: + return "3" + case ZwlrLayerSurfaceV1ErrorInvalidExclusiveEdge: + return "4" + default: + return "" + } +} + +func (e ZwlrLayerSurfaceV1Error) String() string { + return e.Name() + "=" + e.Value() +} + +type ZwlrLayerSurfaceV1Anchor uint32 + +// ZwlrLayerSurfaceV1Anchor : +const ( + // ZwlrLayerSurfaceV1AnchorTop : the top edge of the anchor rectangle + ZwlrLayerSurfaceV1AnchorTop ZwlrLayerSurfaceV1Anchor = 1 + // ZwlrLayerSurfaceV1AnchorBottom : the bottom edge of the anchor rectangle + ZwlrLayerSurfaceV1AnchorBottom ZwlrLayerSurfaceV1Anchor = 2 + // ZwlrLayerSurfaceV1AnchorLeft : the left edge of the anchor rectangle + ZwlrLayerSurfaceV1AnchorLeft ZwlrLayerSurfaceV1Anchor = 4 + // ZwlrLayerSurfaceV1AnchorRight : the right edge of the anchor rectangle + ZwlrLayerSurfaceV1AnchorRight ZwlrLayerSurfaceV1Anchor = 8 +) + +func (e ZwlrLayerSurfaceV1Anchor) Name() string { + switch e { + case ZwlrLayerSurfaceV1AnchorTop: + return "top" + case ZwlrLayerSurfaceV1AnchorBottom: + return "bottom" + case ZwlrLayerSurfaceV1AnchorLeft: + return "left" + case ZwlrLayerSurfaceV1AnchorRight: + return "right" + default: + return "" + } +} + +func (e ZwlrLayerSurfaceV1Anchor) Value() string { + switch e { + case ZwlrLayerSurfaceV1AnchorTop: + return "1" + case ZwlrLayerSurfaceV1AnchorBottom: + return "2" + case ZwlrLayerSurfaceV1AnchorLeft: + return "4" + case ZwlrLayerSurfaceV1AnchorRight: + return "8" + default: + return "" + } +} + +func (e ZwlrLayerSurfaceV1Anchor) String() string { + return e.Name() + "=" + e.Value() +} + +// ZwlrLayerSurfaceV1ConfigureEvent : suggest a surface change +// +// The configure event asks the client to resize its surface. +// +// Clients should arrange their surface for the new states, and then send +// an ack_configure request with the serial sent in this configure event at +// some point before committing the new surface. +// +// The client is free to dismiss all but the last configure event it +// received. +// +// The width and height arguments specify the size of the window in +// surface-local coordinates. +// +// The size is a hint, in the sense that the client is free to ignore it if +// it doesn't resize, pick a smaller size (to satisfy aspect ratio or +// resize in steps of NxM pixels). If the client picks a smaller size and +// is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the +// surface will be centered on this axis. +// +// If the width or height arguments are zero, it means the client should +// decide its own window dimension. +type ZwlrLayerSurfaceV1ConfigureEvent struct { + Serial uint32 + Width uint32 + Height uint32 +} +type ZwlrLayerSurfaceV1ConfigureHandlerFunc func(ZwlrLayerSurfaceV1ConfigureEvent) + +// SetConfigureHandler : sets handler for ZwlrLayerSurfaceV1ConfigureEvent +func (i *ZwlrLayerSurfaceV1) SetConfigureHandler(f ZwlrLayerSurfaceV1ConfigureHandlerFunc) { + i.configureHandler = f +} + +// ZwlrLayerSurfaceV1ClosedEvent : surface should be closed +// +// The closed event is sent by the compositor when the surface will no +// longer be shown. The output may have been destroyed or the user may +// have asked for it to be removed. Further changes to the surface will be +// ignored. The client should destroy the resource after receiving this +// event, and create a new surface if they so choose. +type ZwlrLayerSurfaceV1ClosedEvent struct{} +type ZwlrLayerSurfaceV1ClosedHandlerFunc func(ZwlrLayerSurfaceV1ClosedEvent) + +// SetClosedHandler : sets handler for ZwlrLayerSurfaceV1ClosedEvent +func (i *ZwlrLayerSurfaceV1) SetClosedHandler(f ZwlrLayerSurfaceV1ClosedHandlerFunc) { + i.closedHandler = f +} + +func (i *ZwlrLayerSurfaceV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.configureHandler == nil { + return + } + var e ZwlrLayerSurfaceV1ConfigureEvent + l := 0 + e.Serial = client.Uint32(data[l : l+4]) + l += 4 + e.Width = client.Uint32(data[l : l+4]) + l += 4 + e.Height = client.Uint32(data[l : l+4]) + l += 4 + + i.configureHandler(e) + case 1: + if i.closedHandler == nil { + return + } + var e ZwlrLayerSurfaceV1ClosedEvent + + i.closedHandler(e) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_output_management/output_management.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_output_management/output_management.go new file mode 100644 index 0000000..23f934f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_output_management/output_management.go @@ -0,0 +1,1515 @@ +// Generated by go-wayland-scanner +// https://github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/cmd/go-wayland-scanner +// XML file : /home/brandon/repos/dankdots/wlr-output-management-unstable-v1.xml +// +// wlr_output_management_unstable_v1 Protocol Copyright: +// +// Copyright © 2019 Purism SPC +// +// Permission to use, copy, modify, distribute, and sell this +// software and its documentation for any purpose is hereby granted +// without fee, provided that the above copyright notice appear in +// all copies and that both that copyright notice and this permission +// notice appear in supporting documentation, and that the name of +// the copyright holders not be used in advertising or publicity +// pertaining to distribution of the software without specific, +// written prior permission. The copyright holders make no +// representations about the suitability of this software for any +// purpose. It is provided "as is" without express or implied +// warranty. +// +// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS +// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY +// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +// THIS SOFTWARE. + +package wlr_output_management + +import ( + "reflect" + "unsafe" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +func registerServerProxy(ctx *client.Context, proxy client.Proxy, serverID uint32) { + defer func() { + if r := recover(); r != nil { + return + } + }() + ctxVal := reflect.ValueOf(ctx).Elem() + objectsField := ctxVal.FieldByName("objects") + if !objectsField.IsValid() { + return + } + objectsMapPtr := unsafe.Pointer(objectsField.UnsafeAddr()) + objectsMap := (*syncmap.Map[uint32, client.Proxy])(objectsMapPtr) + objectsMap.Store(serverID, proxy) +} + +// ZwlrOutputManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrOutputManagerV1InterfaceName = "zwlr_output_manager_v1" + +// ZwlrOutputManagerV1 : output device configuration manager +// +// This interface is a manager that allows reading and writing the current +// output device configuration. +// +// Output devices that display pixels (e.g. a physical monitor or a virtual +// output in a window) are represented as heads. Heads cannot be created nor +// destroyed by the client, but they can be enabled or disabled and their +// properties can be changed. Each head may have one or more available modes. +// +// Whenever a head appears (e.g. a monitor is plugged in), it will be +// advertised via the head event. Immediately after the output manager is +// bound, all current heads are advertised. +// +// Whenever a head's properties change, the relevant wlr_output_head events +// will be sent. Not all head properties will be sent: only properties that +// have changed need to. +// +// Whenever a head disappears (e.g. a monitor is unplugged), a +// wlr_output_head.finished event will be sent. +// +// After one or more heads appear, change or disappear, the done event will +// be sent. It carries a serial which can be used in a create_configuration +// request to update heads properties. +// +// The information obtained from this protocol should only be used for output +// configuration purposes. This protocol is not designed to be a generic +// output property advertisement protocol for regular clients. Instead, +// protocols such as xdg-output should be used. +type ZwlrOutputManagerV1 struct { + client.BaseProxy + headHandler ZwlrOutputManagerV1HeadHandlerFunc + doneHandler ZwlrOutputManagerV1DoneHandlerFunc + finishedHandler ZwlrOutputManagerV1FinishedHandlerFunc +} + +// NewZwlrOutputManagerV1 : output device configuration manager +// +// This interface is a manager that allows reading and writing the current +// output device configuration. +// +// Output devices that display pixels (e.g. a physical monitor or a virtual +// output in a window) are represented as heads. Heads cannot be created nor +// destroyed by the client, but they can be enabled or disabled and their +// properties can be changed. Each head may have one or more available modes. +// +// Whenever a head appears (e.g. a monitor is plugged in), it will be +// advertised via the head event. Immediately after the output manager is +// bound, all current heads are advertised. +// +// Whenever a head's properties change, the relevant wlr_output_head events +// will be sent. Not all head properties will be sent: only properties that +// have changed need to. +// +// Whenever a head disappears (e.g. a monitor is unplugged), a +// wlr_output_head.finished event will be sent. +// +// After one or more heads appear, change or disappear, the done event will +// be sent. It carries a serial which can be used in a create_configuration +// request to update heads properties. +// +// The information obtained from this protocol should only be used for output +// configuration purposes. This protocol is not designed to be a generic +// output property advertisement protocol for regular clients. Instead, +// protocols such as xdg-output should be used. +func NewZwlrOutputManagerV1(ctx *client.Context) *ZwlrOutputManagerV1 { + zwlrOutputManagerV1 := &ZwlrOutputManagerV1{} + ctx.Register(zwlrOutputManagerV1) + return zwlrOutputManagerV1 +} + +// CreateConfiguration : create a new output configuration object +// +// Create a new output configuration object. This allows to update head +// properties. +func (i *ZwlrOutputManagerV1) CreateConfiguration(serial uint32) (*ZwlrOutputConfigurationV1, error) { + id := NewZwlrOutputConfigurationV1(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// Stop : stop sending events +// +// Indicates the client no longer wishes to receive events for output +// configuration changes. However the compositor may emit further events, +// until the finished event is emitted. +// +// The client must not send any more requests after this one. +func (i *ZwlrOutputManagerV1) Stop() error { + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +func (i *ZwlrOutputManagerV1) Destroy() error { + i.MarkZombie() + return nil +} + +// ZwlrOutputManagerV1HeadEvent : introduce a new head +// +// This event introduces a new head. This happens whenever a new head +// appears (e.g. a monitor is plugged in) or after the output manager is +// bound. +type ZwlrOutputManagerV1HeadEvent struct { + Head *ZwlrOutputHeadV1 +} +type ZwlrOutputManagerV1HeadHandlerFunc func(ZwlrOutputManagerV1HeadEvent) + +// SetHeadHandler : sets handler for ZwlrOutputManagerV1HeadEvent +func (i *ZwlrOutputManagerV1) SetHeadHandler(f ZwlrOutputManagerV1HeadHandlerFunc) { + i.headHandler = f +} + +// ZwlrOutputManagerV1DoneEvent : sent all information about current configuration +// +// This event is sent after all information has been sent after binding to +// the output manager object and after any subsequent changes. This applies +// to child head and mode objects as well. In other words, this event is +// sent whenever a head or mode is created or destroyed and whenever one of +// their properties has been changed. Not all state is re-sent each time +// the current configuration changes: only the actual changes are sent. +// +// This allows changes to the output configuration to be seen as atomic, +// even if they happen via multiple events. +// +// A serial is sent to be used in a future create_configuration request. +type ZwlrOutputManagerV1DoneEvent struct { + Serial uint32 +} +type ZwlrOutputManagerV1DoneHandlerFunc func(ZwlrOutputManagerV1DoneEvent) + +// SetDoneHandler : sets handler for ZwlrOutputManagerV1DoneEvent +func (i *ZwlrOutputManagerV1) SetDoneHandler(f ZwlrOutputManagerV1DoneHandlerFunc) { + i.doneHandler = f +} + +// ZwlrOutputManagerV1FinishedEvent : the compositor has finished with the manager +// +// This event indicates that the compositor is done sending manager events. +// The compositor will destroy the object immediately after sending this +// event, so it will become invalid and the client should release any +// resources associated with it. +type ZwlrOutputManagerV1FinishedEvent struct{} +type ZwlrOutputManagerV1FinishedHandlerFunc func(ZwlrOutputManagerV1FinishedEvent) + +// SetFinishedHandler : sets handler for ZwlrOutputManagerV1FinishedEvent +func (i *ZwlrOutputManagerV1) SetFinishedHandler(f ZwlrOutputManagerV1FinishedHandlerFunc) { + i.finishedHandler = f +} + +func (i *ZwlrOutputManagerV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.headHandler == nil { + return + } + var e ZwlrOutputManagerV1HeadEvent + l := 0 + objectID := client.Uint32(data[l : l+4]) + proxy := i.Context().GetProxy(objectID) + if proxy == nil || proxy.IsZombie() { + head := &ZwlrOutputHeadV1{} + head.SetContext(i.Context()) + head.SetID(objectID) + registerServerProxy(i.Context(), head, objectID) + e.Head = head + } else if head, ok := proxy.(*ZwlrOutputHeadV1); ok { + e.Head = head + } else { + // Stale proxy of wrong type (can happen after suspend/resume) + // Replace it with the correct type + head := &ZwlrOutputHeadV1{} + head.SetContext(i.Context()) + head.SetID(objectID) + registerServerProxy(i.Context(), head, objectID) + e.Head = head + } + l += 4 + + i.headHandler(e) + case 1: + if i.doneHandler == nil { + return + } + var e ZwlrOutputManagerV1DoneEvent + l := 0 + e.Serial = client.Uint32(data[l : l+4]) + l += 4 + + i.doneHandler(e) + case 2: + if i.finishedHandler == nil { + return + } + var e ZwlrOutputManagerV1FinishedEvent + + i.finishedHandler(e) + } +} + +// ZwlrOutputHeadV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrOutputHeadV1InterfaceName = "zwlr_output_head_v1" + +// ZwlrOutputHeadV1 : output device +// +// A head is an output device. The difference between a wl_output object and +// a head is that heads are advertised even if they are turned off. A head +// object only advertises properties and cannot be used directly to change +// them. +// +// A head has some read-only properties: modes, name, description and +// physical_size. These cannot be changed by clients. +// +// Other properties can be updated via a wlr_output_configuration object. +// +// Properties sent via this interface are applied atomically via the +// wlr_output_manager.done event. No guarantees are made regarding the order +// in which properties are sent. +type ZwlrOutputHeadV1 struct { + client.BaseProxy + nameHandler ZwlrOutputHeadV1NameHandlerFunc + descriptionHandler ZwlrOutputHeadV1DescriptionHandlerFunc + physicalSizeHandler ZwlrOutputHeadV1PhysicalSizeHandlerFunc + modeHandler ZwlrOutputHeadV1ModeHandlerFunc + enabledHandler ZwlrOutputHeadV1EnabledHandlerFunc + currentModeHandler ZwlrOutputHeadV1CurrentModeHandlerFunc + positionHandler ZwlrOutputHeadV1PositionHandlerFunc + transformHandler ZwlrOutputHeadV1TransformHandlerFunc + scaleHandler ZwlrOutputHeadV1ScaleHandlerFunc + finishedHandler ZwlrOutputHeadV1FinishedHandlerFunc + makeHandler ZwlrOutputHeadV1MakeHandlerFunc + modelHandler ZwlrOutputHeadV1ModelHandlerFunc + serialNumberHandler ZwlrOutputHeadV1SerialNumberHandlerFunc + adaptiveSyncHandler ZwlrOutputHeadV1AdaptiveSyncHandlerFunc +} + +// NewZwlrOutputHeadV1 : output device +// +// A head is an output device. The difference between a wl_output object and +// a head is that heads are advertised even if they are turned off. A head +// object only advertises properties and cannot be used directly to change +// them. +// +// A head has some read-only properties: modes, name, description and +// physical_size. These cannot be changed by clients. +// +// Other properties can be updated via a wlr_output_configuration object. +// +// Properties sent via this interface are applied atomically via the +// wlr_output_manager.done event. No guarantees are made regarding the order +// in which properties are sent. +func NewZwlrOutputHeadV1(ctx *client.Context) *ZwlrOutputHeadV1 { + zwlrOutputHeadV1 := &ZwlrOutputHeadV1{} + ctx.Register(zwlrOutputHeadV1) + return zwlrOutputHeadV1 +} + +// Release : destroy the head object +// +// This request indicates that the client will no longer use this head +// object. +func (i *ZwlrOutputHeadV1) Release() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ZwlrOutputHeadV1AdaptiveSyncState uint32 + +// ZwlrOutputHeadV1AdaptiveSyncState : +const ( + // ZwlrOutputHeadV1AdaptiveSyncStateDisabled : adaptive sync is disabled + ZwlrOutputHeadV1AdaptiveSyncStateDisabled ZwlrOutputHeadV1AdaptiveSyncState = 0 + // ZwlrOutputHeadV1AdaptiveSyncStateEnabled : adaptive sync is enabled + ZwlrOutputHeadV1AdaptiveSyncStateEnabled ZwlrOutputHeadV1AdaptiveSyncState = 1 +) + +func (e ZwlrOutputHeadV1AdaptiveSyncState) Name() string { + switch e { + case ZwlrOutputHeadV1AdaptiveSyncStateDisabled: + return "disabled" + case ZwlrOutputHeadV1AdaptiveSyncStateEnabled: + return "enabled" + default: + return "" + } +} + +func (e ZwlrOutputHeadV1AdaptiveSyncState) Value() string { + switch e { + case ZwlrOutputHeadV1AdaptiveSyncStateDisabled: + return "0" + case ZwlrOutputHeadV1AdaptiveSyncStateEnabled: + return "1" + default: + return "" + } +} + +func (e ZwlrOutputHeadV1AdaptiveSyncState) String() string { + return e.Name() + "=" + e.Value() +} + +// ZwlrOutputHeadV1NameEvent : head name +// +// This event describes the head name. +// +// The naming convention is compositor defined, but limited to alphanumeric +// characters and dashes (-). Each name is unique among all wlr_output_head +// objects, but if a wlr_output_head object is destroyed the same name may +// be reused later. The names will also remain consistent across sessions +// with the same hardware and software configuration. +// +// Examples of names include 'HDMI-A-1', 'WL-1', 'X11-1', etc. However, do +// not assume that the name is a reflection of an underlying DRM +// connector, X11 connection, etc. +// +// If this head matches a wl_output, the wl_output.name event must report +// the same name. +// +// The name event is sent after a wlr_output_head object is created. This +// event is only sent once per object, and the name does not change over +// the lifetime of the wlr_output_head object. +type ZwlrOutputHeadV1NameEvent struct { + Name string +} +type ZwlrOutputHeadV1NameHandlerFunc func(ZwlrOutputHeadV1NameEvent) + +// SetNameHandler : sets handler for ZwlrOutputHeadV1NameEvent +func (i *ZwlrOutputHeadV1) SetNameHandler(f ZwlrOutputHeadV1NameHandlerFunc) { + i.nameHandler = f +} + +// ZwlrOutputHeadV1DescriptionEvent : head description +// +// This event describes a human-readable description of the head. +// +// The description is a UTF-8 string with no convention defined for its +// contents. Examples might include 'Foocorp 11" Display' or 'Virtual X11 +// output via :1'. However, do not assume that the name is a reflection of +// the make, model, serial of the underlying DRM connector or the display +// name of the underlying X11 connection, etc. +// +// If this head matches a wl_output, the wl_output.description event must +// report the same name. +// +// The description event is sent after a wlr_output_head object is created. +// This event is only sent once per object, and the description does not +// change over the lifetime of the wlr_output_head object. +type ZwlrOutputHeadV1DescriptionEvent struct { + Description string +} +type ZwlrOutputHeadV1DescriptionHandlerFunc func(ZwlrOutputHeadV1DescriptionEvent) + +// SetDescriptionHandler : sets handler for ZwlrOutputHeadV1DescriptionEvent +func (i *ZwlrOutputHeadV1) SetDescriptionHandler(f ZwlrOutputHeadV1DescriptionHandlerFunc) { + i.descriptionHandler = f +} + +// ZwlrOutputHeadV1PhysicalSizeEvent : head physical size +// +// This event describes the physical size of the head. This event is only +// sent if the head has a physical size (e.g. is not a projector or a +// virtual device). +// +// The physical size event is sent after a wlr_output_head object is created. This +// event is only sent once per object, and the physical size does not change over +// the lifetime of the wlr_output_head object. +type ZwlrOutputHeadV1PhysicalSizeEvent struct { + Width int32 + Height int32 +} +type ZwlrOutputHeadV1PhysicalSizeHandlerFunc func(ZwlrOutputHeadV1PhysicalSizeEvent) + +// SetPhysicalSizeHandler : sets handler for ZwlrOutputHeadV1PhysicalSizeEvent +func (i *ZwlrOutputHeadV1) SetPhysicalSizeHandler(f ZwlrOutputHeadV1PhysicalSizeHandlerFunc) { + i.physicalSizeHandler = f +} + +// ZwlrOutputHeadV1ModeEvent : introduce a mode +// +// This event introduces a mode for this head. It is sent once per +// supported mode. +type ZwlrOutputHeadV1ModeEvent struct { + Mode *ZwlrOutputModeV1 +} +type ZwlrOutputHeadV1ModeHandlerFunc func(ZwlrOutputHeadV1ModeEvent) + +// SetModeHandler : sets handler for ZwlrOutputHeadV1ModeEvent +func (i *ZwlrOutputHeadV1) SetModeHandler(f ZwlrOutputHeadV1ModeHandlerFunc) { + i.modeHandler = f +} + +// ZwlrOutputHeadV1EnabledEvent : head is enabled or disabled +// +// This event describes whether the head is enabled. A disabled head is not +// mapped to a region of the global compositor space. +// +// When a head is disabled, some properties (current_mode, position, +// transform and scale) are irrelevant. +type ZwlrOutputHeadV1EnabledEvent struct { + Enabled int32 +} +type ZwlrOutputHeadV1EnabledHandlerFunc func(ZwlrOutputHeadV1EnabledEvent) + +// SetEnabledHandler : sets handler for ZwlrOutputHeadV1EnabledEvent +func (i *ZwlrOutputHeadV1) SetEnabledHandler(f ZwlrOutputHeadV1EnabledHandlerFunc) { + i.enabledHandler = f +} + +// ZwlrOutputHeadV1CurrentModeEvent : current mode +// +// This event describes the mode currently in use for this head. It is only +// sent if the output is enabled. +type ZwlrOutputHeadV1CurrentModeEvent struct { + Mode *ZwlrOutputModeV1 +} +type ZwlrOutputHeadV1CurrentModeHandlerFunc func(ZwlrOutputHeadV1CurrentModeEvent) + +// SetCurrentModeHandler : sets handler for ZwlrOutputHeadV1CurrentModeEvent +func (i *ZwlrOutputHeadV1) SetCurrentModeHandler(f ZwlrOutputHeadV1CurrentModeHandlerFunc) { + i.currentModeHandler = f +} + +// ZwlrOutputHeadV1PositionEvent : current position +// +// This events describes the position of the head in the global compositor +// space. It is only sent if the output is enabled. +type ZwlrOutputHeadV1PositionEvent struct { + X int32 + Y int32 +} +type ZwlrOutputHeadV1PositionHandlerFunc func(ZwlrOutputHeadV1PositionEvent) + +// SetPositionHandler : sets handler for ZwlrOutputHeadV1PositionEvent +func (i *ZwlrOutputHeadV1) SetPositionHandler(f ZwlrOutputHeadV1PositionHandlerFunc) { + i.positionHandler = f +} + +// ZwlrOutputHeadV1TransformEvent : current transformation +// +// This event describes the transformation currently applied to the head. +// It is only sent if the output is enabled. +type ZwlrOutputHeadV1TransformEvent struct { + Transform int32 +} +type ZwlrOutputHeadV1TransformHandlerFunc func(ZwlrOutputHeadV1TransformEvent) + +// SetTransformHandler : sets handler for ZwlrOutputHeadV1TransformEvent +func (i *ZwlrOutputHeadV1) SetTransformHandler(f ZwlrOutputHeadV1TransformHandlerFunc) { + i.transformHandler = f +} + +// ZwlrOutputHeadV1ScaleEvent : current scale +// +// This events describes the scale of the head in the global compositor +// space. It is only sent if the output is enabled. +type ZwlrOutputHeadV1ScaleEvent struct { + Scale float64 +} +type ZwlrOutputHeadV1ScaleHandlerFunc func(ZwlrOutputHeadV1ScaleEvent) + +// SetScaleHandler : sets handler for ZwlrOutputHeadV1ScaleEvent +func (i *ZwlrOutputHeadV1) SetScaleHandler(f ZwlrOutputHeadV1ScaleHandlerFunc) { + i.scaleHandler = f +} + +// ZwlrOutputHeadV1FinishedEvent : the head has disappeared +// +// This event indicates that the head is no longer available. The head +// object becomes inert. Clients should send a destroy request and release +// any resources associated with it. +type ZwlrOutputHeadV1FinishedEvent struct{} +type ZwlrOutputHeadV1FinishedHandlerFunc func(ZwlrOutputHeadV1FinishedEvent) + +// SetFinishedHandler : sets handler for ZwlrOutputHeadV1FinishedEvent +func (i *ZwlrOutputHeadV1) SetFinishedHandler(f ZwlrOutputHeadV1FinishedHandlerFunc) { + i.finishedHandler = f +} + +// ZwlrOutputHeadV1MakeEvent : head manufacturer +// +// This event describes the manufacturer of the head. +// +// Together with the model and serial_number events the purpose is to +// allow clients to recognize heads from previous sessions and for example +// load head-specific configurations back. +// +// It is not guaranteed this event will be ever sent. A reason for that +// can be that the compositor does not have information about the make of +// the head or the definition of a make is not sensible in the current +// setup, for example in a virtual session. Clients can still try to +// identify the head by available information from other events but should +// be aware that there is an increased risk of false positives. +// +// If sent, the make event is sent after a wlr_output_head object is +// created and only sent once per object. The make does not change over +// the lifetime of the wlr_output_head object. +// +// It is not recommended to display the make string in UI to users. For +// that the string provided by the description event should be preferred. +type ZwlrOutputHeadV1MakeEvent struct { + Make string +} +type ZwlrOutputHeadV1MakeHandlerFunc func(ZwlrOutputHeadV1MakeEvent) + +// SetMakeHandler : sets handler for ZwlrOutputHeadV1MakeEvent +func (i *ZwlrOutputHeadV1) SetMakeHandler(f ZwlrOutputHeadV1MakeHandlerFunc) { + i.makeHandler = f +} + +// ZwlrOutputHeadV1ModelEvent : head model +// +// This event describes the model of the head. +// +// Together with the make and serial_number events the purpose is to +// allow clients to recognize heads from previous sessions and for example +// load head-specific configurations back. +// +// It is not guaranteed this event will be ever sent. A reason for that +// can be that the compositor does not have information about the model of +// the head or the definition of a model is not sensible in the current +// setup, for example in a virtual session. Clients can still try to +// identify the head by available information from other events but should +// be aware that there is an increased risk of false positives. +// +// If sent, the model event is sent after a wlr_output_head object is +// created and only sent once per object. The model does not change over +// the lifetime of the wlr_output_head object. +// +// It is not recommended to display the model string in UI to users. For +// that the string provided by the description event should be preferred. +type ZwlrOutputHeadV1ModelEvent struct { + Model string +} +type ZwlrOutputHeadV1ModelHandlerFunc func(ZwlrOutputHeadV1ModelEvent) + +// SetModelHandler : sets handler for ZwlrOutputHeadV1ModelEvent +func (i *ZwlrOutputHeadV1) SetModelHandler(f ZwlrOutputHeadV1ModelHandlerFunc) { + i.modelHandler = f +} + +// ZwlrOutputHeadV1SerialNumberEvent : head serial number +// +// This event describes the serial number of the head. +// +// Together with the make and model events the purpose is to allow clients +// to recognize heads from previous sessions and for example load head- +// specific configurations back. +// +// It is not guaranteed this event will be ever sent. A reason for that +// can be that the compositor does not have information about the serial +// number of the head or the definition of a serial number is not sensible +// in the current setup. Clients can still try to identify the head by +// available information from other events but should be aware that there +// is an increased risk of false positives. +// +// If sent, the serial number event is sent after a wlr_output_head object +// is created and only sent once per object. The serial number does not +// change over the lifetime of the wlr_output_head object. +// +// It is not recommended to display the serial_number string in UI to +// users. For that the string provided by the description event should be +// preferred. +type ZwlrOutputHeadV1SerialNumberEvent struct { + SerialNumber string +} +type ZwlrOutputHeadV1SerialNumberHandlerFunc func(ZwlrOutputHeadV1SerialNumberEvent) + +// SetSerialNumberHandler : sets handler for ZwlrOutputHeadV1SerialNumberEvent +func (i *ZwlrOutputHeadV1) SetSerialNumberHandler(f ZwlrOutputHeadV1SerialNumberHandlerFunc) { + i.serialNumberHandler = f +} + +// ZwlrOutputHeadV1AdaptiveSyncEvent : current adaptive sync state +// +// This event describes whether adaptive sync is currently enabled for +// the head or not. Adaptive sync is also known as Variable Refresh +// Rate or VRR. +type ZwlrOutputHeadV1AdaptiveSyncEvent struct { + State uint32 +} +type ZwlrOutputHeadV1AdaptiveSyncHandlerFunc func(ZwlrOutputHeadV1AdaptiveSyncEvent) + +// SetAdaptiveSyncHandler : sets handler for ZwlrOutputHeadV1AdaptiveSyncEvent +func (i *ZwlrOutputHeadV1) SetAdaptiveSyncHandler(f ZwlrOutputHeadV1AdaptiveSyncHandlerFunc) { + i.adaptiveSyncHandler = f +} + +func (i *ZwlrOutputHeadV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.nameHandler == nil { + return + } + var e ZwlrOutputHeadV1NameEvent + l := 0 + nameLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Name = client.String(data[l : l+nameLen]) + l += nameLen + + i.nameHandler(e) + case 1: + if i.descriptionHandler == nil { + return + } + var e ZwlrOutputHeadV1DescriptionEvent + l := 0 + descriptionLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Description = client.String(data[l : l+descriptionLen]) + l += descriptionLen + + i.descriptionHandler(e) + case 2: + if i.physicalSizeHandler == nil { + return + } + var e ZwlrOutputHeadV1PhysicalSizeEvent + l := 0 + e.Width = int32(client.Uint32(data[l : l+4])) + l += 4 + e.Height = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.physicalSizeHandler(e) + case 3: + if i.modeHandler == nil { + return + } + var e ZwlrOutputHeadV1ModeEvent + l := 0 + objectID := client.Uint32(data[l : l+4]) + proxy := i.Context().GetProxy(objectID) + if proxy == nil || proxy.IsZombie() { + mode := &ZwlrOutputModeV1{} + mode.SetContext(i.Context()) + mode.SetID(objectID) + registerServerProxy(i.Context(), mode, objectID) + e.Mode = mode + } else if mode, ok := proxy.(*ZwlrOutputModeV1); ok { + e.Mode = mode + } else { + // Stale proxy of wrong type (can happen after suspend/resume) + // Replace it with the correct type + mode := &ZwlrOutputModeV1{} + mode.SetContext(i.Context()) + mode.SetID(objectID) + registerServerProxy(i.Context(), mode, objectID) + e.Mode = mode + } + l += 4 + + i.modeHandler(e) + case 4: + if i.enabledHandler == nil { + return + } + var e ZwlrOutputHeadV1EnabledEvent + l := 0 + e.Enabled = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.enabledHandler(e) + case 5: + if i.currentModeHandler == nil { + return + } + var e ZwlrOutputHeadV1CurrentModeEvent + l := 0 + objectID := client.Uint32(data[l : l+4]) + proxy := i.Context().GetProxy(objectID) + if proxy == nil || proxy.IsZombie() { + // Mode not yet registered or zombie, create fresh + mode := &ZwlrOutputModeV1{} + mode.SetContext(i.Context()) + mode.SetID(objectID) + registerServerProxy(i.Context(), mode, objectID) + e.Mode = mode + } else if mode, ok := proxy.(*ZwlrOutputModeV1); ok { + e.Mode = mode + } else { + // Stale proxy of wrong type (can happen after suspend/resume) + // Replace it with the correct type + mode := &ZwlrOutputModeV1{} + mode.SetContext(i.Context()) + mode.SetID(objectID) + registerServerProxy(i.Context(), mode, objectID) + e.Mode = mode + } + l += 4 + + i.currentModeHandler(e) + case 6: + if i.positionHandler == nil { + return + } + var e ZwlrOutputHeadV1PositionEvent + l := 0 + e.X = int32(client.Uint32(data[l : l+4])) + l += 4 + e.Y = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.positionHandler(e) + case 7: + if i.transformHandler == nil { + return + } + var e ZwlrOutputHeadV1TransformEvent + l := 0 + e.Transform = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.transformHandler(e) + case 8: + if i.scaleHandler == nil { + return + } + var e ZwlrOutputHeadV1ScaleEvent + l := 0 + e.Scale = client.Fixed(data[l : l+4]) + l += 4 + + i.scaleHandler(e) + case 9: + if i.finishedHandler == nil { + return + } + var e ZwlrOutputHeadV1FinishedEvent + + i.finishedHandler(e) + case 10: + if i.makeHandler == nil { + return + } + var e ZwlrOutputHeadV1MakeEvent + l := 0 + makeLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Make = client.String(data[l : l+makeLen]) + l += makeLen + + i.makeHandler(e) + case 11: + if i.modelHandler == nil { + return + } + var e ZwlrOutputHeadV1ModelEvent + l := 0 + modelLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.Model = client.String(data[l : l+modelLen]) + l += modelLen + + i.modelHandler(e) + case 12: + if i.serialNumberHandler == nil { + return + } + var e ZwlrOutputHeadV1SerialNumberEvent + l := 0 + serialNumberLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + e.SerialNumber = client.String(data[l : l+serialNumberLen]) + l += serialNumberLen + + i.serialNumberHandler(e) + case 13: + if i.adaptiveSyncHandler == nil { + return + } + var e ZwlrOutputHeadV1AdaptiveSyncEvent + l := 0 + e.State = client.Uint32(data[l : l+4]) + l += 4 + + i.adaptiveSyncHandler(e) + } +} + +// ZwlrOutputModeV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrOutputModeV1InterfaceName = "zwlr_output_mode_v1" + +// ZwlrOutputModeV1 : output mode +// +// This object describes an output mode. +// +// Some heads don't support output modes, in which case modes won't be +// advertised. +// +// Properties sent via this interface are applied atomically via the +// wlr_output_manager.done event. No guarantees are made regarding the order +// in which properties are sent. +type ZwlrOutputModeV1 struct { + client.BaseProxy + sizeHandler ZwlrOutputModeV1SizeHandlerFunc + refreshHandler ZwlrOutputModeV1RefreshHandlerFunc + preferredHandler ZwlrOutputModeV1PreferredHandlerFunc + finishedHandler ZwlrOutputModeV1FinishedHandlerFunc +} + +// NewZwlrOutputModeV1 : output mode +// +// This object describes an output mode. +// +// Some heads don't support output modes, in which case modes won't be +// advertised. +// +// Properties sent via this interface are applied atomically via the +// wlr_output_manager.done event. No guarantees are made regarding the order +// in which properties are sent. +func NewZwlrOutputModeV1(ctx *client.Context) *ZwlrOutputModeV1 { + zwlrOutputModeV1 := &ZwlrOutputModeV1{} + ctx.Register(zwlrOutputModeV1) + return zwlrOutputModeV1 +} + +// Release : destroy the mode object +// +// This request indicates that the client will no longer use this mode +// object. +func (i *ZwlrOutputModeV1) Release() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// ZwlrOutputModeV1SizeEvent : mode size +// +// This event describes the mode size. The size is given in physical +// hardware units of the output device. This is not necessarily the same as +// the output size in the global compositor space. For instance, the output +// may be scaled or transformed. +type ZwlrOutputModeV1SizeEvent struct { + Width int32 + Height int32 +} +type ZwlrOutputModeV1SizeHandlerFunc func(ZwlrOutputModeV1SizeEvent) + +// SetSizeHandler : sets handler for ZwlrOutputModeV1SizeEvent +func (i *ZwlrOutputModeV1) SetSizeHandler(f ZwlrOutputModeV1SizeHandlerFunc) { + i.sizeHandler = f +} + +// ZwlrOutputModeV1RefreshEvent : mode refresh rate +// +// This event describes the mode's fixed vertical refresh rate. It is only +// sent if the mode has a fixed refresh rate. +type ZwlrOutputModeV1RefreshEvent struct { + Refresh int32 +} +type ZwlrOutputModeV1RefreshHandlerFunc func(ZwlrOutputModeV1RefreshEvent) + +// SetRefreshHandler : sets handler for ZwlrOutputModeV1RefreshEvent +func (i *ZwlrOutputModeV1) SetRefreshHandler(f ZwlrOutputModeV1RefreshHandlerFunc) { + i.refreshHandler = f +} + +// ZwlrOutputModeV1PreferredEvent : mode is preferred +// +// This event advertises this mode as preferred. +type ZwlrOutputModeV1PreferredEvent struct{} +type ZwlrOutputModeV1PreferredHandlerFunc func(ZwlrOutputModeV1PreferredEvent) + +// SetPreferredHandler : sets handler for ZwlrOutputModeV1PreferredEvent +func (i *ZwlrOutputModeV1) SetPreferredHandler(f ZwlrOutputModeV1PreferredHandlerFunc) { + i.preferredHandler = f +} + +// ZwlrOutputModeV1FinishedEvent : the mode has disappeared +// +// This event indicates that the mode is no longer available. The mode +// object becomes inert. Clients should send a destroy request and release +// any resources associated with it. +type ZwlrOutputModeV1FinishedEvent struct{} +type ZwlrOutputModeV1FinishedHandlerFunc func(ZwlrOutputModeV1FinishedEvent) + +// SetFinishedHandler : sets handler for ZwlrOutputModeV1FinishedEvent +func (i *ZwlrOutputModeV1) SetFinishedHandler(f ZwlrOutputModeV1FinishedHandlerFunc) { + i.finishedHandler = f +} + +func (i *ZwlrOutputModeV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.sizeHandler == nil { + return + } + var e ZwlrOutputModeV1SizeEvent + l := 0 + e.Width = int32(client.Uint32(data[l : l+4])) + l += 4 + e.Height = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.sizeHandler(e) + case 1: + if i.refreshHandler == nil { + return + } + var e ZwlrOutputModeV1RefreshEvent + l := 0 + e.Refresh = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.refreshHandler(e) + case 2: + if i.preferredHandler == nil { + return + } + var e ZwlrOutputModeV1PreferredEvent + + i.preferredHandler(e) + case 3: + if i.finishedHandler == nil { + return + } + var e ZwlrOutputModeV1FinishedEvent + + i.finishedHandler(e) + } +} + +// ZwlrOutputConfigurationV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrOutputConfigurationV1InterfaceName = "zwlr_output_configuration_v1" + +// ZwlrOutputConfigurationV1 : output configuration +// +// This object is used by the client to describe a full output configuration. +// +// First, the client needs to setup the output configuration. Each head can +// be either enabled (and configured) or disabled. It is a protocol error to +// send two enable_head or disable_head requests with the same head. It is a +// protocol error to omit a head in a configuration. +// +// Then, the client can apply or test the configuration. The compositor will +// then reply with a succeeded, failed or cancelled event. Finally the client +// should destroy the configuration object. +type ZwlrOutputConfigurationV1 struct { + client.BaseProxy + succeededHandler ZwlrOutputConfigurationV1SucceededHandlerFunc + failedHandler ZwlrOutputConfigurationV1FailedHandlerFunc + cancelledHandler ZwlrOutputConfigurationV1CancelledHandlerFunc +} + +// NewZwlrOutputConfigurationV1 : output configuration +// +// This object is used by the client to describe a full output configuration. +// +// First, the client needs to setup the output configuration. Each head can +// be either enabled (and configured) or disabled. It is a protocol error to +// send two enable_head or disable_head requests with the same head. It is a +// protocol error to omit a head in a configuration. +// +// Then, the client can apply or test the configuration. The compositor will +// then reply with a succeeded, failed or cancelled event. Finally the client +// should destroy the configuration object. +func NewZwlrOutputConfigurationV1(ctx *client.Context) *ZwlrOutputConfigurationV1 { + zwlrOutputConfigurationV1 := &ZwlrOutputConfigurationV1{} + ctx.Register(zwlrOutputConfigurationV1) + return zwlrOutputConfigurationV1 +} + +// EnableHead : enable and configure a head +// +// Enable a head. This request creates a head configuration object that can +// be used to change the head's properties. +// +// head: the head to be enabled +func (i *ZwlrOutputConfigurationV1) EnableHead(head *ZwlrOutputHeadV1) (*ZwlrOutputConfigurationHeadV1, error) { + id := NewZwlrOutputConfigurationHeadV1(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], head.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// DisableHead : disable a head +// +// Disable a head. +// +// head: the head to be disabled +func (i *ZwlrOutputConfigurationV1) DisableHead(head *ZwlrOutputHeadV1) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], head.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Apply : apply the configuration +// +// Apply the new output configuration. +// +// In case the configuration is successfully applied, there is no guarantee +// that the new output state matches completely the requested +// configuration. For instance, a compositor might round the scale if it +// doesn't support fractional scaling. +// +// After this request has been sent, the compositor must respond with an +// succeeded, failed or cancelled event. Sending a request that isn't the +// destructor is a protocol error. +func (i *ZwlrOutputConfigurationV1) Apply() error { + const opcode = 2 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Test : test the configuration +// +// Test the new output configuration. The configuration won't be applied, +// but will only be validated. +// +// Even if the compositor succeeds to test a configuration, applying it may +// fail. +// +// After this request has been sent, the compositor must respond with an +// succeeded, failed or cancelled event. Sending a request that isn't the +// destructor is a protocol error. +func (i *ZwlrOutputConfigurationV1) Test() error { + const opcode = 3 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Destroy : destroy the output configuration +// +// Using this request a client can tell the compositor that it is not going +// to use the configuration object anymore. Any changes to the outputs +// that have not been applied will be discarded. +// +// This request also destroys wlr_output_configuration_head objects created +// via this object. +func (i *ZwlrOutputConfigurationV1) Destroy() error { + defer i.MarkZombie() + const opcode = 4 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ZwlrOutputConfigurationV1Error uint32 + +// ZwlrOutputConfigurationV1Error : +const ( + // ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead : head has been configured twice + ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead ZwlrOutputConfigurationV1Error = 1 + // ZwlrOutputConfigurationV1ErrorUnconfiguredHead : head has not been configured + ZwlrOutputConfigurationV1ErrorUnconfiguredHead ZwlrOutputConfigurationV1Error = 2 + // ZwlrOutputConfigurationV1ErrorAlreadyUsed : request sent after configuration has been applied or tested + ZwlrOutputConfigurationV1ErrorAlreadyUsed ZwlrOutputConfigurationV1Error = 3 +) + +func (e ZwlrOutputConfigurationV1Error) Name() string { + switch e { + case ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead: + return "already_configured_head" + case ZwlrOutputConfigurationV1ErrorUnconfiguredHead: + return "unconfigured_head" + case ZwlrOutputConfigurationV1ErrorAlreadyUsed: + return "already_used" + default: + return "" + } +} + +func (e ZwlrOutputConfigurationV1Error) Value() string { + switch e { + case ZwlrOutputConfigurationV1ErrorAlreadyConfiguredHead: + return "1" + case ZwlrOutputConfigurationV1ErrorUnconfiguredHead: + return "2" + case ZwlrOutputConfigurationV1ErrorAlreadyUsed: + return "3" + default: + return "" + } +} + +func (e ZwlrOutputConfigurationV1Error) String() string { + return e.Name() + "=" + e.Value() +} + +// ZwlrOutputConfigurationV1SucceededEvent : configuration changes succeeded +// +// Sent after the compositor has successfully applied the changes or +// tested them. +// +// Upon receiving this event, the client should destroy this object. +// +// If the current configuration has changed, events to describe the changes +// will be sent followed by a wlr_output_manager.done event. +type ZwlrOutputConfigurationV1SucceededEvent struct{} +type ZwlrOutputConfigurationV1SucceededHandlerFunc func(ZwlrOutputConfigurationV1SucceededEvent) + +// SetSucceededHandler : sets handler for ZwlrOutputConfigurationV1SucceededEvent +func (i *ZwlrOutputConfigurationV1) SetSucceededHandler(f ZwlrOutputConfigurationV1SucceededHandlerFunc) { + i.succeededHandler = f +} + +// ZwlrOutputConfigurationV1FailedEvent : configuration changes failed +// +// Sent if the compositor rejects the changes or failed to apply them. The +// compositor should revert any changes made by the apply request that +// triggered this event. +// +// Upon receiving this event, the client should destroy this object. +type ZwlrOutputConfigurationV1FailedEvent struct{} +type ZwlrOutputConfigurationV1FailedHandlerFunc func(ZwlrOutputConfigurationV1FailedEvent) + +// SetFailedHandler : sets handler for ZwlrOutputConfigurationV1FailedEvent +func (i *ZwlrOutputConfigurationV1) SetFailedHandler(f ZwlrOutputConfigurationV1FailedHandlerFunc) { + i.failedHandler = f +} + +// ZwlrOutputConfigurationV1CancelledEvent : configuration has been cancelled +// +// Sent if the compositor cancels the configuration because the state of an +// output changed and the client has outdated information (e.g. after an +// output has been hotplugged). +// +// The client can create a new configuration with a newer serial and try +// again. +// +// Upon receiving this event, the client should destroy this object. +type ZwlrOutputConfigurationV1CancelledEvent struct{} +type ZwlrOutputConfigurationV1CancelledHandlerFunc func(ZwlrOutputConfigurationV1CancelledEvent) + +// SetCancelledHandler : sets handler for ZwlrOutputConfigurationV1CancelledEvent +func (i *ZwlrOutputConfigurationV1) SetCancelledHandler(f ZwlrOutputConfigurationV1CancelledHandlerFunc) { + i.cancelledHandler = f +} + +func (i *ZwlrOutputConfigurationV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.succeededHandler == nil { + return + } + var e ZwlrOutputConfigurationV1SucceededEvent + + i.succeededHandler(e) + case 1: + if i.failedHandler == nil { + return + } + var e ZwlrOutputConfigurationV1FailedEvent + + i.failedHandler(e) + case 2: + if i.cancelledHandler == nil { + return + } + var e ZwlrOutputConfigurationV1CancelledEvent + + i.cancelledHandler(e) + } +} + +// ZwlrOutputConfigurationHeadV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrOutputConfigurationHeadV1InterfaceName = "zwlr_output_configuration_head_v1" + +// ZwlrOutputConfigurationHeadV1 : head configuration +// +// This object is used by the client to update a single head's configuration. +// +// It is a protocol error to set the same property twice. +type ZwlrOutputConfigurationHeadV1 struct { + client.BaseProxy +} + +// NewZwlrOutputConfigurationHeadV1 : head configuration +// +// This object is used by the client to update a single head's configuration. +// +// It is a protocol error to set the same property twice. +func NewZwlrOutputConfigurationHeadV1(ctx *client.Context) *ZwlrOutputConfigurationHeadV1 { + zwlrOutputConfigurationHeadV1 := &ZwlrOutputConfigurationHeadV1{} + ctx.Register(zwlrOutputConfigurationHeadV1) + return zwlrOutputConfigurationHeadV1 +} + +// SetMode : set the mode +// +// This request sets the head's mode. +func (i *ZwlrOutputConfigurationHeadV1) SetMode(mode *ZwlrOutputModeV1) error { + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], mode.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetCustomMode : set a custom mode +// +// This request assigns a custom mode to the head. The size is given in +// physical hardware units of the output device. If set to zero, the +// refresh rate is unspecified. +// +// It is a protocol error to set both a mode and a custom mode. +// +// width: width of the mode in hardware units +// height: height of the mode in hardware units +// refresh: vertical refresh rate in mHz or zero +func (i *ZwlrOutputConfigurationHeadV1) SetCustomMode(width, height, refresh int32) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(refresh)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetPosition : set the position +// +// This request sets the head's position in the global compositor space. +// +// x: x position in the global compositor space +// y: y position in the global compositor space +func (i *ZwlrOutputConfigurationHeadV1) SetPosition(x, y int32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetTransform : set the transform +// +// This request sets the head's transform. +func (i *ZwlrOutputConfigurationHeadV1) SetTransform(transform int32) error { + const opcode = 3 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(transform)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetScale : set the scale +// +// This request sets the head's scale. +func (i *ZwlrOutputConfigurationHeadV1) SetScale(scale float64) error { + const opcode = 4 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutFixed(_reqBuf[l:l+4], scale) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetAdaptiveSync : enable/disable adaptive sync +// +// This request enables/disables adaptive sync. Adaptive sync is also +// known as Variable Refresh Rate or VRR. +func (i *ZwlrOutputConfigurationHeadV1) SetAdaptiveSync(state uint32) error { + const opcode = 5 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(state)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +func (i *ZwlrOutputConfigurationHeadV1) Destroy() error { + i.MarkZombie() + return nil +} + +type ZwlrOutputConfigurationHeadV1Error uint32 + +// ZwlrOutputConfigurationHeadV1Error : +const ( + // ZwlrOutputConfigurationHeadV1ErrorAlreadySet : property has already been set + ZwlrOutputConfigurationHeadV1ErrorAlreadySet ZwlrOutputConfigurationHeadV1Error = 1 + // ZwlrOutputConfigurationHeadV1ErrorInvalidMode : mode doesn't belong to head + ZwlrOutputConfigurationHeadV1ErrorInvalidMode ZwlrOutputConfigurationHeadV1Error = 2 + // ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode : mode is invalid + ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode ZwlrOutputConfigurationHeadV1Error = 3 + // ZwlrOutputConfigurationHeadV1ErrorInvalidTransform : transform value outside enum + ZwlrOutputConfigurationHeadV1ErrorInvalidTransform ZwlrOutputConfigurationHeadV1Error = 4 + // ZwlrOutputConfigurationHeadV1ErrorInvalidScale : scale negative or zero + ZwlrOutputConfigurationHeadV1ErrorInvalidScale ZwlrOutputConfigurationHeadV1Error = 5 + // ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState : invalid enum value used in the set_adaptive_sync request + ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState ZwlrOutputConfigurationHeadV1Error = 6 +) + +func (e ZwlrOutputConfigurationHeadV1Error) Name() string { + switch e { + case ZwlrOutputConfigurationHeadV1ErrorAlreadySet: + return "already_set" + case ZwlrOutputConfigurationHeadV1ErrorInvalidMode: + return "invalid_mode" + case ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode: + return "invalid_custom_mode" + case ZwlrOutputConfigurationHeadV1ErrorInvalidTransform: + return "invalid_transform" + case ZwlrOutputConfigurationHeadV1ErrorInvalidScale: + return "invalid_scale" + case ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState: + return "invalid_adaptive_sync_state" + default: + return "" + } +} + +func (e ZwlrOutputConfigurationHeadV1Error) Value() string { + switch e { + case ZwlrOutputConfigurationHeadV1ErrorAlreadySet: + return "1" + case ZwlrOutputConfigurationHeadV1ErrorInvalidMode: + return "2" + case ZwlrOutputConfigurationHeadV1ErrorInvalidCustomMode: + return "3" + case ZwlrOutputConfigurationHeadV1ErrorInvalidTransform: + return "4" + case ZwlrOutputConfigurationHeadV1ErrorInvalidScale: + return "5" + case ZwlrOutputConfigurationHeadV1ErrorInvalidAdaptiveSyncState: + return "6" + default: + return "" + } +} + +func (e ZwlrOutputConfigurationHeadV1Error) String() string { + return e.Name() + "=" + e.Value() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_output_power/output_power.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_output_power/output_power.go new file mode 100644 index 0000000..8a10088 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_output_power/output_power.go @@ -0,0 +1,283 @@ +// Generated by go-wayland-scanner +// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner +// XML file : internal/proto/xml/wlr-output-power-management-unstable-v1.xml +// +// wlr_output_power_management_unstable_v1 Protocol Copyright: +// +// Copyright © 2019 Purism SPC +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice (including the next +// paragraph) shall be included in all copies or substantial portions of the +// Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package wlr_output_power + +import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + +// ZwlrOutputPowerManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrOutputPowerManagerV1InterfaceName = "zwlr_output_power_manager_v1" + +// ZwlrOutputPowerManagerV1 : manager to create per-output power management +// +// This interface is a manager that allows creating per-output power +// management mode controls. +type ZwlrOutputPowerManagerV1 struct { + client.BaseProxy +} + +// NewZwlrOutputPowerManagerV1 : manager to create per-output power management +// +// This interface is a manager that allows creating per-output power +// management mode controls. +func NewZwlrOutputPowerManagerV1(ctx *client.Context) *ZwlrOutputPowerManagerV1 { + zwlrOutputPowerManagerV1 := &ZwlrOutputPowerManagerV1{} + ctx.Register(zwlrOutputPowerManagerV1) + return zwlrOutputPowerManagerV1 +} + +// GetOutputPower : get a power management for an output +// +// Create an output power management mode control that can be used to +// adjust the power management mode for a given output. +func (i *ZwlrOutputPowerManagerV1) GetOutputPower(output *client.Output) (*ZwlrOutputPowerV1, error) { + id := NewZwlrOutputPowerV1(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], output.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// Destroy : destroy the manager +// +// All objects created by the manager will still remain valid, until their +// appropriate destroy request has been called. +func (i *ZwlrOutputPowerManagerV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// ZwlrOutputPowerV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrOutputPowerV1InterfaceName = "zwlr_output_power_v1" + +// ZwlrOutputPowerV1 : adjust power management mode for an output +// +// This object offers requests to set the power management mode of +// an output. +type ZwlrOutputPowerV1 struct { + client.BaseProxy + modeHandler ZwlrOutputPowerV1ModeHandlerFunc + failedHandler ZwlrOutputPowerV1FailedHandlerFunc +} + +// NewZwlrOutputPowerV1 : adjust power management mode for an output +// +// This object offers requests to set the power management mode of +// an output. +func NewZwlrOutputPowerV1(ctx *client.Context) *ZwlrOutputPowerV1 { + zwlrOutputPowerV1 := &ZwlrOutputPowerV1{} + ctx.Register(zwlrOutputPowerV1) + return zwlrOutputPowerV1 +} + +// SetMode : Set an outputs power save mode +// +// Set an output's power save mode to the given mode. The mode change +// is effective immediately. If the output does not support the given +// mode a failed event is sent. +// +// mode: the power save mode to set +func (i *ZwlrOutputPowerV1) SetMode(mode uint32) error { + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(mode)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Destroy : destroy this power management +// +// Destroys the output power management mode control object. +func (i *ZwlrOutputPowerV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ZwlrOutputPowerV1Mode uint32 + +// ZwlrOutputPowerV1Mode : +const ( + // ZwlrOutputPowerV1ModeOff : Output is turned off. + ZwlrOutputPowerV1ModeOff ZwlrOutputPowerV1Mode = 0 + // ZwlrOutputPowerV1ModeOn : Output is turned on, no power saving + ZwlrOutputPowerV1ModeOn ZwlrOutputPowerV1Mode = 1 +) + +func (e ZwlrOutputPowerV1Mode) Name() string { + switch e { + case ZwlrOutputPowerV1ModeOff: + return "off" + case ZwlrOutputPowerV1ModeOn: + return "on" + default: + return "" + } +} + +func (e ZwlrOutputPowerV1Mode) Value() string { + switch e { + case ZwlrOutputPowerV1ModeOff: + return "0" + case ZwlrOutputPowerV1ModeOn: + return "1" + default: + return "" + } +} + +func (e ZwlrOutputPowerV1Mode) String() string { + return e.Name() + "=" + e.Value() +} + +type ZwlrOutputPowerV1Error uint32 + +// ZwlrOutputPowerV1Error : +const ( + // ZwlrOutputPowerV1ErrorInvalidMode : nonexistent power save mode + ZwlrOutputPowerV1ErrorInvalidMode ZwlrOutputPowerV1Error = 1 +) + +func (e ZwlrOutputPowerV1Error) Name() string { + switch e { + case ZwlrOutputPowerV1ErrorInvalidMode: + return "invalid_mode" + default: + return "" + } +} + +func (e ZwlrOutputPowerV1Error) Value() string { + switch e { + case ZwlrOutputPowerV1ErrorInvalidMode: + return "1" + default: + return "" + } +} + +func (e ZwlrOutputPowerV1Error) String() string { + return e.Name() + "=" + e.Value() +} + +// ZwlrOutputPowerV1ModeEvent : Report a power management mode change +// +// Report the power management mode change of an output. +// +// The mode event is sent after an output changed its power +// management mode. The reason can be a client using set_mode or the +// compositor deciding to change an output's mode. +// This event is also sent immediately when the object is created +// so the client is informed about the current power management mode. +type ZwlrOutputPowerV1ModeEvent struct { + Mode uint32 +} +type ZwlrOutputPowerV1ModeHandlerFunc func(ZwlrOutputPowerV1ModeEvent) + +// SetModeHandler : sets handler for ZwlrOutputPowerV1ModeEvent +func (i *ZwlrOutputPowerV1) SetModeHandler(f ZwlrOutputPowerV1ModeHandlerFunc) { + i.modeHandler = f +} + +// ZwlrOutputPowerV1FailedEvent : object no longer valid +// +// This event indicates that the output power management mode control +// is no longer valid. This can happen for a number of reasons, +// including: +// - The output doesn't support power management +// - Another client already has exclusive power management mode control +// for this output +// - The output disappeared +// +// Upon receiving this event, the client should destroy this object. +type ZwlrOutputPowerV1FailedEvent struct{} +type ZwlrOutputPowerV1FailedHandlerFunc func(ZwlrOutputPowerV1FailedEvent) + +// SetFailedHandler : sets handler for ZwlrOutputPowerV1FailedEvent +func (i *ZwlrOutputPowerV1) SetFailedHandler(f ZwlrOutputPowerV1FailedHandlerFunc) { + i.failedHandler = f +} + +func (i *ZwlrOutputPowerV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.modeHandler == nil { + return + } + var e ZwlrOutputPowerV1ModeEvent + l := 0 + e.Mode = client.Uint32(data[l : l+4]) + l += 4 + + i.modeHandler(e) + case 1: + if i.failedHandler == nil { + return + } + var e ZwlrOutputPowerV1FailedEvent + + i.failedHandler(e) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_screencopy/screencopy.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_screencopy/screencopy.go new file mode 100644 index 0000000..edb9c5b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wlr_screencopy/screencopy.go @@ -0,0 +1,532 @@ +// Generated by go-wayland-scanner +// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner +// XML file : internal/proto/xml/wlr-screencopy-unstable-v1.xml +// +// wlr_screencopy_unstable_v1 Protocol Copyright: +// +// Copyright © 2018 Simon Ser +// Copyright © 2019 Andri Yngvason +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice (including the next +// paragraph) shall be included in all copies or substantial portions of the +// Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package wlr_screencopy + +import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + +// ZwlrScreencopyManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrScreencopyManagerV1InterfaceName = "zwlr_screencopy_manager_v1" + +// ZwlrScreencopyManagerV1 : manager to inform clients and begin capturing +// +// This object is a manager which offers requests to start capturing from a +// source. +type ZwlrScreencopyManagerV1 struct { + client.BaseProxy +} + +// NewZwlrScreencopyManagerV1 : manager to inform clients and begin capturing +// +// This object is a manager which offers requests to start capturing from a +// source. +func NewZwlrScreencopyManagerV1(ctx *client.Context) *ZwlrScreencopyManagerV1 { + zwlrScreencopyManagerV1 := &ZwlrScreencopyManagerV1{} + ctx.Register(zwlrScreencopyManagerV1) + return zwlrScreencopyManagerV1 +} + +// CaptureOutput : capture an output +// +// Capture the next frame of an entire output. +// +// overlayCursor: composite cursor onto the frame +func (i *ZwlrScreencopyManagerV1) CaptureOutput(overlayCursor int32, output *client.Output) (*ZwlrScreencopyFrameV1, error) { + frame := NewZwlrScreencopyFrameV1(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], frame.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(overlayCursor)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], output.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return frame, err +} + +// CaptureOutputRegion : capture an output's region +// +// Capture the next frame of an output's region. +// +// The region is given in output logical coordinates, see +// xdg_output.logical_size. The region will be clipped to the output's +// extents. +// +// overlayCursor: composite cursor onto the frame +func (i *ZwlrScreencopyManagerV1) CaptureOutputRegion(overlayCursor int32, output *client.Output, x, y, width, height int32) (*ZwlrScreencopyFrameV1, error) { + frame := NewZwlrScreencopyFrameV1(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], frame.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(overlayCursor)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], output.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return frame, err +} + +// Destroy : destroy the manager +// +// All objects created by the manager will still remain valid, until their +// appropriate destroy request has been called. +func (i *ZwlrScreencopyManagerV1) Destroy() error { + defer i.MarkZombie() + const opcode = 2 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// ZwlrScreencopyFrameV1InterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ZwlrScreencopyFrameV1InterfaceName = "zwlr_screencopy_frame_v1" + +// ZwlrScreencopyFrameV1 : a frame ready for copy +// +// This object represents a single frame. +// +// When created, a series of buffer events will be sent, each representing a +// supported buffer type. The "buffer_done" event is sent afterwards to +// indicate that all supported buffer types have been enumerated. The client +// will then be able to send a "copy" request. If the capture is successful, +// the compositor will send a "flags" event followed by a "ready" event. +// +// For objects version 2 or lower, wl_shm buffers are always supported, ie. +// the "buffer" event is guaranteed to be sent. +// +// If the capture failed, the "failed" event is sent. This can happen anytime +// before the "ready" event. +// +// Once either a "ready" or a "failed" event is received, the client should +// destroy the frame. +type ZwlrScreencopyFrameV1 struct { + client.BaseProxy + bufferHandler ZwlrScreencopyFrameV1BufferHandlerFunc + flagsHandler ZwlrScreencopyFrameV1FlagsHandlerFunc + readyHandler ZwlrScreencopyFrameV1ReadyHandlerFunc + failedHandler ZwlrScreencopyFrameV1FailedHandlerFunc + damageHandler ZwlrScreencopyFrameV1DamageHandlerFunc + linuxDmabufHandler ZwlrScreencopyFrameV1LinuxDmabufHandlerFunc + bufferDoneHandler ZwlrScreencopyFrameV1BufferDoneHandlerFunc +} + +// NewZwlrScreencopyFrameV1 : a frame ready for copy +// +// This object represents a single frame. +// +// When created, a series of buffer events will be sent, each representing a +// supported buffer type. The "buffer_done" event is sent afterwards to +// indicate that all supported buffer types have been enumerated. The client +// will then be able to send a "copy" request. If the capture is successful, +// the compositor will send a "flags" event followed by a "ready" event. +// +// For objects version 2 or lower, wl_shm buffers are always supported, ie. +// the "buffer" event is guaranteed to be sent. +// +// If the capture failed, the "failed" event is sent. This can happen anytime +// before the "ready" event. +// +// Once either a "ready" or a "failed" event is received, the client should +// destroy the frame. +func NewZwlrScreencopyFrameV1(ctx *client.Context) *ZwlrScreencopyFrameV1 { + zwlrScreencopyFrameV1 := &ZwlrScreencopyFrameV1{} + ctx.Register(zwlrScreencopyFrameV1) + return zwlrScreencopyFrameV1 +} + +// Copy : copy the frame +// +// Copy the frame to the supplied buffer. The buffer must have the +// correct size, see zwlr_screencopy_frame_v1.buffer and +// zwlr_screencopy_frame_v1.linux_dmabuf. The buffer needs to have a +// supported format. +// +// If the frame is successfully copied, "flags" and "ready" events are +// sent. Otherwise, a "failed" event is sent. +func (i *ZwlrScreencopyFrameV1) Copy(buffer *client.Buffer) error { + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], buffer.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Destroy : delete this object, used or not +// +// Destroys the frame. This request can be sent at any time by the client. +func (i *ZwlrScreencopyFrameV1) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// CopyWithDamage : copy the frame when it's damaged +// +// Same as copy, except it waits until there is damage to copy. +func (i *ZwlrScreencopyFrameV1) CopyWithDamage(buffer *client.Buffer) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], buffer.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ZwlrScreencopyFrameV1Error uint32 + +// ZwlrScreencopyFrameV1Error : +const ( + // ZwlrScreencopyFrameV1ErrorAlreadyUsed : the object has already been used to copy a wl_buffer + ZwlrScreencopyFrameV1ErrorAlreadyUsed ZwlrScreencopyFrameV1Error = 0 + // ZwlrScreencopyFrameV1ErrorInvalidBuffer : buffer attributes are invalid + ZwlrScreencopyFrameV1ErrorInvalidBuffer ZwlrScreencopyFrameV1Error = 1 +) + +func (e ZwlrScreencopyFrameV1Error) Name() string { + switch e { + case ZwlrScreencopyFrameV1ErrorAlreadyUsed: + return "already_used" + case ZwlrScreencopyFrameV1ErrorInvalidBuffer: + return "invalid_buffer" + default: + return "" + } +} + +func (e ZwlrScreencopyFrameV1Error) Value() string { + switch e { + case ZwlrScreencopyFrameV1ErrorAlreadyUsed: + return "0" + case ZwlrScreencopyFrameV1ErrorInvalidBuffer: + return "1" + default: + return "" + } +} + +func (e ZwlrScreencopyFrameV1Error) String() string { + return e.Name() + "=" + e.Value() +} + +type ZwlrScreencopyFrameV1Flags uint32 + +// ZwlrScreencopyFrameV1Flags : +const ( + // ZwlrScreencopyFrameV1FlagsYInvert : contents are y-inverted + ZwlrScreencopyFrameV1FlagsYInvert ZwlrScreencopyFrameV1Flags = 1 +) + +func (e ZwlrScreencopyFrameV1Flags) Name() string { + switch e { + case ZwlrScreencopyFrameV1FlagsYInvert: + return "y_invert" + default: + return "" + } +} + +func (e ZwlrScreencopyFrameV1Flags) Value() string { + switch e { + case ZwlrScreencopyFrameV1FlagsYInvert: + return "1" + default: + return "" + } +} + +func (e ZwlrScreencopyFrameV1Flags) String() string { + return e.Name() + "=" + e.Value() +} + +// ZwlrScreencopyFrameV1BufferEvent : wl_shm buffer information +// +// Provides information about wl_shm buffer parameters that need to be +// used for this frame. This event is sent once after the frame is created +// if wl_shm buffers are supported. +type ZwlrScreencopyFrameV1BufferEvent struct { + Format uint32 + Width uint32 + Height uint32 + Stride uint32 +} +type ZwlrScreencopyFrameV1BufferHandlerFunc func(ZwlrScreencopyFrameV1BufferEvent) + +// SetBufferHandler : sets handler for ZwlrScreencopyFrameV1BufferEvent +func (i *ZwlrScreencopyFrameV1) SetBufferHandler(f ZwlrScreencopyFrameV1BufferHandlerFunc) { + i.bufferHandler = f +} + +// ZwlrScreencopyFrameV1FlagsEvent : frame flags +// +// Provides flags about the frame. This event is sent once before the +// "ready" event. +type ZwlrScreencopyFrameV1FlagsEvent struct { + Flags uint32 +} +type ZwlrScreencopyFrameV1FlagsHandlerFunc func(ZwlrScreencopyFrameV1FlagsEvent) + +// SetFlagsHandler : sets handler for ZwlrScreencopyFrameV1FlagsEvent +func (i *ZwlrScreencopyFrameV1) SetFlagsHandler(f ZwlrScreencopyFrameV1FlagsHandlerFunc) { + i.flagsHandler = f +} + +// ZwlrScreencopyFrameV1ReadyEvent : indicates frame is available for reading +// +// Called as soon as the frame is copied, indicating it is available +// for reading. This event includes the time at which the presentation took place. +// +// The timestamp is expressed as tv_sec_hi, tv_sec_lo, tv_nsec triples, +// each component being an unsigned 32-bit value. Whole seconds are in +// tv_sec which is a 64-bit value combined from tv_sec_hi and tv_sec_lo, +// and the additional fractional part in tv_nsec as nanoseconds. Hence, +// for valid timestamps tv_nsec must be in [0, 999999999]. The seconds part +// may have an arbitrary offset at start. +// +// After receiving this event, the client should destroy the object. +type ZwlrScreencopyFrameV1ReadyEvent struct { + TvSecHi uint32 + TvSecLo uint32 + TvNsec uint32 +} +type ZwlrScreencopyFrameV1ReadyHandlerFunc func(ZwlrScreencopyFrameV1ReadyEvent) + +// SetReadyHandler : sets handler for ZwlrScreencopyFrameV1ReadyEvent +func (i *ZwlrScreencopyFrameV1) SetReadyHandler(f ZwlrScreencopyFrameV1ReadyHandlerFunc) { + i.readyHandler = f +} + +// ZwlrScreencopyFrameV1FailedEvent : frame copy failed +// +// This event indicates that the attempted frame copy has failed. +// +// After receiving this event, the client should destroy the object. +type ZwlrScreencopyFrameV1FailedEvent struct{} +type ZwlrScreencopyFrameV1FailedHandlerFunc func(ZwlrScreencopyFrameV1FailedEvent) + +// SetFailedHandler : sets handler for ZwlrScreencopyFrameV1FailedEvent +func (i *ZwlrScreencopyFrameV1) SetFailedHandler(f ZwlrScreencopyFrameV1FailedHandlerFunc) { + i.failedHandler = f +} + +// ZwlrScreencopyFrameV1DamageEvent : carries the coordinates of the damaged region +// +// This event is sent right before the ready event when copy_with_damage is +// requested. It may be generated multiple times for each copy_with_damage +// request. +// +// The arguments describe a box around an area that has changed since the +// last copy request that was derived from the current screencopy manager +// instance. +// +// The union of all regions received between the call to copy_with_damage +// and a ready event is the total damage since the prior ready event. +type ZwlrScreencopyFrameV1DamageEvent struct { + X uint32 + Y uint32 + Width uint32 + Height uint32 +} +type ZwlrScreencopyFrameV1DamageHandlerFunc func(ZwlrScreencopyFrameV1DamageEvent) + +// SetDamageHandler : sets handler for ZwlrScreencopyFrameV1DamageEvent +func (i *ZwlrScreencopyFrameV1) SetDamageHandler(f ZwlrScreencopyFrameV1DamageHandlerFunc) { + i.damageHandler = f +} + +// ZwlrScreencopyFrameV1LinuxDmabufEvent : linux-dmabuf buffer information +// +// Provides information about linux-dmabuf buffer parameters that need to +// be used for this frame. This event is sent once after the frame is +// created if linux-dmabuf buffers are supported. +type ZwlrScreencopyFrameV1LinuxDmabufEvent struct { + Format uint32 + Width uint32 + Height uint32 +} +type ZwlrScreencopyFrameV1LinuxDmabufHandlerFunc func(ZwlrScreencopyFrameV1LinuxDmabufEvent) + +// SetLinuxDmabufHandler : sets handler for ZwlrScreencopyFrameV1LinuxDmabufEvent +func (i *ZwlrScreencopyFrameV1) SetLinuxDmabufHandler(f ZwlrScreencopyFrameV1LinuxDmabufHandlerFunc) { + i.linuxDmabufHandler = f +} + +// ZwlrScreencopyFrameV1BufferDoneEvent : all buffer types reported +// +// This event is sent once after all buffer events have been sent. +// +// The client should proceed to create a buffer of one of the supported +// types, and send a "copy" request. +type ZwlrScreencopyFrameV1BufferDoneEvent struct{} +type ZwlrScreencopyFrameV1BufferDoneHandlerFunc func(ZwlrScreencopyFrameV1BufferDoneEvent) + +// SetBufferDoneHandler : sets handler for ZwlrScreencopyFrameV1BufferDoneEvent +func (i *ZwlrScreencopyFrameV1) SetBufferDoneHandler(f ZwlrScreencopyFrameV1BufferDoneHandlerFunc) { + i.bufferDoneHandler = f +} + +func (i *ZwlrScreencopyFrameV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.bufferHandler == nil { + return + } + var e ZwlrScreencopyFrameV1BufferEvent + l := 0 + e.Format = client.Uint32(data[l : l+4]) + l += 4 + e.Width = client.Uint32(data[l : l+4]) + l += 4 + e.Height = client.Uint32(data[l : l+4]) + l += 4 + e.Stride = client.Uint32(data[l : l+4]) + l += 4 + + i.bufferHandler(e) + case 1: + if i.flagsHandler == nil { + return + } + var e ZwlrScreencopyFrameV1FlagsEvent + l := 0 + e.Flags = client.Uint32(data[l : l+4]) + l += 4 + + i.flagsHandler(e) + case 2: + if i.readyHandler == nil { + return + } + var e ZwlrScreencopyFrameV1ReadyEvent + l := 0 + e.TvSecHi = client.Uint32(data[l : l+4]) + l += 4 + e.TvSecLo = client.Uint32(data[l : l+4]) + l += 4 + e.TvNsec = client.Uint32(data[l : l+4]) + l += 4 + + i.readyHandler(e) + case 3: + if i.failedHandler == nil { + return + } + var e ZwlrScreencopyFrameV1FailedEvent + + i.failedHandler(e) + case 4: + if i.damageHandler == nil { + return + } + var e ZwlrScreencopyFrameV1DamageEvent + l := 0 + e.X = client.Uint32(data[l : l+4]) + l += 4 + e.Y = client.Uint32(data[l : l+4]) + l += 4 + e.Width = client.Uint32(data[l : l+4]) + l += 4 + e.Height = client.Uint32(data[l : l+4]) + l += 4 + + i.damageHandler(e) + case 5: + if i.linuxDmabufHandler == nil { + return + } + var e ZwlrScreencopyFrameV1LinuxDmabufEvent + l := 0 + e.Format = client.Uint32(data[l : l+4]) + l += 4 + e.Width = client.Uint32(data[l : l+4]) + l += 4 + e.Height = client.Uint32(data[l : l+4]) + l += 4 + + i.linuxDmabufHandler(e) + case 6: + if i.bufferDoneHandler == nil { + return + } + var e ZwlrScreencopyFrameV1BufferDoneEvent + + i.bufferDoneHandler(e) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wp_viewporter/viewporter.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wp_viewporter/viewporter.go new file mode 100644 index 0000000..4b390bf --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/wp_viewporter/viewporter.go @@ -0,0 +1,399 @@ +// Generated by go-wayland-scanner +// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner +// XML file : /tmp/viewporter.xml +// +// viewporter Protocol Copyright: +// +// Copyright © 2013-2016 Collabora, Ltd. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice (including the next +// paragraph) shall be included in all copies or substantial portions of the +// Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package wp_viewporter + +import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + +// WpViewporterInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const WpViewporterInterfaceName = "wp_viewporter" + +// WpViewporter : surface cropping and scaling +// +// The global interface exposing surface cropping and scaling +// capabilities is used to instantiate an interface extension for a +// wl_surface object. This extended interface will then allow +// cropping and scaling the surface contents, effectively +// disconnecting the direct relationship between the buffer and the +// surface size. +type WpViewporter struct { + client.BaseProxy +} + +// NewWpViewporter : surface cropping and scaling +// +// The global interface exposing surface cropping and scaling +// capabilities is used to instantiate an interface extension for a +// wl_surface object. This extended interface will then allow +// cropping and scaling the surface contents, effectively +// disconnecting the direct relationship between the buffer and the +// surface size. +func NewWpViewporter(ctx *client.Context) *WpViewporter { + wpViewporter := &WpViewporter{} + ctx.Register(wpViewporter) + return wpViewporter +} + +// Destroy : unbind from the cropping and scaling interface +// +// Informs the server that the client will not be using this +// protocol object anymore. This does not affect any other objects, +// wp_viewport objects included. +func (i *WpViewporter) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// GetViewport : extend surface interface for crop and scale +// +// Instantiate an interface extension for the given wl_surface to +// crop and scale its content. If the given wl_surface already has +// a wp_viewport object associated, the viewport_exists +// protocol error is raised. +// +// surface: the surface +func (i *WpViewporter) GetViewport(surface *client.Surface) (*WpViewport, error) { + id := NewWpViewport(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], surface.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +type WpViewporterError uint32 + +// WpViewporterError : +const ( + // WpViewporterErrorViewportExists : the surface already has a viewport object associated + WpViewporterErrorViewportExists WpViewporterError = 0 +) + +func (e WpViewporterError) Name() string { + switch e { + case WpViewporterErrorViewportExists: + return "viewport_exists" + default: + return "" + } +} + +func (e WpViewporterError) Value() string { + switch e { + case WpViewporterErrorViewportExists: + return "0" + default: + return "" + } +} + +func (e WpViewporterError) String() string { + return e.Name() + "=" + e.Value() +} + +// WpViewportInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const WpViewportInterfaceName = "wp_viewport" + +// WpViewport : crop and scale interface to a wl_surface +// +// An additional interface to a wl_surface object, which allows the +// client to specify the cropping and scaling of the surface +// contents. +// +// This interface works with two concepts: the source rectangle (src_x, +// src_y, src_width, src_height), and the destination size (dst_width, +// dst_height). The contents of the source rectangle are scaled to the +// destination size, and content outside the source rectangle is ignored. +// This state is double-buffered, see wl_surface.commit. +// +// The two parts of crop and scale state are independent: the source +// rectangle, and the destination size. Initially both are unset, that +// is, no scaling is applied. The whole of the current wl_buffer is +// used as the source, and the surface size is as defined in +// wl_surface.attach. +// +// If the destination size is set, it causes the surface size to become +// dst_width, dst_height. The source (rectangle) is scaled to exactly +// this size. This overrides whatever the attached wl_buffer size is, +// unless the wl_buffer is NULL. If the wl_buffer is NULL, the surface +// has no content and therefore no size. Otherwise, the size is always +// at least 1x1 in surface local coordinates. +// +// If the source rectangle is set, it defines what area of the wl_buffer is +// taken as the source. If the source rectangle is set and the destination +// size is not set, then src_width and src_height must be integers, and the +// surface size becomes the source rectangle size. This results in cropping +// without scaling. If src_width or src_height are not integers and +// destination size is not set, the bad_size protocol error is raised when +// the surface state is applied. +// +// The coordinate transformations from buffer pixel coordinates up to +// the surface-local coordinates happen in the following order: +// 1. buffer_transform (wl_surface.set_buffer_transform) +// 2. buffer_scale (wl_surface.set_buffer_scale) +// 3. crop and scale (wp_viewport.set*) +// This means, that the source rectangle coordinates of crop and scale +// are given in the coordinates after the buffer transform and scale, +// i.e. in the coordinates that would be the surface-local coordinates +// if the crop and scale was not applied. +// +// If src_x or src_y are negative, the bad_value protocol error is raised. +// Otherwise, if the source rectangle is partially or completely outside of +// the non-NULL wl_buffer, then the out_of_buffer protocol error is raised +// when the surface state is applied. A NULL wl_buffer does not raise the +// out_of_buffer error. +// +// If the wl_surface associated with the wp_viewport is destroyed, +// all wp_viewport requests except 'destroy' raise the protocol error +// no_surface. +// +// If the wp_viewport object is destroyed, the crop and scale +// state is removed from the wl_surface. The change will be applied +// on the next wl_surface.commit. +type WpViewport struct { + client.BaseProxy +} + +// NewWpViewport : crop and scale interface to a wl_surface +// +// An additional interface to a wl_surface object, which allows the +// client to specify the cropping and scaling of the surface +// contents. +// +// This interface works with two concepts: the source rectangle (src_x, +// src_y, src_width, src_height), and the destination size (dst_width, +// dst_height). The contents of the source rectangle are scaled to the +// destination size, and content outside the source rectangle is ignored. +// This state is double-buffered, see wl_surface.commit. +// +// The two parts of crop and scale state are independent: the source +// rectangle, and the destination size. Initially both are unset, that +// is, no scaling is applied. The whole of the current wl_buffer is +// used as the source, and the surface size is as defined in +// wl_surface.attach. +// +// If the destination size is set, it causes the surface size to become +// dst_width, dst_height. The source (rectangle) is scaled to exactly +// this size. This overrides whatever the attached wl_buffer size is, +// unless the wl_buffer is NULL. If the wl_buffer is NULL, the surface +// has no content and therefore no size. Otherwise, the size is always +// at least 1x1 in surface local coordinates. +// +// If the source rectangle is set, it defines what area of the wl_buffer is +// taken as the source. If the source rectangle is set and the destination +// size is not set, then src_width and src_height must be integers, and the +// surface size becomes the source rectangle size. This results in cropping +// without scaling. If src_width or src_height are not integers and +// destination size is not set, the bad_size protocol error is raised when +// the surface state is applied. +// +// The coordinate transformations from buffer pixel coordinates up to +// the surface-local coordinates happen in the following order: +// 1. buffer_transform (wl_surface.set_buffer_transform) +// 2. buffer_scale (wl_surface.set_buffer_scale) +// 3. crop and scale (wp_viewport.set*) +// This means, that the source rectangle coordinates of crop and scale +// are given in the coordinates after the buffer transform and scale, +// i.e. in the coordinates that would be the surface-local coordinates +// if the crop and scale was not applied. +// +// If src_x or src_y are negative, the bad_value protocol error is raised. +// Otherwise, if the source rectangle is partially or completely outside of +// the non-NULL wl_buffer, then the out_of_buffer protocol error is raised +// when the surface state is applied. A NULL wl_buffer does not raise the +// out_of_buffer error. +// +// If the wl_surface associated with the wp_viewport is destroyed, +// all wp_viewport requests except 'destroy' raise the protocol error +// no_surface. +// +// If the wp_viewport object is destroyed, the crop and scale +// state is removed from the wl_surface. The change will be applied +// on the next wl_surface.commit. +func NewWpViewport(ctx *client.Context) *WpViewport { + wpViewport := &WpViewport{} + ctx.Register(wpViewport) + return wpViewport +} + +// Destroy : remove scaling and cropping from the surface +// +// The associated wl_surface's crop and scale state is removed. +// The change is applied on the next wl_surface.commit. +func (i *WpViewport) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetSource : set the source rectangle for cropping +// +// Set the source rectangle of the associated wl_surface. See +// wp_viewport for the description, and relation to the wl_buffer +// size. +// +// If all of x, y, width and height are -1.0, the source rectangle is +// unset instead. Any other set of values where width or height are zero +// or negative, or x or y are negative, raise the bad_value protocol +// error. +// +// The crop and scale state is double-buffered, see wl_surface.commit. +// +// x: source rectangle x +// y: source rectangle y +// width: source rectangle width +// height: source rectangle height +func (i *WpViewport) SetSource(x, y, width, height float64) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutFixed(_reqBuf[l:l+4], x) + l += 4 + client.PutFixed(_reqBuf[l:l+4], y) + l += 4 + client.PutFixed(_reqBuf[l:l+4], width) + l += 4 + client.PutFixed(_reqBuf[l:l+4], height) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetDestination : set the surface size for scaling +// +// Set the destination size of the associated wl_surface. See +// wp_viewport for the description, and relation to the wl_buffer +// size. +// +// If width is -1 and height is -1, the destination size is unset +// instead. Any other pair of values for width and height that +// contains zero or negative values raises the bad_value protocol +// error. +// +// The crop and scale state is double-buffered, see wl_surface.commit. +// +// width: surface width +// height: surface height +func (i *WpViewport) SetDestination(width, height int32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + client.PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type WpViewportError uint32 + +// WpViewportError : +const ( + // WpViewportErrorBadValue : negative or zero values in width or height + WpViewportErrorBadValue WpViewportError = 0 + // WpViewportErrorBadSize : destination size is not integer + WpViewportErrorBadSize WpViewportError = 1 + // WpViewportErrorOutOfBuffer : source rectangle extends outside of the content area + WpViewportErrorOutOfBuffer WpViewportError = 2 + // WpViewportErrorNoSurface : the wl_surface was destroyed + WpViewportErrorNoSurface WpViewportError = 3 +) + +func (e WpViewportError) Name() string { + switch e { + case WpViewportErrorBadValue: + return "bad_value" + case WpViewportErrorBadSize: + return "bad_size" + case WpViewportErrorOutOfBuffer: + return "out_of_buffer" + case WpViewportErrorNoSurface: + return "no_surface" + default: + return "" + } +} + +func (e WpViewportError) Value() string { + switch e { + case WpViewportErrorBadValue: + return "0" + case WpViewportErrorBadSize: + return "1" + case WpViewportErrorOutOfBuffer: + return "2" + case WpViewportErrorNoSurface: + return "3" + default: + return "" + } +} + +func (e WpViewportError) String() string { + return e.Name() + "=" + e.Value() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/dwl-ipc-unstable-v2.xml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/dwl-ipc-unstable-v2.xml new file mode 100644 index 0000000..a4e0b49 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/dwl-ipc-unstable-v2.xml @@ -0,0 +1,252 @@ + + + + + This protocol allows clients to update and get updates from dwl. + + Warning! The protocol described in this file is experimental and + backward incompatible changes may be made. Backward compatible + changes may be added together with the corresponding interface + version bump. + Backward incompatible changes are done by bumping the version + number in the protocol and interface names and resetting the + interface version. Once the protocol is to be declared stable, + the 'z' prefix and the version number in the protocol and + interface names are removed and the interface version number is + reset. + + + + + This interface is exposed as a global in wl_registry. + + Clients can use this interface to get a dwl_ipc_output. + After binding the client will recieve the dwl_ipc_manager.tags and dwl_ipc_manager.layout events. + The dwl_ipc_manager.tags and dwl_ipc_manager.layout events expose tags and layouts to the client. + + + + + Indicates that the client will not the dwl_ipc_manager object anymore. + Objects created through this instance are not affected. + + + + + + Get a dwl_ipc_outout for the specified wl_output. + + + + + + + + This event is sent after binding. + A roundtrip after binding guarantees the client recieved all tags. + + + + + + + This event is sent after binding. + A roundtrip after binding guarantees the client recieved all layouts. + + + + + + + + Observe and control a dwl output. + + Events are double-buffered: + Clients should cache events and redraw when a dwl_ipc_output.frame event is sent. + + Request are not double-buffered: + The compositor will update immediately upon request. + + + + + + + + + + + Indicates to that the client no longer needs this dwl_ipc_output. + + + + + + Indicates the client should hide or show themselves. + If the client is visible then hide, if hidden then show. + + + + + + Indicates if the output is active. Zero is invalid, nonzero is valid. + + + + + + + Indicates that a tag has been updated. + + + + + + + + + + Indicates a new layout is selected. + + + + + + + Indicates the title has changed. + + + + + + + Indicates the appid has changed. + + + + + + + Indicates the layout has changed. Since layout symbols are dynamic. + As opposed to the zdwl_ipc_manager.layout event, this should take precendence when displaying. + You can ignore the zdwl_ipc_output.layout event. + + + + + + + Indicates that a sequence of status updates have finished and the client should redraw. + + + + + + + + + + + + The tags are updated as follows: + new_tags = (current_tags AND and_tags) XOR xor_tags + + + + + + + + + + + + This request allows clients to instruct the compositor to quit mango. + + + + + + + + + + + + + + + + Indicates if the selected client on this output is fullscreen. + + + + + + + Indicates if the selected client on this output is floating. + + + + + + + Indicates if x coordinates of the selected client. + + + + + + + Indicates if y coordinates of the selected client. + + + + + + + Indicates if width of the selected client. + + + + + + + Indicates if height of the selected client. + + + + + + + last map layer. + + + + + + + current keyboard layout. + + + + + + + current keybind mode. + + + + + + + scale factor of monitor. + + + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/ext-data-control-v1.xml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/ext-data-control-v1.xml new file mode 100644 index 0000000..37ee577 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/ext-data-control-v1.xml @@ -0,0 +1,276 @@ + + + + Copyright © 2018 Simon Ser + Copyright © 2019 Ivan Molodetskikh + Copyright © 2024 Neal Gompa + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + This protocol allows a privileged client to control data devices. In + particular, the client will be able to manage the current selection and take + the role of a clipboard manager. + + Warning! The protocol described in this file is currently in the testing + phase. Backward compatible changes may be added together with the + corresponding interface version bump. Backward incompatible changes can + only be done by creating a new major version of the extension. + + + + + This interface is a manager that allows creating per-seat data device + controls. + + + + + Create a new data source. + + + + + + + Create a data device that can be used to manage a seat's selection. + + + + + + + + All objects created by the manager will still remain valid, until their + appropriate destroy request has been called. + + + + + + + This interface allows a client to manage a seat's selection. + + When the seat is destroyed, this object becomes inert. + + + + + This request asks the compositor to set the selection to the data from + the source on behalf of the client. + + The given source may not be used in any further set_selection or + set_primary_selection requests. Attempting to use a previously used + source triggers the used_source protocol error. + + To unset the selection, set the source to NULL. + + + + + + + Destroys the data device object. + + + + + + The data_offer event introduces a new ext_data_control_offer object, + which will subsequently be used in either the + ext_data_control_device.selection event (for the regular clipboard + selections) or the ext_data_control_device.primary_selection event (for + the primary clipboard selections). Immediately following the + ext_data_control_device.data_offer event, the new data_offer object + will send out ext_data_control_offer.offer events to describe the MIME + types it offers. + + + + + + + The selection event is sent out to notify the client of a new + ext_data_control_offer for the selection for this device. The + ext_data_control_device.data_offer and the ext_data_control_offer.offer + events are sent out immediately before this event to introduce the data + offer object. The selection event is sent to a client when a new + selection is set. The ext_data_control_offer is valid until a new + ext_data_control_offer or NULL is received. The client must destroy the + previous selection ext_data_control_offer, if any, upon receiving this + event. Regardless, the previous selection will be ignored once a new + selection ext_data_control_offer is received. + + The first selection event is sent upon binding the + ext_data_control_device object. + + + + + + + This data control object is no longer valid and should be destroyed by + the client. + + + + + + The primary_selection event is sent out to notify the client of a new + ext_data_control_offer for the primary selection for this device. The + ext_data_control_device.data_offer and the ext_data_control_offer.offer + events are sent out immediately before this event to introduce the data + offer object. The primary_selection event is sent to a client when a + new primary selection is set. The ext_data_control_offer is valid until + a new ext_data_control_offer or NULL is received. The client must + destroy the previous primary selection ext_data_control_offer, if any, + upon receiving this event. Regardless, the previous primary selection + will be ignored once a new primary selection ext_data_control_offer is + received. + + If the compositor supports primary selection, the first + primary_selection event is sent upon binding the + ext_data_control_device object. + + + + + + + This request asks the compositor to set the primary selection to the + data from the source on behalf of the client. + + The given source may not be used in any further set_selection or + set_primary_selection requests. Attempting to use a previously used + source triggers the used_source protocol error. + + To unset the primary selection, set the source to NULL. + + The compositor will ignore this request if it does not support primary + selection. + + + + + + + + + + + + The ext_data_control_source object is the source side of a + ext_data_control_offer. It is created by the source client in a data + transfer and provides a way to describe the offered data and a way to + respond to requests to transfer the data. + + + + + + + + + This request adds a MIME type to the set of MIME types advertised to + targets. Can be called several times to offer multiple types. + + Calling this after ext_data_control_device.set_selection is a protocol + error. + + + + + + + Destroys the data source object. + + + + + + Request for data from the client. Send the data as the specified MIME + type over the passed file descriptor, then close it. + + + + + + + + This data source is no longer valid. The data source has been replaced + by another data source. + + The client should clean up and destroy this data source. + + + + + + + A ext_data_control_offer represents a piece of data offered for transfer + by another client (the source client). The offer describes the different + MIME types that the data can be converted to and provides the mechanism + for transferring the data directly from the source client. + + + + + To transfer the offered data, the client issues this request and + indicates the MIME type it wants to receive. The transfer happens + through the passed file descriptor (typically created with the pipe + system call). The source client writes the data in the MIME type + representation requested and then closes the file descriptor. + + The receiving client reads from the read end of the pipe until EOF and + then closes its end, at which point the transfer is complete. + + This request may happen multiple times for different MIME types. + + + + + + + + Destroys the data offer object. + + + + + + Sent immediately after creating the ext_data_control_offer object. + One event per offered MIME type. + + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/ext-workspace-v1.xml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/ext-workspace-v1.xml new file mode 100644 index 0000000..134b729 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/ext-workspace-v1.xml @@ -0,0 +1,422 @@ + + + + Copyright © 2019 Christopher Billington + Copyright © 2020 Ilia Bozhinov + Copyright © 2022 Victoria Brekenfeld + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + + Workspaces, also called virtual desktops, are groups of surfaces. A + compositor with a concept of workspaces may only show some such groups of + surfaces (those of 'active' workspaces) at a time. 'Activating' a + workspace is a request for the compositor to display that workspace's + surfaces as normal, whereas the compositor may hide or otherwise + de-emphasise surfaces that are associated only with 'inactive' workspaces. + Workspaces are grouped by which sets of outputs they correspond to, and + may contain surfaces only from those outputs. In this way, it is possible + for each output to have its own set of workspaces, or for all outputs (or + any other arbitrary grouping) to share workspaces. Compositors may + optionally conceptually arrange each group of workspaces in an + N-dimensional grid. + + The purpose of this protocol is to enable the creation of taskbars and + docks by providing them with a list of workspaces and their properties, + and allowing them to activate and deactivate workspaces. + + After a client binds the ext_workspace_manager_v1, each workspace will be + sent via the workspace event. + + + + + This event is emitted whenever a new workspace group has been created. + + All initial details of the workspace group (outputs) will be + sent immediately after this event via the corresponding events in + ext_workspace_group_handle_v1 and ext_workspace_handle_v1. + + + + + + + This event is emitted whenever a new workspace has been created. + + All initial details of the workspace (name, coordinates, state) will + be sent immediately after this event via the corresponding events in + ext_workspace_handle_v1. + + Workspaces start off unassigned to any workspace group. + + + + + + + The client must send this request after it has finished sending other + requests. The compositor must process a series of requests preceding a + commit request atomically. + + This allows changes to the workspace properties to be seen as atomic, + even if they happen via multiple events, and even if they involve + multiple ext_workspace_handle_v1 objects, for example, deactivating one + workspace and activating another. + + + + + + This event is sent after all changes in all workspaces and workspace groups have been + sent. + + This allows changes to one or more ext_workspace_group_handle_v1 + properties and ext_workspace_handle_v1 properties + to be seen as atomic, even if they happen via multiple events. + In particular, an output moving from one workspace group to + another sends an output_enter event and an output_leave event to the two + ext_workspace_group_handle_v1 objects in question. The compositor sends + the done event only after updating the output information in both + workspace groups. + + + + + + This event indicates that the compositor is done sending events to the + ext_workspace_manager_v1. The server will destroy the object + immediately after sending this request. + + + + + + Indicates the client no longer wishes to receive events for new + workspace groups. However the compositor may emit further workspace + events, until the finished event is emitted. The compositor is expected + to send the finished event eventually once the stop request has been processed. + + The client must not send any requests after this one, doing so will raise a wl_display + invalid_object error. + + + + + + + + A ext_workspace_group_handle_v1 object represents a workspace group + that is assigned a set of outputs and contains a number of workspaces. + + The set of outputs assigned to the workspace group is conveyed to the client via + output_enter and output_leave events, and its workspaces are conveyed with + workspace events. + + For example, a compositor which has a set of workspaces for each output may + advertise a workspace group (and its workspaces) per output, whereas a compositor + where a workspace spans all outputs may advertise a single workspace group for all + outputs. + + + + + + + + + This event advertises the capabilities supported by the compositor. If + a capability isn't supported, clients should hide or disable the UI + elements that expose this functionality. For instance, if the + compositor doesn't advertise support for creating workspaces, a button + triggering the create_workspace request should not be displayed. + + The compositor will ignore requests it doesn't support. For instance, + a compositor which doesn't advertise support for creating workspaces will ignore + create_workspace requests. + + Compositors must send this event once after creation of an + ext_workspace_group_handle_v1. When the capabilities change, compositors + must send this event again. + + + + + + + This event is emitted whenever an output is assigned to the workspace + group or a new `wl_output` object is bound by the client, which was already + assigned to this workspace_group. + + + + + + + This event is emitted whenever an output is removed from the workspace + group. + + + + + + + This event is emitted whenever a workspace is assigned to this group. + A workspace may only ever be assigned to a single group at a single point + in time, but can be re-assigned during it's lifetime. + + + + + + + This event is emitted whenever a workspace is removed from this group. + + + + + + + This event is send when the group associated with the ext_workspace_group_handle_v1 + has been removed. After sending this request the compositor will immediately consider + the object inert. Any requests will be ignored except the destroy request. + It is guaranteed there won't be any more events referencing this + ext_workspace_group_handle_v1. + + The compositor must remove all workspaces belonging to a workspace group + via a workspace_leave event before removing the workspace group. + + + + + + Request that the compositor create a new workspace with the given name + and assign it to this group. + + There is no guarantee that the compositor will create a new workspace, + or that the created workspace will have the provided name. + + + + + + + Destroys the ext_workspace_group_handle_v1 object. + + This request should be send either when the client does not want to + use the workspace group object any more or after the removed event to finalize + the destruction of the object. + + + + + + + A ext_workspace_handle_v1 object represents a workspace that handles a + group of surfaces. + + Each workspace has: + - a name, conveyed to the client with the name event + - potentially an id conveyed with the id event + - a list of states, conveyed to the client with the state event + - and optionally a set of coordinates, conveyed to the client with the + coordinates event + + The client may request that the compositor activate or deactivate the workspace. + + Each workspace can belong to only a single workspace group. + Depending on the compositor policy, there might be workspaces with + the same name in different workspace groups, but these workspaces are still + separate (e.g. one of them might be active while the other is not). + + + + + If this event is emitted, it will be send immediately after the + ext_workspace_handle_v1 is created or when an id is assigned to + a workspace (at most once during it's lifetime). + + An id will never change during the lifetime of the `ext_workspace_handle_v1` + and is guaranteed to be unique during it's lifetime. + + Ids are not human-readable and shouldn't be displayed, use `name` for that purpose. + + Compositors are expected to only send ids for workspaces likely stable across multiple + sessions and can be used by clients to store preferences for workspaces. Workspaces without + ids should be considered temporary and any data associated with them should be deleted once + the respective object is lost. + + + + + + + This event is emitted immediately after the ext_workspace_handle_v1 is + created and whenever the name of the workspace changes. + + A name is meant to be human-readable and can be displayed to a user. + Unlike the id it is neither stable nor unique. + + + + + + + This event is used to organize workspaces into an N-dimensional grid + within a workspace group, and if supported, is emitted immediately after + the ext_workspace_handle_v1 is created and whenever the coordinates of + the workspace change. Compositors may not send this event if they do not + conceptually arrange workspaces in this way. If compositors simply + number workspaces, without any geometric interpretation, they may send + 1D coordinates, which clients should not interpret as implying any + geometry. Sending an empty array means that the compositor no longer + orders the workspace geometrically. + + Coordinates have an arbitrary number of dimensions N with an uint32 + position along each dimension. By convention if N > 1, the first + dimension is X, the second Y, the third Z, and so on. The compositor may + chose to utilize these events for a more novel workspace layout + convention, however. No guarantee is made about the grid being filled or + bounded; there may be a workspace at coordinate 1 and another at + coordinate 1000 and none in between. Within a workspace group, however, + workspaces must have unique coordinates of equal dimensionality. + + + + + + + The different states that a workspace can have. + + + + + + + The workspace is not visible in its workspace group, and clients + attempting to visualize the compositor workspace state should not + display such workspaces. + + + + + + + This event is emitted immediately after the ext_workspace_handle_v1 is + created and each time the workspace state changes, either because of a + compositor action or because of a request in this protocol. + + Missing states convey the opposite meaning, e.g. an unset active bit + means the workspace is currently inactive. + + + + + + + + + + + + + + This event advertises the capabilities supported by the compositor. If + a capability isn't supported, clients should hide or disable the UI + elements that expose this functionality. For instance, if the + compositor doesn't advertise support for removing workspaces, a button + triggering the remove request should not be displayed. + + The compositor will ignore requests it doesn't support. For instance, + a compositor which doesn't advertise support for remove will ignore + remove requests. + + Compositors must send this event once after creation of an + ext_workspace_handle_v1 . When the capabilities change, compositors + must send this event again. + + + + + + + This event is send when the workspace associated with the ext_workspace_handle_v1 + has been removed. After sending this request, the compositor will immediately consider + the object inert. Any requests will be ignored except the destroy request. + + It is guaranteed there won't be any more events referencing this + ext_workspace_handle_v1. + + The compositor must only remove a workspaces not currently belonging to any + workspace_group. + + + + + + Destroys the ext_workspace_handle_v1 object. + + This request should be made either when the client does not want to + use the workspace object any more or after the remove event to finalize + the destruction of the object. + + + + + + Request that this workspace be activated. + + There is no guarantee the workspace will be actually activated, and + behaviour may be compositor-dependent. For example, activating a + workspace may or may not deactivate all other workspaces in the same + group. + + + + + + Request that this workspace be deactivated. + + There is no guarantee the workspace will be actually deactivated. + + + + + + Requests that this workspace is assigned to the given workspace group. + + There is no guarantee the workspace will be assigned. + + + + + + + Request that this workspace be removed. + + There is no guarantee the workspace will be actually removed. + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/keyboard-shortcuts-inhibit-unstable-v1.xml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/keyboard-shortcuts-inhibit-unstable-v1.xml new file mode 100644 index 0000000..2774876 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/keyboard-shortcuts-inhibit-unstable-v1.xml @@ -0,0 +1,143 @@ + + + + + Copyright © 2017 Red Hat Inc. + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + This protocol specifies a way for a client to request the compositor + to ignore its own keyboard shortcuts for a given seat, so that all + key events from that seat get forwarded to a surface. + + Warning! The protocol described in this file is experimental and + backward incompatible changes may be made. Backward compatible + changes may be added together with the corresponding interface + version bump. + Backward incompatible changes are done by bumping the version + number in the protocol and interface names and resetting the + interface version. Once the protocol is to be declared stable, + the 'z' prefix and the version number in the protocol and + interface names are removed and the interface version number is + reset. + + + + + A global interface used for inhibiting the compositor keyboard shortcuts. + + + + + Destroy the keyboard shortcuts inhibitor manager. + + + + + + Create a new keyboard shortcuts inhibitor object associated with + the given surface for the given seat. + + If shortcuts are already inhibited for the specified seat and surface, + a protocol error "already_inhibited" is raised by the compositor. + + + + + + + + + + + + + + A keyboard shortcuts inhibitor instructs the compositor to ignore + its own keyboard shortcuts when the associated surface has keyboard + focus. As a result, when the surface has keyboard focus on the given + seat, it will receive all key events originating from the specified + seat, even those which would normally be caught by the compositor for + its own shortcuts. + + The Wayland compositor is however under no obligation to disable + all of its shortcuts, and may keep some special key combo for its own + use, including but not limited to one allowing the user to forcibly + restore normal keyboard events routing in the case of an unwilling + client. The compositor may also use the same key combo to reactivate + an existing shortcut inhibitor that was previously deactivated on + user request. + + When the compositor restores its own keyboard shortcuts, an + "inactive" event is emitted to notify the client that the keyboard + shortcuts inhibitor is not effectively active for the surface and + seat any more, and the client should not expect to receive all + keyboard events. + + When the keyboard shortcuts inhibitor is inactive, the client has + no way to forcibly reactivate the keyboard shortcuts inhibitor. + + The user can chose to re-enable a previously deactivated keyboard + shortcuts inhibitor using any mechanism the compositor may offer, + in which case the compositor will send an "active" event to notify + the client. + + If the surface is destroyed, unmapped, or loses the seat's keyboard + focus, the keyboard shortcuts inhibitor becomes irrelevant and the + compositor will restore its own keyboard shortcuts but no "inactive" + event is emitted in this case. + + + + + Remove the keyboard shortcuts inhibitor from the associated wl_surface. + + + + + + This event indicates that the shortcut inhibitor is active. + + The compositor sends this event every time compositor shortcuts + are inhibited on behalf of the surface. When active, the client + may receive input events normally reserved by the compositor + (see zwp_keyboard_shortcuts_inhibitor_v1). + + This occurs typically when the initial request "inhibit_shortcuts" + first becomes active or when the user instructs the compositor to + re-enable and existing shortcuts inhibitor using any mechanism + offered by the compositor. + + + + + + This event indicates that the shortcuts inhibitor is inactive, + normal shortcuts processing is restored by the compositor. + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-gamma-control-unstable-v1.xml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-gamma-control-unstable-v1.xml new file mode 100644 index 0000000..16e0be8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-gamma-control-unstable-v1.xml @@ -0,0 +1,126 @@ + + + + Copyright © 2015 Giulio camuffo + Copyright © 2018 Simon Ser + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + This protocol allows a privileged client to set the gamma tables for + outputs. + + Warning! The protocol described in this file is experimental and + backward incompatible changes may be made. Backward compatible changes + may be added together with the corresponding interface version bump. + Backward incompatible changes are done by bumping the version number in + the protocol and interface names and resetting the interface version. + Once the protocol is to be declared stable, the 'z' prefix and the + version number in the protocol and interface names are removed and the + interface version number is reset. + + + + + This interface is a manager that allows creating per-output gamma + controls. + + + + + Create a gamma control that can be used to adjust gamma tables for the + provided output. + + + + + + + + All objects created by the manager will still remain valid, until their + appropriate destroy request has been called. + + + + + + + This interface allows a client to adjust gamma tables for a particular + output. + + The client will receive the gamma size, and will then be able to set gamma + tables. At any time the compositor can send a failed event indicating that + this object is no longer valid. + + There can only be at most one gamma control object per output, which + has exclusive access to this particular output. When the gamma control + object is destroyed, the gamma table is restored to its original value. + + + + + Advertise the size of each gamma ramp. + + This event is sent immediately when the gamma control object is created. + + + + + + + + + + + Set the gamma table. The file descriptor can be memory-mapped to provide + the raw gamma table, which contains successive gamma ramps for the red, + green and blue channels. Each gamma ramp is an array of 16-byte unsigned + integers which has the same length as the gamma size. + + The file descriptor data must have the same length as three times the + gamma size. + + + + + + + This event indicates that the gamma control is no longer valid. This + can happen for a number of reasons, including: + - The output doesn't support gamma tables + - Setting the gamma tables failed + - Another client already has exclusive gamma control for this output + - The compositor has transferred gamma control to another client + + Upon receiving this event, the client should destroy this object. + + + + + + Destroys the gamma control object. If the object is still valid, this + restores the original gamma tables. + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-layer-shell-unstable-v1.xml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-layer-shell-unstable-v1.xml new file mode 100644 index 0000000..e9f27e4 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-layer-shell-unstable-v1.xml @@ -0,0 +1,407 @@ + + + + Copyright © 2017 Drew DeVault + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + + Clients can use this interface to assign the surface_layer role to + wl_surfaces. Such surfaces are assigned to a "layer" of the output and + rendered with a defined z-depth respective to each other. They may also be + anchored to the edges and corners of a screen and specify input handling + semantics. This interface should be suitable for the implementation of + many desktop shell components, and a broad number of other applications + that interact with the desktop. + + + + + Create a layer surface for an existing surface. This assigns the role of + layer_surface, or raises a protocol error if another role is already + assigned. + + Creating a layer surface from a wl_surface which has a buffer attached + or committed is a client error, and any attempts by a client to attach + or manipulate a buffer prior to the first layer_surface.configure call + must also be treated as errors. + + After creating a layer_surface object and setting it up, the client + must perform an initial commit without any buffer attached. + The compositor will reply with a layer_surface.configure event. + The client must acknowledge it and is then allowed to attach a buffer + to map the surface. + + You may pass NULL for output to allow the compositor to decide which + output to use. Generally this will be the one that the user most + recently interacted with. + + Clients can specify a namespace that defines the purpose of the layer + surface. + + + + + + + + + + + + + + + + + These values indicate which layers a surface can be rendered in. They + are ordered by z depth, bottom-most first. Traditional shell surfaces + will typically be rendered between the bottom and top layers. + Fullscreen shell surfaces are typically rendered at the top layer. + Multiple surfaces can share a single layer, and ordering within a + single layer is undefined. + + + + + + + + + + + + + This request indicates that the client will not use the layer_shell + object any more. Objects that have been created through this instance + are not affected. + + + + + + + An interface that may be implemented by a wl_surface, for surfaces that + are designed to be rendered as a layer of a stacked desktop-like + environment. + + Layer surface state (layer, size, anchor, exclusive zone, + margin, interactivity) is double-buffered, and will be applied at the + time wl_surface.commit of the corresponding wl_surface is called. + + Attaching a null buffer to a layer surface unmaps it. + + Unmapping a layer_surface means that the surface cannot be shown by the + compositor until it is explicitly mapped again. The layer_surface + returns to the state it had right after layer_shell.get_layer_surface. + The client can re-map the surface by performing a commit without any + buffer attached, waiting for a configure event and handling it as usual. + + + + + Sets the size of the surface in surface-local coordinates. The + compositor will display the surface centered with respect to its + anchors. + + If you pass 0 for either value, the compositor will assign it and + inform you of the assignment in the configure event. You must set your + anchor to opposite edges in the dimensions you omit; not doing so is a + protocol error. Both values are 0 by default. + + Size is double-buffered, see wl_surface.commit. + + + + + + + + Requests that the compositor anchor the surface to the specified edges + and corners. If two orthogonal edges are specified (e.g. 'top' and + 'left'), then the anchor point will be the intersection of the edges + (e.g. the top left corner of the output); otherwise the anchor point + will be centered on that edge, or in the center if none is specified. + + Anchor is double-buffered, see wl_surface.commit. + + + + + + + Requests that the compositor avoids occluding an area with other + surfaces. The compositor's use of this information is + implementation-dependent - do not assume that this region will not + actually be occluded. + + A positive value is only meaningful if the surface is anchored to one + edge or an edge and both perpendicular edges. If the surface is not + anchored, anchored to only two perpendicular edges (a corner), anchored + to only two parallel edges or anchored to all edges, a positive value + will be treated the same as zero. + + A positive zone is the distance from the edge in surface-local + coordinates to consider exclusive. + + Surfaces that do not wish to have an exclusive zone may instead specify + how they should interact with surfaces that do. If set to zero, the + surface indicates that it would like to be moved to avoid occluding + surfaces with a positive exclusive zone. If set to -1, the surface + indicates that it would not like to be moved to accommodate for other + surfaces, and the compositor should extend it all the way to the edges + it is anchored to. + + For example, a panel might set its exclusive zone to 10, so that + maximized shell surfaces are not shown on top of it. A notification + might set its exclusive zone to 0, so that it is moved to avoid + occluding the panel, but shell surfaces are shown underneath it. A + wallpaper or lock screen might set their exclusive zone to -1, so that + they stretch below or over the panel. + + The default value is 0. + + Exclusive zone is double-buffered, see wl_surface.commit. + + + + + + + Requests that the surface be placed some distance away from the anchor + point on the output, in surface-local coordinates. Setting this value + for edges you are not anchored to has no effect. + + The exclusive zone includes the margin. + + Margin is double-buffered, see wl_surface.commit. + + + + + + + + + + Types of keyboard interaction possible for layer shell surfaces. The + rationale for this is twofold: (1) some applications are not interested + in keyboard events and not allowing them to be focused can improve the + desktop experience; (2) some applications will want to take exclusive + keyboard focus. + + + + + This value indicates that this surface is not interested in keyboard + events and the compositor should never assign it the keyboard focus. + + This is the default value, set for newly created layer shell surfaces. + + This is useful for e.g. desktop widgets that display information or + only have interaction with non-keyboard input devices. + + + + + Request exclusive keyboard focus if this surface is above the shell surface layer. + + For the top and overlay layers, the seat will always give + exclusive keyboard focus to the top-most layer which has keyboard + interactivity set to exclusive. If this layer contains multiple + surfaces with keyboard interactivity set to exclusive, the compositor + determines the one receiving keyboard events in an implementation- + defined manner. In this case, no guarantee is made when this surface + will receive keyboard focus (if ever). + + For the bottom and background layers, the compositor is allowed to use + normal focus semantics. + + This setting is mainly intended for applications that need to ensure + they receive all keyboard events, such as a lock screen or a password + prompt. + + + + + This requests the compositor to allow this surface to be focused and + unfocused by the user in an implementation-defined manner. The user + should be able to unfocus this surface even regardless of the layer + it is on. + + Typically, the compositor will want to use its normal mechanism to + manage keyboard focus between layer shell surfaces with this setting + and regular toplevels on the desktop layer (e.g. click to focus). + Nevertheless, it is possible for a compositor to require a special + interaction to focus or unfocus layer shell surfaces (e.g. requiring + a click even if focus follows the mouse normally, or providing a + keybinding to switch focus between layers). + + This setting is mainly intended for desktop shell components (e.g. + panels) that allow keyboard interaction. Using this option can allow + implementing a desktop shell that can be fully usable without the + mouse. + + + + + + + Set how keyboard events are delivered to this surface. By default, + layer shell surfaces do not receive keyboard events; this request can + be used to change this. + + This setting is inherited by child surfaces set by the get_popup + request. + + Layer surfaces receive pointer, touch, and tablet events normally. If + you do not want to receive them, set the input region on your surface + to an empty region. + + Keyboard interactivity is double-buffered, see wl_surface.commit. + + + + + + + This assigns an xdg_popup's parent to this layer_surface. This popup + should have been created via xdg_surface::get_popup with the parent set + to NULL, and this request must be invoked before committing the popup's + initial state. + + See the documentation of xdg_popup for more details about what an + xdg_popup is and how it is used. + + + + + + + When a configure event is received, if a client commits the + surface in response to the configure event, then the client + must make an ack_configure request sometime before the commit + request, passing along the serial of the configure event. + + If the client receives multiple configure events before it + can respond to one, it only has to ack the last configure event. + + A client is not required to commit immediately after sending + an ack_configure request - it may even ack_configure several times + before its next surface commit. + + A client may send multiple ack_configure requests before committing, but + only the last request sent before a commit indicates which configure + event the client really is responding to. + + + + + + + This request destroys the layer surface. + + + + + + The configure event asks the client to resize its surface. + + Clients should arrange their surface for the new states, and then send + an ack_configure request with the serial sent in this configure event at + some point before committing the new surface. + + The client is free to dismiss all but the last configure event it + received. + + The width and height arguments specify the size of the window in + surface-local coordinates. + + The size is a hint, in the sense that the client is free to ignore it if + it doesn't resize, pick a smaller size (to satisfy aspect ratio or + resize in steps of NxM pixels). If the client picks a smaller size and + is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the + surface will be centered on this axis. + + If the width or height arguments are zero, it means the client should + decide its own window dimension. + + + + + + + + + The closed event is sent by the compositor when the surface will no + longer be shown. The output may have been destroyed or the user may + have asked for it to be removed. Further changes to the surface will be + ignored. The client should destroy the resource after receiving this + event, and create a new surface if they so choose. + + + + + + + + + + + + + + + + + + + + + + + Change the layer that the surface is rendered on. + + Layer is double-buffered, see wl_surface.commit. + + + + + + + + + Requests an edge for the exclusive zone to apply. The exclusive + edge will be automatically deduced from anchor points when possible, + but when the surface is anchored to a corner, it will be necessary + to set it explicitly to disambiguate, as it is not possible to deduce + which one of the two corner edges should be used. + + The edge must be one the surface is anchored to, otherwise the + invalid_exclusive_edge protocol error will be raised. + + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-output-management-unstable-v1.xml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-output-management-unstable-v1.xml new file mode 100644 index 0000000..541284a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-output-management-unstable-v1.xml @@ -0,0 +1,611 @@ + + + + Copyright © 2019 Purism SPC + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + This protocol exposes interfaces to obtain and modify output device + configuration. + + Warning! The protocol described in this file is experimental and + backward incompatible changes may be made. Backward compatible changes + may be added together with the corresponding interface version bump. + Backward incompatible changes are done by bumping the version number in + the protocol and interface names and resetting the interface version. + Once the protocol is to be declared stable, the 'z' prefix and the + version number in the protocol and interface names are removed and the + interface version number is reset. + + + + + This interface is a manager that allows reading and writing the current + output device configuration. + + Output devices that display pixels (e.g. a physical monitor or a virtual + output in a window) are represented as heads. Heads cannot be created nor + destroyed by the client, but they can be enabled or disabled and their + properties can be changed. Each head may have one or more available modes. + + Whenever a head appears (e.g. a monitor is plugged in), it will be + advertised via the head event. Immediately after the output manager is + bound, all current heads are advertised. + + Whenever a head's properties change, the relevant wlr_output_head events + will be sent. Not all head properties will be sent: only properties that + have changed need to. + + Whenever a head disappears (e.g. a monitor is unplugged), a + wlr_output_head.finished event will be sent. + + After one or more heads appear, change or disappear, the done event will + be sent. It carries a serial which can be used in a create_configuration + request to update heads properties. + + The information obtained from this protocol should only be used for output + configuration purposes. This protocol is not designed to be a generic + output property advertisement protocol for regular clients. Instead, + protocols such as xdg-output should be used. + + + + + This event introduces a new head. This happens whenever a new head + appears (e.g. a monitor is plugged in) or after the output manager is + bound. + + + + + + + This event is sent after all information has been sent after binding to + the output manager object and after any subsequent changes. This applies + to child head and mode objects as well. In other words, this event is + sent whenever a head or mode is created or destroyed and whenever one of + their properties has been changed. Not all state is re-sent each time + the current configuration changes: only the actual changes are sent. + + This allows changes to the output configuration to be seen as atomic, + even if they happen via multiple events. + + A serial is sent to be used in a future create_configuration request. + + + + + + + Create a new output configuration object. This allows to update head + properties. + + + + + + + + Indicates the client no longer wishes to receive events for output + configuration changes. However the compositor may emit further events, + until the finished event is emitted. + + The client must not send any more requests after this one. + + + + + + This event indicates that the compositor is done sending manager events. + The compositor will destroy the object immediately after sending this + event, so it will become invalid and the client should release any + resources associated with it. + + + + + + + A head is an output device. The difference between a wl_output object and + a head is that heads are advertised even if they are turned off. A head + object only advertises properties and cannot be used directly to change + them. + + A head has some read-only properties: modes, name, description and + physical_size. These cannot be changed by clients. + + Other properties can be updated via a wlr_output_configuration object. + + Properties sent via this interface are applied atomically via the + wlr_output_manager.done event. No guarantees are made regarding the order + in which properties are sent. + + + + + This event describes the head name. + + The naming convention is compositor defined, but limited to alphanumeric + characters and dashes (-). Each name is unique among all wlr_output_head + objects, but if a wlr_output_head object is destroyed the same name may + be reused later. The names will also remain consistent across sessions + with the same hardware and software configuration. + + Examples of names include 'HDMI-A-1', 'WL-1', 'X11-1', etc. However, do + not assume that the name is a reflection of an underlying DRM + connector, X11 connection, etc. + + If this head matches a wl_output, the wl_output.name event must report + the same name. + + The name event is sent after a wlr_output_head object is created. This + event is only sent once per object, and the name does not change over + the lifetime of the wlr_output_head object. + + + + + + + This event describes a human-readable description of the head. + + The description is a UTF-8 string with no convention defined for its + contents. Examples might include 'Foocorp 11" Display' or 'Virtual X11 + output via :1'. However, do not assume that the name is a reflection of + the make, model, serial of the underlying DRM connector or the display + name of the underlying X11 connection, etc. + + If this head matches a wl_output, the wl_output.description event must + report the same name. + + The description event is sent after a wlr_output_head object is created. + This event is only sent once per object, and the description does not + change over the lifetime of the wlr_output_head object. + + + + + + + This event describes the physical size of the head. This event is only + sent if the head has a physical size (e.g. is not a projector or a + virtual device). + + The physical size event is sent after a wlr_output_head object is created. This + event is only sent once per object, and the physical size does not change over + the lifetime of the wlr_output_head object. + + + + + + + + This event introduces a mode for this head. It is sent once per + supported mode. + + + + + + + This event describes whether the head is enabled. A disabled head is not + mapped to a region of the global compositor space. + + When a head is disabled, some properties (current_mode, position, + transform and scale) are irrelevant. + + + + + + + This event describes the mode currently in use for this head. It is only + sent if the output is enabled. + + + + + + + This events describes the position of the head in the global compositor + space. It is only sent if the output is enabled. + + + + + + + + This event describes the transformation currently applied to the head. + It is only sent if the output is enabled. + + + + + + + This events describes the scale of the head in the global compositor + space. It is only sent if the output is enabled. + + + + + + + This event indicates that the head is no longer available. The head + object becomes inert. Clients should send a destroy request and release + any resources associated with it. + + + + + + + + This event describes the manufacturer of the head. + + Together with the model and serial_number events the purpose is to + allow clients to recognize heads from previous sessions and for example + load head-specific configurations back. + + It is not guaranteed this event will be ever sent. A reason for that + can be that the compositor does not have information about the make of + the head or the definition of a make is not sensible in the current + setup, for example in a virtual session. Clients can still try to + identify the head by available information from other events but should + be aware that there is an increased risk of false positives. + + If sent, the make event is sent after a wlr_output_head object is + created and only sent once per object. The make does not change over + the lifetime of the wlr_output_head object. + + It is not recommended to display the make string in UI to users. For + that the string provided by the description event should be preferred. + + + + + + + This event describes the model of the head. + + Together with the make and serial_number events the purpose is to + allow clients to recognize heads from previous sessions and for example + load head-specific configurations back. + + It is not guaranteed this event will be ever sent. A reason for that + can be that the compositor does not have information about the model of + the head or the definition of a model is not sensible in the current + setup, for example in a virtual session. Clients can still try to + identify the head by available information from other events but should + be aware that there is an increased risk of false positives. + + If sent, the model event is sent after a wlr_output_head object is + created and only sent once per object. The model does not change over + the lifetime of the wlr_output_head object. + + It is not recommended to display the model string in UI to users. For + that the string provided by the description event should be preferred. + + + + + + + This event describes the serial number of the head. + + Together with the make and model events the purpose is to allow clients + to recognize heads from previous sessions and for example load head- + specific configurations back. + + It is not guaranteed this event will be ever sent. A reason for that + can be that the compositor does not have information about the serial + number of the head or the definition of a serial number is not sensible + in the current setup. Clients can still try to identify the head by + available information from other events but should be aware that there + is an increased risk of false positives. + + If sent, the serial number event is sent after a wlr_output_head object + is created and only sent once per object. The serial number does not + change over the lifetime of the wlr_output_head object. + + It is not recommended to display the serial_number string in UI to + users. For that the string provided by the description event should be + preferred. + + + + + + + + + This request indicates that the client will no longer use this head + object. + + + + + + + + + + + + + This event describes whether adaptive sync is currently enabled for + the head or not. Adaptive sync is also known as Variable Refresh + Rate or VRR. + + + + + + + + This object describes an output mode. + + Some heads don't support output modes, in which case modes won't be + advertised. + + Properties sent via this interface are applied atomically via the + wlr_output_manager.done event. No guarantees are made regarding the order + in which properties are sent. + + + + + This event describes the mode size. The size is given in physical + hardware units of the output device. This is not necessarily the same as + the output size in the global compositor space. For instance, the output + may be scaled or transformed. + + + + + + + + This event describes the mode's fixed vertical refresh rate. It is only + sent if the mode has a fixed refresh rate. + + + + + + + This event advertises this mode as preferred. + + + + + + This event indicates that the mode is no longer available. The mode + object becomes inert. Clients should send a destroy request and release + any resources associated with it. + + + + + + + + This request indicates that the client will no longer use this mode + object. + + + + + + + This object is used by the client to describe a full output configuration. + + First, the client needs to setup the output configuration. Each head can + be either enabled (and configured) or disabled. It is a protocol error to + send two enable_head or disable_head requests with the same head. It is a + protocol error to omit a head in a configuration. + + Then, the client can apply or test the configuration. The compositor will + then reply with a succeeded, failed or cancelled event. Finally the client + should destroy the configuration object. + + + + + + + + + + + Enable a head. This request creates a head configuration object that can + be used to change the head's properties. + + + + + + + + Disable a head. + + + + + + + Apply the new output configuration. + + In case the configuration is successfully applied, there is no guarantee + that the new output state matches completely the requested + configuration. For instance, a compositor might round the scale if it + doesn't support fractional scaling. + + After this request has been sent, the compositor must respond with an + succeeded, failed or cancelled event. Sending a request that isn't the + destructor is a protocol error. + + + + + + Test the new output configuration. The configuration won't be applied, + but will only be validated. + + Even if the compositor succeeds to test a configuration, applying it may + fail. + + After this request has been sent, the compositor must respond with an + succeeded, failed or cancelled event. Sending a request that isn't the + destructor is a protocol error. + + + + + + Sent after the compositor has successfully applied the changes or + tested them. + + Upon receiving this event, the client should destroy this object. + + If the current configuration has changed, events to describe the changes + will be sent followed by a wlr_output_manager.done event. + + + + + + Sent if the compositor rejects the changes or failed to apply them. The + compositor should revert any changes made by the apply request that + triggered this event. + + Upon receiving this event, the client should destroy this object. + + + + + + Sent if the compositor cancels the configuration because the state of an + output changed and the client has outdated information (e.g. after an + output has been hotplugged). + + The client can create a new configuration with a newer serial and try + again. + + Upon receiving this event, the client should destroy this object. + + + + + + Using this request a client can tell the compositor that it is not going + to use the configuration object anymore. Any changes to the outputs + that have not been applied will be discarded. + + This request also destroys wlr_output_configuration_head objects created + via this object. + + + + + + + This object is used by the client to update a single head's configuration. + + It is a protocol error to set the same property twice. + + + + + + + + + + + + + + This request sets the head's mode. + + + + + + + This request assigns a custom mode to the head. The size is given in + physical hardware units of the output device. If set to zero, the + refresh rate is unspecified. + + It is a protocol error to set both a mode and a custom mode. + + + + + + + + + This request sets the head's position in the global compositor space. + + + + + + + + This request sets the head's transform. + + + + + + + This request sets the head's scale. + + + + + + + + + This request enables/disables adaptive sync. Adaptive sync is also + known as Variable Refresh Rate or VRR. + + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-output-power-management-unstable-v1.xml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-output-power-management-unstable-v1.xml new file mode 100644 index 0000000..20dbb77 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-output-power-management-unstable-v1.xml @@ -0,0 +1,128 @@ + + + + Copyright © 2019 Purism SPC + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + This protocol allows clients to control power management modes + of outputs that are currently part of the compositor space. The + intent is to allow special clients like desktop shells to power + down outputs when the system is idle. + + To modify outputs not currently part of the compositor space see + wlr-output-management. + + Warning! The protocol described in this file is experimental and + backward incompatible changes may be made. Backward compatible changes + may be added together with the corresponding interface version bump. + Backward incompatible changes are done by bumping the version number in + the protocol and interface names and resetting the interface version. + Once the protocol is to be declared stable, the 'z' prefix and the + version number in the protocol and interface names are removed and the + interface version number is reset. + + + + + This interface is a manager that allows creating per-output power + management mode controls. + + + + + Create an output power management mode control that can be used to + adjust the power management mode for a given output. + + + + + + + + All objects created by the manager will still remain valid, until their + appropriate destroy request has been called. + + + + + + + This object offers requests to set the power management mode of + an output. + + + + + + + + + + + + + + Set an output's power save mode to the given mode. The mode change + is effective immediately. If the output does not support the given + mode a failed event is sent. + + + + + + + Report the power management mode change of an output. + + The mode event is sent after an output changed its power + management mode. The reason can be a client using set_mode or the + compositor deciding to change an output's mode. + This event is also sent immediately when the object is created + so the client is informed about the current power management mode. + + + + + + + This event indicates that the output power management mode control + is no longer valid. This can happen for a number of reasons, + including: + - The output doesn't support power management + - Another client already has exclusive power management mode control + for this output + - The output disappeared + + Upon receiving this event, the client should destroy this object. + + + + + + Destroys the output power management mode control object. + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-screencopy-unstable-v1.xml b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-screencopy-unstable-v1.xml new file mode 100644 index 0000000..8b20661 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/proto/xml/wlr-screencopy-unstable-v1.xml @@ -0,0 +1,234 @@ + + + + Copyright © 2018 Simon Ser + Copyright © 2019 Andri Yngvason + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + This protocol allows clients to ask the compositor to copy part of the + screen content to a client buffer. + + Warning! The protocol described in this file is experimental and + backward incompatible changes may be made. Backward compatible changes + may be added together with the corresponding interface version bump. + Backward incompatible changes are done by bumping the version number in + the protocol and interface names and resetting the interface version. + Once the protocol is to be declared stable, the 'z' prefix and the + version number in the protocol and interface names are removed and the + interface version number is reset. + + Note! This protocol is deprecated and not intended for production use. + The ext-image-copy-capture-v1 protocol should be used instead. + + + + + This object is a manager which offers requests to start capturing from a + source. + + + + + Capture the next frame of an entire output. + + + + + + + + + Capture the next frame of an output's region. + + The region is given in output logical coordinates, see + xdg_output.logical_size. The region will be clipped to the output's + extents. + + + + + + + + + + + + + All objects created by the manager will still remain valid, until their + appropriate destroy request has been called. + + + + + + + This object represents a single frame. + + When created, a series of buffer events will be sent, each representing a + supported buffer type. The "buffer_done" event is sent afterwards to + indicate that all supported buffer types have been enumerated. The client + will then be able to send a "copy" request. If the capture is successful, + the compositor will send a "flags" event followed by a "ready" event. + + For objects version 2 or lower, wl_shm buffers are always supported, ie. + the "buffer" event is guaranteed to be sent. + + If the capture failed, the "failed" event is sent. This can happen anytime + before the "ready" event. + + Once either a "ready" or a "failed" event is received, the client should + destroy the frame. + + + + + Provides information about wl_shm buffer parameters that need to be + used for this frame. This event is sent once after the frame is created + if wl_shm buffers are supported. + + + + + + + + + + Copy the frame to the supplied buffer. The buffer must have the + correct size, see zwlr_screencopy_frame_v1.buffer and + zwlr_screencopy_frame_v1.linux_dmabuf. The buffer needs to have a + supported format. + + If the frame is successfully copied, "flags" and "ready" events are + sent. Otherwise, a "failed" event is sent. + + + + + + + + + + + + + + + + Provides flags about the frame. This event is sent once before the + "ready" event. + + + + + + + Called as soon as the frame is copied, indicating it is available + for reading. This event includes the time at which the presentation took place. + + The timestamp is expressed as tv_sec_hi, tv_sec_lo, tv_nsec triples, + each component being an unsigned 32-bit value. Whole seconds are in + tv_sec which is a 64-bit value combined from tv_sec_hi and tv_sec_lo, + and the additional fractional part in tv_nsec as nanoseconds. Hence, + for valid timestamps tv_nsec must be in [0, 999999999]. The seconds part + may have an arbitrary offset at start. + + After receiving this event, the client should destroy the object. + + + + + + + + + This event indicates that the attempted frame copy has failed. + + After receiving this event, the client should destroy the object. + + + + + + Destroys the frame. This request can be sent at any time by the client. + + + + + + + Same as copy, except it waits until there is damage to copy. + + + + + + + This event is sent right before the ready event when copy_with_damage is + requested. It may be generated multiple times for each copy_with_damage + request. + + The arguments describe a box around an area that has changed since the + last copy request that was derived from the current screencopy manager + instance. + + The union of all regions received between the call to copy_with_damage + and a ready event is the total damage since the prior ready event. + + + + + + + + + + + Provides information about linux-dmabuf buffer parameters that need to + be used for this frame. This event is sent once after the frame is + created if linux-dmabuf buffers are supported. + + + + + + + + + This event is sent once after all buffer events have been sent. + + The client should proceed to create a buffer of one of the supported + types, and send a "copy" request. + + + + diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/compositor.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/compositor.go new file mode 100644 index 0000000..9bd5f0e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/compositor.go @@ -0,0 +1,694 @@ +package screenshot + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/dwl_ipc" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" + wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type Compositor int + +const ( + CompositorUnknown Compositor = iota + CompositorHyprland + CompositorSway + CompositorNiri + CompositorDWL + CompositorScroll + CompositorMiracle +) + +var detectedCompositor Compositor = -1 + +func DetectCompositor() Compositor { + if detectedCompositor >= 0 { + return detectedCompositor + } + + hyprlandSig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") + niriSocket := os.Getenv("NIRI_SOCKET") + swaySocket := os.Getenv("SWAYSOCK") + scrollSocket := os.Getenv("SCROLLSOCK") + miracleSocket := os.Getenv("MIRACLESOCK") + + switch { + case niriSocket != "": + if _, err := os.Stat(niriSocket); err == nil { + detectedCompositor = CompositorNiri + return detectedCompositor + } + case scrollSocket != "": + if _, err := os.Stat(scrollSocket); err == nil { + detectedCompositor = CompositorScroll + return detectedCompositor + } + case miracleSocket != "": + if _, err := os.Stat(miracleSocket); err == nil { + detectedCompositor = CompositorMiracle + return detectedCompositor + } + case swaySocket != "": + if _, err := os.Stat(swaySocket); err == nil { + detectedCompositor = CompositorSway + return detectedCompositor + } + case hyprlandSig != "": + detectedCompositor = CompositorHyprland + return detectedCompositor + } + + if detectDWLProtocol() { + detectedCompositor = CompositorDWL + return detectedCompositor + } + + detectedCompositor = CompositorUnknown + return detectedCompositor +} + +func detectDWLProtocol() bool { + display, err := client.Connect("") + if err != nil { + return false + } + ctx := display.Context() + defer ctx.Close() + + registry, err := display.GetRegistry() + if err != nil { + return false + } + + found := false + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + if e.Interface == dwl_ipc.ZdwlIpcManagerV2InterfaceName { + found = true + } + }) + + if err := wlhelpers.Roundtrip(display, ctx); err != nil { + return false + } + + return found +} + +func SetCompositorDWL() { + detectedCompositor = CompositorDWL +} + +type WindowGeometry struct { + X int32 + Y int32 + Width int32 + Height int32 + Output string + Scale float64 + OutputX int32 + OutputY int32 + OutputTransform int32 +} + +func GetActiveWindow() (*WindowGeometry, error) { + switch DetectCompositor() { + case CompositorHyprland: + return getHyprlandActiveWindow() + case CompositorDWL: + return getDWLActiveWindow() + default: + return nil, fmt.Errorf("window capture requires Hyprland or DWL") + } +} + +type hyprlandWindow struct { + At [2]int32 `json:"at"` + Size [2]int32 `json:"size"` +} + +func getHyprlandActiveWindow() (*WindowGeometry, error) { + output, err := exec.Command("hyprctl", "-j", "activewindow").Output() + if err != nil { + return nil, fmt.Errorf("hyprctl activewindow: %w", err) + } + + var win hyprlandWindow + if err := json.Unmarshal(output, &win); err != nil { + return nil, fmt.Errorf("parse activewindow: %w", err) + } + + if win.Size[0] <= 0 || win.Size[1] <= 0 { + return nil, fmt.Errorf("no active window") + } + + return &WindowGeometry{ + X: win.At[0], + Y: win.At[1], + Width: win.Size[0], + Height: win.Size[1], + }, nil +} + +type hyprlandMonitor struct { + Name string `json:"name"` + X int32 `json:"x"` + Y int32 `json:"y"` + Width int32 `json:"width"` + Height int32 `json:"height"` + Scale float64 `json:"scale"` + Focused bool `json:"focused"` +} + +func GetHyprlandMonitorScale(name string) float64 { + output, err := exec.Command("hyprctl", "-j", "monitors").Output() + if err != nil { + return 0 + } + + var monitors []hyprlandMonitor + if err := json.Unmarshal(output, &monitors); err != nil { + return 0 + } + + for _, m := range monitors { + if m.Name == name { + return m.Scale + } + } + return 0 +} + +func getHyprlandFocusedMonitor() string { + output, err := exec.Command("hyprctl", "-j", "monitors").Output() + if err != nil { + return "" + } + + var monitors []hyprlandMonitor + if err := json.Unmarshal(output, &monitors); err != nil { + return "" + } + + for _, m := range monitors { + if m.Focused { + return m.Name + } + } + return "" +} + +func GetHyprlandMonitorGeometry(name string) (x, y, w, h int32, ok bool) { + output, err := exec.Command("hyprctl", "-j", "monitors").Output() + if err != nil { + return 0, 0, 0, 0, false + } + + var monitors []hyprlandMonitor + if err := json.Unmarshal(output, &monitors); err != nil { + return 0, 0, 0, 0, false + } + + for _, m := range monitors { + if m.Name == name { + logicalW := int32(float64(m.Width) / m.Scale) + logicalH := int32(float64(m.Height) / m.Scale) + return m.X, m.Y, logicalW, logicalH, true + } + } + return 0, 0, 0, 0, false +} + +type swayWorkspace struct { + Output string `json:"output"` + Focused bool `json:"focused"` +} + +func getSwayFocusedMonitor() string { + output, err := exec.Command("swaymsg", "-t", "get_workspaces").Output() + if err != nil { + return "" + } + + var workspaces []swayWorkspace + if err := json.Unmarshal(output, &workspaces); err != nil { + return "" + } + + for _, ws := range workspaces { + if ws.Focused { + return ws.Output + } + } + return "" +} + +func getScrollFocusedMonitor() string { + output, err := exec.Command("scrollmsg", "-t", "get_workspaces").Output() + if err != nil { + return "" + } + + var workspaces []swayWorkspace + if err := json.Unmarshal(output, &workspaces); err != nil { + return "" + } + + for _, ws := range workspaces { + if ws.Focused { + return ws.Output + } + } + return "" +} + +func getMiracleFocusedMonitor() string { + output, err := exec.Command("miraclemsg", "-t", "get_workspaces").Output() + if err != nil { + return "" + } + + var workspaces []swayWorkspace + if err := json.Unmarshal(output, &workspaces); err != nil { + return "" + } + + for _, ws := range workspaces { + if ws.Focused { + return ws.Output + } + } + return "" +} + +type niriWorkspace struct { + Output string `json:"output"` + IsFocused bool `json:"is_focused"` +} + +func getNiriFocusedMonitor() string { + output, err := exec.Command("niri", "msg", "-j", "workspaces").Output() + if err != nil { + return "" + } + + var workspaces []niriWorkspace + if err := json.Unmarshal(output, &workspaces); err != nil { + return "" + } + + for _, ws := range workspaces { + if ws.IsFocused { + return ws.Output + } + } + return "" +} + +var dwlActiveOutput string + +func SetDWLActiveOutput(name string) { + dwlActiveOutput = name +} + +func getDWLFocusedMonitor() string { + if dwlActiveOutput != "" { + return dwlActiveOutput + } + return queryDWLActiveOutput() +} + +func queryDWLActiveOutput() string { + display, err := client.Connect("") + if err != nil { + return "" + } + ctx := display.Context() + defer ctx.Close() + + registry, err := display.GetRegistry() + if err != nil { + return "" + } + + var dwlManager *dwl_ipc.ZdwlIpcManagerV2 + outputs := make(map[uint32]*client.Output) + + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + switch e.Interface { + case dwl_ipc.ZdwlIpcManagerV2InterfaceName: + mgr := dwl_ipc.NewZdwlIpcManagerV2(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil { + dwlManager = mgr + } + case client.OutputInterfaceName: + out := client.NewOutput(ctx) + version := e.Version + if version > 4 { + version = 4 + } + if err := registry.Bind(e.Name, e.Interface, version, out); err == nil { + outputs[e.Name] = out + } + } + }) + + if err := wlhelpers.Roundtrip(display, ctx); err != nil { + return "" + } + + if dwlManager == nil || len(outputs) == 0 { + return "" + } + + outputNames := make(map[uint32]string) + for name, out := range outputs { + n := name + out.SetNameHandler(func(e client.OutputNameEvent) { + outputNames[n] = e.Name + }) + } + + if err := wlhelpers.Roundtrip(display, ctx); err != nil { + return "" + } + + type outputState struct { + name string + active bool + gotFrame bool + } + states := make(map[uint32]*outputState) + + for name, out := range outputs { + dwlOut, err := dwlManager.GetOutput(out) + if err != nil { + continue + } + state := &outputState{name: outputNames[name]} + states[name] = state + + dwlOut.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) { + state.active = e.Active != 0 + }) + dwlOut.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) { + state.gotFrame = true + }) + } + + allFramesReceived := func() bool { + for _, s := range states { + if !s.gotFrame { + return false + } + } + return true + } + + for !allFramesReceived() { + if err := ctx.Dispatch(); err != nil { + return "" + } + } + + for _, state := range states { + if state.active { + return state.name + } + } + + return "" +} + +func GetFocusedMonitor() string { + switch DetectCompositor() { + case CompositorHyprland: + return getHyprlandFocusedMonitor() + case CompositorSway: + return getSwayFocusedMonitor() + case CompositorScroll: + return getScrollFocusedMonitor() + case CompositorMiracle: + return getMiracleFocusedMonitor() + case CompositorNiri: + return getNiriFocusedMonitor() + case CompositorDWL: + return getDWLFocusedMonitor() + } + return "" +} + +type outputInfo struct { + x, y int32 + scale float64 + transform int32 +} + +func getAllOutputInfos() map[string]*outputInfo { + display, err := client.Connect("") + if err != nil { + return nil + } + ctx := display.Context() + defer ctx.Close() + + registry, err := display.GetRegistry() + if err != nil { + return nil + } + + var outputManager *wlr_output_management.ZwlrOutputManagerV1 + + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName { + mgr := wlr_output_management.NewZwlrOutputManagerV1(ctx) + version := e.Version + if version > 4 { + version = 4 + } + if err := registry.Bind(e.Name, e.Interface, version, mgr); err == nil { + outputManager = mgr + } + } + }) + + if err := wlhelpers.Roundtrip(display, ctx); err != nil { + return nil + } + + if outputManager == nil { + return nil + } + + type headState struct { + name string + x, y int32 + scale float64 + transform int32 + } + heads := make(map[*wlr_output_management.ZwlrOutputHeadV1]*headState) + done := false + + outputManager.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) { + state := &headState{} + heads[e.Head] = state + e.Head.SetNameHandler(func(ne wlr_output_management.ZwlrOutputHeadV1NameEvent) { + state.name = ne.Name + }) + e.Head.SetPositionHandler(func(pe wlr_output_management.ZwlrOutputHeadV1PositionEvent) { + state.x = pe.X + state.y = pe.Y + }) + e.Head.SetScaleHandler(func(se wlr_output_management.ZwlrOutputHeadV1ScaleEvent) { + state.scale = se.Scale + }) + e.Head.SetTransformHandler(func(te wlr_output_management.ZwlrOutputHeadV1TransformEvent) { + state.transform = te.Transform + }) + }) + outputManager.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) { + done = true + }) + + for !done { + if err := ctx.Dispatch(); err != nil { + return nil + } + } + + result := make(map[string]*outputInfo, len(heads)) + for _, state := range heads { + if state.name == "" { + continue + } + result[state.name] = &outputInfo{ + x: state.x, + y: state.y, + scale: state.scale, + transform: state.transform, + } + } + return result +} + +func getOutputInfo(outputName string) (*outputInfo, bool) { + infos := getAllOutputInfos() + if infos == nil { + return nil, false + } + info, ok := infos[outputName] + return info, ok +} + +func getDWLActiveWindow() (*WindowGeometry, error) { + display, err := client.Connect("") + if err != nil { + return nil, fmt.Errorf("connect: %w", err) + } + ctx := display.Context() + defer ctx.Close() + + registry, err := display.GetRegistry() + if err != nil { + return nil, fmt.Errorf("get registry: %w", err) + } + + var dwlManager *dwl_ipc.ZdwlIpcManagerV2 + outputs := make(map[uint32]*client.Output) + + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + switch e.Interface { + case dwl_ipc.ZdwlIpcManagerV2InterfaceName: + mgr := dwl_ipc.NewZdwlIpcManagerV2(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil { + dwlManager = mgr + } + case client.OutputInterfaceName: + out := client.NewOutput(ctx) + version := e.Version + if version > 4 { + version = 4 + } + if err := registry.Bind(e.Name, e.Interface, version, out); err == nil { + outputs[e.Name] = out + } + } + }) + + if err := wlhelpers.Roundtrip(display, ctx); err != nil { + return nil, fmt.Errorf("roundtrip: %w", err) + } + + if dwlManager == nil { + return nil, fmt.Errorf("dwl_ipc_manager not available") + } + + if len(outputs) == 0 { + return nil, fmt.Errorf("no outputs found") + } + + outputNames := make(map[uint32]string) + for name, out := range outputs { + n := name + out.SetNameHandler(func(e client.OutputNameEvent) { + outputNames[n] = e.Name + }) + } + + if err := wlhelpers.Roundtrip(display, ctx); err != nil { + return nil, fmt.Errorf("roundtrip: %w", err) + } + + type dwlOutputState struct { + output *dwl_ipc.ZdwlIpcOutputV2 + name string + active bool + x, y int32 + w, h int32 + scalefactor uint32 + gotFrame bool + } + + dwlOutputs := make(map[uint32]*dwlOutputState) + for name, out := range outputs { + dwlOut, err := dwlManager.GetOutput(out) + if err != nil { + continue + } + state := &dwlOutputState{output: dwlOut, name: outputNames[name]} + dwlOutputs[name] = state + + dwlOut.SetActiveHandler(func(e dwl_ipc.ZdwlIpcOutputV2ActiveEvent) { + state.active = e.Active != 0 + }) + dwlOut.SetXHandler(func(e dwl_ipc.ZdwlIpcOutputV2XEvent) { + state.x = e.X + }) + dwlOut.SetYHandler(func(e dwl_ipc.ZdwlIpcOutputV2YEvent) { + state.y = e.Y + }) + dwlOut.SetWidthHandler(func(e dwl_ipc.ZdwlIpcOutputV2WidthEvent) { + state.w = e.Width + }) + dwlOut.SetHeightHandler(func(e dwl_ipc.ZdwlIpcOutputV2HeightEvent) { + state.h = e.Height + }) + dwlOut.SetScalefactorHandler(func(e dwl_ipc.ZdwlIpcOutputV2ScalefactorEvent) { + state.scalefactor = e.Scalefactor + }) + dwlOut.SetFrameHandler(func(e dwl_ipc.ZdwlIpcOutputV2FrameEvent) { + state.gotFrame = true + }) + } + + allFramesReceived := func() bool { + for _, s := range dwlOutputs { + if !s.gotFrame { + return false + } + } + return true + } + + for !allFramesReceived() { + if err := ctx.Dispatch(); err != nil { + return nil, fmt.Errorf("dispatch: %w", err) + } + } + + for _, state := range dwlOutputs { + if !state.active { + continue + } + if state.w <= 0 || state.h <= 0 { + return nil, fmt.Errorf("no active window") + } + scale := float64(state.scalefactor) / 100.0 + if scale <= 0 { + scale = 1.0 + } + + geom := &WindowGeometry{ + X: state.x, + Y: state.y, + Width: state.w, + Height: state.h, + Output: state.name, + Scale: scale, + } + + if info, ok := getOutputInfo(state.name); ok { + geom.OutputX = info.x + geom.OutputY = info.y + geom.OutputTransform = info.transform + } + + return geom, nil + } + + return nil, fmt.Errorf("no active output found") +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/encode.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/encode.go new file mode 100644 index 0000000..8b40dca --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/encode.go @@ -0,0 +1,172 @@ +package screenshot + +import ( + "bufio" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func BufferToImage(buf *ShmBuffer) *image.RGBA { + return BufferToImageWithFormat(buf, uint32(FormatARGB8888)) +} + +func BufferToImageWithFormat(buf *ShmBuffer, format uint32) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, buf.Width, buf.Height)) + data := buf.Data() + + var swapRB bool + switch format { + case uint32(FormatABGR8888), uint32(FormatXBGR8888): + swapRB = false + default: + swapRB = true + } + + for y := 0; y < buf.Height; y++ { + srcOff := y * buf.Stride + dstOff := y * img.Stride + for x := 0; x < buf.Width; x++ { + si := srcOff + x*4 + di := dstOff + x*4 + if si+3 >= len(data) || di+3 >= len(img.Pix) { + continue + } + if swapRB { + img.Pix[di+0] = data[si+2] + img.Pix[di+1] = data[si+1] + img.Pix[di+2] = data[si+0] + } else { + img.Pix[di+0] = data[si+0] + img.Pix[di+1] = data[si+1] + img.Pix[di+2] = data[si+2] + } + img.Pix[di+3] = 255 + } + } + return img +} + +func EncodePNG(w io.Writer, img image.Image) error { + enc := png.Encoder{CompressionLevel: png.BestSpeed} + return enc.Encode(w, img) +} + +func EncodeJPEG(w io.Writer, img image.Image, quality int) error { + return jpeg.Encode(w, img, &jpeg.Options{Quality: quality}) +} + +func EncodePPM(w io.Writer, img *image.RGBA) error { + bw := bufio.NewWriter(w) + bounds := img.Bounds() + if _, err := fmt.Fprintf(bw, "P6\n%d %d\n255\n", bounds.Dx(), bounds.Dy()); err != nil { + return err + } + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + off := (y-bounds.Min.Y)*img.Stride + (x-bounds.Min.X)*4 + if err := bw.WriteByte(img.Pix[off+0]); err != nil { + return err + } + if err := bw.WriteByte(img.Pix[off+1]); err != nil { + return err + } + if err := bw.WriteByte(img.Pix[off+2]); err != nil { + return err + } + } + } + return bw.Flush() +} + +func GenerateFilename(format Format) string { + t := time.Now() + ext := "png" + switch format { + case FormatJPEG: + ext = "jpg" + case FormatPPM: + ext = "ppm" + } + return fmt.Sprintf("screenshot_%s.%s", t.Format("2006-01-02_15-04-05"), ext) +} + +func GetOutputDir() string { + if dir := os.Getenv("DMS_SCREENSHOT_DIR"); dir != "" { + return dir + } + + if xdgPics := getXDGPicturesDir(); xdgPics != "" { + screenshotDir := filepath.Join(xdgPics, "Screenshots") + if err := os.MkdirAll(screenshotDir, 0o755); err == nil { + return screenshotDir + } + return xdgPics + } + + if home := os.Getenv("HOME"); home != "" { + return home + } + return "." +} + +func getXDGPicturesDir() string { + userConfigDir, err := os.UserConfigDir() + if err != nil { + log.Error("failed to get user config dir", "err", err) + return "" + } + userDirsFile := filepath.Join(userConfigDir, "user-dirs.dirs") + data, err := os.ReadFile(userDirsFile) + if err != nil { + return "" + } + + for _, line := range strings.Split(string(data), "\n") { + if len(line) == 0 || line[0] == '#' { + continue + } + const prefix = "XDG_PICTURES_DIR=" + if !strings.HasPrefix(line, prefix) { + continue + } + path := strings.Trim(line[len(prefix):], "\"") + expanded, err := utils.ExpandPath(path) + if err != nil { + return "" + } + return expanded + } + return "" +} + +func WriteToFile(buf *ShmBuffer, path string, format Format, quality int) error { + return WriteToFileWithFormat(buf, path, format, quality, uint32(FormatARGB8888)) +} + +func WriteToFileWithFormat(buf *ShmBuffer, path string, format Format, quality int, pixelFormat uint32) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + img := BufferToImageWithFormat(buf, pixelFormat) + switch format { + case FormatJPEG: + return EncodeJPEG(f, img, quality) + case FormatPPM: + return EncodePPM(f, img) + default: + return EncodePNG(f, img) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/notify.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/notify.go new file mode 100644 index 0000000..9633b42 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/notify.go @@ -0,0 +1,180 @@ +package screenshot + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/godbus/dbus/v5" +) + +const ( + notifyDest = "org.freedesktop.Notifications" + notifyPath = "/org/freedesktop/Notifications" + notifyInterface = "org.freedesktop.Notifications" +) + +type NotifyResult struct { + FilePath string + Clipboard bool + ImageData []byte + Width int + Height int +} + +func SendNotification(result NotifyResult) { + conn, err := dbus.SessionBus() + if err != nil { + log.Debug("dbus session failed", "err", err) + return + } + + var actions []string + if result.FilePath != "" { + actions = []string{"default", "Open"} + } + + hints := map[string]dbus.Variant{} + if len(result.ImageData) > 0 && result.Width > 0 && result.Height > 0 { + rowstride := result.Width * 3 + hints["image_data"] = dbus.MakeVariant(struct { + Width int32 + Height int32 + Rowstride int32 + HasAlpha bool + BitsPerSample int32 + Channels int32 + Data []byte + }{ + Width: int32(result.Width), + Height: int32(result.Height), + Rowstride: int32(rowstride), + HasAlpha: false, + BitsPerSample: 8, + Channels: 3, + Data: result.ImageData, + }) + } else if result.FilePath != "" { + hints["image_path"] = dbus.MakeVariant(result.FilePath) + } + + summary := "Screenshot captured" + body := "" + if result.Clipboard && result.FilePath != "" { + body = fmt.Sprintf("Copied to clipboard\n%s", filepath.Base(result.FilePath)) + } else if result.Clipboard { + body = "Copied to clipboard" + } else if result.FilePath != "" { + body = filepath.Base(result.FilePath) + } + + obj := conn.Object(notifyDest, notifyPath) + call := obj.Call( + notifyInterface+".Notify", + 0, + "DMS", + uint32(0), + "", + summary, + body, + actions, + hints, + int32(5000), + ) + + if call.Err != nil { + log.Debug("notify call failed", "err", call.Err) + return + } + + var notificationID uint32 + if err := call.Store(¬ificationID); err != nil { + log.Debug("failed to get notification id", "err", err) + return + } + + if len(actions) == 0 || result.FilePath == "" { + return + } + + spawnActionListener(notificationID, result.FilePath) +} + +func spawnActionListener(notificationID uint32, filePath string) { + exe, err := os.Executable() + if err != nil { + log.Debug("failed to get executable", "err", err) + return + } + + cmd := exec.Command(exe, "notify-action", fmt.Sprintf("%d", notificationID), filePath) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + cmd.Start() +} + +func RunNotifyActionListener(args []string) { + if len(args) < 2 { + return + } + + notificationID, err := strconv.ParseUint(args[0], 10, 32) + if err != nil { + return + } + + filePath := args[1] + + conn, err := dbus.SessionBus() + if err != nil { + return + } + + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(notifyPath), + dbus.WithMatchInterface(notifyInterface), + ); err != nil { + return + } + + signals := make(chan *dbus.Signal, 10) + conn.Signal(signals) + + for sig := range signals { + switch sig.Name { + case notifyInterface + ".ActionInvoked": + if len(sig.Body) < 2 { + continue + } + id, ok := sig.Body[0].(uint32) + if !ok || id != uint32(notificationID) { + continue + } + openFile(filePath) + return + + case notifyInterface + ".NotificationClosed": + if len(sig.Body) < 1 { + continue + } + id, ok := sig.Body[0].(uint32) + if !ok || id != uint32(notificationID) { + continue + } + return + } + } +} + +func openFile(filePath string) { + cmd := exec.Command("xdg-open", filePath) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + cmd.Start() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region.go new file mode 100644 index 0000000..f296434 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region.go @@ -0,0 +1,859 @@ +package screenshot + +import ( + "fmt" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/keyboard_shortcuts_inhibit" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_layer_shell" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wp_viewporter" + wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type SelectionState struct { + hasSelection bool // There's a selection to display (pre-loaded or user-drawn) + dragging bool // User is actively drawing a new selection + surface *OutputSurface // Surface where selection was made + // Surface-local logical coordinates (from pointer events) + anchorX float64 + anchorY float64 + currentX float64 + currentY float64 +} + +type RenderSlot struct { + shm *ShmBuffer + pool *client.ShmPool + wlBuf *client.Buffer + busy bool +} + +type OutputSurface struct { + output *WaylandOutput + wlSurface *client.Surface + layerSurf *wlr_layer_shell.ZwlrLayerSurfaceV1 + viewport *wp_viewporter.WpViewport + screenBuf *ShmBuffer + screenBufNoCursor *ShmBuffer + screenFormat uint32 + logicalW int + logicalH int + configured bool + yInverted bool + + // Triple-buffered render slots + slots [3]*RenderSlot + slotsReady bool +} + +type PreCapture struct { + screenBuf *ShmBuffer + screenBufNoCursor *ShmBuffer + format uint32 + yInverted bool +} + +type RegionSelector struct { + screenshoter *Screenshoter + + display *client.Display + registry *client.Registry + ctx *client.Context + + compositor *client.Compositor + shm *client.Shm + seat *client.Seat + pointer *client.Pointer + keyboard *client.Keyboard + layerShell *wlr_layer_shell.ZwlrLayerShellV1 + screencopy *wlr_screencopy.ZwlrScreencopyManagerV1 + viewporter *wp_viewporter.WpViewporter + + shortcutsInhibitMgr *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitManagerV1 + shortcutsInhibitor *keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitorV1 + + outputs map[uint32]*WaylandOutput + outputsMu sync.Mutex + preCapture map[*WaylandOutput]*PreCapture + + surfaces []*OutputSurface + activeSurface *OutputSurface + + // Cursor surface for crosshair + cursorSurface *client.Surface + cursorBuffer *ShmBuffer + cursorWlBuf *client.Buffer + cursorPool *client.ShmPool + + selection SelectionState + pointerX float64 + pointerY float64 + preSelect Region + showCapturedCursor bool + shiftHeld bool + + running bool + cancelled bool + result Region + + capturedBuffer *ShmBuffer + capturedRegion Region +} + +func NewRegionSelector(s *Screenshoter) *RegionSelector { + return &RegionSelector{ + screenshoter: s, + outputs: make(map[uint32]*WaylandOutput), + preCapture: make(map[*WaylandOutput]*PreCapture), + showCapturedCursor: s.config.Cursor == CursorOn, + } +} + +func (r *RegionSelector) Run() (*CaptureResult, bool, error) { + if r.screenshoter != nil && r.screenshoter.config.Reset { + r.preSelect = Region{} + } else { + r.preSelect = GetLastRegion() + } + + if err := r.connect(); err != nil { + return nil, false, fmt.Errorf("wayland connect: %w", err) + } + defer r.cleanup() + + if err := r.setupRegistry(); err != nil { + return nil, false, fmt.Errorf("registry setup: %w", err) + } + + if err := r.roundtrip(); err != nil { + return nil, false, fmt.Errorf("roundtrip after registry: %w", err) + } + + switch { + case r.screencopy == nil: + return nil, false, fmt.Errorf("compositor does not support wlr-screencopy-unstable-v1") + case r.layerShell == nil: + return nil, false, fmt.Errorf("compositor does not support wlr-layer-shell-unstable-v1") + case r.seat == nil: + return nil, false, fmt.Errorf("no seat available") + case r.compositor == nil: + return nil, false, fmt.Errorf("compositor not available") + case r.shm == nil: + return nil, false, fmt.Errorf("wl_shm not available") + case len(r.outputs) == 0: + return nil, false, fmt.Errorf("no outputs available") + } + + if err := r.roundtrip(); err != nil { + return nil, false, fmt.Errorf("roundtrip after protocol check: %w", err) + } + + if err := r.preCaptureAllOutputs(); err != nil { + return nil, false, fmt.Errorf("pre-capture: %w", err) + } + + if err := r.createSurfaces(); err != nil { + return nil, false, fmt.Errorf("create surfaces: %w", err) + } + + _ = r.createCursor() + + if err := r.roundtrip(); err != nil { + return nil, false, fmt.Errorf("roundtrip after surfaces: %w", err) + } + + r.running = true + for r.running { + if err := r.ctx.Dispatch(); err != nil { + return nil, false, fmt.Errorf("dispatch: %w", err) + } + } + + if r.cancelled || r.capturedBuffer == nil { + return nil, r.cancelled, nil + } + + yInverted := false + var format uint32 + if r.selection.surface != nil { + yInverted = r.selection.surface.yInverted + format = r.selection.surface.screenFormat + } + + return &CaptureResult{ + Buffer: r.capturedBuffer, + Region: r.result, + YInverted: yInverted, + Format: format, + }, false, nil +} + +func (r *RegionSelector) connect() error { + display, err := client.Connect("") + if err != nil { + return err + } + r.display = display + r.ctx = display.Context() + return nil +} + +func (r *RegionSelector) roundtrip() error { + return wlhelpers.Roundtrip(r.display, r.ctx) +} + +func (r *RegionSelector) setupRegistry() error { + registry, err := r.display.GetRegistry() + if err != nil { + return err + } + r.registry = registry + + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + r.handleGlobal(e) + }) + + registry.SetGlobalRemoveHandler(func(e client.RegistryGlobalRemoveEvent) { + r.outputsMu.Lock() + delete(r.outputs, e.Name) + r.outputsMu.Unlock() + }) + + return nil +} + +func (r *RegionSelector) handleGlobal(e client.RegistryGlobalEvent) { + switch e.Interface { + case client.CompositorInterfaceName: + comp := client.NewCompositor(r.ctx) + if err := r.registry.Bind(e.Name, e.Interface, e.Version, comp); err == nil { + r.compositor = comp + } + + case client.ShmInterfaceName: + shm := client.NewShm(r.ctx) + if err := r.registry.Bind(e.Name, e.Interface, e.Version, shm); err == nil { + r.shm = shm + } + + case client.SeatInterfaceName: + seat := client.NewSeat(r.ctx) + if err := r.registry.Bind(e.Name, e.Interface, e.Version, seat); err == nil { + r.seat = seat + r.setupInput() + } + + case client.OutputInterfaceName: + output := client.NewOutput(r.ctx) + version := e.Version + if version > 4 { + version = 4 + } + if err := r.registry.Bind(e.Name, e.Interface, version, output); err == nil { + r.outputsMu.Lock() + r.outputs[e.Name] = &WaylandOutput{ + wlOutput: output, + globalName: e.Name, + scale: 1, + fractionalScale: 1.0, + } + r.outputsMu.Unlock() + r.setupOutputHandlers(e.Name, output) + } + + case wlr_layer_shell.ZwlrLayerShellV1InterfaceName: + ls := wlr_layer_shell.NewZwlrLayerShellV1(r.ctx) + version := e.Version + if version > 4 { + version = 4 + } + if err := r.registry.Bind(e.Name, e.Interface, version, ls); err == nil { + r.layerShell = ls + } + + case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName: + sc := wlr_screencopy.NewZwlrScreencopyManagerV1(r.ctx) + version := e.Version + if version > 3 { + version = 3 + } + if err := r.registry.Bind(e.Name, e.Interface, version, sc); err == nil { + r.screencopy = sc + } + + case wp_viewporter.WpViewporterInterfaceName: + vp := wp_viewporter.NewWpViewporter(r.ctx) + if err := r.registry.Bind(e.Name, e.Interface, e.Version, vp); err == nil { + r.viewporter = vp + } + + case keyboard_shortcuts_inhibit.ZwpKeyboardShortcutsInhibitManagerV1InterfaceName: + mgr := keyboard_shortcuts_inhibit.NewZwpKeyboardShortcutsInhibitManagerV1(r.ctx) + if err := r.registry.Bind(e.Name, e.Interface, e.Version, mgr); err == nil { + r.shortcutsInhibitMgr = mgr + } + } +} + +func (r *RegionSelector) setupOutputHandlers(name uint32, output *client.Output) { + output.SetGeometryHandler(func(e client.OutputGeometryEvent) { + r.outputsMu.Lock() + if o, ok := r.outputs[name]; ok { + o.x = e.X + o.y = e.Y + o.transform = int32(e.Transform) + } + r.outputsMu.Unlock() + }) + + output.SetModeHandler(func(e client.OutputModeEvent) { + if e.Flags&uint32(client.OutputModeCurrent) == 0 { + return + } + r.outputsMu.Lock() + if o, ok := r.outputs[name]; ok { + o.width = e.Width + o.height = e.Height + } + r.outputsMu.Unlock() + }) + + output.SetScaleHandler(func(e client.OutputScaleEvent) { + r.outputsMu.Lock() + if o, ok := r.outputs[name]; ok { + o.scale = e.Factor + o.fractionalScale = float64(e.Factor) + } + r.outputsMu.Unlock() + }) + + output.SetNameHandler(func(e client.OutputNameEvent) { + r.outputsMu.Lock() + if o, ok := r.outputs[name]; ok { + o.name = e.Name + } + r.outputsMu.Unlock() + }) +} + +func (r *RegionSelector) preCaptureAllOutputs() error { + r.outputsMu.Lock() + outputs := make([]*WaylandOutput, 0, len(r.outputs)) + for _, o := range r.outputs { + outputs = append(outputs, o) + } + r.outputsMu.Unlock() + + pending := len(outputs) * 2 + done := make(chan struct{}, pending) + + for _, output := range outputs { + pc := &PreCapture{} + r.preCapture[output] = pc + + r.preCaptureOutput(output, pc, true, func() { done <- struct{}{} }) + r.preCaptureOutput(output, pc, false, func() { done <- struct{}{} }) + } + + for i := 0; i < pending; i++ { + if err := r.ctx.Dispatch(); err != nil { + return err + } + select { + case <-done: + default: + i-- + } + } + return nil +} + +func (r *RegionSelector) preCaptureOutput(output *WaylandOutput, pc *PreCapture, withCursor bool, onReady func()) { + cursor := int32(0) + if withCursor { + cursor = 1 + } + + frame, err := r.screencopy.CaptureOutput(cursor, output.wlOutput) + if err != nil { + log.Error("screencopy capture failed", "err", err) + onReady() + return + } + + var capturedBuf *ShmBuffer + + var capturedFormat PixelFormat + frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) { + capturedFormat = PixelFormat(e.Format) + bpp := capturedFormat.BytesPerPixel() + if int(e.Stride) < int(e.Width)*bpp { + log.Error("invalid stride from compositor", "stride", e.Stride, "width", e.Width, "bpp", bpp) + return + } + buf, err := CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride)) + if err != nil { + log.Error("create screen buffer failed", "err", err) + return + } + + capturedBuf = buf + buf.Format = capturedFormat + + pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size())) + if err != nil { + log.Error("create shm pool failed", "err", err) + return + } + + wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), e.Format) + if err != nil { + log.Error("create wl_buffer failed", "err", err) + pool.Destroy() + return + } + + if err := frame.Copy(wlBuf); err != nil { + log.Error("frame copy failed", "err", err) + } + pool.Destroy() + }) + + frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) { + if withCursor { + pc.yInverted = (e.Flags & 1) != 0 + } + }) + + frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) { + frame.Destroy() + + if capturedBuf == nil { + onReady() + return + } + + if capturedFormat.Is24Bit() { + converted, newFormat, err := capturedBuf.ConvertTo32Bit(capturedFormat) + if err != nil { + log.Error("convert 24-bit to 32-bit failed", "err", err) + } else if converted != capturedBuf { + capturedBuf.Close() + capturedBuf = converted + capturedFormat = newFormat + } + } + + pc.format = uint32(capturedFormat) + + if pc.yInverted { + capturedBuf.FlipVertical() + pc.yInverted = false + } + + if output.transform != TransformNormal { + invTransform := InverseTransform(output.transform) + transformed, err := capturedBuf.ApplyTransform(invTransform) + if err != nil { + log.Error("apply transform failed", "err", err) + } else if transformed != capturedBuf { + capturedBuf.Close() + capturedBuf = transformed + } + } + + if withCursor { + pc.screenBuf = capturedBuf + } else { + pc.screenBufNoCursor = capturedBuf + } + + onReady() + }) + + frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) { + log.Error("screencopy failed") + frame.Destroy() + onReady() + }) +} + +func (r *RegionSelector) createSurfaces() error { + r.outputsMu.Lock() + outputs := make([]*WaylandOutput, 0, len(r.outputs)) + for _, o := range r.outputs { + outputs = append(outputs, o) + } + r.outputsMu.Unlock() + + for _, output := range outputs { + os, err := r.createOutputSurface(output) + if err != nil { + return fmt.Errorf("output %s: %w", output.name, err) + } + r.surfaces = append(r.surfaces, os) + } + + return nil +} + +func (r *RegionSelector) createCursor() error { + const size = 24 + const hotspot = size / 2 + + surface, err := r.compositor.CreateSurface() + if err != nil { + return fmt.Errorf("create cursor surface: %w", err) + } + r.cursorSurface = surface + + buf, err := CreateShmBuffer(size, size, size*4) + if err != nil { + return fmt.Errorf("create cursor buffer: %w", err) + } + r.cursorBuffer = buf + + // Draw crosshair + data := buf.Data() + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + off := (y*size + x) * 4 + // Vertical line + if x >= hotspot-1 && x <= hotspot && y >= 2 && y < size-2 { + data[off+0] = 255 // B + data[off+1] = 255 // G + data[off+2] = 255 // R + data[off+3] = 255 // A + continue + } + // Horizontal line + if y >= hotspot-1 && y <= hotspot && x >= 2 && x < size-2 { + data[off+0] = 255 + data[off+1] = 255 + data[off+2] = 255 + data[off+3] = 255 + continue + } + // Transparent + data[off+0] = 0 + data[off+1] = 0 + data[off+2] = 0 + data[off+3] = 0 + } + } + + pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size())) + if err != nil { + return fmt.Errorf("create cursor pool: %w", err) + } + r.cursorPool = pool + + wlBuf, err := pool.CreateBuffer(0, size, size, size*4, uint32(FormatARGB8888)) + if err != nil { + return fmt.Errorf("create cursor wl_buffer: %w", err) + } + r.cursorWlBuf = wlBuf + + if err := surface.Attach(wlBuf, 0, 0); err != nil { + return fmt.Errorf("attach cursor: %w", err) + } + if err := surface.Damage(0, 0, size, size); err != nil { + return fmt.Errorf("damage cursor: %w", err) + } + if err := surface.Commit(); err != nil { + return fmt.Errorf("commit cursor: %w", err) + } + + return nil +} + +func (r *RegionSelector) createOutputSurface(output *WaylandOutput) (*OutputSurface, error) { + surface, err := r.compositor.CreateSurface() + if err != nil { + return nil, fmt.Errorf("create surface: %w", err) + } + + layerSurf, err := r.layerShell.GetLayerSurface( + surface, + output.wlOutput, + uint32(wlr_layer_shell.ZwlrLayerShellV1LayerOverlay), + "dms-screenshot", + ) + if err != nil { + return nil, fmt.Errorf("get layer surface: %w", err) + } + + os := &OutputSurface{ + output: output, + wlSurface: surface, + layerSurf: layerSurf, + } + + if r.viewporter != nil { + vp, err := r.viewporter.GetViewport(surface) + if err == nil { + os.viewport = vp + } + } + + if err := layerSurf.SetAnchor( + uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorTop) | + uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorBottom) | + uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorLeft) | + uint32(wlr_layer_shell.ZwlrLayerSurfaceV1AnchorRight), + ); err != nil { + return nil, fmt.Errorf("set anchor: %w", err) + } + if err := layerSurf.SetExclusiveZone(-1); err != nil { + return nil, fmt.Errorf("set exclusive zone: %w", err) + } + if err := layerSurf.SetKeyboardInteractivity(uint32(wlr_layer_shell.ZwlrLayerSurfaceV1KeyboardInteractivityExclusive)); err != nil { + return nil, fmt.Errorf("set keyboard interactivity: %w", err) + } + + layerSurf.SetConfigureHandler(func(e wlr_layer_shell.ZwlrLayerSurfaceV1ConfigureEvent) { + if err := layerSurf.AckConfigure(e.Serial); err != nil { + log.Error("ack configure failed", "err", err) + return + } + os.logicalW = int(e.Width) + os.logicalH = int(e.Height) + os.configured = true + r.captureForSurface(os) + r.ensureShortcutsInhibitor(os) + }) + + layerSurf.SetClosedHandler(func(e wlr_layer_shell.ZwlrLayerSurfaceV1ClosedEvent) { + r.running = false + r.cancelled = true + }) + + if err := surface.Commit(); err != nil { + return nil, fmt.Errorf("surface commit: %w", err) + } + + return os, nil +} + +func (r *RegionSelector) ensureShortcutsInhibitor(os *OutputSurface) { + if r.shortcutsInhibitMgr == nil || r.seat == nil || r.shortcutsInhibitor != nil { + return + } + inhibitor, err := r.shortcutsInhibitMgr.InhibitShortcuts(os.wlSurface, r.seat) + if err == nil { + r.shortcutsInhibitor = inhibitor + } +} + +func (r *RegionSelector) captureForSurface(os *OutputSurface) { + pc := r.preCapture[os.output] + if pc == nil { + return + } + + os.screenBuf = pc.screenBuf + os.screenBufNoCursor = pc.screenBufNoCursor + os.screenFormat = pc.format + os.yInverted = pc.yInverted + + if os.logicalW > 0 && os.screenBuf != nil { + os.output.fractionalScale = float64(os.screenBuf.Width) / float64(os.logicalW) + } + + r.initRenderBuffer(os) + r.applyPreSelection(os) + r.redrawSurface(os) +} + +func (r *RegionSelector) initRenderBuffer(os *OutputSurface) { + if os.screenBuf == nil { + return + } + + for i := 0; i < 3; i++ { + slot := &RenderSlot{} + + buf, err := CreateShmBuffer(os.screenBuf.Width, os.screenBuf.Height, os.screenBuf.Stride) + if err != nil { + log.Error("create render slot buffer failed", "err", err) + return + } + slot.shm = buf + + pool, err := r.shm.CreatePool(buf.Fd(), int32(buf.Size())) + if err != nil { + log.Error("create render slot pool failed", "err", err) + buf.Close() + return + } + slot.pool = pool + + wlBuf, err := pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), os.screenFormat) + if err != nil { + log.Error("create render slot wl_buffer failed", "err", err) + pool.Destroy() + buf.Close() + return + } + slot.wlBuf = wlBuf + + slotRef := slot + wlBuf.SetReleaseHandler(func(e client.BufferReleaseEvent) { + slotRef.busy = false + }) + + os.slots[i] = slot + } + os.slotsReady = true +} + +func (os *OutputSurface) acquireFreeSlot() *RenderSlot { + for _, slot := range os.slots { + if slot != nil && !slot.busy { + return slot + } + } + return nil +} + +func (r *RegionSelector) applyPreSelection(os *OutputSurface) { + if r.preSelect.IsEmpty() || os.screenBuf == nil || r.selection.hasSelection { + return + } + + if r.preSelect.Output != "" && r.preSelect.Output != os.output.name { + return + } + + scaleX := float64(os.logicalW) / float64(os.screenBuf.Width) + scaleY := float64(os.logicalH) / float64(os.screenBuf.Height) + + x1 := float64(r.preSelect.X-os.output.x) * scaleX + y1 := float64(r.preSelect.Y-os.output.y) * scaleY + x2 := float64(r.preSelect.X-os.output.x+r.preSelect.Width) * scaleX + y2 := float64(r.preSelect.Y-os.output.y+r.preSelect.Height) * scaleY + + r.selection.hasSelection = true + r.selection.dragging = false + r.selection.surface = os + r.selection.anchorX = x1 + r.selection.anchorY = y1 + r.selection.currentX = x2 + r.selection.currentY = y2 + r.activeSurface = os +} + +func (r *RegionSelector) getSourceBuffer(os *OutputSurface) *ShmBuffer { + if !r.showCapturedCursor && os.screenBufNoCursor != nil { + return os.screenBufNoCursor + } + return os.screenBuf +} + +func (r *RegionSelector) redrawSurface(os *OutputSurface) { + srcBuf := r.getSourceBuffer(os) + if srcBuf == nil || !os.slotsReady { + return + } + + slot := os.acquireFreeSlot() + if slot == nil { + return + } + + slot.shm.CopyFrom(srcBuf) + + // Draw overlay (dimming + selection) into this slot + r.drawOverlay(os, slot.shm) + + if os.viewport != nil { + _ = os.wlSurface.SetBufferScale(1) + _ = os.viewport.SetSource(0, 0, float64(slot.shm.Width), float64(slot.shm.Height)) + _ = os.viewport.SetDestination(int32(os.logicalW), int32(os.logicalH)) + } else { + bufferScale := os.output.scale + if bufferScale <= 0 { + bufferScale = 1 + } + _ = os.wlSurface.SetBufferScale(bufferScale) + } + + _ = os.wlSurface.Attach(slot.wlBuf, 0, 0) + _ = os.wlSurface.Damage(0, 0, int32(os.logicalW), int32(os.logicalH)) + _ = os.wlSurface.Commit() + + // Mark this slot as busy until compositor releases it + slot.busy = true +} + +func (r *RegionSelector) cleanup() { + if r.cursorWlBuf != nil { + r.cursorWlBuf.Destroy() + } + if r.cursorPool != nil { + r.cursorPool.Destroy() + } + if r.cursorSurface != nil { + r.cursorSurface.Destroy() + } + if r.cursorBuffer != nil { + r.cursorBuffer.Close() + } + + for _, os := range r.surfaces { + for _, slot := range os.slots { + if slot == nil { + continue + } + if slot.wlBuf != nil { + slot.wlBuf.Destroy() + } + if slot.pool != nil { + slot.pool.Destroy() + } + if slot.shm != nil { + slot.shm.Close() + } + } + if os.viewport != nil { + os.viewport.Destroy() + } + if os.layerSurf != nil { + os.layerSurf.Destroy() + } + if os.wlSurface != nil { + os.wlSurface.Destroy() + } + if os.screenBuf != nil { + os.screenBuf.Close() + } + if os.screenBufNoCursor != nil { + os.screenBufNoCursor.Close() + } + } + + if r.shortcutsInhibitor != nil { + _ = r.shortcutsInhibitor.Destroy() + } + if r.shortcutsInhibitMgr != nil { + _ = r.shortcutsInhibitMgr.Destroy() + } + if r.viewporter != nil { + r.viewporter.Destroy() + } + if r.screencopy != nil { + r.screencopy.Destroy() + } + if r.pointer != nil { + r.pointer.Release() + } + if r.keyboard != nil { + r.keyboard.Release() + } + if r.display != nil { + r.ctx.Close() + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region_input.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region_input.go new file mode 100644 index 0000000..e243443 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region_input.go @@ -0,0 +1,274 @@ +package screenshot + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +func (r *RegionSelector) setupInput() { + if r.seat == nil { + return + } + + r.seat.SetCapabilitiesHandler(func(e client.SeatCapabilitiesEvent) { + if e.Capabilities&uint32(client.SeatCapabilityPointer) != 0 && r.pointer == nil { + if pointer, err := r.seat.GetPointer(); err == nil { + r.pointer = pointer + r.setupPointerHandlers() + } + } + if e.Capabilities&uint32(client.SeatCapabilityKeyboard) != 0 && r.keyboard == nil { + if keyboard, err := r.seat.GetKeyboard(); err == nil { + r.keyboard = keyboard + r.setupKeyboardHandlers() + } + } + }) +} + +func (r *RegionSelector) setupPointerHandlers() { + r.pointer.SetEnterHandler(func(e client.PointerEnterEvent) { + if r.cursorSurface != nil { + _ = r.pointer.SetCursor(e.Serial, r.cursorSurface, 12, 12) + } + + r.activeSurface = nil + for _, os := range r.surfaces { + if os.wlSurface.ID() == e.Surface.ID() { + r.activeSurface = os + break + } + } + + r.pointerX = e.SurfaceX + r.pointerY = e.SurfaceY + }) + + r.pointer.SetMotionHandler(func(e client.PointerMotionEvent) { + if r.activeSurface == nil { + return + } + + r.pointerX = e.SurfaceX + r.pointerY = e.SurfaceY + + if !r.selection.dragging { + return + } + + curX, curY := e.SurfaceX, e.SurfaceY + if r.shiftHeld { + dx := curX - r.selection.anchorX + dy := curY - r.selection.anchorY + adx, ady := dx, dy + if adx < 0 { + adx = -adx + } + if ady < 0 { + ady = -ady + } + size := adx + if ady > adx { + size = ady + } + if dx < 0 { + curX = r.selection.anchorX - size + } else { + curX = r.selection.anchorX + size + } + if dy < 0 { + curY = r.selection.anchorY - size + } else { + curY = r.selection.anchorY + size + } + } + + r.selection.currentX = curX + r.selection.currentY = curY + for _, os := range r.surfaces { + r.redrawSurface(os) + } + }) + + r.pointer.SetButtonHandler(func(e client.PointerButtonEvent) { + if r.activeSurface == nil { + return + } + + switch e.Button { + case 0x110: // BTN_LEFT + switch e.State { + case 1: // pressed + r.preSelect = Region{} + r.selection.hasSelection = true + r.selection.dragging = true + r.selection.surface = r.activeSurface + r.selection.anchorX = r.pointerX + r.selection.anchorY = r.pointerY + r.selection.currentX = r.pointerX + r.selection.currentY = r.pointerY + for _, os := range r.surfaces { + r.redrawSurface(os) + } + case 0: // released + r.selection.dragging = false + for _, os := range r.surfaces { + r.redrawSurface(os) + } + if r.screenshoter != nil && r.screenshoter.config.NoConfirm && r.selection.hasSelection { + r.finishSelection() + } + } + default: + r.cancelled = true + r.running = false + } + }) +} + +func (r *RegionSelector) setupKeyboardHandlers() { + r.keyboard.SetModifiersHandler(func(e client.KeyboardModifiersEvent) { + r.shiftHeld = e.ModsDepressed&1 != 0 + }) + + r.keyboard.SetKeyHandler(func(e client.KeyboardKeyEvent) { + if e.State != 1 { + return + } + + switch e.Key { + case 1: + r.cancelled = true + r.running = false + case 25: + r.showCapturedCursor = !r.showCapturedCursor + for _, os := range r.surfaces { + r.redrawSurface(os) + } + case 28, 57, 96: + if r.selection.hasSelection { + r.finishSelection() + } + } + }) +} + +func (r *RegionSelector) finishSelection() { + if r.selection.surface == nil { + r.running = false + return + } + + os := r.selection.surface + srcBuf := r.getSourceBuffer(os) + if srcBuf == nil { + r.running = false + return + } + + x1, y1 := r.selection.anchorX, r.selection.anchorY + x2, y2 := r.selection.currentX, r.selection.currentY + + if x1 > x2 { + x1, x2 = x2, x1 + } + if y1 > y2 { + y1, y2 = y2, y1 + } + + scaleX, scaleY := 1.0, 1.0 + if os.logicalW > 0 { + scaleX = float64(srcBuf.Width) / float64(os.logicalW) + scaleY = float64(srcBuf.Height) / float64(os.logicalH) + } + + bx1 := int(x1 * scaleX) + by1 := int(y1 * scaleY) + bx2 := int(x2 * scaleX) + by2 := int(y2 * scaleY) + + // Clamp to buffer bounds + if bx1 < 0 { + bx1 = 0 + } + if by1 < 0 { + by1 = 0 + } + if bx2 > srcBuf.Width { + bx2 = srcBuf.Width + } + if by2 > srcBuf.Height { + by2 = srcBuf.Height + } + + w, h := bx2-bx1+1, by2-by1+1 + if r.shiftHeld && w != h { + if w < h { + h = w + } else { + w = h + } + } + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + + // Create cropped buffer and copy pixels directly + cropped, err := CreateShmBuffer(w, h, w*4) + if err != nil { + r.running = false + return + } + + srcData := srcBuf.Data() + dstData := cropped.Data() + for y := 0; y < h; y++ { + srcY := by1 + y + if os.yInverted { + srcY = srcBuf.Height - 1 - (by1 + y) + } + if srcY < 0 || srcY >= srcBuf.Height { + continue + } + dstY := y + if os.yInverted { + dstY = h - 1 - y + } + for x := 0; x < w; x++ { + srcX := bx1 + x + if srcX < 0 || srcX >= srcBuf.Width { + continue + } + si := srcY*srcBuf.Stride + srcX*4 + di := dstY*cropped.Stride + x*4 + if si+3 < len(srcData) && di+3 < len(dstData) { + dstData[di+0] = srcData[si+0] + dstData[di+1] = srcData[si+1] + dstData[di+2] = srcData[si+2] + dstData[di+3] = srcData[si+3] + } + } + } + + r.capturedBuffer = cropped + r.capturedRegion = Region{ + X: int32(bx1), + Y: int32(by1), + Width: int32(w), + Height: int32(h), + Output: os.output.name, + } + + // Also store for "last region" feature with global coords + r.result = Region{ + X: int32(bx1) + os.output.x, + Y: int32(by1) + os.output.y, + Width: int32(w), + Height: int32(h), + Output: os.output.name, + } + + r.running = false +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region_render.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region_render.go new file mode 100644 index 0000000..4071f8a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/region_render.go @@ -0,0 +1,326 @@ +package screenshot + +import "fmt" + +var fontGlyphs = map[rune][12]uint8{ + '0': {0x3C, 0x66, 0x66, 0x6E, 0x76, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00}, + '1': {0x18, 0x38, 0x78, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00, 0x00}, + '2': {0x3C, 0x66, 0x66, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x66, 0x7E, 0x00, 0x00}, + '3': {0x3C, 0x66, 0x06, 0x06, 0x1C, 0x06, 0x06, 0x06, 0x66, 0x3C, 0x00, 0x00}, + '4': {0x0C, 0x1C, 0x3C, 0x6C, 0xCC, 0xCC, 0xFE, 0x0C, 0x0C, 0x1E, 0x00, 0x00}, + '5': {0x7E, 0x60, 0x60, 0x60, 0x7C, 0x06, 0x06, 0x06, 0x66, 0x3C, 0x00, 0x00}, + '6': {0x1C, 0x30, 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00}, + '7': {0x7E, 0x66, 0x06, 0x06, 0x0C, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x00}, + '8': {0x3C, 0x66, 0x66, 0x66, 0x3C, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00}, + '9': {0x3C, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x06, 0x0C, 0x38, 0x00, 0x00}, + 'x': {0x00, 0x00, 0x00, 0x66, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x66, 0x00, 0x00}, + 'E': {0x7E, 0x60, 0x60, 0x60, 0x7C, 0x60, 0x60, 0x60, 0x60, 0x7E, 0x00, 0x00}, + 'P': {0x7C, 0x66, 0x66, 0x66, 0x7C, 0x60, 0x60, 0x60, 0x60, 0x60, 0x00, 0x00}, + 'S': {0x3C, 0x66, 0x60, 0x60, 0x3C, 0x06, 0x06, 0x06, 0x66, 0x3C, 0x00, 0x00}, + 'a': {0x00, 0x00, 0x00, 0x3C, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x00, 0x00}, + 'c': {0x00, 0x00, 0x00, 0x3C, 0x66, 0x60, 0x60, 0x60, 0x66, 0x3C, 0x00, 0x00}, + 'd': {0x00, 0x00, 0x06, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x00, 0x00}, + 'e': {0x00, 0x00, 0x00, 0x3C, 0x66, 0x66, 0x7E, 0x60, 0x60, 0x3C, 0x00, 0x00}, + 'h': {0x00, 0x60, 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x00, 0x00}, + 'i': {0x00, 0x18, 0x00, 0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00, 0x00}, + 'n': {0x00, 0x00, 0x00, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x00, 0x00}, + 'o': {0x00, 0x00, 0x00, 0x3C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00, 0x00}, + 'p': {0x00, 0x00, 0x00, 0x7C, 0x66, 0x66, 0x66, 0x7C, 0x60, 0x60, 0x00, 0x00}, + 'r': {0x00, 0x00, 0x00, 0x6E, 0x76, 0x60, 0x60, 0x60, 0x60, 0x60, 0x00, 0x00}, + 's': {0x00, 0x00, 0x00, 0x3E, 0x60, 0x60, 0x3C, 0x06, 0x06, 0x7C, 0x00, 0x00}, + 't': {0x00, 0x18, 0x18, 0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x0E, 0x00, 0x00}, + 'u': {0x00, 0x00, 0x00, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3E, 0x00, 0x00}, + 'w': {0x00, 0x00, 0x00, 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00, 0x00}, + 'l': {0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00, 0x00}, + ' ': {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + ':': {0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00}, + '/': {0x00, 0x02, 0x06, 0x0C, 0x18, 0x18, 0x30, 0x60, 0x40, 0x00, 0x00, 0x00}, + '[': {0x3C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3C, 0x00, 0x00}, + ']': {0x3C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x3C, 0x00, 0x00}, +} + +type OverlayStyle struct { + BackgroundR, BackgroundG, BackgroundB, BackgroundA uint8 + TextR, TextG, TextB uint8 + AccentR, AccentG, AccentB uint8 +} + +var DefaultOverlayStyle = OverlayStyle{ + BackgroundR: 30, BackgroundG: 30, BackgroundB: 30, BackgroundA: 220, + TextR: 255, TextG: 255, TextB: 255, + AccentR: 100, AccentG: 180, AccentB: 255, +} + +func (r *RegionSelector) drawOverlay(os *OutputSurface, renderBuf *ShmBuffer) { + data := renderBuf.Data() + stride := renderBuf.Stride + w, h := renderBuf.Width, renderBuf.Height + format := os.screenFormat + + // Dim the entire buffer + for y := 0; y < h; y++ { + off := y * stride + for x := 0; x < w; x++ { + i := off + x*4 + if i+3 >= len(data) { + continue + } + data[i+0] = uint8(int(data[i+0]) * 3 / 5) + data[i+1] = uint8(int(data[i+1]) * 3 / 5) + data[i+2] = uint8(int(data[i+2]) * 3 / 5) + } + } + + r.drawHUD(data, stride, w, h, format) + + if !r.selection.hasSelection || r.selection.surface != os { + return + } + + scaleX := float64(w) / float64(os.logicalW) + scaleY := float64(h) / float64(os.logicalH) + + bx1 := int(r.selection.anchorX * scaleX) + by1 := int(r.selection.anchorY * scaleY) + bx2 := int(r.selection.currentX * scaleX) + by2 := int(r.selection.currentY * scaleY) + + if bx1 > bx2 { + bx1, bx2 = bx2, bx1 + } + if by1 > by2 { + by1, by2 = by2, by1 + } + + bx1 = clamp(bx1, 0, w-1) + by1 = clamp(by1, 0, h-1) + bx2 = clamp(bx2, 0, w-1) + by2 = clamp(by2, 0, h-1) + + srcBuf := r.getSourceBuffer(os) + srcData := srcBuf.Data() + for y := by1; y <= by2; y++ { + rowOff := y * stride + for x := bx1; x <= bx2; x++ { + si := y*srcBuf.Stride + x*4 + di := rowOff + x*4 + if si+3 >= len(srcData) || di+3 >= len(data) { + continue + } + data[di+0] = srcData[si+0] + data[di+1] = srcData[si+1] + data[di+2] = srcData[si+2] + data[di+3] = srcData[si+3] + } + } + + selW, selH := bx2-bx1+1, by2-by1+1 + if r.shiftHeld && selW != selH { + if selW < selH { + selH = selW + } else { + selW = selH + } + } + r.drawBorder(data, stride, w, h, bx1, by1, selW, selH, format) + r.drawDimensions(data, stride, w, h, bx1, by1, selW, selH, format) +} + +func (r *RegionSelector) drawHUD(data []byte, stride, bufW, bufH int, format uint32) { + if r.selection.dragging { + return + } + + style := LoadOverlayStyle() + const charW, charH, padding, itemSpacing = 8, 12, 12, 24 + + cursorLabel := "hide" + if !r.showCapturedCursor { + cursorLabel = "show" + } + captureKey := "Space/Enter" + if r.screenshoter != nil && r.screenshoter.config.NoConfirm { + captureKey = "Drag+Release" + } + + items := []struct{ key, desc string }{ + {captureKey, "capture"}, + {"P", cursorLabel + " cursor"}, + {"Esc", "cancel"}, + } + + totalW := 0 + for i, item := range items { + totalW += len(item.key)*(charW+1) + 4 + len(item.desc)*(charW+1) + if i < len(items)-1 { + totalW += itemSpacing + } + } + + hudW := totalW + padding*2 + hudH := charH + padding*2 + hudX := (bufW - hudW) / 2 + hudY := bufH - hudH - 20 + + r.fillRect(data, stride, bufW, bufH, hudX, hudY, hudW, hudH, + style.BackgroundR, style.BackgroundG, style.BackgroundB, style.BackgroundA, format) + + tx, ty := hudX+padding, hudY+padding + for i, item := range items { + r.drawText(data, stride, bufW, bufH, tx, ty, item.key, + style.AccentR, style.AccentG, style.AccentB, format) + tx += len(item.key) * (charW + 1) + + r.drawText(data, stride, bufW, bufH, tx, ty, " "+item.desc, + style.TextR, style.TextG, style.TextB, format) + tx += (1 + len(item.desc)) * (charW + 1) + + if i < len(items)-1 { + tx += itemSpacing + } + } +} + +func (r *RegionSelector) drawBorder(data []byte, stride, bufW, bufH, x, y, w, h int, format uint32) { + const thickness = 2 + for i := 0; i < thickness; i++ { + r.drawHLine(data, stride, bufW, bufH, x-i, y-i, w+2*i, format) + r.drawHLine(data, stride, bufW, bufH, x-i, y+h+i-1, w+2*i, format) + r.drawVLine(data, stride, bufW, bufH, x-i, y-i, h+2*i, format) + r.drawVLine(data, stride, bufW, bufH, x+w+i-1, y-i, h+2*i, format) + } +} + +func (r *RegionSelector) drawHLine(data []byte, stride, bufW, bufH, x, y, length int, _ uint32) { + if y < 0 || y >= bufH { + return + } + rowOff := y * stride + for i := 0; i < length; i++ { + px := x + i + if px < 0 || px >= bufW { + continue + } + off := rowOff + px*4 + if off+3 >= len(data) { + continue + } + data[off], data[off+1], data[off+2], data[off+3] = 255, 255, 255, 255 + } +} + +func (r *RegionSelector) drawVLine(data []byte, stride, bufW, bufH, x, y, length int, _ uint32) { + if x < 0 || x >= bufW { + return + } + for i := 0; i < length; i++ { + py := y + i + if py < 0 || py >= bufH { + continue + } + off := py*stride + x*4 + if off+3 >= len(data) { + continue + } + data[off], data[off+1], data[off+2], data[off+3] = 255, 255, 255, 255 + } +} + +func (r *RegionSelector) drawDimensions(data []byte, stride, bufW, bufH, x, y, w, h int, format uint32) { + text := fmt.Sprintf("%dx%d", w, h) + + const charW, charH = 8, 12 + textW := len(text) * (charW + 1) + textH := charH + + tx := x + (w-textW)/2 + ty := y + h + 8 + + if ty+textH > bufH { + ty = y - textH - 8 + } + tx = clamp(tx, 0, bufW-textW) + + r.fillRect(data, stride, bufW, bufH, tx-4, ty-2, textW+8, textH+4, 0, 0, 0, 200, format) + r.drawText(data, stride, bufW, bufH, tx, ty, text, 255, 255, 255, format) +} + +func (r *RegionSelector) fillRect(data []byte, stride, bufW, bufH, x, y, w, h int, cr, cg, cb, ca uint8, format uint32) { + alpha := float64(ca) / 255.0 + invAlpha := 1.0 - alpha + + c0, c2 := cb, cr + if format == uint32(FormatABGR8888) || format == uint32(FormatXBGR8888) { + c0, c2 = cr, cb + } + + for py := y; py < y+h && py < bufH; py++ { + if py < 0 { + continue + } + for px := x; px < x+w && px < bufW; px++ { + if px < 0 { + continue + } + off := py*stride + px*4 + if off+3 >= len(data) { + continue + } + data[off+0] = uint8(float64(data[off+0])*invAlpha + float64(c0)*alpha) + data[off+1] = uint8(float64(data[off+1])*invAlpha + float64(cg)*alpha) + data[off+2] = uint8(float64(data[off+2])*invAlpha + float64(c2)*alpha) + data[off+3] = 255 + } + } +} + +func (r *RegionSelector) drawText(data []byte, stride, bufW, bufH, x, y int, text string, cr, cg, cb uint8, format uint32) { + for i, ch := range text { + r.drawChar(data, stride, bufW, bufH, x+i*9, y, ch, cr, cg, cb, format) + } +} + +func (r *RegionSelector) drawChar(data []byte, stride, bufW, bufH, x, y int, ch rune, cr, cg, cb uint8, format uint32) { + glyph, ok := fontGlyphs[ch] + if !ok { + return + } + + c0, c2 := cb, cr + if format == uint32(FormatABGR8888) || format == uint32(FormatXBGR8888) { + c0, c2 = cr, cb + } + + for row := 0; row < 12; row++ { + py := y + row + if py < 0 || py >= bufH { + continue + } + bits := glyph[row] + for col := 0; col < 8; col++ { + if (bits & (1 << (7 - col))) == 0 { + continue + } + px := x + col + if px < 0 || px >= bufW { + continue + } + off := py*stride + px*4 + if off+3 >= len(data) { + continue + } + data[off], data[off+1], data[off+2], data[off+3] = c0, cg, c2, 255 + } + } +} + +func clamp(v, lo, hi int) int { + switch { + case v < lo: + return lo + case v > hi: + return hi + default: + return v + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/screenshot.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/screenshot.go new file mode 100644 index 0000000..42d3404 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/screenshot.go @@ -0,0 +1,1089 @@ +package screenshot + +import ( + "fmt" + "math" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_screencopy" + wlhelpers "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type WaylandOutput struct { + wlOutput *client.Output + globalName uint32 + name string + x, y int32 + width int32 + height int32 + scale int32 + fractionalScale float64 + transform int32 +} + +type CaptureResult struct { + Buffer *ShmBuffer + Region Region + YInverted bool + Format uint32 +} + +type Screenshoter struct { + config Config + + display *client.Display + registry *client.Registry + ctx *client.Context + + compositor *client.Compositor + shm *client.Shm + screencopy *wlr_screencopy.ZwlrScreencopyManagerV1 + + outputs map[uint32]*WaylandOutput + outputsMu sync.Mutex +} + +func New(config Config) *Screenshoter { + return &Screenshoter{ + config: config, + outputs: make(map[uint32]*WaylandOutput), + } +} + +func (s *Screenshoter) Run() (*CaptureResult, error) { + if err := s.connect(); err != nil { + return nil, fmt.Errorf("wayland connect: %w", err) + } + defer s.cleanup() + + if err := s.setupRegistry(); err != nil { + return nil, fmt.Errorf("registry setup: %w", err) + } + + if err := s.roundtrip(); err != nil { + return nil, fmt.Errorf("roundtrip: %w", err) + } + + if s.screencopy == nil { + return nil, fmt.Errorf("compositor does not support wlr-screencopy-unstable-v1") + } + + if err := s.roundtrip(); err != nil { + return nil, fmt.Errorf("roundtrip: %w", err) + } + + switch s.config.Mode { + case ModeLastRegion: + return s.captureLastRegion() + case ModeRegion: + return s.captureRegion() + case ModeWindow: + return s.captureWindow() + case ModeOutput: + return s.captureOutput(s.config.OutputName) + case ModeFullScreen: + return s.captureFullScreen() + case ModeAllScreens: + return s.captureAllScreens() + default: + return s.captureRegion() + } +} + +func (s *Screenshoter) captureLastRegion() (*CaptureResult, error) { + lastRegion := GetLastRegion() + if lastRegion.IsEmpty() { + return s.captureRegion() + } + + output := s.findOutputForRegion(lastRegion) + if output == nil { + return s.captureRegion() + } + + return s.captureRegionOnOutput(output, lastRegion) +} + +func (s *Screenshoter) captureRegion() (*CaptureResult, error) { + if s.config.Reset { + if err := SaveLastRegion(Region{}); err != nil { + log.Debug("failed to reset last region", "err", err) + } + } + + selector := NewRegionSelector(s) + result, cancelled, err := selector.Run() + if err != nil { + return nil, fmt.Errorf("region selection: %w", err) + } + if cancelled || result == nil { + return nil, nil + } + + if err := SaveLastRegion(result.Region); err != nil { + log.Debug("failed to save last region", "err", err) + } + + return result, nil +} + +func (s *Screenshoter) captureWindow() (*CaptureResult, error) { + geom, err := GetActiveWindow() + if err != nil { + return nil, err + } + + region := Region{ + X: geom.X, + Y: geom.Y, + Width: geom.Width, + Height: geom.Height, + } + + var output *WaylandOutput + if geom.Output != "" { + output = s.findOutputByName(geom.Output) + } + if output == nil { + output = s.findOutputForRegion(region) + } + if output == nil { + return nil, fmt.Errorf("could not find output for window") + } + + switch DetectCompositor() { + case CompositorHyprland: + return s.captureAndCrop(output, region) + case CompositorDWL: + return s.captureDWLWindow(output, region, geom) + default: + return s.captureRegionOnOutput(output, region) + } +} + +func (s *Screenshoter) captureDWLWindow(output *WaylandOutput, region Region, geom *WindowGeometry) (*CaptureResult, error) { + result, err := s.captureWholeOutput(output) + if err != nil { + return nil, err + } + + scale := geom.Scale + if scale <= 0 || scale == 1.0 { + if output.fractionalScale > 1.0 { + scale = output.fractionalScale + } + } + if scale <= 0 { + scale = 1.0 + } + + localX := int(float64(region.X-geom.OutputX) * scale) + localY := int(float64(region.Y-geom.OutputY) * scale) + w := int(float64(region.Width) * scale) + h := int(float64(region.Height) * scale) + + if localX < 0 { + w += localX + localX = 0 + } + if localY < 0 { + h += localY + localY = 0 + } + if localX+w > result.Buffer.Width { + w = result.Buffer.Width - localX + } + if localY+h > result.Buffer.Height { + h = result.Buffer.Height - localY + } + + if w <= 0 || h <= 0 { + result.Buffer.Close() + return nil, fmt.Errorf("window not visible on output") + } + + cropped, err := CreateShmBuffer(w, h, w*4) + if err != nil { + result.Buffer.Close() + return nil, fmt.Errorf("create crop buffer: %w", err) + } + + srcData := result.Buffer.Data() + dstData := cropped.Data() + + for y := 0; y < h; y++ { + srcY := localY + y + if result.YInverted { + srcY = result.Buffer.Height - 1 - (localY + y) + } + if srcY < 0 || srcY >= result.Buffer.Height { + continue + } + + dstY := y + if result.YInverted { + dstY = h - 1 - y + } + + for x := 0; x < w; x++ { + srcX := localX + x + if srcX < 0 || srcX >= result.Buffer.Width { + continue + } + + si := srcY*result.Buffer.Stride + srcX*4 + di := dstY*cropped.Stride + x*4 + + if si+3 >= len(srcData) || di+3 >= len(dstData) { + continue + } + + dstData[di+0] = srcData[si+0] + dstData[di+1] = srcData[si+1] + dstData[di+2] = srcData[si+2] + dstData[di+3] = srcData[si+3] + } + } + + result.Buffer.Close() + cropped.Format = PixelFormat(result.Format) + + return &CaptureResult{ + Buffer: cropped, + Region: region, + YInverted: false, + Format: result.Format, + }, nil +} + +func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) { + output := s.findFocusedOutput() + if output == nil { + s.outputsMu.Lock() + for _, o := range s.outputs { + output = o + break + } + s.outputsMu.Unlock() + } + + if output == nil { + return nil, fmt.Errorf("no output available") + } + + return s.captureWholeOutput(output) +} + +func (s *Screenshoter) captureOutput(name string) (*CaptureResult, error) { + s.outputsMu.Lock() + var output *WaylandOutput + for _, o := range s.outputs { + if o.name == name { + output = o + break + } + } + s.outputsMu.Unlock() + + if output == nil { + return nil, fmt.Errorf("output %q not found", name) + } + + return s.captureWholeOutput(output) +} + +func (s *Screenshoter) captureAllScreens() (*CaptureResult, error) { + s.outputsMu.Lock() + outputs := make([]*WaylandOutput, 0, len(s.outputs)) + for _, o := range s.outputs { + outputs = append(outputs, o) + } + s.outputsMu.Unlock() + + if len(outputs) == 0 { + return nil, fmt.Errorf("no outputs available") + } + if len(outputs) == 1 { + return s.captureWholeOutput(outputs[0]) + } + + wlrInfos := getAllOutputInfos() + + type pendingOutput struct { + result *CaptureResult + logX float64 + logY float64 + scale float64 + } + var pending []pendingOutput + maxScale := 1.0 + + for _, output := range outputs { + result, err := s.captureWholeOutput(output) + if err != nil { + log.Warn("failed to capture output", "name", output.name, "err", err) + continue + } + + logX, logY := float64(output.x), float64(output.y) + scale := float64(output.scale) + + switch DetectCompositor() { + case CompositorHyprland: + if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok { + logX, logY = float64(hx), float64(hy) + } + if hs := GetHyprlandMonitorScale(output.name); hs > 0 { + scale = hs + } + default: + if wlrInfos != nil { + if info, ok := wlrInfos[output.name]; ok { + logX, logY = float64(info.x), float64(info.y) + if info.scale > 0 { + scale = info.scale + } + } + } + } + + if scale <= 0 { + scale = 1.0 + } + + pending = append(pending, pendingOutput{result: result, logX: logX, logY: logY, scale: scale}) + if scale > maxScale { + maxScale = scale + } + } + + if len(pending) == 0 { + return nil, fmt.Errorf("failed to capture any outputs") + } + if len(pending) == 1 { + return pending[0].result, nil + } + + type layoutEntry struct { + result *CaptureResult + canvasX int + canvasY int + canvasW int + canvasH int + } + entries := make([]layoutEntry, len(pending)) + var minX, minY, maxX, maxY int + + for i, p := range pending { + cx := int(math.Round(p.logX * maxScale)) + cy := int(math.Round(p.logY * maxScale)) + cw := int(math.Round(float64(p.result.Buffer.Width) * maxScale / p.scale)) + ch := int(math.Round(float64(p.result.Buffer.Height) * maxScale / p.scale)) + + entries[i] = layoutEntry{result: p.result, canvasX: cx, canvasY: cy, canvasW: cw, canvasH: ch} + + right := cx + cw + bottom := cy + ch + if i == 0 { + minX, minY, maxX, maxY = cx, cy, right, bottom + continue + } + if cx < minX { + minX = cx + } + if cy < minY { + minY = cy + } + if right > maxX { + maxX = right + } + if bottom > maxY { + maxY = bottom + } + } + + totalW := maxX - minX + totalH := maxY - minY + composite, err := CreateShmBuffer(totalW, totalH, totalW*4) + if err != nil { + for _, e := range entries { + e.result.Buffer.Close() + } + return nil, fmt.Errorf("create composite buffer: %w", err) + } + composite.Clear() + + var format uint32 + for _, e := range entries { + if format == 0 { + format = e.result.Format + } + s.blitBufferScaled(composite, e.result.Buffer, + e.canvasX-minX, e.canvasY-minY, e.canvasW, e.canvasH, + e.result.YInverted) + e.result.Buffer.Close() + } + + return &CaptureResult{ + Buffer: composite, + Region: Region{X: int32(minX), Y: int32(minY), Width: int32(totalW), Height: int32(totalH)}, + Format: format, + }, nil +} + +func (s *Screenshoter) blitBufferScaled(dst, src *ShmBuffer, dstX, dstY, dstW, dstH int, yInverted bool) { + if dstW <= 0 || dstH <= 0 { + return + } + + srcData := src.Data() + dstData := dst.Data() + + for dy := 0; dy < dstH; dy++ { + canvasY := dstY + dy + if canvasY < 0 || canvasY >= dst.Height { + continue + } + + srcY := dy * src.Height / dstH + if yInverted { + srcY = src.Height - 1 - srcY + } + if srcY < 0 || srcY >= src.Height { + continue + } + + srcRowOff := srcY * src.Stride + dstRowOff := canvasY * dst.Stride + + for dx := 0; dx < dstW; dx++ { + canvasX := dstX + dx + if canvasX < 0 || canvasX >= dst.Width { + continue + } + + srcX := dx * src.Width / dstW + if srcX >= src.Width { + continue + } + + si := srcRowOff + srcX*4 + di := dstRowOff + canvasX*4 + + if si+3 >= len(srcData) || di+3 >= len(dstData) { + continue + } + + dstData[di+0] = srcData[si+0] + dstData[di+1] = srcData[si+1] + dstData[di+2] = srcData[si+2] + dstData[di+3] = srcData[si+3] + } + } +} + +func (s *Screenshoter) captureWholeOutput(output *WaylandOutput) (*CaptureResult, error) { + cursor := int32(s.config.Cursor) + + frame, err := s.screencopy.CaptureOutput(cursor, output.wlOutput) + if err != nil { + return nil, fmt.Errorf("capture output: %w", err) + } + + result, err := s.processFrame(frame, Region{ + X: output.x, + Y: output.y, + Width: output.width, + Height: output.height, + Output: output.name, + }) + if err != nil { + return nil, err + } + + if result.YInverted { + result.Buffer.FlipVertical() + result.YInverted = false + } + + if output.transform == TransformNormal { + return result, nil + } + + invTransform := InverseTransform(output.transform) + transformed, err := result.Buffer.ApplyTransform(invTransform) + if err != nil { + result.Buffer.Close() + return nil, fmt.Errorf("apply transform: %w", err) + } + + if transformed != result.Buffer { + result.Buffer.Close() + result.Buffer = transformed + } + + result.Region.Width = int32(transformed.Width) + result.Region.Height = int32(transformed.Height) + + return result, nil +} + +func (s *Screenshoter) captureAndCrop(output *WaylandOutput, region Region) (*CaptureResult, error) { + result, err := s.captureWholeOutput(output) + if err != nil { + return nil, err + } + + outX, outY := output.x, output.y + scale := float64(output.scale) + if hx, hy, _, _, ok := GetHyprlandMonitorGeometry(output.name); ok { + outX, outY = hx, hy + } + if s := GetHyprlandMonitorScale(output.name); s > 0 { + scale = s + } + if scale <= 0 { + scale = 1.0 + } + + localX := int(float64(region.X-outX) * scale) + localY := int(float64(region.Y-outY) * scale) + w := int(float64(region.Width) * scale) + h := int(float64(region.Height) * scale) + + cropped, err := CreateShmBuffer(w, h, w*4) + if err != nil { + result.Buffer.Close() + return nil, fmt.Errorf("create crop buffer: %w", err) + } + + srcData := result.Buffer.Data() + dstData := cropped.Data() + + for y := 0; y < h; y++ { + srcY := localY + y + if result.YInverted { + srcY = result.Buffer.Height - 1 - (localY + y) + } + if srcY < 0 || srcY >= result.Buffer.Height { + continue + } + + dstY := y + if result.YInverted { + dstY = h - 1 - y + } + + for x := 0; x < w; x++ { + srcX := localX + x + if srcX < 0 || srcX >= result.Buffer.Width { + continue + } + + si := srcY*result.Buffer.Stride + srcX*4 + di := dstY*cropped.Stride + x*4 + + if si+3 >= len(srcData) || di+3 >= len(dstData) { + continue + } + + dstData[di+0] = srcData[si+0] + dstData[di+1] = srcData[si+1] + dstData[di+2] = srcData[si+2] + dstData[di+3] = srcData[si+3] + } + } + + result.Buffer.Close() + cropped.Format = PixelFormat(result.Format) + + return &CaptureResult{ + Buffer: cropped, + Region: region, + YInverted: false, + Format: result.Format, + }, nil +} + +func (s *Screenshoter) captureRegionOnOutput(output *WaylandOutput, region Region) (*CaptureResult, error) { + if output.transform != TransformNormal { + return s.captureRegionOnTransformedOutput(output, region) + } + + scale := output.fractionalScale + if scale <= 0 && DetectCompositor() == CompositorHyprland { + scale = GetHyprlandMonitorScale(output.name) + } + if scale <= 0 { + scale = float64(output.scale) + } + if scale <= 0 { + scale = 1.0 + } + + localX := int32(float64(region.X-output.x) * scale) + localY := int32(float64(region.Y-output.y) * scale) + w := int32(float64(region.Width) * scale) + h := int32(float64(region.Height) * scale) + + if DetectCompositor() == CompositorDWL { + scaledOutW := int32(float64(output.width) * scale) + scaledOutH := int32(float64(output.height) * scale) + if localX >= scaledOutW { + localX = localX % scaledOutW + } + if localY >= scaledOutH { + localY = localY % scaledOutH + } + if localX+w > scaledOutW { + w = scaledOutW - localX + } + if localY+h > scaledOutH { + h = scaledOutH - localY + } + if localX < 0 { + w += localX + localX = 0 + } + if localY < 0 { + h += localY + localY = 0 + } + } + + cursor := int32(s.config.Cursor) + + frame, err := s.screencopy.CaptureOutputRegion(cursor, output.wlOutput, localX, localY, w, h) + if err != nil { + return nil, fmt.Errorf("capture region: %w", err) + } + + return s.processFrame(frame, region) +} + +func (s *Screenshoter) captureRegionOnTransformedOutput(output *WaylandOutput, region Region) (*CaptureResult, error) { + result, err := s.captureWholeOutput(output) + if err != nil { + return nil, err + } + + scale := output.fractionalScale + if scale <= 0 && DetectCompositor() == CompositorHyprland { + scale = GetHyprlandMonitorScale(output.name) + } + if scale <= 0 { + scale = float64(output.scale) + } + if scale <= 0 { + scale = 1.0 + } + + localX := int(float64(region.X-output.x) * scale) + localY := int(float64(region.Y-output.y) * scale) + w := int(float64(region.Width) * scale) + h := int(float64(region.Height) * scale) + + if localX < 0 { + w += localX + localX = 0 + } + if localY < 0 { + h += localY + localY = 0 + } + if localX+w > result.Buffer.Width { + w = result.Buffer.Width - localX + } + if localY+h > result.Buffer.Height { + h = result.Buffer.Height - localY + } + + if w <= 0 || h <= 0 { + result.Buffer.Close() + return nil, fmt.Errorf("region not visible on output") + } + + cropped, err := CreateShmBuffer(w, h, w*4) + if err != nil { + result.Buffer.Close() + return nil, fmt.Errorf("create crop buffer: %w", err) + } + + srcData := result.Buffer.Data() + dstData := cropped.Data() + + for y := 0; y < h; y++ { + srcOff := (localY+y)*result.Buffer.Stride + localX*4 + dstOff := y * cropped.Stride + if srcOff+w*4 <= len(srcData) && dstOff+w*4 <= len(dstData) { + copy(dstData[dstOff:dstOff+w*4], srcData[srcOff:srcOff+w*4]) + } + } + + result.Buffer.Close() + cropped.Format = PixelFormat(result.Format) + + return &CaptureResult{ + Buffer: cropped, + Region: region, + YInverted: false, + Format: result.Format, + }, nil +} + +func (s *Screenshoter) processFrame(frame *wlr_screencopy.ZwlrScreencopyFrameV1, region Region) (*CaptureResult, error) { + var buf *ShmBuffer + var pool *client.ShmPool + var wlBuf *client.Buffer + var format PixelFormat + var yInverted bool + ready := false + failed := false + + frame.SetBufferHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferEvent) { + format = PixelFormat(e.Format) + bpp := format.BytesPerPixel() + if int(e.Stride) < int(e.Width)*bpp { + log.Error("invalid stride from compositor", "stride", e.Stride, "width", e.Width, "bpp", bpp) + return + } + var err error + buf, err = CreateShmBuffer(int(e.Width), int(e.Height), int(e.Stride)) + if err != nil { + log.Error("failed to create buffer", "err", err) + return + } + buf.Format = format + }) + + frame.SetFlagsHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FlagsEvent) { + yInverted = (e.Flags & 1) != 0 + }) + + frame.SetBufferDoneHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1BufferDoneEvent) { + if buf == nil { + return + } + + var err error + pool, err = s.shm.CreatePool(buf.Fd(), int32(buf.Size())) + if err != nil { + log.Error("failed to create pool", "err", err) + return + } + + wlBuf, err = pool.CreateBuffer(0, int32(buf.Width), int32(buf.Height), int32(buf.Stride), uint32(format)) + if err != nil { + pool.Destroy() + pool = nil + log.Error("failed to create wl_buffer", "err", err) + return + } + + if err := frame.Copy(wlBuf); err != nil { + log.Error("failed to copy frame", "err", err) + } + }) + + frame.SetReadyHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1ReadyEvent) { + ready = true + }) + + frame.SetFailedHandler(func(e wlr_screencopy.ZwlrScreencopyFrameV1FailedEvent) { + failed = true + }) + + for !ready && !failed { + if err := s.ctx.Dispatch(); err != nil { + frame.Destroy() + return nil, fmt.Errorf("dispatch: %w", err) + } + } + + frame.Destroy() + if wlBuf != nil { + wlBuf.Destroy() + } + if pool != nil { + pool.Destroy() + } + + if failed { + if buf != nil { + buf.Close() + } + return nil, fmt.Errorf("frame capture failed") + } + + if format.Is24Bit() { + converted, newFormat, err := buf.ConvertTo32Bit(format) + if err != nil { + buf.Close() + return nil, fmt.Errorf("convert 24-bit to 32-bit: %w", err) + } + if converted != buf { + buf.Close() + buf = converted + } + format = newFormat + } + + return &CaptureResult{ + Buffer: buf, + Region: region, + YInverted: yInverted, + Format: uint32(format), + }, nil +} + +func (s *Screenshoter) findOutputByName(name string) *WaylandOutput { + s.outputsMu.Lock() + defer s.outputsMu.Unlock() + for _, o := range s.outputs { + if o.name == name { + return o + } + } + return nil +} + +func (s *Screenshoter) findOutputForRegion(region Region) *WaylandOutput { + s.outputsMu.Lock() + defer s.outputsMu.Unlock() + + cx := region.X + region.Width/2 + cy := region.Y + region.Height/2 + + for _, o := range s.outputs { + x, y, w, h := o.x, o.y, o.width, o.height + if DetectCompositor() == CompositorHyprland { + if hx, hy, hw, hh, ok := GetHyprlandMonitorGeometry(o.name); ok { + x, y, w, h = hx, hy, hw, hh + } + } + if cx >= x && cx < x+w && cy >= y && cy < y+h { + return o + } + } + + for _, o := range s.outputs { + x, y, w, h := o.x, o.y, o.width, o.height + if DetectCompositor() == CompositorHyprland { + if hx, hy, hw, hh, ok := GetHyprlandMonitorGeometry(o.name); ok { + x, y, w, h = hx, hy, hw, hh + } + } + if region.X >= x && region.X < x+w && + region.Y >= y && region.Y < y+h { + return o + } + } + + return nil +} + +func (s *Screenshoter) findFocusedOutput() *WaylandOutput { + if mon := GetFocusedMonitor(); mon != "" { + s.outputsMu.Lock() + defer s.outputsMu.Unlock() + for _, o := range s.outputs { + if o.name == mon { + return o + } + } + } + s.outputsMu.Lock() + defer s.outputsMu.Unlock() + for _, o := range s.outputs { + return o + } + return nil +} + +func (s *Screenshoter) connect() error { + display, err := client.Connect("") + if err != nil { + return err + } + s.display = display + s.ctx = display.Context() + return nil +} + +func (s *Screenshoter) roundtrip() error { + return wlhelpers.Roundtrip(s.display, s.ctx) +} + +func (s *Screenshoter) setupRegistry() error { + registry, err := s.display.GetRegistry() + if err != nil { + return err + } + s.registry = registry + + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + s.handleGlobal(e) + }) + + registry.SetGlobalRemoveHandler(func(e client.RegistryGlobalRemoveEvent) { + s.outputsMu.Lock() + delete(s.outputs, e.Name) + s.outputsMu.Unlock() + }) + + return nil +} + +func (s *Screenshoter) handleGlobal(e client.RegistryGlobalEvent) { + switch e.Interface { + case client.CompositorInterfaceName: + comp := client.NewCompositor(s.ctx) + if err := s.registry.Bind(e.Name, e.Interface, e.Version, comp); err == nil { + s.compositor = comp + } + + case client.ShmInterfaceName: + shm := client.NewShm(s.ctx) + if err := s.registry.Bind(e.Name, e.Interface, e.Version, shm); err == nil { + s.shm = shm + } + + case client.OutputInterfaceName: + output := client.NewOutput(s.ctx) + version := e.Version + if version > 4 { + version = 4 + } + if err := s.registry.Bind(e.Name, e.Interface, version, output); err == nil { + s.outputsMu.Lock() + s.outputs[e.Name] = &WaylandOutput{ + wlOutput: output, + globalName: e.Name, + scale: 1, + fractionalScale: 1.0, + } + s.outputsMu.Unlock() + s.setupOutputHandlers(e.Name, output) + } + + case wlr_screencopy.ZwlrScreencopyManagerV1InterfaceName: + sc := wlr_screencopy.NewZwlrScreencopyManagerV1(s.ctx) + version := e.Version + if version > 3 { + version = 3 + } + if err := s.registry.Bind(e.Name, e.Interface, version, sc); err == nil { + s.screencopy = sc + } + } +} + +func (s *Screenshoter) setupOutputHandlers(name uint32, output *client.Output) { + output.SetGeometryHandler(func(e client.OutputGeometryEvent) { + s.outputsMu.Lock() + if o, ok := s.outputs[name]; ok { + o.x, o.y = e.X, e.Y + o.transform = int32(e.Transform) + } + s.outputsMu.Unlock() + }) + + output.SetModeHandler(func(e client.OutputModeEvent) { + if e.Flags&uint32(client.OutputModeCurrent) == 0 { + return + } + s.outputsMu.Lock() + if o, ok := s.outputs[name]; ok { + o.width, o.height = e.Width, e.Height + } + s.outputsMu.Unlock() + }) + + output.SetScaleHandler(func(e client.OutputScaleEvent) { + s.outputsMu.Lock() + if o, ok := s.outputs[name]; ok { + o.scale = e.Factor + o.fractionalScale = float64(e.Factor) + } + s.outputsMu.Unlock() + }) + + output.SetNameHandler(func(e client.OutputNameEvent) { + s.outputsMu.Lock() + if o, ok := s.outputs[name]; ok { + o.name = e.Name + } + s.outputsMu.Unlock() + }) +} + +func (s *Screenshoter) cleanup() { + if s.screencopy != nil { + s.screencopy.Destroy() + } + if s.display != nil { + s.ctx.Close() + } +} + +func (s *Screenshoter) GetOutputs() []*WaylandOutput { + s.outputsMu.Lock() + defer s.outputsMu.Unlock() + out := make([]*WaylandOutput, 0, len(s.outputs)) + for _, o := range s.outputs { + out = append(out, o) + } + return out +} + +func ListOutputs() ([]Output, error) { + sc := New(DefaultConfig()) + if err := sc.connect(); err != nil { + return nil, err + } + defer sc.cleanup() + + if err := sc.setupRegistry(); err != nil { + return nil, err + } + if err := sc.roundtrip(); err != nil { + return nil, err + } + if err := sc.roundtrip(); err != nil { + return nil, err + } + + sc.outputsMu.Lock() + defer sc.outputsMu.Unlock() + + compositor := DetectCompositor() + result := make([]Output, 0, len(sc.outputs)) + for _, o := range sc.outputs { + out := Output{ + Name: o.name, + X: o.x, + Y: o.y, + Width: o.width, + Height: o.height, + Scale: o.scale, + FractionalScale: o.fractionalScale, + Transform: o.transform, + } + + switch compositor { + case CompositorHyprland: + if hx, hy, hw, hh, ok := GetHyprlandMonitorGeometry(o.name); ok { + out.X, out.Y = hx, hy + out.Width, out.Height = hw, hh + } + if s := GetHyprlandMonitorScale(o.name); s > 0 { + out.FractionalScale = s + } + } + + result = append(result, out) + } + return result, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/shm.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/shm.go new file mode 100644 index 0000000..a178cf0 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/shm.go @@ -0,0 +1,35 @@ +package screenshot + +import "github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/shm" + +type PixelFormat = shm.PixelFormat + +const ( + FormatARGB8888 = shm.FormatARGB8888 + FormatXRGB8888 = shm.FormatXRGB8888 + FormatABGR8888 = shm.FormatABGR8888 + FormatXBGR8888 = shm.FormatXBGR8888 + FormatRGB888 = shm.FormatRGB888 + FormatBGR888 = shm.FormatBGR888 +) + +const ( + TransformNormal = shm.TransformNormal + Transform90 = shm.Transform90 + Transform180 = shm.Transform180 + Transform270 = shm.Transform270 + TransformFlipped = shm.TransformFlipped + TransformFlipped90 = shm.TransformFlipped90 + TransformFlipped180 = shm.TransformFlipped180 + TransformFlipped270 = shm.TransformFlipped270 +) + +type ShmBuffer = shm.Buffer + +func CreateShmBuffer(width, height, stride int) (*ShmBuffer, error) { + return shm.CreateBuffer(width, height, stride) +} + +func InverseTransform(transform int32) int32 { + return shm.InverseTransform(transform) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/state.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/state.go new file mode 100644 index 0000000..0ba6c63 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/state.go @@ -0,0 +1,65 @@ +package screenshot + +import ( + "encoding/json" + "os" + "path" + "path/filepath" +) + +type PersistentState struct { + LastRegion Region `json:"last_region"` +} + +func getStateFilePath() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = path.Join(os.Getenv("HOME"), ".cache") + } + return filepath.Join(cacheDir, "dms", "screenshot-state.json") +} + +func LoadState() (*PersistentState, error) { + path := getStateFilePath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &PersistentState{}, nil + } + return nil, err + } + + var state PersistentState + if err := json.Unmarshal(data, &state); err != nil { + return &PersistentState{}, nil + } + return &state, nil +} + +func SaveState(state *PersistentState) error { + path := getStateFilePath() + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func GetLastRegion() Region { + state, err := LoadState() + if err != nil { + return Region{} + } + return state.LastRegion +} + +func SaveLastRegion(r Region) error { + state, _ := LoadState() + state.LastRegion = r + return SaveState(state) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/theme.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/theme.go new file mode 100644 index 0000000..a2bd5f6 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/theme.go @@ -0,0 +1,125 @@ +package screenshot + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type ThemeColors struct { + Background string `json:"surface"` + OnSurface string `json:"on_surface"` + Primary string `json:"primary"` +} + +type ColorScheme struct { + Dark ThemeColors `json:"dark"` + Light ThemeColors `json:"light"` +} + +type ColorsFile struct { + Colors ColorScheme `json:"colors"` +} + +var cachedStyle *OverlayStyle + +func LoadOverlayStyle() OverlayStyle { + if cachedStyle != nil { + return *cachedStyle + } + + style := DefaultOverlayStyle + colors := loadColorsFile() + if colors == nil { + cachedStyle = &style + return style + } + + theme := &colors.Dark + if isLightMode() { + theme = &colors.Light + } + + if bg, ok := parseHexColor(theme.Background); ok { + style.BackgroundR, style.BackgroundG, style.BackgroundB = bg[0], bg[1], bg[2] + } + if text, ok := parseHexColor(theme.OnSurface); ok { + style.TextR, style.TextG, style.TextB = text[0], text[1], text[2] + } + if accent, ok := parseHexColor(theme.Primary); ok { + style.AccentR, style.AccentG, style.AccentB = accent[0], accent[1], accent[2] + } + + cachedStyle = &style + return style +} + +func loadColorsFile() *ColorScheme { + path := getColorsFilePath() + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var file ColorsFile + if err := json.Unmarshal(data, &file); err != nil { + return nil + } + + return &file.Colors +} + +func getColorsFilePath() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + log.Error("Failed to get user cache dir", "err", err) + return "" + } + return filepath.Join(cacheDir, "DankMaterialShell", "dms-colors.json") +} + +func isLightMode() bool { + scheme, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme") + if err != nil { + return false + } + + switch scheme { + case "'prefer-light'", "'default'": + return true + } + return false +} + +func parseHexColor(hex string) ([3]uint8, bool) { + hex = strings.TrimPrefix(hex, "#") + if len(hex) != 6 { + return [3]uint8{}, false + } + + var r, g, b uint8 + for i, ptr := range []*uint8{&r, &g, &b} { + val := 0 + for j := 0; j < 2; j++ { + c := hex[i*2+j] + val *= 16 + switch { + case c >= '0' && c <= '9': + val += int(c - '0') + case c >= 'a' && c <= 'f': + val += int(c - 'a' + 10) + case c >= 'A' && c <= 'F': + val += int(c - 'A' + 10) + default: + return [3]uint8{}, false + } + } + *ptr = uint8(val) + } + + return [3]uint8{r, g, b}, true +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/types.go new file mode 100644 index 0000000..00cbe1c --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/screenshot/types.go @@ -0,0 +1,81 @@ +package screenshot + +type Mode int + +const ( + ModeRegion Mode = iota + ModeWindow + ModeFullScreen + ModeAllScreens + ModeOutput + ModeLastRegion +) + +type Format int + +const ( + FormatPNG Format = iota + FormatJPEG + FormatPPM +) + +type CursorMode int + +const ( + CursorOff CursorMode = iota + CursorOn +) + +type Region struct { + X int32 `json:"x"` + Y int32 `json:"y"` + Width int32 `json:"width"` + Height int32 `json:"height"` + Output string `json:"output,omitempty"` +} + +func (r Region) IsEmpty() bool { + return r.Width <= 0 || r.Height <= 0 +} + +type Output struct { + Name string + X, Y int32 + Width int32 + Height int32 + Scale int32 + FractionalScale float64 + Transform int32 +} + +type Config struct { + Mode Mode + OutputName string + Cursor CursorMode + NoConfirm bool + Reset bool + Format Format + Quality int + OutputDir string + Filename string + Clipboard bool + SaveFile bool + Notify bool + Stdout bool +} + +func DefaultConfig() Config { + return Config{ + Mode: ModeRegion, + Cursor: CursorOff, + NoConfirm: false, + Reset: false, + Format: FormatPNG, + Quality: 90, + OutputDir: "", + Filename: "", + Clipboard: true, + SaveFile: true, + Notify: true, + } +} 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 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +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, ¤tState) { + 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, ×tamp) + 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, ¤tState) { + 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, ¤tState) { + 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, ¤tState) { + 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, ¤tState) { + 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, ¤tState) { + 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 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +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 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +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] = ðernetDeviceInfo{ + 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] = ðernetDeviceInfo{ + 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, ¤tState) { + 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\": , \"method\": \"...\", \"params\": {...}}") + log.Info("Response format: {\"id\": , \"result\": {...}} or {\"id\": , \"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<= 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, ¤tState) { + 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, ¤tState) { + 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 +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/manager.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/manager.go new file mode 100644 index 0000000..2ebe7ae --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/manager.go @@ -0,0 +1,258 @@ +package themes + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/afero" +) + +type Manager struct { + fs afero.Fs + themesDir string +} + +func NewManager() (*Manager, error) { + return NewManagerWithFs(afero.NewOsFs()) +} + +func NewManagerWithFs(fs afero.Fs) (*Manager, error) { + themesDir := getThemesDir() + return &Manager{ + fs: fs, + themesDir: themesDir, + }, nil +} + +func getThemesDir() string { + configDir, err := os.UserConfigDir() + if err != nil { + log.Error("failed to get user config dir", "err", err) + return "" + } + return filepath.Join(configDir, "DankMaterialShell", "themes") +} + +func (m *Manager) IsInstalled(theme Theme) (bool, error) { + path := m.getInstalledPath(theme.ID) + exists, err := afero.Exists(m.fs, path) + if err != nil { + return false, err + } + return exists, nil +} + +func (m *Manager) getInstalledDir(themeID string) string { + return filepath.Join(m.themesDir, themeID) +} + +func (m *Manager) getInstalledPath(themeID string) string { + return filepath.Join(m.getInstalledDir(themeID), "theme.json") +} + +func (m *Manager) Install(theme Theme, registryThemeDir string) error { + themeDir := m.getInstalledDir(theme.ID) + + exists, err := afero.DirExists(m.fs, themeDir) + if err != nil { + return fmt.Errorf("failed to check if theme exists: %w", err) + } + + if exists { + return fmt.Errorf("theme already installed: %s", theme.Name) + } + + if err := m.fs.MkdirAll(themeDir, 0o755); err != nil { + return fmt.Errorf("failed to create theme directory: %w", err) + } + + data, err := json.MarshalIndent(theme, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal theme: %w", err) + } + + themePath := filepath.Join(themeDir, "theme.json") + if err := afero.WriteFile(m.fs, themePath, data, 0o644); err != nil { + return fmt.Errorf("failed to write theme file: %w", err) + } + + m.copyPreviewFiles(registryThemeDir, themeDir, theme) + return nil +} + +func (m *Manager) copyPreviewFiles(srcDir, dstDir string, theme Theme) { + previews := []string{"preview-dark.svg", "preview-light.svg"} + + if theme.Variants != nil { + for _, v := range theme.Variants.Options { + previews = append(previews, + fmt.Sprintf("preview-%s.svg", v.ID), + fmt.Sprintf("preview-%s-dark.svg", v.ID), + fmt.Sprintf("preview-%s-light.svg", v.ID), + ) + } + } + + for _, preview := range previews { + srcPath := filepath.Join(srcDir, preview) + if exists, _ := afero.Exists(m.fs, srcPath); !exists { + continue + } + data, err := afero.ReadFile(m.fs, srcPath) + if err != nil { + continue + } + dstPath := filepath.Join(dstDir, preview) + _ = afero.WriteFile(m.fs, dstPath, data, 0o644) + } +} + +func (m *Manager) InstallFromRegistry(registry *Registry, themeID string) error { + theme, err := registry.Get(themeID) + if err != nil { + return err + } + + registryThemeDir := registry.GetThemeDir(theme.SourceDir) + return m.Install(*theme, registryThemeDir) +} + +func (m *Manager) Update(theme Theme) error { + themePath := m.getInstalledPath(theme.ID) + + exists, err := afero.Exists(m.fs, themePath) + if err != nil { + return fmt.Errorf("failed to check if theme exists: %w", err) + } + + if !exists { + return fmt.Errorf("theme not installed: %s", theme.Name) + } + + data, err := json.MarshalIndent(theme, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal theme: %w", err) + } + + if err := afero.WriteFile(m.fs, themePath, data, 0o644); err != nil { + return fmt.Errorf("failed to write theme file: %w", err) + } + + return nil +} + +func (m *Manager) Uninstall(theme Theme) error { + return m.UninstallByID(theme.ID) +} + +func (m *Manager) UninstallByID(themeID string) error { + themeDir := m.getInstalledDir(themeID) + + exists, err := afero.DirExists(m.fs, themeDir) + if err != nil { + return fmt.Errorf("failed to check if theme exists: %w", err) + } + + if !exists { + return fmt.Errorf("theme not installed: %s", themeID) + } + + if err := m.fs.RemoveAll(themeDir); err != nil { + return fmt.Errorf("failed to remove theme: %w", err) + } + + return nil +} + +func (m *Manager) ListInstalled() ([]string, error) { + exists, err := afero.DirExists(m.fs, m.themesDir) + if err != nil { + return nil, err + } + + if !exists { + return []string{}, nil + } + + entries, err := afero.ReadDir(m.fs, m.themesDir) + if err != nil { + return nil, fmt.Errorf("failed to read themes directory: %w", err) + } + + var installed []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + themeID := entry.Name() + themePath := filepath.Join(m.themesDir, themeID, "theme.json") + if exists, _ := afero.Exists(m.fs, themePath); exists { + installed = append(installed, themeID) + } + } + + return installed, nil +} + +func (m *Manager) GetInstalledTheme(themeID string) (*Theme, error) { + themePath := m.getInstalledPath(themeID) + + data, err := afero.ReadFile(m.fs, themePath) + if err != nil { + return nil, fmt.Errorf("failed to read theme file: %w", err) + } + + var theme Theme + if err := json.Unmarshal(data, &theme); err != nil { + return nil, fmt.Errorf("failed to parse theme file: %w", err) + } + + return &theme, nil +} + +func (m *Manager) HasUpdates(themeID string, registryTheme Theme) (bool, error) { + installed, err := m.GetInstalledTheme(themeID) + if err != nil { + return false, err + } + + return compareVersions(installed.Version, registryTheme.Version) < 0, nil +} + +func compareVersions(installed, registry string) int { + installedParts := strings.Split(installed, ".") + registryParts := strings.Split(registry, ".") + + maxLen := len(installedParts) + if len(registryParts) > maxLen { + maxLen = len(registryParts) + } + + for i := 0; i < maxLen; i++ { + var installedNum, registryNum int + if i < len(installedParts) { + fmt.Sscanf(installedParts[i], "%d", &installedNum) + } + if i < len(registryParts) { + fmt.Sscanf(registryParts[i], "%d", ®istryNum) + } + + if installedNum < registryNum { + return -1 + } + if installedNum > registryNum { + return 1 + } + } + + return 0 +} + +func (m *Manager) GetThemesDir() string { + return m.themesDir +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/registry.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/registry.go new file mode 100644 index 0000000..5a39913 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/registry.go @@ -0,0 +1,309 @@ +package themes + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/go-git/go-git/v6" + "github.com/spf13/afero" +) + +const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git" + +type ColorScheme struct { + Primary string `json:"primary,omitempty"` + PrimaryText string `json:"primaryText,omitempty"` + PrimaryContainer string `json:"primaryContainer,omitempty"` + Secondary string `json:"secondary,omitempty"` + Surface string `json:"surface,omitempty"` + SurfaceText string `json:"surfaceText,omitempty"` + SurfaceVariant string `json:"surfaceVariant,omitempty"` + SurfaceVariantText string `json:"surfaceVariantText,omitempty"` + SurfaceTint string `json:"surfaceTint,omitempty"` + Background string `json:"background,omitempty"` + BackgroundText string `json:"backgroundText,omitempty"` + Outline string `json:"outline,omitempty"` + SurfaceContainer string `json:"surfaceContainer,omitempty"` + SurfaceContainerHigh string `json:"surfaceContainerHigh,omitempty"` + SurfaceContainerHighest string `json:"surfaceContainerHighest,omitempty"` + Error string `json:"error,omitempty"` + Warning string `json:"warning,omitempty"` + Info string `json:"info,omitempty"` +} + +type ThemeVariant struct { + ID string `json:"id"` + Name string `json:"name"` + Dark ColorScheme `json:"dark,omitempty"` + Light ColorScheme `json:"light,omitempty"` +} + +type ThemeFlavor struct { + ID string `json:"id"` + Name string `json:"name"` + Dark ColorScheme `json:"dark,omitempty"` + Light ColorScheme `json:"light,omitempty"` +} + +type ThemeAccent struct { + ID string `json:"id"` + Name string `json:"name"` + FlavorColors map[string]ColorScheme `json:"-"` +} + +func (a *ThemeAccent) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + a.FlavorColors = make(map[string]ColorScheme) + var mErr error + for key, value := range raw { + switch key { + case "id": + mErr = errors.Join(mErr, json.Unmarshal(value, &a.ID)) + case "name": + mErr = errors.Join(mErr, json.Unmarshal(value, &a.Name)) + default: + var colors ColorScheme + if err := json.Unmarshal(value, &colors); err == nil { + a.FlavorColors[key] = colors + } else { + mErr = errors.Join(mErr, fmt.Errorf("failed to unmarshal flavor colors for key %s: %w", key, err)) + } + } + } + return mErr +} + +func (a ThemeAccent) MarshalJSON() ([]byte, error) { + m := map[string]any{ + "id": a.ID, + "name": a.Name, + } + for k, v := range a.FlavorColors { + m[k] = v + } + return json.Marshal(m) +} + +type MultiVariantDefaults struct { + Dark map[string]string `json:"dark,omitempty"` + Light map[string]string `json:"light,omitempty"` +} + +type ThemeVariants struct { + Type string `json:"type,omitempty"` + Default string `json:"default,omitempty"` + Defaults *MultiVariantDefaults `json:"defaults,omitempty"` + Options []ThemeVariant `json:"options,omitempty"` + Flavors []ThemeFlavor `json:"flavors,omitempty"` + Accents []ThemeAccent `json:"accents,omitempty"` +} + +type Theme struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Dark ColorScheme `json:"dark"` + Light ColorScheme `json:"light"` + Variants *ThemeVariants `json:"variants,omitempty"` + PreviewPath string `json:"-"` + SourceDir string `json:"sourceDir,omitempty"` +} + +type GitClient interface { + PlainClone(path string, url string) error + Pull(path string) error +} + +type realGitClient struct{} + +func (g *realGitClient) PlainClone(path string, url string) error { + _, err := git.PlainClone(path, &git.CloneOptions{ + URL: url, + Progress: os.Stdout, + }) + return err +} + +func (g *realGitClient) Pull(path string) error { + repo, err := git.PlainOpen(path) + if err != nil { + return err + } + + worktree, err := repo.Worktree() + if err != nil { + return err + } + + err = worktree.Pull(&git.PullOptions{}) + if err != nil && err.Error() != "already up-to-date" { + return err + } + + return nil +} + +type Registry struct { + fs afero.Fs + cacheDir string + themes []Theme + git GitClient +} + +func NewRegistry() (*Registry, error) { + return NewRegistryWithFs(afero.NewOsFs()) +} + +func NewRegistryWithFs(fs afero.Fs) (*Registry, error) { + cacheDir := getCacheDir() + return &Registry{ + fs: fs, + cacheDir: cacheDir, + git: &realGitClient{}, + }, nil +} + +func getCacheDir() string { + return filepath.Join(os.TempDir(), "dankdots-plugin-registry") +} + +func (r *Registry) Update() error { + exists, err := afero.DirExists(r.fs, r.cacheDir) + if err != nil { + return fmt.Errorf("failed to check cache directory: %w", err) + } + + if !exists { + if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil { + return fmt.Errorf("failed to clone registry: %w", err) + } + } else { + if err := r.git.Pull(r.cacheDir); err != nil { + if err := r.fs.RemoveAll(r.cacheDir); err != nil { + return fmt.Errorf("failed to remove corrupted registry: %w", err) + } + + if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil { + return fmt.Errorf("failed to re-clone registry: %w", err) + } + } + } + + return r.loadThemes() +} + +func (r *Registry) loadThemes() error { + themesDir := filepath.Join(r.cacheDir, "themes") + + entries, err := afero.ReadDir(r.fs, themesDir) + if err != nil { + return fmt.Errorf("failed to read themes directory: %w", err) + } + + r.themes = []Theme{} + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + themeDir := filepath.Join(themesDir, entry.Name()) + themeFile := filepath.Join(themeDir, "theme.json") + + data, err := afero.ReadFile(r.fs, themeFile) + if err != nil { + continue + } + + var theme Theme + if err := json.Unmarshal(data, &theme); err != nil { + continue + } + + if theme.ID == "" { + theme.ID = entry.Name() + } + theme.SourceDir = entry.Name() + + previewPath := filepath.Join(themeDir, "preview.svg") + if exists, _ := afero.Exists(r.fs, previewPath); exists { + theme.PreviewPath = previewPath + } + + r.themes = append(r.themes, theme) + } + + return nil +} + +func (r *Registry) List() ([]Theme, error) { + if len(r.themes) == 0 { + if err := r.Update(); err != nil { + return nil, err + } + } + + return SortByFirstParty(r.themes), nil +} + +func (r *Registry) Search(query string) ([]Theme, error) { + allThemes, err := r.List() + if err != nil { + return nil, err + } + + if query == "" { + return allThemes, nil + } + + return SortByFirstParty(FuzzySearch(query, allThemes)), nil +} + +func (r *Registry) Get(idOrName string) (*Theme, error) { + themes, err := r.List() + if err != nil { + return nil, err + } + + for _, t := range themes { + if t.ID == idOrName { + return &t, nil + } + } + + for _, t := range themes { + if t.Name == idOrName { + return &t, nil + } + } + + return nil, fmt.Errorf("theme not found: %s", idOrName) +} + +func (r *Registry) GetThemeSourcePath(themeID string) string { + return filepath.Join(r.cacheDir, "themes", themeID, "theme.json") +} + +func (r *Registry) GetThemeDir(themeID string) string { + return filepath.Join(r.cacheDir, "themes", themeID) +} + +func SortByFirstParty(themes []Theme) []Theme { + return themes +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/search.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/search.go new file mode 100644 index 0000000..347f791 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/themes/search.go @@ -0,0 +1,40 @@ +package themes + +import ( + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func FuzzySearch(query string, themes []Theme) []Theme { + if query == "" { + return themes + } + + queryLower := strings.ToLower(query) + return utils.Filter(themes, func(t Theme) bool { + return fuzzyMatch(queryLower, strings.ToLower(t.Name)) || + fuzzyMatch(queryLower, strings.ToLower(t.Description)) || + fuzzyMatch(queryLower, strings.ToLower(t.Author)) + }) +} + +func fuzzyMatch(query, text string) bool { + queryIdx := 0 + for _, char := range text { + if queryIdx < len(query) && char == rune(query[queryIdx]) { + queryIdx++ + } + } + return queryIdx == len(query) +} + +func FindByIDOrName(idOrName string, themes []Theme) *Theme { + if t, found := utils.Find(themes, func(t Theme) bool { return t.ID == idOrName }); found { + return &t + } + if t, found := utils.Find(themes, func(t Theme) bool { return t.Name == idOrName }); found { + return &t + } + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/app.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/app.go new file mode 100644 index 0000000..d07430a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/app.go @@ -0,0 +1,240 @@ +package tui + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type Model struct { + version string + state ApplicationState + + osInfo *distros.OSInfo + dependencies []deps.Dependency + err error + + spinner spinner.Model + passwordInput textinput.Model + width int + height int + isLoading bool + styles Styles + + logMessages []string + logChan chan string + logFilePath string + packageProgressChan chan packageInstallProgressMsg + packageProgress packageInstallProgressMsg + installationLogs []string + showDebugLogs bool + + selectedWM int + selectedTerminal int + selectedDep int + selectedConfig int + reinstallItems map[string]bool + disabledItems map[string]bool + replaceConfigs map[string]bool + skipGentooUseFlags bool + sudoPassword string + existingConfigs []ExistingConfigInfo + fingerprintFailed bool +} + +func NewModel(version string, logFilePath string) Model { + s := spinner.New() + s.Spinner = spinner.Dot + + theme := TerminalTheme() + styles := NewStyles(theme) + s.Style = styles.SpinnerStyle + + pi := textinput.New() + pi.Placeholder = "Enter sudo password" + pi.EchoMode = textinput.EchoPassword + pi.EchoCharacter = '•' + pi.Focus() + + logChan := make(chan string, 1000) + packageProgressChan := make(chan packageInstallProgressMsg, 100) + + return Model{ + version: version, + state: StateWelcome, + spinner: s, + passwordInput: pi, + isLoading: true, + styles: styles, + + logMessages: []string{}, + logChan: logChan, + logFilePath: logFilePath, + packageProgressChan: packageProgressChan, + packageProgress: packageInstallProgressMsg{ + progress: 0.0, + step: "Initializing package installation", + isComplete: false, + }, + showDebugLogs: false, + selectedWM: 0, + selectedTerminal: 0, // Default to Ghostty + selectedDep: 0, + selectedConfig: 0, + reinstallItems: make(map[string]bool), + disabledItems: make(map[string]bool), + replaceConfigs: make(map[string]bool), + installationLogs: []string{}, + } +} + +func (m Model) GetLogChan() <-chan string { + return m.logChan +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + m.listenForLogs(), + m.detectOS(), + ) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "ctrl+c": + return m, tea.Quit + case "ctrl+d": + // Toggle debug logs view (except during password input states) + if m.state != StatePasswordPrompt && m.state != StateFingerprintAuth { + m.showDebugLogs = !m.showDebugLogs + return m, nil + } + } + } + + if tickMsg, ok := msg.(spinner.TickMsg); ok { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(tickMsg) + return m, tea.Batch(cmd, m.listenForLogs()) + } + + if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok { + m.width = sizeMsg.Width + m.height = sizeMsg.Height + } + + if logMsg, ok := msg.(logMsg); ok { + m.logMessages = append(m.logMessages, logMsg.message) + return m, m.listenForLogs() + } + + switch m.state { + case StateWelcome: + return m.updateWelcomeState(msg) + case StateSelectWindowManager: + return m.updateSelectWindowManagerState(msg) + case StateSelectTerminal: + return m.updateSelectTerminalState(msg) + case StateDetectingDeps: + return m.updateDetectingDepsState(msg) + case StateDependencyReview: + return m.updateDependencyReviewState(msg) + case StateGentooUseFlags: + return m.updateGentooUseFlagsState(msg) + case StateGentooGCCCheck: + return m.updateGentooGCCCheckState(msg) + case StateAuthMethodChoice: + return m.updateAuthMethodChoiceState(msg) + case StateFingerprintAuth: + return m.updateFingerprintAuthState(msg) + case StatePasswordPrompt: + return m.updatePasswordPromptState(msg) + case StateInstallingPackages: + return m.updateInstallingPackagesState(msg) + case StateConfigConfirmation: + return m.updateConfigConfirmationState(msg) + case StateDeployingConfigs: + return m.updateDeployingConfigsState(msg) + case StateInstallComplete: + return m.updateInstallCompleteState(msg) + case StateError: + return m.updateErrorState(msg) + default: + return m, m.listenForLogs() + } +} + +func (m Model) View() string { + // If debug logs are shown, show that view regardless of state + if m.showDebugLogs { + return m.viewDebugLogs() + } + + switch m.state { + case StateWelcome: + return m.viewWelcome() + case StateSelectWindowManager: + return m.viewSelectWindowManager() + case StateSelectTerminal: + return m.viewSelectTerminal() + case StateDetectingDeps: + return m.viewDetectingDeps() + case StateDependencyReview: + return m.viewDependencyReview() + case StateGentooUseFlags: + return m.viewGentooUseFlags() + case StateGentooGCCCheck: + return m.viewGentooGCCCheck() + case StateAuthMethodChoice: + return m.viewAuthMethodChoice() + case StateFingerprintAuth: + return m.viewFingerprintAuth() + case StatePasswordPrompt: + return m.viewPasswordPrompt() + case StateInstallingPackages: + return m.viewInstallingPackages() + case StateConfigConfirmation: + return m.viewConfigConfirmation() + case StateDeployingConfigs: + return m.viewDeployingConfigs() + case StateInstallComplete: + return m.viewInstallComplete() + case StateError: + return m.viewError() + default: + return m.viewWelcome() + } +} + +func (m Model) listenForLogs() tea.Cmd { + return func() tea.Msg { + select { + case msg, ok := <-m.logChan: + if !ok { + return nil + } + return logMsg{message: msg} + default: + return nil + } + } +} + +func (m Model) detectOS() tea.Cmd { + return func() tea.Msg { + info, err := distros.GetOSInfo() + osInfoMsg := &distros.OSInfo{} + if info != nil { + osInfoMsg.Distribution = info.Distribution + osInfoMsg.Version = info.Version + osInfoMsg.VersionID = info.VersionID + osInfoMsg.PrettyName = info.PrettyName + osInfoMsg.Architecture = info.Architecture + } + return osInfoCompleteMsg{info: osInfoMsg, err: err} + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/banner.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/banner.go new file mode 100644 index 0000000..e3a2ca3 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/banner.go @@ -0,0 +1,21 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +func (m Model) renderBanner() string { + logo := ` +██████╗ █████╗ ███╗ ██╗██╗ ██╗ +██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝ +██║ ██║███████║██╔██╗ ██║█████╔╝ +██║ ██║██╔══██║██║╚██╗██║██╔═██╗ +██████╔╝██║ ██║██║ ╚████║██║ ██╗ +╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ` + + theme := TerminalTheme() + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true). + MarginBottom(1) + + return style.Render(logo) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/messages.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/messages.go new file mode 100644 index 0000000..7c03098 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/messages.go @@ -0,0 +1,39 @@ +package tui + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" +) + +type logMsg struct { + message string +} + +type osInfoCompleteMsg struct { + info *distros.OSInfo + err error +} + +type depsDetectedMsg struct { + deps []deps.Dependency + err error +} + +type packageInstallProgressMsg struct { + progress float64 + step string + isComplete bool + needsSudo bool + commandInfo string + logOutput string + error error +} + +type packageProgressCompletedMsg struct{} + +type passwordValidMsg struct { + password string + valid bool +} + +type delayCompleteMsg struct{} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/states.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/states.go new file mode 100644 index 0000000..6e9e194 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/states.go @@ -0,0 +1,22 @@ +package tui + +type ApplicationState int + +const ( + StateWelcome ApplicationState = iota + StateSelectWindowManager + StateSelectTerminal + StateDetectingDeps + StateDependencyReview + StateGentooUseFlags + StateGentooGCCCheck + StateAuthMethodChoice + StateFingerprintAuth + StatePasswordPrompt + StateInstallingPackages + StateConfigConfirmation + StateDeployingConfigs + StateInstallComplete + StateFinalComplete + StateError +) diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/styles.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/styles.go new file mode 100644 index 0000000..45a9dae --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/styles.go @@ -0,0 +1,124 @@ +package tui + +import ( + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/lipgloss" +) + +type AppTheme struct { + Primary string + Secondary string + Accent string + Text string + Subtle string + Error string + Warning string + Success string + Background string + Surface string +} + +func TerminalTheme() AppTheme { + return AppTheme{ + Primary: "6", // #625690 - purple + Secondary: "5", // #36247a - dark purple + Accent: "12", // #7060ac - light purple + Text: "7", // #2e2e2e - dark gray + Subtle: "8", // #4a4a4a - medium gray + Error: "1", // #d83636 - red + Warning: "3", // #ffff89 - yellow + Success: "2", // #53e550 - green + Background: "15", // #1a1a1a - near black + Surface: "8", // #4a4a4a - medium gray + } +} + +func NewStyles(theme AppTheme) Styles { + return Styles{ + Title: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true). + MarginLeft(1). + MarginBottom(1), + + Normal: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Text)), + + Bold: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Text)). + Bold(true), + + Subtle: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Subtle)), + + Error: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Error)), + + Warning: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Warning)), + + StatusBar: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#33275e")). + Background(lipgloss.Color(theme.Primary)). + Padding(0, 1), + + Key: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Accent)). + Bold(true), + + SpinnerStyle: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)), + + Success: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Success)). + Bold(true), + + HighlightButton: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#33275e")). + Background(lipgloss.Color(theme.Primary)). + Padding(0, 2). + Bold(true), + + SelectedOption: lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Accent)). + Bold(true), + + CodeBlock: lipgloss.NewStyle(). + Background(lipgloss.Color(theme.Surface)). + Foreground(lipgloss.Color(theme.Text)). + Padding(1, 2). + MarginLeft(2), + } +} + +type Styles struct { + Title lipgloss.Style + Normal lipgloss.Style + Bold lipgloss.Style + Subtle lipgloss.Style + Warning lipgloss.Style + Error lipgloss.Style + StatusBar lipgloss.Style + Key lipgloss.Style + SpinnerStyle lipgloss.Style + Success lipgloss.Style + HighlightButton lipgloss.Style + SelectedOption lipgloss.Style + CodeBlock lipgloss.Style +} + +func (s Styles) NewThemedProgress(width int) progress.Model { + theme := TerminalTheme() + prog := progress.New( + progress.WithGradient(theme.Secondary, theme.Primary), + ) + + prog.Width = width + prog.ShowPercentage = true + prog.PercentFormat = "%.0f%%" + prog.PercentageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Text)). + Bold(true) + + return prog +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_config.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_config.go new file mode 100644 index 0000000..14daff3 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_config.go @@ -0,0 +1,382 @@ +package tui + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/config" + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + tea "github.com/charmbracelet/bubbletea" +) + +type configDeploymentResult struct { + results []config.DeploymentResult + error error +} + +type ExistingConfigInfo struct { + ConfigType string + Path string + Exists bool +} + +type configCheckResult struct { + configs []ExistingConfigInfo + error error +} + +func (m Model) viewDeployingConfigs() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Deploying Configurations") + b.WriteString(title) + b.WriteString("\n\n") + + spinner := m.spinner.View() + status := m.styles.Normal.Render("Setting up configuration files...") + fmt.Fprintf(&b, "%s %s", spinner, status) + b.WriteString("\n\n") + + // Show progress information + info := m.styles.Subtle.Render("• Creating backups of existing configurations\n• Deploying optimized configurations\n• Detecting system paths") + b.WriteString(info) + + // Show live log output if available + if len(m.installationLogs) > 0 { + b.WriteString("\n\n") + logHeader := m.styles.Subtle.Render("Configuration Log:") + b.WriteString(logHeader) + b.WriteString("\n") + + // Show last few lines of logs + maxLines := 5 + startIdx := 0 + if len(m.installationLogs) > maxLines { + startIdx = len(m.installationLogs) - maxLines + } + + for i := startIdx; i < len(m.installationLogs); i++ { + if m.installationLogs[i] != "" { + logLine := m.styles.Subtle.Render(" " + m.installationLogs[i]) + b.WriteString(logLine) + b.WriteString("\n") + } + } + } + + return b.String() +} + +func (m Model) updateDeployingConfigsState(msg tea.Msg) (tea.Model, tea.Cmd) { + if result, ok := msg.(configDeploymentResult); ok { + if result.error != nil { + m.err = result.error + m.state = StateError + m.isLoading = false + return m, nil + } + + for _, deployResult := range result.results { + if deployResult.Deployed { + logMsg := fmt.Sprintf("✓ %s configuration deployed", deployResult.ConfigType) + if deployResult.BackupPath != "" { + logMsg += fmt.Sprintf(" (backup: %s)", deployResult.BackupPath) + } + m.installationLogs = append(m.installationLogs, logMsg) + } + } + + m.state = StateInstallComplete + m.isLoading = false + return m, nil + } + + return m, m.listenForLogs() +} + +func (m Model) deployConfigurations() tea.Cmd { + return func() tea.Msg { + // Determine the selected window manager + var wm deps.WindowManager + switch m.selectedWM { + case 0: + wm = deps.WindowManagerNiri + case 1: + wm = deps.WindowManagerHyprland + default: + wm = deps.WindowManagerNiri + } + + // Determine the selected terminal + var terminal deps.Terminal + if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { + switch m.selectedTerminal { + case 0: + terminal = deps.TerminalKitty + case 1: + terminal = deps.TerminalAlacritty + default: + terminal = deps.TerminalKitty + } + } else { + switch m.selectedTerminal { + case 0: + terminal = deps.TerminalGhostty + case 1: + terminal = deps.TerminalKitty + default: + terminal = deps.TerminalAlacritty + } + } + + deployer := config.NewConfigDeployer(m.logChan) + + results, err := deployer.DeployConfigurationsSelectiveWithReinstalls(context.Background(), wm, terminal, m.dependencies, m.replaceConfigs, m.reinstallItems) + + return configDeploymentResult{ + results: results, + error: err, + } + } +} + +func (m Model) viewConfigConfirmation() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Configuration Deployment") + b.WriteString(title) + b.WriteString("\n\n") + + if len(m.existingConfigs) == 0 { + // No existing configs, proceed directly + info := m.styles.Normal.Render("No existing configurations found. Proceeding with deployment...") + b.WriteString(info) + return b.String() + } + + // Show existing configurations with toggle options + for i, configInfo := range m.existingConfigs { + if configInfo.Exists { + var status string + var replaceMarker string + + shouldReplace := m.replaceConfigs[configInfo.ConfigType] + if _, exists := m.replaceConfigs[configInfo.ConfigType]; !exists { + shouldReplace = true + m.replaceConfigs[configInfo.ConfigType] = true + } + + if shouldReplace { + replaceMarker = "🔄 " + status = m.styles.Warning.Render("Will replace") + } else { + replaceMarker = "✓ " + status = m.styles.Success.Render("Keep existing") + } + + var line string + if i == m.selectedConfig { + line = fmt.Sprintf("▶ %s%-15s %s", replaceMarker, configInfo.ConfigType, status) + line += fmt.Sprintf("\n %s", configInfo.Path) + line = m.styles.SelectedOption.Render(line) + } else { + line = fmt.Sprintf(" %s%-15s %s", replaceMarker, configInfo.ConfigType, status) + line += fmt.Sprintf("\n %s", configInfo.Path) + line = m.styles.Normal.Render(line) + } + + b.WriteString(line) + b.WriteString("\n\n") + } + } + + backup := m.styles.Success.Render("✓ Replaced configurations will be backed up with timestamp") + b.WriteString(backup) + b.WriteString("\n\n") + + help := m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle replace/keep, Enter: Continue") + b.WriteString(help) + + return b.String() +} + +func (m Model) updateConfigConfirmationState(msg tea.Msg) (tea.Model, tea.Cmd) { + if result, ok := msg.(configCheckResult); ok { + if result.error != nil { + m.err = result.error + m.state = StateError + return m, nil + } + + m.existingConfigs = result.configs + + firstExistingSet := false + for i, config := range result.configs { + if config.Exists { + m.replaceConfigs[config.ConfigType] = true + if !firstExistingSet { + m.selectedConfig = i + firstExistingSet = true + } + } + } + + hasExisting := false + for _, config := range result.configs { + if config.Exists { + hasExisting = true + break + } + } + + if !hasExisting { + // No existing configs, proceed directly to deployment + m.state = StateDeployingConfigs + return m, m.deployConfigurations() + } + + // Show confirmation view + return m, nil + } + + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "up": + if m.selectedConfig > 0 { + for i := m.selectedConfig - 1; i >= 0; i-- { + if m.existingConfigs[i].Exists { + m.selectedConfig = i + break + } + } + } + case "down": + if m.selectedConfig < len(m.existingConfigs)-1 { + for i := m.selectedConfig + 1; i < len(m.existingConfigs); i++ { + if m.existingConfigs[i].Exists { + m.selectedConfig = i + break + } + } + } + case " ": + if len(m.existingConfigs) > 0 && m.selectedConfig < len(m.existingConfigs) { + configType := m.existingConfigs[m.selectedConfig].ConfigType + if m.existingConfigs[m.selectedConfig].Exists { + m.replaceConfigs[configType] = !m.replaceConfigs[configType] + } + } + case "enter": + m.state = StateDeployingConfigs + return m, m.deployConfigurations() + } + } + + return m, nil +} + +func (m Model) checkExistingConfigurations() tea.Cmd { + return func() tea.Msg { + var configs []ExistingConfigInfo + + if m.selectedWM == 0 { + niriPath := filepath.Join(os.Getenv("HOME"), ".config", "niri", "config.kdl") + niriExists := false + if _, err := os.Stat(niriPath); err == nil { + niriExists = true + } + configs = append(configs, ExistingConfigInfo{ + ConfigType: "Niri", + Path: niriPath, + Exists: niriExists, + }) + } else { + hyprlandPath := filepath.Join(os.Getenv("HOME"), ".config", "hypr", "hyprland.conf") + hyprlandExists := false + if _, err := os.Stat(hyprlandPath); err == nil { + hyprlandExists = true + } + configs = append(configs, ExistingConfigInfo{ + ConfigType: "Hyprland", + Path: hyprlandPath, + Exists: hyprlandExists, + }) + } + + if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { + if m.selectedTerminal == 0 { + kittyPath := filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf") + kittyExists := false + if _, err := os.Stat(kittyPath); err == nil { + kittyExists = true + } + configs = append(configs, ExistingConfigInfo{ + ConfigType: "Kitty", + Path: kittyPath, + Exists: kittyExists, + }) + } else { + alacrittyPath := filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml") + alacrittyExists := false + if _, err := os.Stat(alacrittyPath); err == nil { + alacrittyExists = true + } + configs = append(configs, ExistingConfigInfo{ + ConfigType: "Alacritty", + Path: alacrittyPath, + Exists: alacrittyExists, + }) + } + } else { + switch m.selectedTerminal { + case 0: + ghosttyPath := filepath.Join(os.Getenv("HOME"), ".config", "ghostty", "config") + ghosttyExists := false + if _, err := os.Stat(ghosttyPath); err == nil { + ghosttyExists = true + } + configs = append(configs, ExistingConfigInfo{ + ConfigType: "Ghostty", + Path: ghosttyPath, + Exists: ghosttyExists, + }) + case 1: + kittyPath := filepath.Join(os.Getenv("HOME"), ".config", "kitty", "kitty.conf") + kittyExists := false + if _, err := os.Stat(kittyPath); err == nil { + kittyExists = true + } + configs = append(configs, ExistingConfigInfo{ + ConfigType: "Kitty", + Path: kittyPath, + Exists: kittyExists, + }) + default: + alacrittyPath := filepath.Join(os.Getenv("HOME"), ".config", "alacritty", "alacritty.toml") + alacrittyExists := false + if _, err := os.Stat(alacrittyPath); err == nil { + alacrittyExists = true + } + configs = append(configs, ExistingConfigInfo{ + ConfigType: "Alacritty", + Path: alacrittyPath, + Exists: alacrittyExists, + }) + } + } + + return configCheckResult{ + configs: configs, + error: nil, + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_dependencies.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_dependencies.go new file mode 100644 index 0000000..13f0335 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_dependencies.go @@ -0,0 +1,304 @@ +package tui + +import ( + "context" + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) viewDetectingDeps() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Detecting Dependencies") + b.WriteString(title) + b.WriteString("\n\n") + + spinner := m.spinner.View() + status := m.styles.Normal.Render("Scanning system for existing packages and configurations...") + fmt.Fprintf(&b, "%s %s", spinner, status) + + return b.String() +} + +func (m Model) viewDependencyReview() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Dependency Review") + b.WriteString(title) + b.WriteString("\n\n") + + if len(m.dependencies) > 0 { + for i, dep := range m.dependencies { + var status string + var reinstallMarker string + var variantMarker string + + isDMS := dep.Name == "dms (DankMaterialShell)" + + if dep.CanToggle && dep.Variant == deps.VariantGit { + variantMarker = "[git] " + } + + if m.disabledItems[dep.Name] { + reinstallMarker = "✗ " + status = m.styles.Subtle.Render("Will skip") + } else if m.reinstallItems[dep.Name] { + reinstallMarker = "🔄 " + status = m.styles.Warning.Render("Will upgrade") + } else if isDMS { + reinstallMarker = "⚡ " + switch dep.Status { + case deps.StatusInstalled: + status = m.styles.Success.Render("✓ Required (installed)") + case deps.StatusMissing: + status = m.styles.Warning.Render("○ Required (will install)") + case deps.StatusNeedsUpdate: + status = m.styles.Warning.Render("△ Required (needs update)") + case deps.StatusNeedsReinstall: + status = m.styles.Error.Render("! Required (needs reinstall)") + } + } else { + switch dep.Status { + case deps.StatusInstalled: + status = m.styles.Subtle.Render("✓ Already installed") + case deps.StatusMissing: + status = m.styles.Warning.Render("○ Will install") + case deps.StatusNeedsUpdate: + status = m.styles.Warning.Render("△ Will install") + case deps.StatusNeedsReinstall: + status = m.styles.Error.Render("! Will install") + } + } + + note := "" + if dep.Name == "dms-greeter" { + note = m.styles.Subtle.Render(" (selection replaces your current display manager)") + } + + var line string + if i == m.selectedDep { + line = fmt.Sprintf("▶ %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status) + if dep.Version != "" { + line += fmt.Sprintf(" (%s)", dep.Version) + } + line = m.styles.SelectedOption.Render(line) + note + } else { + line = fmt.Sprintf(" %s%s%-25s %s", reinstallMarker, variantMarker, dep.Name, status) + if dep.Version != "" { + line += fmt.Sprintf(" (%s)", dep.Version) + } + line = m.styles.Normal.Render(line) + note + } + + b.WriteString(line) + b.WriteString("\n") + } + } + + b.WriteString("\n") + help := m.styles.Subtle.Render("↑/↓: Navigate, Space: Toggle, G: Toggle stable/git, Enter: Continue") + b.WriteString(help) + + return b.String() +} + +func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) { + if depsMsg, ok := msg.(depsDetectedMsg); ok { + m.isLoading = false + if depsMsg.err != nil { + m.err = depsMsg.err + m.state = StateError + } else { + m.dependencies = depsMsg.deps + // dms-greeter is opt-in skipped by default + for _, dep := range depsMsg.deps { + if dep.Name == "dms-greeter" { + m.disabledItems["dms-greeter"] = true + break + } + } + m.state = StateDependencyReview + } + return m, m.listenForLogs() + } + return m, m.listenForLogs() +} + +func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "up": + if m.selectedDep > 0 { + m.selectedDep-- + } + case "down": + if m.selectedDep < len(m.dependencies)-1 { + m.selectedDep++ + } + case " ": + if len(m.dependencies) > 0 { + depName := m.dependencies[m.selectedDep].Name + isDMS := depName == "dms (DankMaterialShell)" + + if !isDMS { + isInstalled := m.dependencies[m.selectedDep].Status == deps.StatusInstalled || + m.dependencies[m.selectedDep].Status == deps.StatusNeedsReinstall + + if isInstalled { + m.reinstallItems[depName] = !m.reinstallItems[depName] + m.disabledItems[depName] = false + } else { + m.disabledItems[depName] = !m.disabledItems[depName] + m.reinstallItems[depName] = false + } + } + } + case "g", "G": + if len(m.dependencies) > 0 && m.dependencies[m.selectedDep].CanToggle { + if m.dependencies[m.selectedDep].Variant == deps.VariantStable { + m.dependencies[m.selectedDep].Variant = deps.VariantGit + } else { + m.dependencies[m.selectedDep].Variant = deps.VariantStable + } + } + case "enter": + // Check if on Gentoo - show USE flags screen + if m.osInfo != nil { + if config, exists := distros.Registry[m.osInfo.Distribution.ID]; exists && config.Family == distros.FamilyGentoo { + m.state = StateGentooUseFlags + return m, nil + } + } + // Check if fingerprint is enabled + if checkFingerprintEnabled() { + m.state = StateAuthMethodChoice + m.selectedConfig = 0 // Default to fingerprint + return m, nil + } else { + m.state = StatePasswordPrompt + m.passwordInput.Focus() + return m, nil + } + case "esc": + m.state = StateSelectWindowManager + return m, nil + } + } + return m, m.listenForLogs() +} + +func (m Model) installPackages() tea.Cmd { + return func() tea.Msg { + if m.osInfo == nil { + return packageInstallProgressMsg{ + progress: 0.0, + step: "Error: OS info not available", + isComplete: true, + } + } + + installer, err := distros.NewPackageInstaller(m.osInfo.Distribution.ID, m.logChan) + if err != nil { + return packageInstallProgressMsg{ + progress: 0.0, + step: fmt.Sprintf("Error: %s", err.Error()), + isComplete: true, + } + } + + // Convert TUI selection to deps enum + var wm deps.WindowManager + if m.selectedWM == 0 { + wm = deps.WindowManagerNiri + } else { + wm = deps.WindowManagerHyprland + } + + installerProgressChan := make(chan distros.InstallProgressMsg, 100) + + go func() { + defer close(installerProgressChan) + err := installer.InstallPackages(context.Background(), m.dependencies, wm, m.sudoPassword, m.reinstallItems, m.disabledItems, m.skipGentooUseFlags, installerProgressChan) + if err != nil { + installerProgressChan <- distros.InstallProgressMsg{ + Progress: 0.0, + Step: fmt.Sprintf("Installation error: %s", err.Error()), + IsComplete: true, + Error: err, + } + } + }() + + // Convert installer messages to TUI messages + go func() { + for msg := range installerProgressChan { + // Run optional greeter setup + if msg.Phase == distros.PhaseComplete && msg.IsComplete && msg.Error == nil { + greeterSelected := false + for _, dep := range m.dependencies { + if dep.Name == "dms-greeter" && !m.disabledItems["dms-greeter"] { + greeterSelected = true + break + } + } + if greeterSelected { + compositorName := "niri" + if m.selectedWM == 1 { + compositorName = "Hyprland" + } + m.packageProgressChan <- packageInstallProgressMsg{ + progress: 0.92, + step: "Configuring DMS greeter...", + logOutput: "Starting automated greeter setup...", + } + greeterLogFunc := func(line string) { + m.packageProgressChan <- packageInstallProgressMsg{ + progress: 0.94, + step: "Configuring DMS greeter...", + logOutput: line, + } + } + if err := greeter.AutoSetupGreeter(compositorName, m.sudoPassword, greeterLogFunc); err != nil { + m.packageProgressChan <- packageInstallProgressMsg{ + progress: 0.96, + step: "Greeter setup warning", + logOutput: fmt.Sprintf("⚠ Greeter auto-setup warning (non-fatal): %v", err), + } + } + } + } + tuiMsg := packageInstallProgressMsg{ + progress: msg.Progress, + step: msg.Step, + isComplete: msg.IsComplete, + needsSudo: msg.NeedsSudo, + commandInfo: msg.CommandInfo, + logOutput: msg.LogOutput, + error: msg.Error, + } + if msg.IsComplete { + m.logChan <- fmt.Sprintf("[DEBUG] Sending completion signal: step=%s, progress=%.2f", msg.Step, msg.Progress) + } + m.packageProgressChan <- tuiMsg + } + m.logChan <- "[DEBUG] Installer channel closed" + }() + + return packageInstallProgressMsg{ + progress: 0.05, + step: "Starting installation...", + isComplete: false, + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_gentoo_gcc.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_gentoo_gcc.go new file mode 100644 index 0000000..74195a4 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_gentoo_gcc.go @@ -0,0 +1,103 @@ +package tui + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type gccVersionCheckMsg struct { + version string + major int + err error +} + +func (m Model) viewGentooGCCCheck() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("GCC Version Check Failed") + b.WriteString(title) + b.WriteString("\n\n") + + error := m.styles.Error.Render("⚠ Hyprland requires GCC 15 or newer") + b.WriteString(error) + b.WriteString("\n\n") + + info := m.styles.Normal.Render("Your current GCC version is too old. Please upgrade GCC before continuing.") + b.WriteString(info) + b.WriteString("\n\n") + + instructionsTitle := m.styles.Subtle.Render("To upgrade GCC:") + b.WriteString(instructionsTitle) + b.WriteString("\n\n") + + steps := []string{ + "1. Install GCC 15 (if not already installed):", + " sudo emerge -1av =sys-devel/gcc:15", + "", + "2. Switch to GCC 15 using gcc-config:", + " sudo gcc-config $(gcc-config -l | grep gcc-15 | awk '{print $2}' | head -1)", + "", + "3. Update environment:", + " source /etc/profile", + "", + "4. Restart this installer", + } + + for _, step := range steps { + stepLine := m.styles.Subtle.Render(step) + b.WriteString(stepLine) + b.WriteString("\n") + } + + b.WriteString("\n") + help := m.styles.Subtle.Render("Press Esc to go back, Ctrl+C to exit") + b.WriteString(help) + + return b.String() +} + +func (m Model) updateGentooGCCCheckState(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "esc": + m.state = StateSelectWindowManager + return m, nil + } + } + return m, m.listenForLogs() +} + +func (m Model) checkGCCVersion() tea.Cmd { + return func() tea.Msg { + cmd := exec.CommandContext(context.Background(), "gcc", "-dumpversion") + output, err := cmd.Output() + if err != nil { + return gccVersionCheckMsg{err: err} + } + + version := strings.TrimSpace(string(output)) + parts := strings.Split(version, ".") + if len(parts) == 0 { + return gccVersionCheckMsg{err: fmt.Errorf("invalid gcc version format")} + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return gccVersionCheckMsg{err: err} + } + + return gccVersionCheckMsg{ + version: version, + major: major, + err: nil, + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_gentoo_use_flags.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_gentoo_use_flags.go new file mode 100644 index 0000000..eb259ef --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_gentoo_use_flags.go @@ -0,0 +1,92 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) viewGentooUseFlags() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Gentoo Global USE Flags") + b.WriteString(title) + b.WriteString("\n\n") + + info := m.styles.Normal.Render("The following global USE flags will be enabled in /etc/portage/make.conf:") + b.WriteString(info) + b.WriteString("\n\n") + + for _, flag := range distros.GentooGlobalUseFlags { + flagLine := m.styles.Success.Render(fmt.Sprintf(" • %s", flag)) + b.WriteString(flagLine) + b.WriteString("\n") + } + + b.WriteString("\n") + note := m.styles.Subtle.Render("These flags ensure proper Qt6, Wayland, and compositor support.") + b.WriteString(note) + b.WriteString("\n\n") + + var toggleLine string + if m.skipGentooUseFlags { + toggleLine = "▶ [✗] Skip adding global USE flags (will use existing configuration)" + toggleLine = m.styles.Warning.Render(toggleLine) + } else { + toggleLine = " [ ] Skip adding global USE flags (will use existing configuration)" + toggleLine = m.styles.Subtle.Render(toggleLine) + } + b.WriteString(toggleLine) + b.WriteString("\n\n") + + help := m.styles.Subtle.Render("Space: Toggle skip, Enter: Continue, Esc: Go back") + b.WriteString(help) + + return b.String() +} + +func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) { + if gccMsg, ok := msg.(gccVersionCheckMsg); ok { + if gccMsg.err != nil || gccMsg.major < 15 { + m.state = StateGentooGCCCheck + return m, nil + } + if checkFingerprintEnabled() { + m.state = StateAuthMethodChoice + m.selectedConfig = 0 + } else { + m.state = StatePasswordPrompt + m.passwordInput.Focus() + } + return m, nil + } + + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case " ": + m.skipGentooUseFlags = !m.skipGentooUseFlags + return m, nil + case "enter": + if m.selectedWM == 1 { + return m, m.checkGCCVersion() + } + if checkFingerprintEnabled() { + m.state = StateAuthMethodChoice + m.selectedConfig = 0 + } else { + m.state = StatePasswordPrompt + m.passwordInput.Focus() + } + return m, nil + case "esc": + m.state = StateDependencyReview + return m, nil + } + } + return m, m.listenForLogs() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_install.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_install.go new file mode 100644 index 0000000..c110711 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_install.go @@ -0,0 +1,405 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// wrapText wraps text to the specified width +func wrapText(text string, width int) string { + if len(text) <= width { + return text + } + + var result strings.Builder + words := strings.Fields(text) + currentLine := "" + + for _, word := range words { + if len(currentLine) == 0 { + currentLine = word + } else if len(currentLine)+1+len(word) <= width { + currentLine += " " + word + } else { + result.WriteString(currentLine) + result.WriteString("\n") + currentLine = word + } + } + + if len(currentLine) > 0 { + result.WriteString(currentLine) + } + + return result.String() +} + +func (m Model) viewInstallingPackages() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Installing Packages") + b.WriteString(title) + b.WriteString("\n\n") + + if !m.packageProgress.isComplete { + spinner := m.spinner.View() + status := m.styles.Normal.Render(m.packageProgress.step) + fmt.Fprintf(&b, "%s %s", spinner, status) + b.WriteString("\n\n") + + // Show progress bar + progressBar := fmt.Sprintf("[%s%s] %.0f%%", + strings.Repeat("█", int(m.packageProgress.progress*30)), + strings.Repeat("░", 30-int(m.packageProgress.progress*30)), + m.packageProgress.progress*100) + b.WriteString(m.styles.Normal.Render(progressBar)) + b.WriteString("\n") + + // Show command info if available + if m.packageProgress.commandInfo != "" { + cmdInfo := m.styles.Subtle.Render("$ " + m.packageProgress.commandInfo) + b.WriteString(cmdInfo) + b.WriteString("\n") + } + + // Show live log output + if len(m.installationLogs) > 0 { + b.WriteString("\n") + logHeader := m.styles.Subtle.Render("Live Output:") + b.WriteString(logHeader) + b.WriteString("\n") + + // Show last few lines of accumulated logs + maxLines := 8 + startIdx := 0 + if len(m.installationLogs) > maxLines { + startIdx = len(m.installationLogs) - maxLines + } + + for i := startIdx; i < len(m.installationLogs); i++ { + if m.installationLogs[i] != "" { + logLine := m.styles.Subtle.Render(" " + m.installationLogs[i]) + b.WriteString(logLine) + b.WriteString("\n") + } + } + } + + // Show error if any + if m.packageProgress.error != nil { + b.WriteString("\n") + wrappedErrorMsg := wrapText("Error: "+m.packageProgress.error.Error(), 80) + errorMsg := m.styles.Error.Render(wrappedErrorMsg) + b.WriteString(errorMsg) + } + + // Show sudo prompt if needed + if m.packageProgress.needsSudo { + sudoWarning := m.styles.Warning.Render("⚠ Using provided sudo password") + b.WriteString(sudoWarning) + } + } else { + if m.packageProgress.error != nil { + wrappedFailedMsg := wrapText("✗ Installation failed: "+m.packageProgress.error.Error(), 80) + errorMsg := m.styles.Error.Render(wrappedFailedMsg) + b.WriteString(errorMsg) + } else { + success := m.styles.Success.Render("✓ Installation complete!") + b.WriteString(success) + } + } + + return b.String() +} + +func dmsPackageName(distroID string, dependencies []deps.Dependency) string { + config, ok := distros.Registry[distroID] + if !ok { + return "dms" + } + + var isGit bool + for _, dep := range dependencies { + if dep.Name == "dms (DankMaterialShell)" { + isGit = dep.Variant == deps.VariantGit + break + } + } + + switch config.Family { + case distros.FamilyArch: + if isGit { + return "dms-shell-git" + } + return "dms-shell" + case distros.FamilyFedora, distros.FamilyUbuntu, distros.FamilyDebian, distros.FamilySUSE: + if isGit { + return "dms-git" + } + return "dms" + default: + return "dms" + } +} + +func uninstallCommand(distroID string, dependencies []deps.Dependency) string { + config, ok := distros.Registry[distroID] + if !ok { + return "" + } + if config.Family == distros.FamilyGentoo { + return "rm -rf ~/.config/quickshell/dms && sudo rm /usr/local/bin/dms" + } + pkg := dmsPackageName(distroID, dependencies) + switch config.Family { + case distros.FamilyArch: + return "sudo pacman -Rs " + pkg + case distros.FamilyFedora: + return "sudo dnf remove " + pkg + case distros.FamilyUbuntu, distros.FamilyDebian: + return "sudo apt remove " + pkg + case distros.FamilySUSE: + return "sudo zypper remove " + pkg + default: + return "" + } +} + +func (m Model) viewInstallComplete() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Success.Render("Setup Complete!") + b.WriteString(title) + b.WriteString("\n\n") + + success := m.styles.Success.Render("✓ All packages installed and configurations deployed.") + b.WriteString(success) + b.WriteString("\n\n") + + accomplishments := []string{ + "• Window manager and dependencies installed", + "• Terminal and development tools configured", + "• Configuration files deployed with backups", + "• System optimized for DankMaterialShell", + } + + for _, item := range accomplishments { + b.WriteString(m.styles.Subtle.Render(item)) + b.WriteString("\n") + } + + b.WriteString("\n") + info := m.styles.Normal.Render("Your system is ready! Log out and log back in to start using\nyour new desktop environment.\nIf you do not have a greeter, login with \"niri-session\" or \"Hyprland\"") + b.WriteString(info) + b.WriteString("\n\n") + + theme := TerminalTheme() + cmdStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Accent)) + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle)) + + b.WriteString(labelStyle.Render("Troubleshooting:") + "\n") + b.WriteString(labelStyle.Render(" Disable autostart: ") + cmdStyle.Render("systemctl --user disable dms") + "\n") + b.WriteString(labelStyle.Render(" View logs: ") + cmdStyle.Render("journalctl --user -u dms") + "\n") + + if m.osInfo != nil { + if cmd := uninstallCommand(m.osInfo.Distribution.ID, m.dependencies); cmd != "" { + b.WriteString(labelStyle.Render(" Uninstall: ") + cmdStyle.Render(cmd) + "\n") + } + } + + b.WriteString("\n") + b.WriteString(m.styles.Normal.Render("Press Enter to exit.")) + + if m.logFilePath != "" { + b.WriteString("\n\n") + logInfo := m.styles.Subtle.Render(fmt.Sprintf("Full logs: %s", m.logFilePath)) + b.WriteString(logInfo) + } + + return b.String() +} + +func (m Model) viewError() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Error.Render("Installation Failed") + b.WriteString(title) + b.WriteString("\n\n") + + if m.err != nil { + wrappedError := wrapText("✗ "+m.err.Error(), 80) + error := m.styles.Error.Render(wrappedError) + b.WriteString(error) + b.WriteString("\n\n") + } + + // Show package progress error if available + if m.packageProgress.error != nil { + wrappedPackageError := wrapText("Package Installation Error: "+m.packageProgress.error.Error(), 80) + packageError := m.styles.Error.Render(wrappedPackageError) + b.WriteString(packageError) + b.WriteString("\n\n") + } + + // Show persistent installation logs + if len(m.installationLogs) > 0 { + logHeader := m.styles.Warning.Render("Installation Logs (last 15 lines):") + b.WriteString(logHeader) + b.WriteString("\n") + + maxLines := 15 + startIdx := 0 + if len(m.installationLogs) > maxLines { + startIdx = len(m.installationLogs) - maxLines + } + + for i := startIdx; i < len(m.installationLogs); i++ { + if m.installationLogs[i] != "" { + logLine := m.styles.Subtle.Render(" " + m.installationLogs[i]) + b.WriteString(logLine) + b.WriteString("\n") + } + } + b.WriteString("\n") + } + + hint := m.styles.Subtle.Render("Press Ctrl+D for full debug logs") + b.WriteString(hint) + b.WriteString("\n") + + if m.logFilePath != "" { + b.WriteString("\n") + logInfo := m.styles.Warning.Render(fmt.Sprintf("Full logs: %s", m.logFilePath)) + b.WriteString(logInfo) + b.WriteString("\n") + } + + help := m.styles.Subtle.Render("Press Enter to exit") + b.WriteString(help) + + return b.String() +} + +func (m Model) updateInstallingPackagesState(msg tea.Msg) (tea.Model, tea.Cmd) { + if progressMsg, ok := msg.(packageInstallProgressMsg); ok { + m.packageProgress = progressMsg + + // Accumulate log output + if progressMsg.logOutput != "" { + m.installationLogs = append(m.installationLogs, progressMsg.logOutput) + // Keep only last 50 lines to preserve more context for debugging + if len(m.installationLogs) > 50 { + m.installationLogs = m.installationLogs[len(m.installationLogs)-50:] + } + } + + if progressMsg.isComplete { + if progressMsg.error != nil { + m.state = StateError + m.isLoading = false + } else { + m.installationLogs = []string{} + m.state = StateConfigConfirmation + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.checkExistingConfigurations()) + } + } + return m, m.listenForPackageProgress() + } + return m, m.listenForLogs() +} + +func (m Model) updateInstallCompleteState(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "enter": + return m, tea.Quit + } + } + return m, m.listenForLogs() +} + +func (m Model) updateErrorState(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "enter": + return m, tea.Quit + } + } + return m, m.listenForLogs() +} + +func (m Model) listenForPackageProgress() tea.Cmd { + return func() tea.Msg { + msg, ok := <-m.packageProgressChan + if !ok { + return packageProgressCompletedMsg{} + } + // Always return the message, completion will be handled in updateInstallingPackagesState + return msg + } +} + +func (m Model) viewDebugLogs() string { + var b strings.Builder + + theme := TerminalTheme() + + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true) + + b.WriteString(titleStyle.Render("Debug Logs")) + b.WriteString("\n\n") + + // Combine both logMessages and installationLogs + allLogs := append([]string{}, m.logMessages...) + allLogs = append(allLogs, m.installationLogs...) + + if len(allLogs) == 0 { + b.WriteString("No logs available\n") + } else { + // Calculate available height (reserve space for header and footer) + maxHeight := m.height - 6 + if maxHeight < 10 { + maxHeight = 10 + } + + // Show the most recent logs + startIdx := 0 + if len(allLogs) > maxHeight { + startIdx = len(allLogs) - maxHeight + } + + for i := startIdx; i < len(allLogs); i++ { + if allLogs[i] != "" { + fmt.Fprintf(&b, "%d: %s\n", i, allLogs[i]) + } + } + + if startIdx > 0 { + subtleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Subtle)) + b.WriteString(subtleStyle.Render(fmt.Sprintf("... (%d older log entries hidden)\n", startIdx))) + } + } + + b.WriteString("\n") + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(theme.Accent)) + b.WriteString(statusStyle.Render("Press Ctrl+D to return, Ctrl+C to quit")) + + return b.String() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_password.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_password.go new file mode 100644 index 0000000..deec061 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_password.go @@ -0,0 +1,338 @@ +package tui + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) viewAuthMethodChoice() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Authentication Method") + b.WriteString(title) + b.WriteString("\n\n") + + message := "Fingerprint authentication is available.\nHow would you like to authenticate?" + b.WriteString(m.styles.Normal.Render(message)) + b.WriteString("\n\n") + + // Option 0: Fingerprint + if m.selectedConfig == 0 { + option := m.styles.SelectedOption.Render("▶ Use Fingerprint") + b.WriteString(option) + } else { + option := m.styles.Normal.Render(" Use Fingerprint") + b.WriteString(option) + } + b.WriteString("\n") + + // Option 1: Password + if m.selectedConfig == 1 { + option := m.styles.SelectedOption.Render("▶ Use Password") + b.WriteString(option) + } else { + option := m.styles.Normal.Render(" Use Password") + b.WriteString(option) + } + b.WriteString("\n\n") + + help := m.styles.Subtle.Render("↑/↓: Navigate, Enter: Select, Esc: Back") + b.WriteString(help) + + return b.String() +} + +func (m Model) viewFingerprintAuth() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Fingerprint Authentication") + b.WriteString(title) + b.WriteString("\n\n") + + if m.fingerprintFailed { + errorMsg := m.styles.Error.Render("✗ Fingerprint authentication failed") + b.WriteString(errorMsg) + b.WriteString("\n") + retryMsg := m.styles.Subtle.Render("Returning to authentication menu...") + b.WriteString(retryMsg) + } else { + message := "Please place your finger on the fingerprint reader." + b.WriteString(m.styles.Normal.Render(message)) + b.WriteString("\n\n") + + spinner := m.spinner.View() + status := m.styles.Normal.Render("Waiting for fingerprint...") + fmt.Fprintf(&b, "%s %s", spinner, status) + } + + return b.String() +} + +func (m Model) viewPasswordPrompt() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Password Authentication") + b.WriteString(title) + b.WriteString("\n\n") + + message := "Installation requires sudo privileges.\nPlease enter your password to continue:" + b.WriteString(m.styles.Normal.Render(message)) + b.WriteString("\n\n") + + // Password input + b.WriteString(m.passwordInput.View()) + b.WriteString("\n") + + // Show validation status + if m.packageProgress.step == "Validating sudo password..." { + spinner := m.spinner.View() + status := m.styles.Normal.Render(m.packageProgress.step) + b.WriteString(spinner + " " + status) + b.WriteString("\n") + } else if m.packageProgress.error != nil { + errorMsg := m.styles.Error.Render("✗ " + m.packageProgress.error.Error() + ". Please try again.") + b.WriteString(errorMsg) + b.WriteString("\n") + } else if m.packageProgress.step == "Password validation failed" { + errorMsg := m.styles.Error.Render("✗ Incorrect password. Please try again.") + b.WriteString(errorMsg) + b.WriteString("\n") + } + + b.WriteString("\n") + help := m.styles.Subtle.Render("Enter: Continue, Esc: Back, Ctrl+C: Cancel") + b.WriteString(help) + + return b.String() +} + +func (m Model) updateAuthMethodChoiceState(msg tea.Msg) (tea.Model, tea.Cmd) { + m.fingerprintFailed = false + + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "up": + if m.selectedConfig > 0 { + m.selectedConfig-- + } + case "down": + if m.selectedConfig < 1 { + m.selectedConfig++ + } + case "enter": + if m.selectedConfig == 0 { + m.state = StateFingerprintAuth + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.tryFingerprint()) + } else { + m.state = StatePasswordPrompt + m.passwordInput.Focus() + return m, nil + } + case "esc": + m.state = StateDependencyReview + return m, nil + } + } + return m, nil +} + +func (m Model) updateFingerprintAuthState(msg tea.Msg) (tea.Model, tea.Cmd) { + if validMsg, ok := msg.(passwordValidMsg); ok { + if validMsg.valid { + m.sudoPassword = "" + m.packageProgress = packageInstallProgressMsg{} + m.state = StateInstallingPackages + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.installPackages()) + } else { + m.fingerprintFailed = true + return m, m.delayThenReturn() + } + } + + if _, ok := msg.(delayCompleteMsg); ok { + m.fingerprintFailed = false + m.selectedConfig = 0 + m.state = StateAuthMethodChoice + return m, nil + } + + return m, m.listenForLogs() +} + +func (m Model) updatePasswordPromptState(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + if validMsg, ok := msg.(passwordValidMsg); ok { + if validMsg.valid { + // Password is valid, proceed with installation + m.sudoPassword = validMsg.password + m.passwordInput.SetValue("") // Clear password input + // Clear any error state + m.packageProgress = packageInstallProgressMsg{} + m.state = StateInstallingPackages + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.installPackages()) + } else { + // Password is invalid, show error and stay on password prompt + m.packageProgress = packageInstallProgressMsg{ + progress: 0.0, + step: "Password validation failed", + error: fmt.Errorf("incorrect password"), + logOutput: "Authentication failed", + } + m.passwordInput.SetValue("") + m.passwordInput.Focus() + return m, nil + } + } + + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "enter": + // Don't allow multiple validation attempts while one is in progress + if m.packageProgress.step == "Validating sudo password..." { + return m, nil + } + + // Validate password first + password := m.passwordInput.Value() + if password == "" { + return m, nil // Don't proceed with empty password + } + + // Clear any previous error and show validation in progress + m.packageProgress = packageInstallProgressMsg{ + progress: 0.01, + step: "Validating sudo password...", + isComplete: false, + logOutput: "Testing password with sudo -v", + } + return m, m.validatePassword(password) + case "esc": + // Go back to dependency review + m.passwordInput.SetValue("") + m.packageProgress = packageInstallProgressMsg{} // Clear any validation state + m.state = StateDependencyReview + return m, nil + } + } + + m.passwordInput, cmd = m.passwordInput.Update(msg) + return m, cmd +} + +func checkFingerprintEnabled() bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Check if pam_fprintd.so is in PAM config + cmd := exec.CommandContext(ctx, "grep", "-q", "pam_fprintd.so", "/etc/pam.d/system-auth") + if err := cmd.Run(); err != nil { + return false + } + + // Check if fprintd-list exists and user has enrolled fingerprints + user := os.Getenv("USER") + if user == "" { + return false + } + + listCmd := exec.CommandContext(ctx, "fprintd-list", user) + output, err := listCmd.CombinedOutput() + if err != nil { + return false + } + + // If output contains "finger:" or similar, fingerprints are enrolled + return strings.Contains(string(output), "finger") +} + +func (m Model) delayThenReturn() tea.Cmd { + return func() tea.Msg { + time.Sleep(2 * time.Second) + return delayCompleteMsg{} + } +} + +func (m Model) tryFingerprint() tea.Cmd { + return func() tea.Msg { + clearCmd := exec.Command("sudo", "-k") + clearCmd.Run() + + tmpDir := os.TempDir() + askpassScript := filepath.Join(tmpDir, fmt.Sprintf("danklinux-fp-%d.sh", time.Now().UnixNano())) + + scriptContent := "#!/bin/sh\nexit 1\n" + if err := os.WriteFile(askpassScript, []byte(scriptContent), 0o700); err != nil { + return passwordValidMsg{password: "", valid: false} + } + defer os.Remove(askpassScript) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "sudo", "-A", "-v") + cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript)) + + err := cmd.Run() + + if err != nil { + return passwordValidMsg{password: "", valid: false} + } + + return passwordValidMsg{password: "", valid: true} + } +} + +func (m Model) validatePassword(password string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "sudo", "-S", "-v") + + stdin, err := cmd.StdinPipe() + if err != nil { + return passwordValidMsg{password: "", valid: false} + } + + if err := cmd.Start(); err != nil { + return passwordValidMsg{password: "", valid: false} + } + + _, err = fmt.Fprintf(stdin, "%s\n", password) + stdin.Close() + if err != nil { + return passwordValidMsg{password: "", valid: false} + } + + err = cmd.Wait() + + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return passwordValidMsg{password: "", valid: false} + } + return passwordValidMsg{password: "", valid: false} + } + + return passwordValidMsg{password: password, valid: true} + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_selection.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_selection.go new file mode 100644 index 0000000..e45105e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_selection.go @@ -0,0 +1,224 @@ +package tui + +import ( + "context" + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) viewSelectWindowManager() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Choose Window Manager") + b.WriteString(title) + b.WriteString("\n\n") + + options := []struct { + name string + description string + }{ + {"niri", "Scrollable-tiling Wayland compositor."}, + } + + if m.osInfo == nil || m.osInfo.Distribution.ID != "debian" { + options = append(options, struct { + name string + description string + }{"Hyprland", "Dynamic tiling Wayland compositor."}) + } + + for i, option := range options { + if i == m.selectedWM { + selected := m.styles.SelectedOption.Render("▶ " + option.name) + b.WriteString(selected) + b.WriteString("\n") + desc := m.styles.Subtle.Render(" " + option.description) + b.WriteString(desc) + } else { + normal := m.styles.Normal.Render(" " + option.name) + b.WriteString(normal) + b.WriteString("\n") + desc := m.styles.Subtle.Render(" " + option.description) + b.WriteString(desc) + } + b.WriteString("\n") + if i < len(options)-1 { + b.WriteString("\n") + } + } + + b.WriteString("\n") + help := m.styles.Subtle.Render("Use ↑/↓ to navigate, Enter to select, Esc to go back") + b.WriteString(help) + + return b.String() +} + +func (m Model) viewSelectTerminal() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + title := m.styles.Title.Render("Choose Terminal Emulator") + b.WriteString(title) + b.WriteString("\n\n") + + var options []struct { + name string + description string + } + + if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { + options = []struct { + name string + description string + }{ + {"kitty", "A feature-rich, customizable terminal emulator."}, + {"alacritty", "A simple terminal emulator."}, + } + } else { + options = []struct { + name string + description string + }{ + {"ghostty", "A fast, native terminal emulator built in Zig."}, + {"kitty", "A feature-rich, customizable terminal emulator."}, + {"alacritty", "A simple terminal emulator."}, + } + } + + for i, option := range options { + if i == m.selectedTerminal { + selected := m.styles.SelectedOption.Render("▶ " + option.name) + b.WriteString(selected) + b.WriteString("\n") + desc := m.styles.Subtle.Render(" " + option.description) + b.WriteString(desc) + } else { + normal := m.styles.Normal.Render(" " + option.name) + b.WriteString(normal) + b.WriteString("\n") + desc := m.styles.Subtle.Render(" " + option.description) + b.WriteString(desc) + } + b.WriteString("\n") + if i < len(options)-1 { + b.WriteString("\n") + } + } + + b.WriteString("\n") + help := m.styles.Subtle.Render("Use ↑/↓ to navigate, Enter to select, Esc to go back") + b.WriteString(help) + + return b.String() +} + +func (m Model) updateSelectTerminalState(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + maxTerminalIndex := 2 + if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { + maxTerminalIndex = 1 + } + + switch keyMsg.String() { + case "up": + if m.selectedTerminal > 0 { + m.selectedTerminal-- + } + case "down": + if m.selectedTerminal < maxTerminalIndex { + m.selectedTerminal++ + } + case "enter": + m.state = StateDetectingDeps + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.detectDependencies()) + case "esc": + m.state = StateSelectWindowManager + return m, m.listenForLogs() + } + } + return m, m.listenForLogs() +} + +func (m Model) updateSelectWindowManagerState(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + maxWMIndex := 1 + if m.osInfo != nil && m.osInfo.Distribution.ID == "debian" { + maxWMIndex = 0 + } + + switch keyMsg.String() { + case "up": + if m.selectedWM > 0 { + m.selectedWM-- + } + case "down": + if m.selectedWM < maxWMIndex { + m.selectedWM++ + } + case "enter": + m.state = StateSelectTerminal + return m, m.listenForLogs() + case "esc": + // Go back to welcome screen + m.state = StateWelcome + return m, m.listenForLogs() + } + } + return m, m.listenForLogs() +} + +func (m Model) detectDependencies() tea.Cmd { + return func() tea.Msg { + if m.osInfo == nil { + return depsDetectedMsg{deps: nil, err: fmt.Errorf("OS info not available")} + } + + detector, err := distros.NewDependencyDetector(m.osInfo.Distribution.ID, m.logChan) + if err != nil { + return depsDetectedMsg{deps: nil, err: err} + } + + // Convert TUI selection to deps enum + var wm deps.WindowManager + if m.selectedWM == 0 { + wm = deps.WindowManagerNiri // First option is Niri + } else { + wm = deps.WindowManagerHyprland // Second option is Hyprland + } + + var terminal deps.Terminal + if m.osInfo != nil && m.osInfo.Distribution.ID == "gentoo" { + switch m.selectedTerminal { + case 0: + terminal = deps.TerminalKitty + case 1: + terminal = deps.TerminalAlacritty + default: + terminal = deps.TerminalKitty + } + } else { + switch m.selectedTerminal { + case 0: + terminal = deps.TerminalGhostty + case 1: + terminal = deps.TerminalKitty + default: + terminal = deps.TerminalAlacritty + } + } + + dependencies, err := detector.DetectDependenciesWithTerminal(context.Background(), wm, terminal) + return depsDetectedMsg{deps: dependencies, err: err} + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_welcome.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_welcome.go new file mode 100644 index 0000000..c5ae862 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/tui/views_welcome.go @@ -0,0 +1,216 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (m Model) viewWelcome() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + + theme := TerminalTheme() + + decorator := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Accent)). + Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + titleBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(theme.Primary)). + Padding(0, 2). + MarginBottom(1) + + titleText := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true). + Render("dankinstall") + + versionTag := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Accent)). + Italic(true). + Render(" // Dank Linux Installer") + + subtitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Subtle)). + Italic(true). + Render("Quickstart for a Dank Desktop") + + b.WriteString(decorator) + b.WriteString("\n") + b.WriteString(titleBox.Render(titleText + versionTag)) + b.WriteString("\n") + b.WriteString(subtitle) + b.WriteString("\n\n") + + if m.osInfo != nil { + if distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { + errorBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#FF6B6B")). + Padding(1, 2). + MarginBottom(1) + + errorTitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B6B")). + Bold(true). + Render("⚠ UNSUPPORTED DISTRIBUTION") + + var errorMsg string + switch m.osInfo.Distribution.ID { + case "ubuntu": + errorMsg = fmt.Sprintf("Ubuntu %s is not supported.\n\nOnly Ubuntu 25.04+ is supported.\n\nPlease upgrade to Ubuntu 25.04 or later.", m.osInfo.VersionID) + case "debian": + errorMsg = fmt.Sprintf("Debian %s is not supported.\n\nOnly Debian 13+ (Trixie) is supported.\n\nPlease upgrade to Debian 13 or later.", m.osInfo.VersionID) + case "nixos": + errorMsg = "See the NixOS documentation for installation instructions: https://danklinux.com/docs/dankmaterialshell/nixos." + default: + errorMsg = fmt.Sprintf("%s is not supported.\nFeel free to request on https://github.com/AvengeMedia/DankMaterialShell", m.osInfo.PrettyName) + } + + errorMsgStyled := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Text)). + Render(errorMsg) + + b.WriteString(errorBox.Render(errorTitle + "\n\n" + errorMsgStyled)) + b.WriteString("\n\n") + } else { + // System info box + sysBox := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(theme.Subtle)). + Padding(0, 1). + MarginBottom(1) + + // Style the distro name with its color + distroStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.osInfo.Distribution.HexColorCode)). + Bold(true) + distroName := distroStyle.Render(m.osInfo.PrettyName) + + archStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Accent)) + + sysInfo := fmt.Sprintf("System: %s / %s", distroName, archStyle.Render(m.osInfo.Architecture)) + b.WriteString(sysBox.Render(sysInfo)) + b.WriteString("\n") + + // Feature list with better styling + featTitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true). + Underline(true). + Render("WHAT YOU GET") + b.WriteString(featTitle + "\n\n") + + features := []string{ + "[shell] dms (DankMaterialShell)", + "[wm] niri or Hyprland", + "[term] Ghostty, kitty, or Alacritty", + "[style] All the themes, automatically.", + "[config] DANK defaults - keybindings, rules, animations, etc.", + } + + for i, feat := range features { + prefix := feat[:9] + content := feat[10:] + + prefixStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Accent)). + Bold(true) + + contentStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Text)) + + if i == len(features)-1 { + contentStyle = contentStyle.Bold(true) + } + + fmt.Fprintf(&b, " %s %s\n", + prefixStyle.Render(prefix), + contentStyle.Render(content)) + } + + b.WriteString("\n") + + noteStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Subtle)). + Italic(true) + note := noteStyle.Render("* Existing configs can be replaced (and backed up) or preserved") + b.WriteString(note) + b.WriteString("\n") + + if m.osInfo.Distribution.ID == "gentoo" { + gentooNote := noteStyle.Render("* Will set per-package USE flags and unmask testing packages as needed") + b.WriteString(gentooNote) + b.WriteString("\n") + } + + b.WriteString("\n") + } + + } else if m.isLoading { + spinner := m.spinner.View() + loading := m.styles.Normal.Render("Detecting system...") + fmt.Fprintf(&b, "%s %s\n\n", spinner, loading) + } + + // Footer with better visual separation + footerDivider := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Subtle)). + Render("───────────────────────────────────────────────────────────") + b.WriteString(footerDivider + "\n") + + if m.osInfo != nil { + ctrlKey := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true). + Render("Ctrl+C") + + if distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { + b.WriteString(m.styles.Subtle.Render("Press ") + ctrlKey + m.styles.Subtle.Render(" to quit")) + } else { + enterKey := lipgloss.NewStyle(). + Foreground(lipgloss.Color(theme.Primary)). + Bold(true). + Render("Enter") + + b.WriteString(m.styles.Subtle.Render("Press ") + enterKey + m.styles.Subtle.Render(" to choose window manager, ") + ctrlKey + m.styles.Subtle.Render(" to quit")) + } + } else { + help := m.styles.Subtle.Render("Press Enter to continue, Ctrl+C to quit") + b.WriteString(help) + } + + return b.String() +} + +func (m Model) updateWelcomeState(msg tea.Msg) (tea.Model, tea.Cmd) { + if completeMsg, ok := msg.(osInfoCompleteMsg); ok { + m.isLoading = false + if completeMsg.err != nil { + m.err = completeMsg.err + m.state = StateError + } else { + m.osInfo = completeMsg.info + } + return m, m.listenForLogs() + } + + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "enter": + if m.osInfo != nil && !distros.IsUnsupportedDistro(m.osInfo.Distribution.ID, m.osInfo.VersionID) { + m.state = StateSelectWindowManager + return m, m.listenForLogs() + } + } + } + return m, m.listenForLogs() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/dbus.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/dbus.go new file mode 100644 index 0000000..bafd51f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/dbus.go @@ -0,0 +1,37 @@ +package utils + +import ( + "slices" + + "github.com/godbus/dbus/v5" +) + +func IsDBusServiceAvailable(busName string) bool { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return false + } + defer conn.Close() + + obj := conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus") + var owned bool + if err := obj.Call("org.freedesktop.DBus.NameHasOwner", 0, busName).Store(&owned); err != nil { + return false + } + return owned +} + +func IsDBusServiceActivatable(busName string) bool { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return false + } + defer conn.Close() + + obj := conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus") + var activatable []string + if err := obj.Call("org.freedesktop.DBus.ListActivatableNames", 0).Store(&activatable); err != nil { + return false + } + return slices.Contains(activatable, busName) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/exec.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/exec.go new file mode 100644 index 0000000..aa1eb31 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/exec.go @@ -0,0 +1,44 @@ +package utils + +import ( + "os/exec" +) + +type AppChecker interface { + CommandExists(cmd string) bool + AnyCommandExists(cmds ...string) bool + FlatpakExists(name string) bool + AnyFlatpakExists(flatpaks ...string) bool +} + +type DefaultAppChecker struct{} + +func (DefaultAppChecker) CommandExists(cmd string) bool { + return CommandExists(cmd) +} + +func (DefaultAppChecker) AnyCommandExists(cmds ...string) bool { + return AnyCommandExists(cmds...) +} + +func (DefaultAppChecker) FlatpakExists(name string) bool { + return FlatpakExists(name) +} + +func (DefaultAppChecker) AnyFlatpakExists(flatpaks ...string) bool { + return AnyFlatpakExists(flatpaks...) +} + +func CommandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +func AnyCommandExists(cmds ...string) bool { + for _, cmd := range cmds { + if CommandExists(cmd) { + return true + } + } + return false +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/flatpak.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/flatpak.go new file mode 100644 index 0000000..f1c9009 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/flatpak.go @@ -0,0 +1,83 @@ +package utils + +import ( + "bytes" + "errors" + "os/exec" + "slices" + "strings" +) + +func FlatpakInPath() bool { + _, err := exec.LookPath("flatpak") + return err == nil +} + +func FlatpakExists(name string) bool { + if !FlatpakInPath() { + return false + } + + cmd := exec.Command("flatpak", "info", name) + err := cmd.Run() + return err == nil +} + +func FlatpakSearchBySubstring(substring string) bool { + if !FlatpakInPath() { + return false + } + + cmd := exec.Command("flatpak", "list", "--app") + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return false + } + + out := stdout.String() + + for line := range strings.SplitSeq(out, "\n") { + fields := strings.Fields(line) + if len(fields) > 1 { + id := fields[1] + idParts := strings.Split(id, ".") + // We are assuming that the last part of the ID is + // the package name we're looking for. This might + // not always be true, some developers use arbitrary + // suffixes. + if len(idParts) > 0 && idParts[len(idParts)-1] == substring { + cmd := exec.Command("flatpak", "info", id) + err := cmd.Run() + return err == nil + } + } + } + return false +} + +func AnyFlatpakExists(flatpaks ...string) bool { + return slices.ContainsFunc(flatpaks, FlatpakExists) +} + +func FlatpakInstallationDir(name string) (string, error) { + if !FlatpakInPath() { + return "", errors.New("flatpak not found in PATH") + } + + cmd := exec.Command("flatpak", "info", "--show-location", name) + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return "", errors.New("flatpak not installed: " + name) + } + + location := strings.TrimSpace(stdout.String()) + if location == "" { + return "", errors.New("installation directory not found for: " + name) + } + + return location, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/flatpak_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/flatpak_test.go new file mode 100644 index 0000000..97282ae --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/flatpak_test.go @@ -0,0 +1,271 @@ +package utils + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFlatpakInPathAvailable(t *testing.T) { + result := FlatpakInPath() + if !result { + t.Skip("flatpak not in PATH") + } + if !result { + t.Errorf("expected true when flatpak is in PATH") + } +} + +func TestFlatpakInPathUnavailable(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("PATH", tempDir) + + result := FlatpakInPath() + if result { + t.Errorf("expected false when flatpak not in PATH, got true") + } +} + +func TestFlatpakExistsValidPackage(t *testing.T) { + if !FlatpakInPath() { + t.Skip("flatpak not in PATH") + } + + result := FlatpakExists("com.nonexistent.package.test") + if result { + t.Logf("package exists (unexpected but not an error)") + } +} + +func TestFlatpakExistsNoFlatpak(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("PATH", tempDir) + + result := FlatpakExists("any.package.name") + if result { + t.Errorf("expected false when flatpak not in PATH, got true") + } +} + +func TestFlatpakSearchBySubstringNoFlatpak(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("PATH", tempDir) + + result := FlatpakSearchBySubstring("test") + if result { + t.Errorf("expected false when flatpak not in PATH, got true") + } +} + +func TestFlatpakSearchBySubstringNonexistent(t *testing.T) { + if !FlatpakInPath() { + t.Skip("flatpak not in PATH") + } + + result := FlatpakSearchBySubstring("ThisIsAVeryUnlikelyPackageName12345") + if result { + t.Errorf("expected false for nonexistent package substring") + } +} + +func TestFlatpakInstallationDirNoFlatpak(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("PATH", tempDir) + + _, err := FlatpakInstallationDir("any.package.name") + if err == nil { + t.Errorf("expected error when flatpak not in PATH") + } + if err != nil && !strings.Contains(err.Error(), "not found in PATH") { + t.Errorf("expected 'not found in PATH' error, got: %v", err) + } +} + +func TestFlatpakInstallationDirNonexistent(t *testing.T) { + if !FlatpakInPath() { + t.Skip("flatpak not in PATH") + } + + _, err := FlatpakInstallationDir("com.nonexistent.package.test") + if err == nil { + t.Errorf("expected error for nonexistent package") + } + if err != nil && !strings.Contains(err.Error(), "not installed") { + t.Errorf("expected 'not installed' error, got: %v", err) + } +} + +func TestFlatpakInstallationDirValid(t *testing.T) { + if !FlatpakInPath() { + t.Skip("flatpak not in PATH") + } + + // This test requires a known installed flatpak + // We can't guarantee any specific flatpak is installed, + // so we'll skip if we can't find a common one + commonFlatpaks := []string{ + "org.mozilla.firefox", + "org.gnome.Calculator", + "org.freedesktop.Platform", + } + + var testPackage string + for _, pkg := range commonFlatpaks { + if FlatpakExists(pkg) { + testPackage = pkg + break + } + } + + if testPackage == "" { + t.Skip("no common flatpak packages found for testing") + } + + result, err := FlatpakInstallationDir(testPackage) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == "" { + t.Errorf("expected non-empty installation directory") + } + if !strings.Contains(result, testPackage) { + t.Logf("installation directory %s doesn't contain package name (may be expected)", result) + } +} + +func TestFlatpakExistsCommandFailure(t *testing.T) { + if !FlatpakInPath() { + t.Skip("flatpak not in PATH") + } + + // Mock a failing flatpak command through PATH interception + tempDir := t.TempDir() + fakeFlatpak := filepath.Join(tempDir, "flatpak") + + script := "#!/bin/sh\nexit 1\n" + err := os.WriteFile(fakeFlatpak, []byte(script), 0o755) + if err != nil { + t.Fatalf("failed to create fake flatpak: %v", err) + } + + originalPath := os.Getenv("PATH") + t.Setenv("PATH", tempDir+":"+originalPath) + + result := FlatpakExists("test.package") + if result { + t.Errorf("expected false when flatpak command fails, got true") + } +} + +func TestFlatpakSearchBySubstringCommandFailure(t *testing.T) { + if !FlatpakInPath() { + t.Skip("flatpak not in PATH") + } + + // Mock a failing flatpak command through PATH interception + tempDir := t.TempDir() + fakeFlatpak := filepath.Join(tempDir, "flatpak") + + script := "#!/bin/sh\nexit 1\n" + err := os.WriteFile(fakeFlatpak, []byte(script), 0o755) + if err != nil { + t.Fatalf("failed to create fake flatpak: %v", err) + } + + originalPath := os.Getenv("PATH") + t.Setenv("PATH", tempDir+":"+originalPath) + + result := FlatpakSearchBySubstring("test") + if result { + t.Errorf("expected false when flatpak command fails, got true") + } +} + +func TestFlatpakInstallationDirCommandFailure(t *testing.T) { + if !FlatpakInPath() { + t.Skip("flatpak not in PATH") + } + + // Mock a failing flatpak command through PATH interception + tempDir := t.TempDir() + fakeFlatpak := filepath.Join(tempDir, "flatpak") + + script := "#!/bin/sh\nexit 1\n" + err := os.WriteFile(fakeFlatpak, []byte(script), 0o755) + if err != nil { + t.Fatalf("failed to create fake flatpak: %v", err) + } + + originalPath := os.Getenv("PATH") + t.Setenv("PATH", tempDir+":"+originalPath) + + _, err = FlatpakInstallationDir("test.package") + if err == nil { + t.Errorf("expected error when flatpak command fails") + } + if err != nil && !strings.Contains(err.Error(), "not installed") { + t.Errorf("expected 'not installed' error, got: %v", err) + } +} + +func TestAnyFlatpakExistsSomeExist(t *testing.T) { + tempDir := t.TempDir() + fakeFlatpak := filepath.Join(tempDir, "flatpak") + + // Script that succeeds only for "app.exists.test" + script := `#!/bin/sh +if [ "$1" = "info" ] && [ "$2" = "app.exists.test" ]; then + exit 0 +fi +exit 1 +` + err := os.WriteFile(fakeFlatpak, []byte(script), 0o755) + if err != nil { + t.Fatalf("failed to create fake flatpak: %v", err) + } + + originalPath := os.Getenv("PATH") + t.Setenv("PATH", tempDir+":"+originalPath) + + result := AnyFlatpakExists("com.nonexistent.flatpak", "app.exists.test", "com.another.nonexistent") + if !result { + t.Errorf("expected true when at least one flatpak exists") + } +} + +func TestAnyFlatpakExistsNoneExist(t *testing.T) { + tempDir := t.TempDir() + fakeFlatpak := filepath.Join(tempDir, "flatpak") + + script := "#!/bin/sh\nexit 1\n" + err := os.WriteFile(fakeFlatpak, []byte(script), 0o755) + if err != nil { + t.Fatalf("failed to create fake flatpak: %v", err) + } + + originalPath := os.Getenv("PATH") + t.Setenv("PATH", tempDir+":"+originalPath) + + result := AnyFlatpakExists("com.nonexistent.flatpak1", "com.nonexistent.flatpak2") + if result { + t.Errorf("expected false when no flatpaks exist") + } +} + +func TestAnyFlatpakExistsNoFlatpak(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("PATH", tempDir) + + result := AnyFlatpakExists("any.package.name", "another.package") + if result { + t.Errorf("expected false when flatpak not in PATH, got true") + } +} + +func TestAnyFlatpakExistsEmpty(t *testing.T) { + result := AnyFlatpakExists() + if result { + t.Errorf("expected false when no flatpaks specified") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/group.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/group.go new file mode 100644 index 0000000..1a0a950 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/group.go @@ -0,0 +1,37 @@ +package utils + +import ( + "os" + "strings" +) + +func HasGroup(groupName string) bool { + return HasGroupIn(groupName, "/etc/group") +} + +func HasGroupIn(groupName, path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + return HasGroupData(groupName, string(data)) +} + +func HasGroupData(groupName, data string) bool { + prefix := groupName + ":" + for line := range strings.SplitSeq(data, "\n") { + if strings.HasPrefix(line, prefix) { + return true + } + } + return false +} + +func FindGroupData(data string, candidates ...string) (string, bool) { + for _, candidate := range candidates { + if HasGroupData(candidate, data) { + return candidate, true + } + } + return "", false +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/group_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/group_test.go new file mode 100644 index 0000000..4b401dc --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/group_test.go @@ -0,0 +1,142 @@ +package utils + +import "testing" + +const testGroupData = `root:x:0:brltty,root +sys:x:3:bin,testuser +mem:x:8: +ftp:x:11: +mail:x:12: +log:x:19: +smmsp:x:25: +proc:x:26: +games:x:50: +lock:x:54: +network:x:90: +floppy:x:94: +scanner:x:96: +power:x:98: +nobody:x:65534: +adm:x:999:daemon +wheel:x:998:testuser +utmp:x:997: +audio:x:996:brltty +disk:x:995: +input:x:994:brltty,testuser,greeter +kmem:x:993: +kvm:x:992:libvirt-qemu,qemu,testuser +lp:x:991:cups,testuser +optical:x:990: +render:x:989: +sgx:x:988: +storage:x:987: +tty:x:5:brltty +uucp:x:986:brltty +video:x:985:cosmic-greeter,greeter,testuser +users:x:984: +groups:x:983: +systemd-journal:x:982: +rfkill:x:981: +bin:x:1:daemon +daemon:x:2:bin +http:x:33: +dbus:x:81: +systemd-coredump:x:980: +systemd-network:x:979: +systemd-oom:x:978: +systemd-journal-remote:x:977: +systemd-resolve:x:976: +systemd-timesync:x:975: +tss:x:974: +uuidd:x:973: +alpm:x:972: +polkitd:x:102: +testuser:x:1000: +avahi:x:971: +git:x:970: +nvidia-persistenced:x:143: +i2c:x:969:testuser +seat:x:968: +rtkit:x:133: +brlapi:x:967:brltty +gdm:x:120: +brltty:x:966: +colord:x:965: +flatpak:x:964: +geoclue:x:963:testuser +gnome-remote-desktop:x:962: +saned:x:961: +usbmux:x:140: +cosmic-greeter:x:960: +greeter:x:959:testuser +openvpn:x:958: +nm-openvpn:x:957: +named:x:40: +_talkd:x:956: +keyd:x:955: +cups:x:209:testuser +docker:x:954:testuser +mysql:x:953: +radicale:x:952: +onepassword:x:1001: +nixbld:x:951:nixbld01,nixbld02,nixbld03,nixbld04,nixbld05,nixbld06,nixbld07,nixbld08,nixbld09,nixbld10 +virtlogin:x:940: +libvirt:x:939:testuser +libvirt-qemu:x:938: +qemu:x:937: +dnsmasq:x:936: +clock:x:935: +dms-greeter:x:1002:greeter,testuser +pcscd:x:934: +test:x:1003: +empower:x:933: +` + +func TestHasGroupData(t *testing.T) { + tests := []struct { + group string + want bool + }{ + {"greeter", true}, + {"root", true}, + {"docker", true}, + {"cosmic-greeter", true}, + {"dms-greeter", true}, + {"nonexistent", false}, + {"greet", false}, + } + + for _, tt := range tests { + if got := HasGroupData(tt.group, testGroupData); got != tt.want { + t.Errorf("HasGroupData(%q) = %v, want %v", tt.group, got, tt.want) + } + } +} + +func TestFindGroupData(t *testing.T) { + tests := []struct { + name string + candidates []string + wantGroup string + wantFound bool + }{ + {"first match wins", []string{"greeter", "greetd", "_greeter"}, "greeter", true}, + {"fallback to second", []string{"greetd", "greeter"}, "greeter", true}, + {"none found", []string{"_greetd", "greetd"}, "", false}, + {"single match", []string{"docker"}, "docker", true}, + } + + for _, tt := range tests { + got, found := FindGroupData(testGroupData, tt.candidates...) + if got != tt.wantGroup || found != tt.wantFound { + t.Errorf("%s: FindGroupData(%v) = (%q, %v), want (%q, %v)", + tt.name, tt.candidates, got, found, tt.wantGroup, tt.wantFound) + } + } +} + +func TestHasGroupDataEmpty(t *testing.T) { + if HasGroupData("greeter", "") { + t.Error("expected false for empty data") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/gsettings.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/gsettings.go new file mode 100644 index 0000000..cc8d084 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/gsettings.go @@ -0,0 +1,31 @@ +package utils + +import ( + "fmt" + "os/exec" + "strings" +) + +func dconfPath(schema, key string) string { + return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key +} + +// GsettingsGet reads a gsettings value, falling back to dconf read. +func GsettingsGet(schema, key string) (string, error) { + if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil { + return strings.TrimSpace(string(out)), nil + } + out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output() + if err != nil { + return "", fmt.Errorf("gsettings/dconf get failed for %s %s: %w", schema, key, err) + } + return strings.TrimSpace(string(out)), nil +} + +// GsettingsSet writes a gsettings value, falling back to dconf write. +func GsettingsSet(schema, key, value string) error { + if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil { + return nil + } + return exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/math.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/math.go new file mode 100644 index 0000000..408af9f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/math.go @@ -0,0 +1,13 @@ +package utils + +import "golang.org/x/exp/constraints" + +func Clamp[T constraints.Ordered](val, min, max T) T { + if val < min { + return min + } + if val > max { + return max + } + return val +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/paths.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/paths.go new file mode 100644 index 0000000..053097e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/paths.go @@ -0,0 +1,70 @@ +package utils + +import ( + "os" + "path/filepath" + "strings" +) + +func XDGStateHome() string { + if dir := os.Getenv("XDG_STATE_HOME"); dir != "" { + return dir + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "state") +} + +func XDGDataHome() string { + if dir := os.Getenv("XDG_DATA_HOME"); dir != "" { + return dir + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share") +} + +func XDGCacheHome() string { + if dir, err := os.UserCacheDir(); err == nil { + return dir + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cache") +} + +func XDGConfigHome() string { + if dir, err := os.UserConfigDir(); err == nil { + return dir + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} + +func EmacsConfigDir() string { + home, _ := os.UserHomeDir() + + emacsD := filepath.Join(home, ".emacs.d") + if info, err := os.Stat(emacsD); err == nil && info.IsDir() { + return emacsD + } + + xdgEmacs := filepath.Join(XDGConfigHome(), "emacs") + if info, err := os.Stat(xdgEmacs); err == nil && info.IsDir() { + return xdgEmacs + } + + return "" +} + +func ExpandPath(path string) (string, error) { + expanded := os.ExpandEnv(path) + expanded = filepath.Clean(expanded) + + if strings.HasPrefix(expanded, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + expanded = filepath.Join(home, expanded[1:]) + } + + return expanded, nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/paths_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/paths_test.go new file mode 100644 index 0000000..964f2a7 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/paths_test.go @@ -0,0 +1,43 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExpandPathTilde(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory") + } + result, err := ExpandPath("~/test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := filepath.Join(home, "test") + if result != expected { + t.Errorf("expected %s, got %s", expected, result) + } +} + +func TestExpandPathEnvVar(t *testing.T) { + t.Setenv("TEST_PATH_VAR", "/custom/path") + result, err := ExpandPath("$TEST_PATH_VAR/subdir") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "/custom/path/subdir" { + t.Errorf("expected /custom/path/subdir, got %s", result) + } +} + +func TestExpandPathAbsolute(t *testing.T) { + result, err := ExpandPath("/absolute/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "/absolute/path" { + t.Errorf("expected /absolute/path, got %s", result) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/slices.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/slices.go new file mode 100644 index 0000000..b636ddc --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/slices.go @@ -0,0 +1,56 @@ +package utils + +func Filter[T any](items []T, predicate func(T) bool) []T { + var result []T + for _, item := range items { + if predicate(item) { + result = append(result, item) + } + } + return result +} + +func Find[T any](items []T, predicate func(T) bool) (T, bool) { + for _, item := range items { + if predicate(item) { + return item, true + } + } + var zero T + return zero, false +} + +func Map[T, U any](items []T, transform func(T) U) []U { + result := make([]U, len(items)) + for i, item := range items { + result[i] = transform(item) + } + return result +} + +func Contains[T comparable](items []T, target T) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} + +func Any[T any](items []T, predicate func(T) bool) bool { + for _, item := range items { + if predicate(item) { + return true + } + } + return false +} + +func All[T any](items []T, predicate func(T) bool) bool { + for _, item := range items { + if !predicate(item) { + return false + } + } + return true +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/slices_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/slices_test.go new file mode 100644 index 0000000..8edcee5 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/utils/slices_test.go @@ -0,0 +1,72 @@ +package utils + +import ( + "testing" +) + +func TestFilter(t *testing.T) { + nums := []int{1, 2, 3, 4, 5} + evens := Filter(nums, func(n int) bool { return n%2 == 0 }) + if len(evens) != 2 || evens[0] != 2 || evens[1] != 4 { + t.Errorf("expected [2, 4], got %v", evens) + } +} + +func TestFilterEmpty(t *testing.T) { + result := Filter([]int{1, 2, 3}, func(n int) bool { return n > 10 }) + if len(result) != 0 { + t.Errorf("expected empty slice, got %v", result) + } +} + +func TestFind(t *testing.T) { + nums := []int{1, 2, 3, 4, 5} + val, found := Find(nums, func(n int) bool { return n == 3 }) + if !found || val != 3 { + t.Errorf("expected 3, got %v (found=%v)", val, found) + } +} + +func TestFindNotFound(t *testing.T) { + nums := []int{1, 2, 3} + val, found := Find(nums, func(n int) bool { return n == 99 }) + if found || val != 0 { + t.Errorf("expected zero value not found, got %v (found=%v)", val, found) + } +} + +func TestMap(t *testing.T) { + nums := []int{1, 2, 3} + doubled := Map(nums, func(n int) int { return n * 2 }) + if len(doubled) != 3 || doubled[0] != 2 || doubled[1] != 4 || doubled[2] != 6 { + t.Errorf("expected [2, 4, 6], got %v", doubled) + } +} + +func TestMapTypeConversion(t *testing.T) { + nums := []int{1, 2, 3} + strs := Map(nums, func(n int) string { return string(rune('a' + n - 1)) }) + if strs[0] != "a" || strs[1] != "b" || strs[2] != "c" { + t.Errorf("expected [a, b, c], got %v", strs) + } +} + +func TestContains(t *testing.T) { + nums := []int{1, 2, 3} + if !Contains(nums, 2) { + t.Error("expected to contain 2") + } + if Contains(nums, 99) { + t.Error("expected not to contain 99") + } +} + +func TestAny(t *testing.T) { + nums := []int{1, 2, 3, 4, 5} + if !Any(nums, func(n int) bool { return n > 4 }) { + t.Error("expected any > 4") + } + if Any(nums, func(n int) bool { return n > 10 }) { + t.Error("expected none > 10") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/version/version.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/version/version.go new file mode 100644 index 0000000..db8636e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/version/version.go @@ -0,0 +1,268 @@ +package version + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +type VersionInfo struct { + Current string + Latest string + IsGit bool + IsBranch bool + IsTag bool + HasUpdate bool +} + +// VersionFetcher is an interface for fetching version information +type VersionFetcher interface { + GetCurrentVersion(dmsPath string) (string, error) + GetLatestVersion(dmsPath string) (string, error) +} + +// DefaultVersionFetcher is the default implementation that uses git/curl +type DefaultVersionFetcher struct{} + +func (d *DefaultVersionFetcher) GetCurrentVersion(dmsPath string) (string, error) { + if _, err := os.Stat(filepath.Join(dmsPath, ".git")); err == nil { + originalDir, err := os.Getwd() + if err != nil { + return "", err + } + defer func() { + if err := os.Chdir(originalDir); err != nil { + log.Warnf("failed to change back to original directory: %v", err) + } + }() + + if err := os.Chdir(dmsPath); err != nil { + return "", fmt.Errorf("failed to change to DMS directory: %w", err) + } + + tagCmd := exec.Command("git", "describe", "--exact-match", "--tags", "HEAD") + if tagOutput, err := tagCmd.Output(); err == nil { + return strings.TrimSpace(string(tagOutput)), nil + } + + branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + if branchOutput, err := branchCmd.Output(); err == nil { + branch := strings.TrimSpace(string(branchOutput)) + revCmd := exec.Command("git", "rev-parse", "--short", "HEAD") + if revOutput, err := revCmd.Output(); err == nil { + rev := strings.TrimSpace(string(revOutput)) + return fmt.Sprintf("%s@%s", branch, rev), nil + } + return branch, nil + } + } + + cmd := exec.Command("dms", "--version") + if output, err := cmd.Output(); err == nil { + return strings.TrimSpace(string(output)), nil + } + + return "unknown", nil +} + +func (d *DefaultVersionFetcher) GetLatestVersion(dmsPath string) (string, error) { + if _, err := os.Stat(filepath.Join(dmsPath, ".git")); err == nil { + originalDir, err := os.Getwd() + if err != nil { + return "", err + } + defer func() { + if err := os.Chdir(originalDir); err != nil { + log.Warnf("failed to change back to original directory: %v", err) + } + }() + + if err := os.Chdir(dmsPath); err != nil { + return "", fmt.Errorf("failed to change to DMS directory: %w", err) + } + + currentRefCmd := exec.Command("git", "symbolic-ref", "-q", "HEAD") + currentRefOutput, _ := currentRefCmd.Output() + onBranch := len(currentRefOutput) > 0 + + if !onBranch { + tagCmd := exec.Command("git", "describe", "--exact-match", "--tags", "HEAD") + if _, err := tagCmd.Output(); err == nil { + // Add timeout to git fetch to prevent hanging + fetchCmd := exec.Command("timeout", "5s", "git", "fetch", "origin", "--tags", "--quiet") + if err := fetchCmd.Run(); err != nil { + log.Debugf("git fetch tags failed (continuing with local tags): %v", err) + } + + latestTagCmd := exec.Command("git", "tag", "-l", "v*", "--sort=-version:refname") + latestTagOutput, err := latestTagCmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get latest tag: %w", err) + } + + tags := strings.Split(strings.TrimSpace(string(latestTagOutput)), "\n") + if len(tags) == 0 || tags[0] == "" { + return "", fmt.Errorf("no version tags found") + } + return tags[0], nil + } + } else { + branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + branchOutput, err := branchCmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get current branch: %w", err) + } + currentBranch := strings.TrimSpace(string(branchOutput)) + + // Add timeout to git fetch to prevent hanging + fetchCmd := exec.Command("timeout", "5s", "git", "fetch", "origin", currentBranch, "--quiet") + if err := fetchCmd.Run(); err != nil { + log.Debugf("git fetch branch failed (continuing with local ref): %v", err) + } + + remoteRevCmd := exec.Command("git", "rev-parse", "--short", fmt.Sprintf("origin/%s", currentBranch)) + remoteRevOutput, err := remoteRevCmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get remote revision: %w", err) + } + remoteRev := strings.TrimSpace(string(remoteRevOutput)) + return fmt.Sprintf("%s@%s", currentBranch, remoteRev), nil + } + } + + // Add timeout to prevent hanging when GitHub is down + cmd := exec.Command("curl", "-s", "--max-time", "5", "https://api.github.com/repos/AvengeMedia/DankMaterialShell/releases/latest") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to fetch latest release: %w", err) + } + + var result struct { + TagName string `json:"tag_name"` + } + if err := json.Unmarshal(output, &result); err != nil { + for _, line := range strings.Split(string(output), "\n") { + if strings.Contains(line, "\"tag_name\"") { + parts := strings.Split(line, "\"") + if len(parts) >= 4 { + return parts[3], nil + } + } + } + return "", fmt.Errorf("failed to parse latest version: %w", err) + } + + return result.TagName, nil +} + +// defaultFetcher is used by the public functions +var defaultFetcher VersionFetcher = &DefaultVersionFetcher{} + +func GetCurrentDMSVersion() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") + if _, err := os.Stat(dmsPath); os.IsNotExist(err) { + return "", fmt.Errorf("DMS not installed") + } + + return defaultFetcher.GetCurrentVersion(dmsPath) +} + +func GetLatestDMSVersion() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") + return defaultFetcher.GetLatestVersion(dmsPath) +} + +func GetDMSVersionInfo() (*VersionInfo, error) { + return GetDMSVersionInfoWithFetcher(defaultFetcher) +} + +func GetDMSVersionInfoWithFetcher(fetcher VersionFetcher) (*VersionInfo, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + + dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") + if _, err := os.Stat(dmsPath); os.IsNotExist(err) { + return nil, fmt.Errorf("DMS not installed") + } + + current, err := fetcher.GetCurrentVersion(dmsPath) + if err != nil { + return nil, err + } + + latest, err := fetcher.GetLatestVersion(dmsPath) + if err != nil { + return nil, fmt.Errorf("failed to get latest version: %w", err) + } + + info := &VersionInfo{ + Current: current, + Latest: latest, + IsGit: strings.Contains(current, "@"), + IsBranch: strings.Contains(current, "@"), + IsTag: !strings.Contains(current, "@") && strings.HasPrefix(current, "v"), + } + + if info.IsBranch { + parts := strings.Split(current, "@") + latestParts := strings.Split(latest, "@") + if len(parts) == 2 && len(latestParts) == 2 { + info.HasUpdate = parts[1] != latestParts[1] + } + } else if info.IsTag { + info.HasUpdate = current != latest + } else { + info.HasUpdate = false + } + + return info, nil +} + +func CompareVersions(v1, v2 string) int { + v1 = strings.TrimPrefix(v1, "v") + v2 = strings.TrimPrefix(v2, "v") + + parts1 := strings.Split(v1, ".") + parts2 := strings.Split(v2, ".") + + maxLen := len(parts1) + if len(parts2) > maxLen { + maxLen = len(parts2) + } + + for i := 0; i < maxLen; i++ { + var p1, p2 int + if i < len(parts1) { + fmt.Sscanf(parts1[i], "%d", &p1) //nolint:errcheck + } + if i < len(parts2) { + fmt.Sscanf(parts2[i], "%d", &p2) //nolint:errcheck + } + + if p1 < p2 { + return -1 + } + if p1 > p2 { + return 1 + } + } + + return 0 +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/version/version_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/version/version_test.go new file mode 100644 index 0000000..1e424c1 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/version/version_test.go @@ -0,0 +1,362 @@ +package version + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + mocks_version "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/version" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func TestCompareVersions(t *testing.T) { + tests := []struct { + v1 string + v2 string + expected int + }{ + {"v0.1.0", "v0.1.0", 0}, + {"v0.1.0", "v0.1.1", -1}, + {"v0.1.1", "v0.1.0", 1}, + {"v0.1.10", "v0.1.2", 1}, + {"v0.2.0", "v0.1.9", 1}, + {"0.1.0", "0.1.0", 0}, + {"1.0.0", "v1.0.0", 0}, + {"v1.2.3", "v1.2.4", -1}, + {"v2.0.0", "v1.9.9", 1}, + } + + for _, tt := range tests { + result := CompareVersions(tt.v1, tt.v2) + if result != tt.expected { + t.Errorf("CompareVersions(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected) + } + } +} + +func TestGetDMSVersionInfo_Structure(t *testing.T) { + // Create a temp directory with a fake DMS installation + tempDir := t.TempDir() + dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") + os.MkdirAll(dmsPath, 0o755) + + // Create a .git directory to simulate git installation + os.MkdirAll(filepath.Join(dmsPath, ".git"), 0o755) + + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + os.Setenv("HOME", tempDir) + + // Create mock fetcher + mockFetcher := mocks_version.NewMockVersionFetcher(t) + mockFetcher.EXPECT().GetCurrentVersion(dmsPath).Return("v0.1.0", nil) + mockFetcher.EXPECT().GetLatestVersion(dmsPath).Return("v0.1.1", nil) + + info, err := GetDMSVersionInfoWithFetcher(mockFetcher) + if err != nil { + t.Fatalf("GetDMSVersionInfoWithFetcher() failed: %v", err) + } + + if info == nil { + t.Fatal("GetDMSVersionInfoWithFetcher() returned nil") + } + + if info.Current != "v0.1.0" { + t.Errorf("Current version = %s, expected v0.1.0", info.Current) + } + + if info.Latest != "v0.1.1" { + t.Errorf("Latest version = %s, expected v0.1.1", info.Latest) + } + + if !info.HasUpdate { + t.Error("HasUpdate should be true when current != latest") + } + + if !info.IsTag { + t.Error("IsTag should be true for v0.1.0") + } + + t.Logf("Current: %s, Latest: %s, HasUpdate: %v", info.Current, info.Latest, info.HasUpdate) +} + +func TestGetDMSVersionInfo_BranchVersion(t *testing.T) { + tempDir := t.TempDir() + dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") + os.MkdirAll(dmsPath, 0o755) + os.MkdirAll(filepath.Join(dmsPath, ".git"), 0o755) + + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + os.Setenv("HOME", tempDir) + + mockFetcher := mocks_version.NewMockVersionFetcher(t) + mockFetcher.EXPECT().GetCurrentVersion(dmsPath).Return("master@abc1234", nil) + mockFetcher.EXPECT().GetLatestVersion(dmsPath).Return("master@def5678", nil) + + info, err := GetDMSVersionInfoWithFetcher(mockFetcher) + if err != nil { + t.Fatalf("GetDMSVersionInfoWithFetcher() failed: %v", err) + } + + if !info.IsBranch { + t.Error("IsBranch should be true for branch@commit format") + } + + if !info.IsGit { + t.Error("IsGit should be true for branch@commit format") + } + + if !info.HasUpdate { + t.Error("HasUpdate should be true when commits differ") + } +} + +func TestGetDMSVersionInfo_NoUpdate(t *testing.T) { + tempDir := t.TempDir() + dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") + os.MkdirAll(dmsPath, 0o755) + os.MkdirAll(filepath.Join(dmsPath, ".git"), 0o755) + + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + os.Setenv("HOME", tempDir) + + mockFetcher := mocks_version.NewMockVersionFetcher(t) + mockFetcher.EXPECT().GetCurrentVersion(dmsPath).Return("v0.1.0", nil) + mockFetcher.EXPECT().GetLatestVersion(dmsPath).Return("v0.1.0", nil) + + info, err := GetDMSVersionInfoWithFetcher(mockFetcher) + if err != nil { + t.Fatalf("GetDMSVersionInfoWithFetcher() failed: %v", err) + } + + if info.HasUpdate { + t.Error("HasUpdate should be false when current == latest") + } +} + +func TestGetCurrentDMSVersion_NotInstalled(t *testing.T) { + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + + tempDir := t.TempDir() + os.Setenv("HOME", tempDir) + + _, err := GetCurrentDMSVersion() + if err == nil { + t.Error("Expected error when DMS not installed, got nil") + } +} + +func TestGetCurrentDMSVersion_GitTag(t *testing.T) { + if !utils.CommandExists("git") { + t.Skip("git not available") + } + + tempDir := t.TempDir() + dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") + os.MkdirAll(dmsPath, 0o755) + + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + os.Setenv("HOME", tempDir) + + exec.Command("git", "init", dmsPath).Run() + exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run() + exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run() + + testFile := filepath.Join(dmsPath, "test.txt") + os.WriteFile(testFile, []byte("test"), 0o644) + exec.Command("git", "-C", dmsPath, "add", ".").Run() + exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run() + exec.Command("git", "-C", dmsPath, "tag", "v0.1.0").Run() + + version, err := GetCurrentDMSVersion() + if err != nil { + t.Fatalf("GetCurrentDMSVersion() failed: %v", err) + } + + if version != "v0.1.0" { + t.Errorf("Expected version v0.1.0, got %s", version) + } +} + +func TestGetCurrentDMSVersion_GitBranch(t *testing.T) { + if !utils.CommandExists("git") { + t.Skip("git not available") + } + + tempDir := t.TempDir() + dmsPath := filepath.Join(tempDir, ".config", "quickshell", "dms") + os.MkdirAll(dmsPath, 0o755) + + originalHome := os.Getenv("HOME") + defer os.Setenv("HOME", originalHome) + os.Setenv("HOME", tempDir) + + exec.Command("git", "init", dmsPath).Run() + exec.Command("git", "-C", dmsPath, "config", "user.email", "test@test.com").Run() + exec.Command("git", "-C", dmsPath, "config", "user.name", "Test User").Run() + exec.Command("git", "-C", dmsPath, "checkout", "-b", "master").Run() + + testFile := filepath.Join(dmsPath, "test.txt") + os.WriteFile(testFile, []byte("test"), 0o644) + exec.Command("git", "-C", dmsPath, "add", ".").Run() + exec.Command("git", "-C", dmsPath, "commit", "-m", "initial").Run() + + version, err := GetCurrentDMSVersion() + if err != nil { + t.Fatalf("GetCurrentDMSVersion() failed: %v", err) + } + + if version == "" { + t.Error("Expected non-empty version") + } + + if len(version) < 7 { + t.Errorf("Expected version with branch@commit format, got %s", version) + } +} + +func TestVersionInfo_IsGit(t *testing.T) { + tests := []struct { + current string + isGit bool + isBranch bool + isTag bool + }{ + {"v0.1.0", false, false, true}, + {"master@abc1234", true, true, false}, + {"dev@def5678", true, true, false}, + {"v0.2.0", false, false, true}, + {"unknown", false, false, false}, + } + + for _, tt := range tests { + info := &VersionInfo{ + IsGit: tt.isGit, + IsBranch: tt.isBranch, + IsTag: tt.isTag, + } + + actualIsGit := len(tt.current) > 0 && tt.current[0] != 'v' && tt.current != "unknown" + actualIsBranch := len(tt.current) > 0 && tt.current[0] != 'v' + actualIsTag := len(tt.current) > 0 && tt.current[0] == 'v' + + if tt.current == "unknown" { + actualIsGit = false + actualIsBranch = false + actualIsTag = false + } + + if info.IsGit != tt.isGit { + t.Errorf("For %s: IsGit = %v; want %v", tt.current, info.IsGit, tt.isGit) + } + if info.IsBranch != tt.isBranch { + t.Errorf("For %s: IsBranch = %v; want %v", tt.current, info.IsBranch, tt.isBranch) + } + if info.IsTag != tt.isTag { + t.Errorf("For %s: IsTag = %v; want %v", tt.current, info.IsTag, tt.isTag) + } + + _ = actualIsGit + _ = actualIsBranch + _ = actualIsTag + } +} + +func TestVersionInfo_HasUpdate_Branch(t *testing.T) { + tests := []struct { + current string + latest string + hasUpdate bool + }{ + {"master@abc1234", "master@abc1234", false}, + {"master@abc1234", "master@def5678", true}, + {"dev@abc1234", "dev@abc1234", false}, + {"dev@old1234", "dev@new5678", true}, + } + + for _, tt := range tests { + info := &VersionInfo{ + HasUpdate: tt.hasUpdate, + } + + if info.HasUpdate != tt.hasUpdate { + t.Errorf("For current=%s, latest=%s: HasUpdate = %v; want %v", + tt.current, tt.latest, info.HasUpdate, tt.hasUpdate) + } + } +} + +func TestVersionInfo_HasUpdate_Tag(t *testing.T) { + tests := []struct { + current string + latest string + hasUpdate bool + }{ + {"v0.1.0", "v0.1.0", false}, + {"v0.1.0", "v0.1.1", true}, + {"v0.1.5", "v0.1.5", false}, + {"v0.1.9", "v0.2.0", true}, + } + + for _, tt := range tests { + info := &VersionInfo{ + HasUpdate: tt.hasUpdate, + } + + if info.HasUpdate != tt.hasUpdate { + t.Errorf("For current=%s, latest=%s: HasUpdate = %v; want %v", + tt.current, tt.latest, info.HasUpdate, tt.hasUpdate) + } + } +} + +func TestGetLatestDMSVersion_FallbackParsing(t *testing.T) { + jsonResponse := `{ + "tag_name": "v0.1.17", + "name": "Release v0.1.17" + }` + + lines := []string{ + ` "tag_name": "v0.1.17",`, + ` "name": "Release v0.1.17"`, + } + + for _, line := range lines { + if len(line) > 0 && line[0:15] == ` "tag_name": "` { + parts := []string{"", "", "", "v0.1.17"} + version := parts[3] + if version != "v0.1.17" { + t.Errorf("Failed to parse version from line: %s", line) + } + } + } + + _ = jsonResponse +} + +func TestCompareVersions_EdgeCases(t *testing.T) { + tests := []struct { + v1 string + v2 string + expected int + }{ + {"", "", 0}, + {"v1", "v1", 0}, + {"v1.0", "v1", 0}, + {"v1.0.0", "v1.0", 0}, + {"v1.0.1", "v1.0", 1}, + {"v1", "v1.0.1", -1}, + } + + for _, tt := range tests { + result := CompareVersions(tt.v1, tt.v2) + if result != tt.expected { + t.Errorf("CompareVersions(%q, %q) = %d; want %d", tt.v1, tt.v2, result, tt.expected) + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/wayland/client/helpers.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/wayland/client/helpers.go new file mode 100644 index 0000000..9075cd3 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/wayland/client/helpers.go @@ -0,0 +1,26 @@ +package client + +import wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + +func Roundtrip(display *wlclient.Display, ctx *wlclient.Context) error { + callback, err := display.Sync() + if err != nil { + return err + } + + done := make(chan struct{}) + callback.SetDoneHandler(func(e wlclient.CallbackDoneEvent) { + close(done) + }) + + for { + select { + case <-done: + return nil + default: + if err := ctx.Dispatch(); err != nil { + return err + } + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/wayland/shm/buffer.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/wayland/shm/buffer.go new file mode 100644 index 0000000..9cfee20 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/wayland/shm/buffer.go @@ -0,0 +1,298 @@ +package shm + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +type PixelFormat uint32 + +const ( + FormatARGB8888 PixelFormat = 0 + FormatXRGB8888 PixelFormat = 1 + FormatABGR8888 PixelFormat = 0x34324241 + FormatXBGR8888 PixelFormat = 0x34324258 + FormatRGB888 PixelFormat = 0x34324752 + FormatBGR888 PixelFormat = 0x34324742 +) + +func (f PixelFormat) BytesPerPixel() int { + switch f { + case FormatRGB888, FormatBGR888: + return 3 + default: + return 4 + } +} + +func (f PixelFormat) Is24Bit() bool { + return f == FormatRGB888 || f == FormatBGR888 +} + +type Buffer struct { + fd int + data []byte + size int + Width int + Height int + Stride int + Format PixelFormat +} + +func CreateBuffer(width, height, stride int) (*Buffer, error) { + size := stride * height + + fd, err := unix.MemfdCreate("dms-shm", 0) + if err != nil { + return nil, fmt.Errorf("memfd_create: %w", err) + } + + if err := unix.Ftruncate(fd, int64(size)); err != nil { + unix.Close(fd) + return nil, fmt.Errorf("ftruncate: %w", err) + } + + data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) + if err != nil { + unix.Close(fd) + return nil, fmt.Errorf("mmap: %w", err) + } + + return &Buffer{ + fd: fd, + data: data, + size: size, + Width: width, + Height: height, + Stride: stride, + }, nil +} + +func (b *Buffer) Fd() int { return b.fd } +func (b *Buffer) Size() int { return b.size } +func (b *Buffer) Data() []byte { return b.data } + +func (b *Buffer) Close() error { + var firstErr error + + if b.data != nil { + if err := unix.Munmap(b.data); err != nil && firstErr == nil { + firstErr = fmt.Errorf("munmap: %w", err) + } + b.data = nil + } + + if b.fd >= 0 { + if err := unix.Close(b.fd); err != nil && firstErr == nil { + firstErr = fmt.Errorf("close: %w", err) + } + b.fd = -1 + } + + return firstErr +} + +func (b *Buffer) ConvertTo32Bit(srcFormat PixelFormat) (*Buffer, PixelFormat, error) { + if !srcFormat.Is24Bit() { + return b, srcFormat, nil + } + + dstFormat := FormatXRGB8888 + dstStride := b.Width * 4 + + dst, err := CreateBuffer(b.Width, b.Height, dstStride) + if err != nil { + return nil, srcFormat, err + } + dst.Format = dstFormat + + srcData := b.data + dstData := dst.data + + // DRM format names are counterintuitive on little-endian: + // RGB888 memory layout: B, G, R (name is logical order, not memory) + // BGR888 memory layout: R, G, B + isBGRMemory := srcFormat == FormatRGB888 + + for y := 0; y < b.Height; y++ { + srcRow := y * b.Stride + dstRow := y * dstStride + for x := 0; x < b.Width; x++ { + si := srcRow + x*3 + di := dstRow + x*4 + if isBGRMemory { + // RGB888: src memory is B,G,R -> dst XRGB8888 memory B,G,R,X + dstData[di+0] = srcData[si+0] + dstData[di+1] = srcData[si+1] + dstData[di+2] = srcData[si+2] + } else { + // BGR888: src memory is R,G,B -> dst XRGB8888 memory B,G,R,X + dstData[di+0] = srcData[si+2] + dstData[di+1] = srcData[si+1] + dstData[di+2] = srcData[si+0] + } + dstData[di+3] = 0xFF + } + } + + return dst, dstFormat, nil +} + +func (b *Buffer) GetPixelRGBA(x, y int) (r, g, b2, a uint8) { + if x < 0 || x >= b.Width || y < 0 || y >= b.Height { + return + } + + off := y*b.Stride + x*4 + if off+3 >= len(b.data) { + return + } + + switch b.Format { + case FormatXBGR8888, FormatABGR8888: + return b.data[off], b.data[off+1], b.data[off+2], 0xFF + default: + return b.data[off+2], b.data[off+1], b.data[off], 0xFF + } +} + +func (b *Buffer) GetPixelBGRA(x, y int) (b2, g, r, a uint8) { + if x < 0 || x >= b.Width || y < 0 || y >= b.Height { + return + } + + off := y*b.Stride + x*4 + if off+3 >= len(b.data) { + return + } + + return b.data[off], b.data[off+1], b.data[off+2], b.data[off+3] +} + +func (b *Buffer) ConvertBGRAtoRGBA() { + for y := 0; y < b.Height; y++ { + rowOff := y * b.Stride + for x := 0; x < b.Width; x++ { + off := rowOff + x*4 + if off+3 >= len(b.data) { + continue + } + b.data[off], b.data[off+2] = b.data[off+2], b.data[off] + } + } +} + +func (b *Buffer) FlipVertical() { + tmp := make([]byte, b.Stride) + for y := 0; y < b.Height/2; y++ { + topOff := y * b.Stride + botOff := (b.Height - 1 - y) * b.Stride + copy(tmp, b.data[topOff:topOff+b.Stride]) + copy(b.data[topOff:topOff+b.Stride], b.data[botOff:botOff+b.Stride]) + copy(b.data[botOff:botOff+b.Stride], tmp) + } +} + +func (b *Buffer) Clear() { + for i := range b.data { + b.data[i] = 0 + } +} + +func (b *Buffer) CopyFrom(src *Buffer) { + copy(b.data, src.data) +} + +const ( + TransformNormal = 0 + Transform90 = 1 + Transform180 = 2 + Transform270 = 3 + TransformFlipped = 4 + TransformFlipped90 = 5 + TransformFlipped180 = 6 + TransformFlipped270 = 7 +) + +func (b *Buffer) ApplyTransform(transform int32) (*Buffer, error) { + if transform == TransformNormal { + return b, nil + } + + var newW, newH int + switch transform { + case Transform90, Transform270, TransformFlipped90, TransformFlipped270: + newW, newH = b.Height, b.Width + default: + newW, newH = b.Width, b.Height + } + + newBuf, err := CreateBuffer(newW, newH, newW*4) + if err != nil { + return nil, err + } + newBuf.Format = b.Format + + srcData := b.data + dstData := newBuf.data + + for sy := 0; sy < b.Height; sy++ { + for sx := 0; sx < b.Width; sx++ { + var dx, dy int + + switch transform { + case Transform90: // 90° CCW + dx = sy + dy = b.Width - 1 - sx + case Transform180: + dx = b.Width - 1 - sx + dy = b.Height - 1 - sy + case Transform270: // 270° CCW = 90° CW + dx = b.Height - 1 - sy + dy = sx + case TransformFlipped: + dx = b.Width - 1 - sx + dy = sy + case TransformFlipped90: + dx = sy + dy = sx + case TransformFlipped180: + dx = sx + dy = b.Height - 1 - sy + case TransformFlipped270: + dx = b.Height - 1 - sy + dy = b.Width - 1 - sx + default: + dx, dy = sx, sy + } + + si := sy*b.Stride + sx*4 + di := dy*newBuf.Stride + dx*4 + + if si+3 < len(srcData) && di+3 < len(dstData) { + dstData[di+0] = srcData[si+0] + dstData[di+1] = srcData[si+1] + dstData[di+2] = srcData[si+2] + dstData[di+3] = srcData[si+3] + } + } + } + + return newBuf, nil +} + +func InverseTransform(transform int32) int32 { + switch transform { + case Transform90: + return Transform270 + case Transform270: + return Transform90 + case TransformFlipped90: + return TransformFlipped270 + case TransformFlipped270: + return TransformFlipped90 + default: + return transform + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser.go new file mode 100644 index 0000000..918c4f9 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser.go @@ -0,0 +1,658 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" +) + +type HyprlandWindowRule struct { + MatchClass string + MatchTitle string + MatchXWayland *bool + MatchFloating *bool + MatchFullscreen *bool + MatchPinned *bool + MatchInitialised *bool + Rule string + Value string + Source string + RawLine string +} + +type HyprlandRulesParser struct { + configDir string + processedFiles map[string]bool + rules []HyprlandWindowRule + currentSource string + dmsRulesExists bool + dmsRulesIncluded bool + includeCount int + dmsIncludePos int + rulesAfterDMS int + dmsProcessed bool +} + +func NewHyprlandRulesParser(configDir string) *HyprlandRulesParser { + return &HyprlandRulesParser{ + configDir: configDir, + processedFiles: make(map[string]bool), + rules: []HyprlandWindowRule{}, + dmsIncludePos: -1, + } +} + +func (p *HyprlandRulesParser) Parse() ([]HyprlandWindowRule, error) { + expandedDir, err := utils.ExpandPath(p.configDir) + if err != nil { + return nil, err + } + + dmsRulesPath := filepath.Join(expandedDir, "dms", "windowrules.conf") + if _, err := os.Stat(dmsRulesPath); err == nil { + p.dmsRulesExists = true + } + + mainConfig := filepath.Join(expandedDir, "hyprland.conf") + if err := p.parseFile(mainConfig); err != nil { + return nil, err + } + + if p.dmsRulesExists && !p.dmsProcessed { + p.parseDMSRulesDirectly(dmsRulesPath) + } + + return p.rules, nil +} + +func (p *HyprlandRulesParser) parseDMSRulesDirectly(dmsRulesPath string) { + data, err := os.ReadFile(dmsRulesPath) + if err != nil { + return + } + + prevSource := p.currentSource + p.currentSource = dmsRulesPath + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + p.parseLine(line) + } + + p.currentSource = prevSource + p.dmsProcessed = true +} + +func (p *HyprlandRulesParser) parseFile(filePath string) error { + absPath, err := filepath.Abs(filePath) + if err != nil { + return err + } + + if p.processedFiles[absPath] { + return nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil + } + + prevSource := p.currentSource + p.currentSource = absPath + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "source") { + p.handleSource(trimmed, filepath.Dir(absPath)) + continue + } + + p.parseLine(line) + } + + p.currentSource = prevSource + return nil +} + +func (p *HyprlandRulesParser) handleSource(line string, baseDir string) { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + return + } + + sourcePath := strings.TrimSpace(parts[1]) + isDMSSource := sourcePath == "dms/windowrules.conf" || strings.HasSuffix(sourcePath, "/dms/windowrules.conf") + + p.includeCount++ + if isDMSSource { + p.dmsRulesIncluded = true + p.dmsIncludePos = p.includeCount + p.dmsProcessed = true + } + + fullPath := sourcePath + if !filepath.IsAbs(sourcePath) { + fullPath = filepath.Join(baseDir, sourcePath) + } + + expanded, err := utils.ExpandPath(fullPath) + if err != nil { + return + } + + _ = p.parseFile(expanded) +} + +func (p *HyprlandRulesParser) parseLine(line string) { + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "windowrule") { + rule := p.parseWindowRuleLine(trimmed) + if rule != nil { + rule.Source = p.currentSource + p.rules = append(p.rules, *rule) + } + } +} + +var windowRuleV2Regex = regexp.MustCompile(`^windowrulev?2?\s*=\s*(.+)$`) + +func (p *HyprlandRulesParser) parseWindowRuleLine(line string) *HyprlandWindowRule { + matches := windowRuleV2Regex.FindStringSubmatch(line) + if len(matches) < 2 { + return nil + } + + content := strings.TrimSpace(matches[1]) + isV2 := strings.HasPrefix(line, "windowrulev2") + + rule := &HyprlandWindowRule{ + RawLine: line, + } + + if isV2 { + p.parseWindowRuleV2(content, rule) + } else { + p.parseWindowRuleV1(content, rule) + } + + return rule +} + +func (p *HyprlandRulesParser) parseWindowRuleV1(content string, rule *HyprlandWindowRule) { + parts := strings.SplitN(content, ",", 2) + if len(parts) < 2 { + return + } + + rule.Rule = strings.TrimSpace(parts[0]) + rule.MatchClass = strings.TrimSpace(parts[1]) +} + +func (p *HyprlandRulesParser) parseWindowRuleV2(content string, rule *HyprlandWindowRule) { + parts := strings.SplitN(content, ",", 2) + if len(parts) < 2 { + return + } + + ruleAndValue := strings.TrimSpace(parts[0]) + matchPart := strings.TrimSpace(parts[1]) + + if idx := strings.Index(ruleAndValue, " "); idx > 0 { + rule.Rule = ruleAndValue[:idx] + rule.Value = strings.TrimSpace(ruleAndValue[idx+1:]) + } else { + rule.Rule = ruleAndValue + } + + matchPairs := strings.Split(matchPart, ",") + for _, pair := range matchPairs { + pair = strings.TrimSpace(pair) + if colonIdx := strings.Index(pair, ":"); colonIdx > 0 { + key := strings.TrimSpace(pair[:colonIdx]) + value := strings.TrimSpace(pair[colonIdx+1:]) + + switch key { + case "class": + rule.MatchClass = value + case "title": + rule.MatchTitle = value + case "xwayland": + b := value == "1" || value == "true" + rule.MatchXWayland = &b + case "floating": + b := value == "1" || value == "true" + rule.MatchFloating = &b + case "fullscreen": + b := value == "1" || value == "true" + rule.MatchFullscreen = &b + case "pinned": + b := value == "1" || value == "true" + rule.MatchPinned = &b + case "initialised", "initialized": + b := value == "1" || value == "true" + rule.MatchInitialised = &b + } + } + } +} + +func (p *HyprlandRulesParser) HasDMSRulesIncluded() bool { + return p.dmsRulesIncluded +} + +func (p *HyprlandRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus { + status := &windowrules.DMSRulesStatus{ + Exists: p.dmsRulesExists, + Included: p.dmsRulesIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + RulesAfterDMS: p.rulesAfterDMS, + } + + switch { + case !p.dmsRulesExists: + status.Effective = false + status.StatusMessage = "dms/windowrules.conf does not exist" + case !p.dmsRulesIncluded: + status.Effective = false + status.StatusMessage = "dms/windowrules.conf is not sourced in config" + case p.rulesAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.rulesAfterDMS + status.StatusMessage = "Some DMS rules may be overridden by config rules" + default: + status.Effective = true + status.StatusMessage = "DMS window rules are active" + } + + return status +} + +type HyprlandRulesParseResult struct { + Rules []HyprlandWindowRule + DMSRulesIncluded bool + DMSStatus *windowrules.DMSRulesStatus +} + +func ParseHyprlandWindowRules(configDir string) (*HyprlandRulesParseResult, error) { + parser := NewHyprlandRulesParser(configDir) + rules, err := parser.Parse() + if err != nil { + return nil, err + } + return &HyprlandRulesParseResult{ + Rules: rules, + DMSRulesIncluded: parser.HasDMSRulesIncluded(), + DMSStatus: parser.buildDMSStatus(), + }, nil +} + +func applyHyprlandRuleAction(actions *windowrules.Actions, rule, value string) { + t := true + switch rule { + case "float": + actions.OpenFloating = &t + case "tile": + actions.Tile = &t + case "fullscreen": + actions.OpenFullscreen = &t + case "maximize": + actions.OpenMaximized = &t + case "nofocus": + actions.NoFocus = &t + case "noborder": + actions.NoBorder = &t + case "noshadow": + actions.NoShadow = &t + case "nodim": + actions.NoDim = &t + case "noblur": + actions.NoBlur = &t + case "noanim": + actions.NoAnim = &t + case "norounding": + actions.NoRounding = &t + case "pin": + actions.Pin = &t + case "opaque": + actions.Opaque = &t + case "forcergbx": + actions.ForcergbX = &t + case "opacity": + if f, err := strconv.ParseFloat(value, 64); err == nil { + actions.Opacity = &f + } + case "size": + actions.Size = value + case "move": + actions.Move = value + case "monitor": + actions.Monitor = value + case "workspace": + actions.Workspace = value + case "idleinhibit": + actions.Idleinhibit = value + case "rounding": + if i, err := strconv.Atoi(value); err == nil { + actions.CornerRadius = &i + } + } +} + +func ConvertHyprlandRulesToWindowRules(hyprRules []HyprlandWindowRule) []windowrules.WindowRule { + result := make([]windowrules.WindowRule, 0, len(hyprRules)) + for i, hr := range hyprRules { + wr := windowrules.WindowRule{ + ID: strconv.Itoa(i), + Enabled: true, + Source: hr.Source, + MatchCriteria: windowrules.MatchCriteria{ + AppID: hr.MatchClass, + Title: hr.MatchTitle, + XWayland: hr.MatchXWayland, + IsFloating: hr.MatchFloating, + Fullscreen: hr.MatchFullscreen, + Pinned: hr.MatchPinned, + Initialised: hr.MatchInitialised, + }, + } + applyHyprlandRuleAction(&wr.Actions, hr.Rule, hr.Value) + result = append(result, wr) + } + return result +} + +type HyprlandWritableProvider struct { + configDir string +} + +func NewHyprlandWritableProvider(configDir string) *HyprlandWritableProvider { + return &HyprlandWritableProvider{configDir: configDir} +} + +func (p *HyprlandWritableProvider) Name() string { + return "hyprland" +} + +func (p *HyprlandWritableProvider) GetOverridePath() string { + expanded, _ := utils.ExpandPath(p.configDir) + return filepath.Join(expanded, "dms", "windowrules.conf") +} + +func (p *HyprlandWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) { + result, err := ParseHyprlandWindowRules(p.configDir) + if err != nil { + return nil, err + } + return &windowrules.RuleSet{ + Title: "Hyprland Window Rules", + Provider: "hyprland", + Rules: ConvertHyprlandRulesToWindowRules(result.Rules), + DMSRulesIncluded: result.DMSRulesIncluded, + DMSStatus: result.DMSStatus, + }, nil +} + +func (p *HyprlandWritableProvider) SetRule(rule windowrules.WindowRule) error { + rules, err := p.LoadDMSRules() + if err != nil { + rules = []windowrules.WindowRule{} + } + + found := false + for i, r := range rules { + if r.ID == rule.ID { + rules[i] = rule + found = true + break + } + } + if !found { + rules = append(rules, rule) + } + + return p.writeDMSRules(rules) +} + +func (p *HyprlandWritableProvider) RemoveRule(id string) error { + rules, err := p.LoadDMSRules() + if err != nil { + return err + } + + newRules := make([]windowrules.WindowRule, 0, len(rules)) + for _, r := range rules { + if r.ID != id { + newRules = append(newRules, r) + } + } + + return p.writeDMSRules(newRules) +} + +func (p *HyprlandWritableProvider) ReorderRules(ids []string) error { + rules, err := p.LoadDMSRules() + if err != nil { + return err + } + + ruleMap := make(map[string]windowrules.WindowRule) + for _, r := range rules { + ruleMap[r.ID] = r + } + + newRules := make([]windowrules.WindowRule, 0, len(ids)) + for _, id := range ids { + if r, ok := ruleMap[id]; ok { + newRules = append(newRules, r) + delete(ruleMap, id) + } + } + + for _, r := range ruleMap { + newRules = append(newRules, r) + } + + return p.writeDMSRules(newRules) +} + +var dmsRuleCommentRegex = regexp.MustCompile(`^#\s*DMS-RULE:\s*id=([^,]+),\s*name=(.*)$`) + +func (p *HyprlandWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) { + rulesPath := p.GetOverridePath() + data, err := os.ReadFile(rulesPath) + if err != nil { + if os.IsNotExist(err) { + return []windowrules.WindowRule{}, nil + } + return nil, err + } + + var rules []windowrules.WindowRule + var currentID, currentName string + lines := strings.Split(string(data), "\n") + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if matches := dmsRuleCommentRegex.FindStringSubmatch(trimmed); matches != nil { + currentID = matches[1] + currentName = matches[2] + continue + } + + if strings.HasPrefix(trimmed, "windowrulev2") { + parser := NewHyprlandRulesParser(p.configDir) + hrule := parser.parseWindowRuleLine(trimmed) + if hrule == nil { + continue + } + + wr := windowrules.WindowRule{ + ID: currentID, + Name: currentName, + Enabled: true, + Source: rulesPath, + MatchCriteria: windowrules.MatchCriteria{ + AppID: hrule.MatchClass, + Title: hrule.MatchTitle, + XWayland: hrule.MatchXWayland, + IsFloating: hrule.MatchFloating, + Fullscreen: hrule.MatchFullscreen, + Pinned: hrule.MatchPinned, + Initialised: hrule.MatchInitialised, + }, + } + applyHyprlandRuleAction(&wr.Actions, hrule.Rule, hrule.Value) + + if wr.ID == "" { + wr.ID = hrule.MatchClass + if wr.ID == "" { + wr.ID = hrule.MatchTitle + } + } + + rules = append(rules, wr) + currentID = "" + currentName = "" + } + } + + return rules, nil +} + +func (p *HyprlandWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error { + rulesPath := p.GetOverridePath() + + if err := os.MkdirAll(filepath.Dir(rulesPath), 0755); err != nil { + return err + } + + var lines []string + lines = append(lines, "# DMS Window Rules - Managed by DankMaterialShell") + lines = append(lines, "# Do not edit manually - changes may be overwritten") + lines = append(lines, "") + + for _, rule := range rules { + lines = append(lines, p.formatRuleLines(rule)...) + } + + return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644) +} + +func (p *HyprlandWritableProvider) formatRuleLines(rule windowrules.WindowRule) []string { + var lines []string + lines = append(lines, fmt.Sprintf("# DMS-RULE: id=%s, name=%s", rule.ID, rule.Name)) + + var matchParts []string + if rule.MatchCriteria.AppID != "" { + matchParts = append(matchParts, fmt.Sprintf("class:%s", rule.MatchCriteria.AppID)) + } + if rule.MatchCriteria.Title != "" { + matchParts = append(matchParts, fmt.Sprintf("title:%s", rule.MatchCriteria.Title)) + } + if rule.MatchCriteria.XWayland != nil { + matchParts = append(matchParts, fmt.Sprintf("xwayland:%d", boolToInt(*rule.MatchCriteria.XWayland))) + } + if rule.MatchCriteria.IsFloating != nil { + matchParts = append(matchParts, fmt.Sprintf("floating:%d", boolToInt(*rule.MatchCriteria.IsFloating))) + } + if rule.MatchCriteria.Fullscreen != nil { + matchParts = append(matchParts, fmt.Sprintf("fullscreen:%d", boolToInt(*rule.MatchCriteria.Fullscreen))) + } + if rule.MatchCriteria.Pinned != nil { + matchParts = append(matchParts, fmt.Sprintf("pinned:%d", boolToInt(*rule.MatchCriteria.Pinned))) + } + + matchStr := strings.Join(matchParts, ", ") + a := rule.Actions + + if a.OpenFloating != nil && *a.OpenFloating { + lines = append(lines, fmt.Sprintf("windowrulev2 = float, %s", matchStr)) + } + if a.Tile != nil && *a.Tile { + lines = append(lines, fmt.Sprintf("windowrulev2 = tile, %s", matchStr)) + } + if a.OpenFullscreen != nil && *a.OpenFullscreen { + lines = append(lines, fmt.Sprintf("windowrulev2 = fullscreen, %s", matchStr)) + } + if a.OpenMaximized != nil && *a.OpenMaximized { + lines = append(lines, fmt.Sprintf("windowrulev2 = maximize, %s", matchStr)) + } + if a.NoFocus != nil && *a.NoFocus { + lines = append(lines, fmt.Sprintf("windowrulev2 = nofocus, %s", matchStr)) + } + if a.NoBorder != nil && *a.NoBorder { + lines = append(lines, fmt.Sprintf("windowrulev2 = noborder, %s", matchStr)) + } + if a.NoShadow != nil && *a.NoShadow { + lines = append(lines, fmt.Sprintf("windowrulev2 = noshadow, %s", matchStr)) + } + if a.NoDim != nil && *a.NoDim { + lines = append(lines, fmt.Sprintf("windowrulev2 = nodim, %s", matchStr)) + } + if a.NoBlur != nil && *a.NoBlur { + lines = append(lines, fmt.Sprintf("windowrulev2 = noblur, %s", matchStr)) + } + if a.NoAnim != nil && *a.NoAnim { + lines = append(lines, fmt.Sprintf("windowrulev2 = noanim, %s", matchStr)) + } + if a.NoRounding != nil && *a.NoRounding { + lines = append(lines, fmt.Sprintf("windowrulev2 = norounding, %s", matchStr)) + } + if a.Pin != nil && *a.Pin { + lines = append(lines, fmt.Sprintf("windowrulev2 = pin, %s", matchStr)) + } + if a.Opaque != nil && *a.Opaque { + lines = append(lines, fmt.Sprintf("windowrulev2 = opaque, %s", matchStr)) + } + if a.ForcergbX != nil && *a.ForcergbX { + lines = append(lines, fmt.Sprintf("windowrulev2 = forcergbx, %s", matchStr)) + } + if a.Opacity != nil { + lines = append(lines, fmt.Sprintf("windowrulev2 = opacity %.2f, %s", *a.Opacity, matchStr)) + } + if a.Size != "" { + lines = append(lines, fmt.Sprintf("windowrulev2 = size %s, %s", a.Size, matchStr)) + } + if a.Move != "" { + lines = append(lines, fmt.Sprintf("windowrulev2 = move %s, %s", a.Move, matchStr)) + } + if a.Monitor != "" { + lines = append(lines, fmt.Sprintf("windowrulev2 = monitor %s, %s", a.Monitor, matchStr)) + } + if a.Workspace != "" { + lines = append(lines, fmt.Sprintf("windowrulev2 = workspace %s, %s", a.Workspace, matchStr)) + } + if a.CornerRadius != nil { + lines = append(lines, fmt.Sprintf("windowrulev2 = rounding %d, %s", *a.CornerRadius, matchStr)) + } + if a.Idleinhibit != "" { + lines = append(lines, fmt.Sprintf("windowrulev2 = idleinhibit %s, %s", a.Idleinhibit, matchStr)) + } + + if len(lines) == 1 { + lines = append(lines, fmt.Sprintf("# (no actions defined for rule %s)", rule.ID)) + } + + lines = append(lines, "") + return lines +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser_test.go new file mode 100644 index 0000000..5341b4b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/hyprland_parser_test.go @@ -0,0 +1,280 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseWindowRuleV1(t *testing.T) { + parser := NewHyprlandRulesParser("") + + tests := []struct { + name string + line string + wantClass string + wantRule string + wantNil bool + }{ + { + name: "basic float rule", + line: "windowrule = float, ^(firefox)$", + wantClass: "^(firefox)$", + wantRule: "float", + }, + { + name: "tile rule", + line: "windowrule = tile, steam", + wantClass: "steam", + wantRule: "tile", + }, + { + name: "no match returns empty class", + line: "windowrule = float", + wantClass: "", + wantRule: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parser.parseWindowRuleLine(tt.line) + if tt.wantNil { + if result != nil { + t.Errorf("expected nil, got %+v", result) + } + return + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result.MatchClass != tt.wantClass { + t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass) + } + if result.Rule != tt.wantRule { + t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule) + } + }) + } +} + +func TestParseWindowRuleV2(t *testing.T) { + parser := NewHyprlandRulesParser("") + + tests := []struct { + name string + line string + wantClass string + wantTitle string + wantRule string + wantValue string + }{ + { + name: "float with class", + line: "windowrulev2 = float, class:^(firefox)$", + wantClass: "^(firefox)$", + wantRule: "float", + }, + { + name: "opacity with value", + line: "windowrulev2 = opacity 0.8, class:^(code)$", + wantClass: "^(code)$", + wantRule: "opacity", + wantValue: "0.8", + }, + { + name: "size with value and title", + line: "windowrulev2 = size 800 600, class:^(steam)$, title:Settings", + wantClass: "^(steam)$", + wantTitle: "Settings", + wantRule: "size", + wantValue: "800 600", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parser.parseWindowRuleLine(tt.line) + if result == nil { + t.Fatal("expected non-nil result") + } + if result.MatchClass != tt.wantClass { + t.Errorf("MatchClass = %q, want %q", result.MatchClass, tt.wantClass) + } + if result.MatchTitle != tt.wantTitle { + t.Errorf("MatchTitle = %q, want %q", result.MatchTitle, tt.wantTitle) + } + if result.Rule != tt.wantRule { + t.Errorf("Rule = %q, want %q", result.Rule, tt.wantRule) + } + if result.Value != tt.wantValue { + t.Errorf("Value = %q, want %q", result.Value, tt.wantValue) + } + }) + } +} + +func TestConvertHyprlandRulesToWindowRules(t *testing.T) { + hyprRules := []HyprlandWindowRule{ + {MatchClass: "^(firefox)$", Rule: "float"}, + {MatchClass: "^(code)$", Rule: "opacity", Value: "0.9"}, + {MatchClass: "^(steam)$", Rule: "maximize"}, + } + + result := ConvertHyprlandRulesToWindowRules(hyprRules) + + if len(result) != 3 { + t.Errorf("expected 3 rules, got %d", len(result)) + } + + if result[0].MatchCriteria.AppID != "^(firefox)$" { + t.Errorf("rule 0 AppID = %q, want ^(firefox)$", result[0].MatchCriteria.AppID) + } + if result[0].Actions.OpenFloating == nil || !*result[0].Actions.OpenFloating { + t.Error("rule 0 should have OpenFloating = true") + } + + if result[1].Actions.Opacity == nil || *result[1].Actions.Opacity != 0.9 { + t.Errorf("rule 1 Opacity = %v, want 0.9", result[1].Actions.Opacity) + } + + if result[2].Actions.OpenMaximized == nil || !*result[2].Actions.OpenMaximized { + t.Error("rule 2 should have OpenMaximized = true") + } +} + +func TestHyprlandWritableProvider(t *testing.T) { + tmpDir := t.TempDir() + provider := NewHyprlandWritableProvider(tmpDir) + + if provider.Name() != "hyprland" { + t.Errorf("Name() = %q, want hyprland", provider.Name()) + } + + expectedPath := filepath.Join(tmpDir, "dms", "windowrules.conf") + if provider.GetOverridePath() != expectedPath { + t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath) + } +} + +func TestHyprlandSetAndLoadDMSRules(t *testing.T) { + tmpDir := t.TempDir() + provider := NewHyprlandWritableProvider(tmpDir) + + rule := newTestWindowRule("test_id", "Test Rule", "^(firefox)$") + rule.Actions.OpenFloating = boolPtr(true) + + if err := provider.SetRule(rule); err != nil { + t.Fatalf("SetRule failed: %v", err) + } + + rules, err := provider.LoadDMSRules() + if err != nil { + t.Fatalf("LoadDMSRules failed: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + if rules[0].ID != "test_id" { + t.Errorf("ID = %q, want test_id", rules[0].ID) + } + if rules[0].MatchCriteria.AppID != "^(firefox)$" { + t.Errorf("AppID = %q, want ^(firefox)$", rules[0].MatchCriteria.AppID) + } +} + +func TestHyprlandRemoveRule(t *testing.T) { + tmpDir := t.TempDir() + provider := NewHyprlandWritableProvider(tmpDir) + + rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$") + rule1.Actions.OpenFloating = boolPtr(true) + rule2 := newTestWindowRule("rule2", "Rule 2", "^(app2)$") + rule2.Actions.OpenFloating = boolPtr(true) + + _ = provider.SetRule(rule1) + _ = provider.SetRule(rule2) + + if err := provider.RemoveRule("rule1"); err != nil { + t.Fatalf("RemoveRule failed: %v", err) + } + + rules, _ := provider.LoadDMSRules() + if len(rules) != 1 { + t.Fatalf("expected 1 rule after removal, got %d", len(rules)) + } + if rules[0].ID != "rule2" { + t.Errorf("remaining rule ID = %q, want rule2", rules[0].ID) + } +} + +func TestHyprlandReorderRules(t *testing.T) { + tmpDir := t.TempDir() + provider := NewHyprlandWritableProvider(tmpDir) + + rule1 := newTestWindowRule("rule1", "Rule 1", "^(app1)$") + rule1.Actions.OpenFloating = boolPtr(true) + rule2 := newTestWindowRule("rule2", "Rule 2", "^(app2)$") + rule2.Actions.OpenFloating = boolPtr(true) + rule3 := newTestWindowRule("rule3", "Rule 3", "^(app3)$") + rule3.Actions.OpenFloating = boolPtr(true) + + _ = provider.SetRule(rule1) + _ = provider.SetRule(rule2) + _ = provider.SetRule(rule3) + + if err := provider.ReorderRules([]string{"rule3", "rule1", "rule2"}); err != nil { + t.Fatalf("ReorderRules failed: %v", err) + } + + rules, _ := provider.LoadDMSRules() + if len(rules) != 3 { + t.Fatalf("expected 3 rules, got %d", len(rules)) + } + expectedOrder := []string{"rule3", "rule1", "rule2"} + for i, expectedID := range expectedOrder { + if rules[i].ID != expectedID { + t.Errorf("rule %d ID = %q, want %q", i, rules[i].ID, expectedID) + } + } +} + +func TestHyprlandParseConfigWithSource(t *testing.T) { + tmpDir := t.TempDir() + + mainConfig := ` +windowrulev2 = float, class:^(mainapp)$ +source = ./extra.conf +` + extraConfig := ` +windowrulev2 = tile, class:^(extraapp)$ +` + + if err := os.WriteFile(filepath.Join(tmpDir, "hyprland.conf"), []byte(mainConfig), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "extra.conf"), []byte(extraConfig), 0644); err != nil { + t.Fatal(err) + } + + parser := NewHyprlandRulesParser(tmpDir) + rules, err := parser.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(rules) != 2 { + t.Errorf("expected 2 rules, got %d", len(rules)) + } +} + +func TestBoolToInt(t *testing.T) { + if boolToInt(true) != 1 { + t.Error("boolToInt(true) should be 1") + } + if boolToInt(false) != 0 { + t.Error("boolToInt(false) should be 0") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser.go new file mode 100644 index 0000000..5bb5ec4 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser.go @@ -0,0 +1,875 @@ +package providers + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/sblinch/kdl-go" + "github.com/sblinch/kdl-go/document" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" +) + +type NiriWindowRule struct { + MatchAppID string + MatchTitle string + MatchIsFloating *bool + MatchIsActive *bool + MatchIsFocused *bool + MatchIsActiveInColumn *bool + MatchIsWindowCastTarget *bool + MatchIsUrgent *bool + MatchAtStartup *bool + Opacity *float64 + OpenFloating *bool + OpenMaximized *bool + OpenMaximizedToEdges *bool + OpenFullscreen *bool + OpenFocused *bool + OpenOnOutput string + OpenOnWorkspace string + DefaultColumnWidth string + DefaultWindowHeight string + VariableRefreshRate *bool + BlockOutFrom string + DefaultColumnDisplay string + ScrollFactor *float64 + CornerRadius *int + ClipToGeometry *bool + TiledState *bool + MinWidth *int + MaxWidth *int + MinHeight *int + MaxHeight *int + BorderColor string + FocusRingColor string + FocusRingOff *bool + BorderOff *bool + DrawBorderWithBg *bool + Source string +} + +type NiriRulesParser struct { + configDir string + processedFiles map[string]bool + rules []NiriWindowRule + currentSource string + dmsRulesIncluded bool + dmsRulesExists bool + includeCount int + dmsIncludePos int + rulesAfterDMS int + dmsProcessed bool +} + +func NewNiriRulesParser(configDir string) *NiriRulesParser { + return &NiriRulesParser{ + configDir: configDir, + processedFiles: make(map[string]bool), + rules: []NiriWindowRule{}, + dmsIncludePos: -1, + } +} + +func (p *NiriRulesParser) Parse() ([]NiriWindowRule, error) { + dmsRulesPath := filepath.Join(p.configDir, "dms", "windowrules.kdl") + if _, err := os.Stat(dmsRulesPath); err == nil { + p.dmsRulesExists = true + } + + configPath := filepath.Join(p.configDir, "config.kdl") + if err := p.parseFile(configPath); err != nil { + return nil, err + } + + if p.dmsRulesExists && !p.dmsProcessed { + p.parseDMSRulesDirectly(dmsRulesPath) + } + + return p.rules, nil +} + +func (p *NiriRulesParser) parseDMSRulesDirectly(dmsRulesPath string) { + data, err := os.ReadFile(dmsRulesPath) + if err != nil { + return + } + + doc, err := kdl.Parse(strings.NewReader(string(data))) + if err != nil { + return + } + + prevSource := p.currentSource + p.currentSource = dmsRulesPath + p.processNodes(doc.Nodes, filepath.Dir(dmsRulesPath)) + p.currentSource = prevSource + p.dmsProcessed = true +} + +func (p *NiriRulesParser) parseFile(filePath string) error { + absPath, err := filepath.Abs(filePath) + if err != nil { + return err + } + + if p.processedFiles[absPath] { + return nil + } + p.processedFiles[absPath] = true + + data, err := os.ReadFile(absPath) + if err != nil { + return nil + } + + doc, err := kdl.Parse(strings.NewReader(string(data))) + if err != nil { + return err + } + + prevSource := p.currentSource + p.currentSource = absPath + baseDir := filepath.Dir(absPath) + p.processNodes(doc.Nodes, baseDir) + p.currentSource = prevSource + + return nil +} + +func (p *NiriRulesParser) processNodes(nodes []*document.Node, baseDir string) { + for _, node := range nodes { + name := node.Name.String() + + switch name { + case "include": + p.handleInclude(node, baseDir) + case "window-rule": + p.parseWindowRuleNode(node) + } + } +} + +func (p *NiriRulesParser) handleInclude(node *document.Node, baseDir string) { + if len(node.Arguments) == 0 { + return + } + + includePath := strings.Trim(node.Arguments[0].String(), "\"") + isDMSInclude := includePath == "dms/windowrules.kdl" || strings.HasSuffix(includePath, "/dms/windowrules.kdl") + + p.includeCount++ + if isDMSInclude { + p.dmsRulesIncluded = true + p.dmsIncludePos = p.includeCount + p.dmsProcessed = true + } + + fullPath := filepath.Join(baseDir, includePath) + if filepath.IsAbs(includePath) { + fullPath = includePath + } + + _ = p.parseFile(fullPath) +} + +func (p *NiriRulesParser) parseWindowRuleNode(node *document.Node) { + if node.Children == nil { + return + } + + rule := NiriWindowRule{ + Source: p.currentSource, + } + + for _, child := range node.Children { + childName := child.Name.String() + + switch childName { + case "match": + p.parseMatchNode(child, &rule) + case "opacity": + if len(child.Arguments) > 0 { + val := child.Arguments[0].ResolvedValue() + if f, ok := val.(float64); ok { + rule.Opacity = &f + } + } + case "open-floating": + b := p.parseBoolArg(child) + rule.OpenFloating = &b + case "open-maximized": + b := p.parseBoolArg(child) + rule.OpenMaximized = &b + case "open-maximized-to-edges": + b := p.parseBoolArg(child) + rule.OpenMaximizedToEdges = &b + case "open-fullscreen": + b := p.parseBoolArg(child) + rule.OpenFullscreen = &b + case "open-focused": + b := p.parseBoolArg(child) + rule.OpenFocused = &b + case "open-on-output": + if len(child.Arguments) > 0 { + rule.OpenOnOutput = child.Arguments[0].ValueString() + } + case "open-on-workspace": + if len(child.Arguments) > 0 { + rule.OpenOnWorkspace = child.Arguments[0].ValueString() + } + case "default-column-width": + rule.DefaultColumnWidth = p.parseSizeNode(child) + case "default-window-height": + rule.DefaultWindowHeight = p.parseSizeNode(child) + case "variable-refresh-rate": + b := p.parseBoolArg(child) + rule.VariableRefreshRate = &b + case "block-out-from": + if len(child.Arguments) > 0 { + rule.BlockOutFrom = child.Arguments[0].ValueString() + } + case "default-column-display": + if len(child.Arguments) > 0 { + rule.DefaultColumnDisplay = child.Arguments[0].ValueString() + } + case "scroll-factor": + if len(child.Arguments) > 0 { + val := child.Arguments[0].ResolvedValue() + if f, ok := val.(float64); ok { + rule.ScrollFactor = &f + } + } + case "geometry-corner-radius": + if len(child.Arguments) > 0 { + val := child.Arguments[0].ResolvedValue() + if i, ok := val.(int64); ok { + intVal := int(i) + rule.CornerRadius = &intVal + } + } + case "clip-to-geometry": + b := p.parseBoolArg(child) + rule.ClipToGeometry = &b + case "tiled-state": + b := p.parseBoolArg(child) + rule.TiledState = &b + case "min-width": + if len(child.Arguments) > 0 { + val := child.Arguments[0].ResolvedValue() + if i, ok := val.(int64); ok { + intVal := int(i) + rule.MinWidth = &intVal + } + } + case "max-width": + if len(child.Arguments) > 0 { + val := child.Arguments[0].ResolvedValue() + if i, ok := val.(int64); ok { + intVal := int(i) + rule.MaxWidth = &intVal + } + } + case "min-height": + if len(child.Arguments) > 0 { + val := child.Arguments[0].ResolvedValue() + if i, ok := val.(int64); ok { + intVal := int(i) + rule.MinHeight = &intVal + } + } + case "max-height": + if len(child.Arguments) > 0 { + val := child.Arguments[0].ResolvedValue() + if i, ok := val.(int64); ok { + intVal := int(i) + rule.MaxHeight = &intVal + } + } + case "border": + p.parseBorderNode(child, &rule) + case "focus-ring": + p.parseFocusRingNode(child, &rule) + case "draw-border-with-background": + b := p.parseBoolArg(child) + rule.DrawBorderWithBg = &b + } + } + + p.rules = append(p.rules, rule) +} + +func (p *NiriRulesParser) parseSizeNode(node *document.Node) string { + if node.Children == nil { + return "" + } + for _, child := range node.Children { + name := child.Name.String() + if len(child.Arguments) > 0 { + val := child.Arguments[0].ResolvedValue() + switch name { + case "fixed": + if i, ok := val.(int64); ok { + return "fixed " + strconv.FormatInt(i, 10) + } + case "proportion": + if f, ok := val.(float64); ok { + return "proportion " + strconv.FormatFloat(f, 'f', -1, 64) + } + } + } + } + return "" +} + +func (p *NiriRulesParser) parseMatchNode(node *document.Node, rule *NiriWindowRule) { + if node.Properties == nil { + return + } + + if val, ok := node.Properties.Get("app-id"); ok { + rule.MatchAppID = val.ValueString() + } + if val, ok := node.Properties.Get("title"); ok { + rule.MatchTitle = val.ValueString() + } + if val, ok := node.Properties.Get("is-floating"); ok { + b := val.ValueString() == "true" + rule.MatchIsFloating = &b + } + if val, ok := node.Properties.Get("is-active"); ok { + b := val.ValueString() == "true" + rule.MatchIsActive = &b + } + if val, ok := node.Properties.Get("is-focused"); ok { + b := val.ValueString() == "true" + rule.MatchIsFocused = &b + } + if val, ok := node.Properties.Get("is-active-in-column"); ok { + b := val.ValueString() == "true" + rule.MatchIsActiveInColumn = &b + } + if val, ok := node.Properties.Get("is-window-cast-target"); ok { + b := val.ValueString() == "true" + rule.MatchIsWindowCastTarget = &b + } + if val, ok := node.Properties.Get("is-urgent"); ok { + b := val.ValueString() == "true" + rule.MatchIsUrgent = &b + } + if val, ok := node.Properties.Get("at-startup"); ok { + b := val.ValueString() == "true" + rule.MatchAtStartup = &b + } +} + +func (p *NiriRulesParser) parseBorderNode(node *document.Node, rule *NiriWindowRule) { + if node.Children == nil { + return + } + + for _, child := range node.Children { + switch child.Name.String() { + case "off": + b := true + rule.BorderOff = &b + case "active-color": + if len(child.Arguments) > 0 { + rule.BorderColor = child.Arguments[0].ValueString() + } + } + } +} + +func (p *NiriRulesParser) parseFocusRingNode(node *document.Node, rule *NiriWindowRule) { + if node.Children == nil { + return + } + + for _, child := range node.Children { + switch child.Name.String() { + case "off": + b := true + rule.FocusRingOff = &b + case "active-color": + if len(child.Arguments) > 0 { + rule.FocusRingColor = child.Arguments[0].ValueString() + } + } + } +} + +func (p *NiriRulesParser) parseBoolArg(node *document.Node) bool { + if len(node.Arguments) == 0 { + return true + } + return node.Arguments[0].ValueString() != "false" +} + +func (p *NiriRulesParser) HasDMSRulesIncluded() bool { + return p.dmsRulesIncluded +} + +func (p *NiriRulesParser) buildDMSStatus() *windowrules.DMSRulesStatus { + status := &windowrules.DMSRulesStatus{ + Exists: p.dmsRulesExists, + Included: p.dmsRulesIncluded, + IncludePosition: p.dmsIncludePos, + TotalIncludes: p.includeCount, + RulesAfterDMS: p.rulesAfterDMS, + } + + switch { + case !p.dmsRulesExists: + status.Effective = false + status.StatusMessage = "dms/windowrules.kdl does not exist" + case !p.dmsRulesIncluded: + status.Effective = false + status.StatusMessage = "dms/windowrules.kdl is not included in config.kdl" + case p.rulesAfterDMS > 0: + status.Effective = true + status.OverriddenBy = p.rulesAfterDMS + status.StatusMessage = "Some DMS rules may be overridden by config rules" + default: + status.Effective = true + status.StatusMessage = "DMS window rules are active" + } + + return status +} + +type NiriRulesParseResult struct { + Rules []NiriWindowRule + DMSRulesIncluded bool + DMSStatus *windowrules.DMSRulesStatus +} + +func ParseNiriWindowRules(configDir string) (*NiriRulesParseResult, error) { + parser := NewNiriRulesParser(configDir) + rules, err := parser.Parse() + if err != nil { + return nil, err + } + return &NiriRulesParseResult{ + Rules: rules, + DMSRulesIncluded: parser.HasDMSRulesIncluded(), + DMSStatus: parser.buildDMSStatus(), + }, nil +} + +func ConvertNiriRulesToWindowRules(niriRules []NiriWindowRule) []windowrules.WindowRule { + result := make([]windowrules.WindowRule, 0, len(niriRules)) + for i, nr := range niriRules { + wr := windowrules.WindowRule{ + ID: fmt.Sprintf("rule_%d", i), + Enabled: true, + Source: nr.Source, + MatchCriteria: windowrules.MatchCriteria{ + AppID: nr.MatchAppID, + Title: nr.MatchTitle, + IsFloating: nr.MatchIsFloating, + IsActive: nr.MatchIsActive, + IsFocused: nr.MatchIsFocused, + IsActiveInColumn: nr.MatchIsActiveInColumn, + IsWindowCastTarget: nr.MatchIsWindowCastTarget, + IsUrgent: nr.MatchIsUrgent, + AtStartup: nr.MatchAtStartup, + }, + Actions: windowrules.Actions{ + Opacity: nr.Opacity, + OpenFloating: nr.OpenFloating, + OpenMaximized: nr.OpenMaximized, + OpenMaximizedToEdges: nr.OpenMaximizedToEdges, + OpenFullscreen: nr.OpenFullscreen, + OpenFocused: nr.OpenFocused, + OpenOnOutput: nr.OpenOnOutput, + OpenOnWorkspace: nr.OpenOnWorkspace, + DefaultColumnWidth: nr.DefaultColumnWidth, + DefaultWindowHeight: nr.DefaultWindowHeight, + VariableRefreshRate: nr.VariableRefreshRate, + BlockOutFrom: nr.BlockOutFrom, + DefaultColumnDisplay: nr.DefaultColumnDisplay, + ScrollFactor: nr.ScrollFactor, + CornerRadius: nr.CornerRadius, + ClipToGeometry: nr.ClipToGeometry, + TiledState: nr.TiledState, + MinWidth: nr.MinWidth, + MaxWidth: nr.MaxWidth, + MinHeight: nr.MinHeight, + MaxHeight: nr.MaxHeight, + BorderColor: nr.BorderColor, + FocusRingColor: nr.FocusRingColor, + FocusRingOff: nr.FocusRingOff, + BorderOff: nr.BorderOff, + DrawBorderWithBg: nr.DrawBorderWithBg, + }, + } + result = append(result, wr) + } + return result +} + +type NiriWritableProvider struct { + configDir string +} + +func NewNiriWritableProvider(configDir string) *NiriWritableProvider { + return &NiriWritableProvider{configDir: configDir} +} + +func (p *NiriWritableProvider) Name() string { + return "niri" +} + +func (p *NiriWritableProvider) GetOverridePath() string { + return filepath.Join(p.configDir, "dms", "windowrules.kdl") +} + +func (p *NiriWritableProvider) GetRuleSet() (*windowrules.RuleSet, error) { + result, err := ParseNiriWindowRules(p.configDir) + if err != nil { + return nil, err + } + return &windowrules.RuleSet{ + Title: "Niri Window Rules", + Provider: "niri", + Rules: ConvertNiriRulesToWindowRules(result.Rules), + DMSRulesIncluded: result.DMSRulesIncluded, + DMSStatus: result.DMSStatus, + }, nil +} + +func (p *NiriWritableProvider) SetRule(rule windowrules.WindowRule) error { + rules, err := p.LoadDMSRules() + if err != nil { + rules = []windowrules.WindowRule{} + } + + found := false + for i, r := range rules { + if r.ID == rule.ID { + rules[i] = rule + found = true + break + } + } + if !found { + rules = append(rules, rule) + } + + return p.writeDMSRules(rules) +} + +func (p *NiriWritableProvider) RemoveRule(id string) error { + rules, err := p.LoadDMSRules() + if err != nil { + return err + } + + newRules := make([]windowrules.WindowRule, 0, len(rules)) + for _, r := range rules { + if r.ID != id { + newRules = append(newRules, r) + } + } + + return p.writeDMSRules(newRules) +} + +func (p *NiriWritableProvider) ReorderRules(ids []string) error { + rules, err := p.LoadDMSRules() + if err != nil { + return err + } + + ruleMap := make(map[string]windowrules.WindowRule) + for _, r := range rules { + ruleMap[r.ID] = r + } + + newRules := make([]windowrules.WindowRule, 0, len(ids)) + for _, id := range ids { + if r, ok := ruleMap[id]; ok { + newRules = append(newRules, r) + delete(ruleMap, id) + } + } + + for _, r := range ruleMap { + newRules = append(newRules, r) + } + + return p.writeDMSRules(newRules) +} + +var niriMetaCommentRegex = regexp.MustCompile(`^//\s*@id=(\S*)\s*@name=(.*)$`) + +func (p *NiriWritableProvider) LoadDMSRules() ([]windowrules.WindowRule, error) { + rulesPath := p.GetOverridePath() + data, err := os.ReadFile(rulesPath) + if err != nil { + if os.IsNotExist(err) { + return []windowrules.WindowRule{}, nil + } + return nil, err + } + + content := string(data) + lines := strings.Split(content, "\n") + + type ruleMeta struct { + id string + name string + } + var metas []ruleMeta + var currentID, currentName string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if matches := niriMetaCommentRegex.FindStringSubmatch(trimmed); matches != nil { + currentID = matches[1] + currentName = strings.TrimSpace(matches[2]) + continue + } + if strings.HasPrefix(trimmed, "window-rule") { + metas = append(metas, ruleMeta{id: currentID, name: currentName}) + currentID = "" + currentName = "" + } + } + + doc, err := kdl.Parse(strings.NewReader(content)) + if err != nil { + return nil, err + } + + parser := NewNiriRulesParser(p.configDir) + parser.currentSource = rulesPath + + for _, node := range doc.Nodes { + if node.Name.String() == "window-rule" { + parser.parseWindowRuleNode(node) + } + } + + var rules []windowrules.WindowRule + for i, nr := range parser.rules { + id := "" + name := "" + if i < len(metas) { + id = metas[i].id + name = metas[i].name + } + if id == "" { + id = fmt.Sprintf("dms_rule_%d", i) + } + + wr := windowrules.WindowRule{ + ID: id, + Name: name, + Enabled: true, + Source: rulesPath, + MatchCriteria: windowrules.MatchCriteria{ + AppID: nr.MatchAppID, + Title: nr.MatchTitle, + IsFloating: nr.MatchIsFloating, + IsActive: nr.MatchIsActive, + IsFocused: nr.MatchIsFocused, + IsActiveInColumn: nr.MatchIsActiveInColumn, + IsWindowCastTarget: nr.MatchIsWindowCastTarget, + IsUrgent: nr.MatchIsUrgent, + AtStartup: nr.MatchAtStartup, + }, + Actions: windowrules.Actions{ + Opacity: nr.Opacity, + OpenFloating: nr.OpenFloating, + OpenMaximized: nr.OpenMaximized, + OpenMaximizedToEdges: nr.OpenMaximizedToEdges, + OpenFullscreen: nr.OpenFullscreen, + OpenFocused: nr.OpenFocused, + OpenOnOutput: nr.OpenOnOutput, + OpenOnWorkspace: nr.OpenOnWorkspace, + DefaultColumnWidth: nr.DefaultColumnWidth, + DefaultWindowHeight: nr.DefaultWindowHeight, + VariableRefreshRate: nr.VariableRefreshRate, + BlockOutFrom: nr.BlockOutFrom, + DefaultColumnDisplay: nr.DefaultColumnDisplay, + ScrollFactor: nr.ScrollFactor, + CornerRadius: nr.CornerRadius, + ClipToGeometry: nr.ClipToGeometry, + TiledState: nr.TiledState, + MinWidth: nr.MinWidth, + MaxWidth: nr.MaxWidth, + MinHeight: nr.MinHeight, + MaxHeight: nr.MaxHeight, + BorderColor: nr.BorderColor, + FocusRingColor: nr.FocusRingColor, + FocusRingOff: nr.FocusRingOff, + BorderOff: nr.BorderOff, + DrawBorderWithBg: nr.DrawBorderWithBg, + }, + } + + rules = append(rules, wr) + } + + return rules, nil +} + +func (p *NiriWritableProvider) writeDMSRules(rules []windowrules.WindowRule) error { + rulesPath := p.GetOverridePath() + + if err := os.MkdirAll(filepath.Dir(rulesPath), 0755); err != nil { + return err + } + + var lines []string + lines = append(lines, "// DMS Window Rules - Managed by DankMaterialShell") + lines = append(lines, "// Do not edit manually - changes may be overwritten") + lines = append(lines, "") + + for _, rule := range rules { + lines = append(lines, p.formatRule(rule)) + lines = append(lines, "") + } + + return os.WriteFile(rulesPath, []byte(strings.Join(lines, "\n")), 0644) +} + +func (p *NiriWritableProvider) formatRule(rule windowrules.WindowRule) string { + var lines []string + lines = append(lines, fmt.Sprintf("// @id=%s @name=%s", rule.ID, rule.Name)) + lines = append(lines, "window-rule {") + + m := rule.MatchCriteria + if m.AppID != "" || m.Title != "" || m.IsFloating != nil || m.IsActive != nil || + m.IsFocused != nil || m.IsActiveInColumn != nil || m.IsWindowCastTarget != nil || + m.IsUrgent != nil || m.AtStartup != nil { + var matchProps []string + if m.AppID != "" { + matchProps = append(matchProps, fmt.Sprintf("app-id=%q", m.AppID)) + } + if m.Title != "" { + matchProps = append(matchProps, fmt.Sprintf("title=%q", m.Title)) + } + if m.IsFloating != nil { + matchProps = append(matchProps, fmt.Sprintf("is-floating=%t", *m.IsFloating)) + } + if m.IsActive != nil { + matchProps = append(matchProps, fmt.Sprintf("is-active=%t", *m.IsActive)) + } + if m.IsFocused != nil { + matchProps = append(matchProps, fmt.Sprintf("is-focused=%t", *m.IsFocused)) + } + if m.IsActiveInColumn != nil { + matchProps = append(matchProps, fmt.Sprintf("is-active-in-column=%t", *m.IsActiveInColumn)) + } + if m.IsWindowCastTarget != nil { + matchProps = append(matchProps, fmt.Sprintf("is-window-cast-target=%t", *m.IsWindowCastTarget)) + } + if m.IsUrgent != nil { + matchProps = append(matchProps, fmt.Sprintf("is-urgent=%t", *m.IsUrgent)) + } + if m.AtStartup != nil { + matchProps = append(matchProps, fmt.Sprintf("at-startup=%t", *m.AtStartup)) + } + lines = append(lines, " match "+strings.Join(matchProps, " ")) + } + + a := rule.Actions + if a.Opacity != nil { + lines = append(lines, fmt.Sprintf(" opacity %.2f", *a.Opacity)) + } + if a.OpenFloating != nil && *a.OpenFloating { + lines = append(lines, " open-floating true") + } + if a.OpenMaximized != nil && *a.OpenMaximized { + lines = append(lines, " open-maximized true") + } + if a.OpenMaximizedToEdges != nil && *a.OpenMaximizedToEdges { + lines = append(lines, " open-maximized-to-edges true") + } + if a.OpenFullscreen != nil && *a.OpenFullscreen { + lines = append(lines, " open-fullscreen true") + } + if a.OpenFocused != nil { + lines = append(lines, fmt.Sprintf(" open-focused %t", *a.OpenFocused)) + } + if a.OpenOnOutput != "" { + lines = append(lines, fmt.Sprintf(" open-on-output %q", a.OpenOnOutput)) + } + if a.OpenOnWorkspace != "" { + lines = append(lines, fmt.Sprintf(" open-on-workspace %q", a.OpenOnWorkspace)) + } + if a.DefaultColumnWidth != "" { + lines = append(lines, formatSizeProperty("default-column-width", a.DefaultColumnWidth)) + } + if a.DefaultWindowHeight != "" { + lines = append(lines, formatSizeProperty("default-window-height", a.DefaultWindowHeight)) + } + if a.VariableRefreshRate != nil && *a.VariableRefreshRate { + lines = append(lines, " variable-refresh-rate true") + } + if a.BlockOutFrom != "" { + lines = append(lines, fmt.Sprintf(" block-out-from %q", a.BlockOutFrom)) + } + if a.DefaultColumnDisplay != "" { + lines = append(lines, fmt.Sprintf(" default-column-display %q", a.DefaultColumnDisplay)) + } + if a.ScrollFactor != nil { + lines = append(lines, fmt.Sprintf(" scroll-factor %.2f", *a.ScrollFactor)) + } + if a.CornerRadius != nil { + lines = append(lines, fmt.Sprintf(" geometry-corner-radius %d", *a.CornerRadius)) + } + if a.ClipToGeometry != nil && *a.ClipToGeometry { + lines = append(lines, " clip-to-geometry true") + } + if a.TiledState != nil && *a.TiledState { + lines = append(lines, " tiled-state true") + } + if a.MinWidth != nil { + lines = append(lines, fmt.Sprintf(" min-width %d", *a.MinWidth)) + } + if a.MaxWidth != nil { + lines = append(lines, fmt.Sprintf(" max-width %d", *a.MaxWidth)) + } + if a.MinHeight != nil { + lines = append(lines, fmt.Sprintf(" min-height %d", *a.MinHeight)) + } + if a.MaxHeight != nil { + lines = append(lines, fmt.Sprintf(" max-height %d", *a.MaxHeight)) + } + if a.BorderOff != nil && *a.BorderOff { + lines = append(lines, " border { off; }") + } else if a.BorderColor != "" { + lines = append(lines, fmt.Sprintf(" border { active-color %q; }", a.BorderColor)) + } + if a.FocusRingOff != nil && *a.FocusRingOff { + lines = append(lines, " focus-ring { off; }") + } else if a.FocusRingColor != "" { + lines = append(lines, fmt.Sprintf(" focus-ring { active-color %q; }", a.FocusRingColor)) + } + if a.DrawBorderWithBg != nil { + lines = append(lines, fmt.Sprintf(" draw-border-with-background %t", *a.DrawBorderWithBg)) + } + + lines = append(lines, "}") + return strings.Join(lines, "\n") +} + +func formatSizeProperty(name, value string) string { + parts := strings.SplitN(value, " ", 2) + if len(parts) == 2 { + return fmt.Sprintf(" %s { %s %s; }", name, parts[0], parts[1]) + } + // Bare number without type prefix — default to "fixed" + if _, err := strconv.Atoi(value); err == nil { + return fmt.Sprintf(" %s { fixed %s; }", name, value) + } + return fmt.Sprintf(" %s { }", name) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser_test.go new file mode 100644 index 0000000..f764b6a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/niri_parser_test.go @@ -0,0 +1,335 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNiriParseBasicWindowRule(t *testing.T) { + tmpDir := t.TempDir() + + config := ` +window-rule { + match app-id="^firefox$" + opacity 0.9 + open-floating true +} +` + if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil { + t.Fatal(err) + } + + parser := NewNiriRulesParser(tmpDir) + rules, err := parser.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + rule := rules[0] + if rule.MatchAppID != "^firefox$" { + t.Errorf("MatchAppID = %q, want ^firefox$", rule.MatchAppID) + } + if rule.Opacity == nil || *rule.Opacity != 0.9 { + t.Errorf("Opacity = %v, want 0.9", rule.Opacity) + } + if rule.OpenFloating == nil || !*rule.OpenFloating { + t.Error("OpenFloating should be true") + } +} + +func TestNiriParseMultipleRules(t *testing.T) { + tmpDir := t.TempDir() + + config := ` +window-rule { + match app-id="app1" + open-maximized true +} + +window-rule { + match app-id="app2" + open-fullscreen true +} +` + if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil { + t.Fatal(err) + } + + parser := NewNiriRulesParser(tmpDir) + rules, err := parser.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(rules)) + } + + if rules[0].MatchAppID != "app1" { + t.Errorf("rule 0 MatchAppID = %q, want app1", rules[0].MatchAppID) + } + if rules[1].MatchAppID != "app2" { + t.Errorf("rule 1 MatchAppID = %q, want app2", rules[1].MatchAppID) + } +} + +func TestConvertNiriRulesToWindowRules(t *testing.T) { + niriRules := []NiriWindowRule{ + {MatchAppID: "^firefox$", Opacity: floatPtr(0.8)}, + {MatchAppID: "^code$", OpenFloating: boolPtr(true)}, + } + + result := ConvertNiriRulesToWindowRules(niriRules) + + if len(result) != 2 { + t.Errorf("expected 2 rules, got %d", len(result)) + } + + if result[0].MatchCriteria.AppID != "^firefox$" { + t.Errorf("rule 0 AppID = %q, want ^firefox$", result[0].MatchCriteria.AppID) + } + if result[0].Actions.Opacity == nil || *result[0].Actions.Opacity != 0.8 { + t.Errorf("rule 0 Opacity = %v, want 0.8", result[0].Actions.Opacity) + } + + if result[1].Actions.OpenFloating == nil || !*result[1].Actions.OpenFloating { + t.Error("rule 1 should have OpenFloating = true") + } +} + +func TestNiriWritableProvider(t *testing.T) { + tmpDir := t.TempDir() + provider := NewNiriWritableProvider(tmpDir) + + if provider.Name() != "niri" { + t.Errorf("Name() = %q, want niri", provider.Name()) + } + + expectedPath := filepath.Join(tmpDir, "dms", "windowrules.kdl") + if provider.GetOverridePath() != expectedPath { + t.Errorf("GetOverridePath() = %q, want %q", provider.GetOverridePath(), expectedPath) + } +} + +func TestNiriSetAndLoadDMSRules(t *testing.T) { + tmpDir := t.TempDir() + provider := NewNiriWritableProvider(tmpDir) + + rule := newTestWindowRule("test_id", "Test Rule", "^firefox$") + rule.Actions.OpenFloating = boolPtr(true) + rule.Actions.Opacity = floatPtr(0.85) + + if err := provider.SetRule(rule); err != nil { + t.Fatalf("SetRule failed: %v", err) + } + + rules, err := provider.LoadDMSRules() + if err != nil { + t.Fatalf("LoadDMSRules failed: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + if rules[0].ID != "test_id" { + t.Errorf("ID = %q, want test_id", rules[0].ID) + } + if rules[0].MatchCriteria.AppID != "^firefox$" { + t.Errorf("AppID = %q, want ^firefox$", rules[0].MatchCriteria.AppID) + } +} + +func TestNiriRemoveRule(t *testing.T) { + tmpDir := t.TempDir() + provider := NewNiriWritableProvider(tmpDir) + + rule1 := newTestWindowRule("rule1", "Rule 1", "app1") + rule1.Actions.OpenFloating = boolPtr(true) + rule2 := newTestWindowRule("rule2", "Rule 2", "app2") + rule2.Actions.OpenFloating = boolPtr(true) + + _ = provider.SetRule(rule1) + _ = provider.SetRule(rule2) + + if err := provider.RemoveRule("rule1"); err != nil { + t.Fatalf("RemoveRule failed: %v", err) + } + + rules, _ := provider.LoadDMSRules() + if len(rules) != 1 { + t.Fatalf("expected 1 rule after removal, got %d", len(rules)) + } + if rules[0].ID != "rule2" { + t.Errorf("remaining rule ID = %q, want rule2", rules[0].ID) + } +} + +func TestNiriReorderRules(t *testing.T) { + tmpDir := t.TempDir() + provider := NewNiriWritableProvider(tmpDir) + + rule1 := newTestWindowRule("rule1", "Rule 1", "app1") + rule1.Actions.OpenFloating = boolPtr(true) + rule2 := newTestWindowRule("rule2", "Rule 2", "app2") + rule2.Actions.OpenFloating = boolPtr(true) + rule3 := newTestWindowRule("rule3", "Rule 3", "app3") + rule3.Actions.OpenFloating = boolPtr(true) + + _ = provider.SetRule(rule1) + _ = provider.SetRule(rule2) + _ = provider.SetRule(rule3) + + if err := provider.ReorderRules([]string{"rule3", "rule1", "rule2"}); err != nil { + t.Fatalf("ReorderRules failed: %v", err) + } + + rules, _ := provider.LoadDMSRules() + if len(rules) != 3 { + t.Fatalf("expected 3 rules, got %d", len(rules)) + } + expectedOrder := []string{"rule3", "rule1", "rule2"} + for i, expectedID := range expectedOrder { + if rules[i].ID != expectedID { + t.Errorf("rule %d ID = %q, want %q", i, rules[i].ID, expectedID) + } + } +} + +func TestNiriParseConfigWithInclude(t *testing.T) { + tmpDir := t.TempDir() + + mainConfig := ` +window-rule { + match app-id="mainapp" + opacity 1.0 +} + +include "extra.kdl" +` + extraConfig := ` +window-rule { + match app-id="extraapp" + open-maximized true +} +` + + if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(mainConfig), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "extra.kdl"), []byte(extraConfig), 0644); err != nil { + t.Fatal(err) + } + + parser := NewNiriRulesParser(tmpDir) + rules, err := parser.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(rules) != 2 { + t.Errorf("expected 2 rules, got %d", len(rules)) + } +} + +func TestNiriParseSizeNode(t *testing.T) { + tmpDir := t.TempDir() + + config := ` +window-rule { + match app-id="testapp" + default-column-width { fixed 800; } + default-window-height { proportion 0.5; } +} +` + if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil { + t.Fatal(err) + } + + parser := NewNiriRulesParser(tmpDir) + rules, err := parser.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + + if len(rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(rules)) + } + + if rules[0].DefaultColumnWidth != "fixed 800" { + t.Errorf("DefaultColumnWidth = %q, want 'fixed 800'", rules[0].DefaultColumnWidth) + } + if rules[0].DefaultWindowHeight != "proportion 0.5" { + t.Errorf("DefaultWindowHeight = %q, want 'proportion 0.5'", rules[0].DefaultWindowHeight) + } +} + +func TestFormatSizeProperty(t *testing.T) { + tests := []struct { + name string + propName string + value string + want string + }{ + { + name: "fixed size", + propName: "default-column-width", + value: "fixed 800", + want: " default-column-width { fixed 800; }", + }, + { + name: "proportion", + propName: "default-window-height", + value: "proportion 0.5", + want: " default-window-height { proportion 0.5; }", + }, + { + name: "invalid format", + propName: "default-column-width", + value: "invalid", + want: " default-column-width { }", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatSizeProperty(tt.propName, tt.value) + if result != tt.want { + t.Errorf("formatSizeProperty(%q, %q) = %q, want %q", + tt.propName, tt.value, result, tt.want) + } + }) + } +} + +func TestNiriDMSRulesStatus(t *testing.T) { + tmpDir := t.TempDir() + + config := ` +window-rule { + match app-id="testapp" + opacity 0.9 +} +` + if err := os.WriteFile(filepath.Join(tmpDir, "config.kdl"), []byte(config), 0644); err != nil { + t.Fatal(err) + } + + result, err := ParseNiriWindowRules(tmpDir) + if err != nil { + t.Fatalf("ParseNiriWindowRules failed: %v", err) + } + + if result.DMSStatus == nil { + t.Fatal("DMSStatus should not be nil") + } + + if result.DMSStatus.Exists { + t.Error("DMSStatus.Exists should be false when dms rules file doesn't exist") + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/providers_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/providers_test.go new file mode 100644 index 0000000..de84a38 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/providers/providers_test.go @@ -0,0 +1,22 @@ +package providers + +import "github.com/AvengeMedia/DankMaterialShell/core/internal/windowrules" + +func newTestWindowRule(id, name, appID string) windowrules.WindowRule { + return windowrules.WindowRule{ + ID: id, + Name: name, + Enabled: true, + MatchCriteria: windowrules.MatchCriteria{ + AppID: appID, + }, + } +} + +func boolPtr(b bool) *bool { + return &b +} + +func floatPtr(f float64) *float64 { + return &f +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/types.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/types.go new file mode 100644 index 0000000..556acb7 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/internal/windowrules/types.go @@ -0,0 +1,103 @@ +package windowrules + +type MatchCriteria struct { + AppID string `json:"appId,omitempty"` + Title string `json:"title,omitempty"` + IsFloating *bool `json:"isFloating,omitempty"` + IsActive *bool `json:"isActive,omitempty"` + IsFocused *bool `json:"isFocused,omitempty"` + IsActiveInColumn *bool `json:"isActiveInColumn,omitempty"` + IsWindowCastTarget *bool `json:"isWindowCastTarget,omitempty"` + IsUrgent *bool `json:"isUrgent,omitempty"` + AtStartup *bool `json:"atStartup,omitempty"` + XWayland *bool `json:"xwayland,omitempty"` + Fullscreen *bool `json:"fullscreen,omitempty"` + Pinned *bool `json:"pinned,omitempty"` + Initialised *bool `json:"initialised,omitempty"` +} + +type Actions struct { + Opacity *float64 `json:"opacity,omitempty"` + OpenFloating *bool `json:"openFloating,omitempty"` + OpenMaximized *bool `json:"openMaximized,omitempty"` + OpenMaximizedToEdges *bool `json:"openMaximizedToEdges,omitempty"` + OpenFullscreen *bool `json:"openFullscreen,omitempty"` + OpenFocused *bool `json:"openFocused,omitempty"` + OpenOnOutput string `json:"openOnOutput,omitempty"` + OpenOnWorkspace string `json:"openOnWorkspace,omitempty"` + DefaultColumnWidth string `json:"defaultColumnWidth,omitempty"` + DefaultWindowHeight string `json:"defaultWindowHeight,omitempty"` + VariableRefreshRate *bool `json:"variableRefreshRate,omitempty"` + BlockOutFrom string `json:"blockOutFrom,omitempty"` + DefaultColumnDisplay string `json:"defaultColumnDisplay,omitempty"` + ScrollFactor *float64 `json:"scrollFactor,omitempty"` + CornerRadius *int `json:"cornerRadius,omitempty"` + ClipToGeometry *bool `json:"clipToGeometry,omitempty"` + TiledState *bool `json:"tiledState,omitempty"` + MinWidth *int `json:"minWidth,omitempty"` + MaxWidth *int `json:"maxWidth,omitempty"` + MinHeight *int `json:"minHeight,omitempty"` + MaxHeight *int `json:"maxHeight,omitempty"` + BorderColor string `json:"borderColor,omitempty"` + FocusRingColor string `json:"focusRingColor,omitempty"` + FocusRingOff *bool `json:"focusRingOff,omitempty"` + BorderOff *bool `json:"borderOff,omitempty"` + DrawBorderWithBg *bool `json:"drawBorderWithBackground,omitempty"` + Size string `json:"size,omitempty"` + Move string `json:"move,omitempty"` + Monitor string `json:"monitor,omitempty"` + Workspace string `json:"workspace,omitempty"` + Tile *bool `json:"tile,omitempty"` + NoFocus *bool `json:"nofocus,omitempty"` + NoBorder *bool `json:"noborder,omitempty"` + NoShadow *bool `json:"noshadow,omitempty"` + NoDim *bool `json:"nodim,omitempty"` + NoBlur *bool `json:"noblur,omitempty"` + NoAnim *bool `json:"noanim,omitempty"` + NoRounding *bool `json:"norounding,omitempty"` + Pin *bool `json:"pin,omitempty"` + Opaque *bool `json:"opaque,omitempty"` + ForcergbX *bool `json:"forcergbx,omitempty"` + Idleinhibit string `json:"idleinhibit,omitempty"` +} + +type WindowRule struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Enabled bool `json:"enabled"` + MatchCriteria MatchCriteria `json:"matchCriteria"` + Actions Actions `json:"actions"` + Source string `json:"source,omitempty"` +} + +type DMSRulesStatus struct { + Exists bool `json:"exists"` + Included bool `json:"included"` + IncludePosition int `json:"includePosition"` + TotalIncludes int `json:"totalIncludes"` + RulesAfterDMS int `json:"rulesAfterDms"` + Effective bool `json:"effective"` + OverriddenBy int `json:"overriddenBy"` + StatusMessage string `json:"statusMessage"` +} + +type RuleSet struct { + Title string `json:"title"` + Provider string `json:"provider"` + Rules []WindowRule `json:"rules"` + DMSRulesIncluded bool `json:"dmsRulesIncluded"` + DMSStatus *DMSRulesStatus `json:"dmsStatus,omitempty"` +} + +type Provider interface { + Name() string + GetRuleSet() (*RuleSet, error) +} + +type WritableProvider interface { + Provider + SetRule(rule WindowRule) error + RemoveRule(id string) error + ReorderRules(ids []string) error + GetOverridePath() string +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/dbusutil/variant.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/dbusutil/variant.go new file mode 100644 index 0000000..4080b66 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/dbusutil/variant.go @@ -0,0 +1,95 @@ +package dbusutil + +import "github.com/godbus/dbus/v5" + +func As[T any](v dbus.Variant) (T, bool) { + val, ok := v.Value().(T) + return val, ok +} + +func AsOr[T any](v dbus.Variant, def T) T { + if val, ok := v.Value().(T); ok { + return val + } + return def +} + +func Get[T any](m map[string]dbus.Variant, key string) (T, bool) { + v, ok := m[key] + if !ok { + var zero T + return zero, false + } + return As[T](v) +} + +func GetOr[T any](m map[string]dbus.Variant, key string, def T) T { + v, ok := m[key] + if !ok { + return def + } + return AsOr(v, def) +} + +func Normalize(v any) any { + switch val := v.(type) { + case dbus.Variant: + return Normalize(val.Value()) + case dbus.ObjectPath: + return string(val) + case []dbus.ObjectPath: + result := make([]string, len(val)) + for i, p := range val { + result[i] = string(p) + } + return result + case map[string]dbus.Variant: + result := make(map[string]any) + for k, vv := range val { + result[k] = Normalize(vv.Value()) + } + return result + case map[string]any: + result := make(map[string]any) + for k, vv := range val { + result[k] = Normalize(vv) + } + return result + case map[dbus.ObjectPath]map[string]map[string]dbus.Variant: + result := make(map[string]any) + for path, ifaces := range val { + ifaceMap := make(map[string]any) + for ifaceName, props := range ifaces { + propMap := make(map[string]any) + for propName, propVal := range props { + propMap[propName] = Normalize(propVal.Value()) + } + ifaceMap[ifaceName] = propMap + } + result[string(path)] = ifaceMap + } + return result + case []any: + result := make([]any, len(val)) + for i, item := range val { + result[i] = Normalize(item) + } + return result + case []dbus.Variant: + result := make([]any, len(val)) + for i, item := range val { + result[i] = Normalize(item.Value()) + } + return result + default: + return v + } +} + +func NormalizeSlice(values []any) []any { + result := make([]any, len(values)) + for i, v := range values { + result[i] = Normalize(v) + } + return result +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/dbusutil/variant_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/dbusutil/variant_test.go new file mode 100644 index 0000000..a927e54 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/dbusutil/variant_test.go @@ -0,0 +1,155 @@ +package dbusutil + +import ( + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" +) + +func TestAs(t *testing.T) { + t.Run("string", func(t *testing.T) { + v := dbus.MakeVariant("hello") + val, ok := As[string](v) + assert.True(t, ok) + assert.Equal(t, "hello", val) + }) + + t.Run("bool", func(t *testing.T) { + v := dbus.MakeVariant(true) + val, ok := As[bool](v) + assert.True(t, ok) + assert.True(t, val) + }) + + t.Run("int32", func(t *testing.T) { + v := dbus.MakeVariant(int32(42)) + val, ok := As[int32](v) + assert.True(t, ok) + assert.Equal(t, int32(42), val) + }) + + t.Run("wrong type", func(t *testing.T) { + v := dbus.MakeVariant("hello") + _, ok := As[int](v) + assert.False(t, ok) + }) +} + +func TestAsOr(t *testing.T) { + t.Run("exists", func(t *testing.T) { + v := dbus.MakeVariant("hello") + val := AsOr(v, "default") + assert.Equal(t, "hello", val) + }) + + t.Run("wrong type uses default", func(t *testing.T) { + v := dbus.MakeVariant(123) + val := AsOr(v, "default") + assert.Equal(t, "default", val) + }) +} + +func TestGet(t *testing.T) { + m := map[string]dbus.Variant{ + "name": dbus.MakeVariant("test"), + "enabled": dbus.MakeVariant(true), + "count": dbus.MakeVariant(int32(5)), + } + + t.Run("exists", func(t *testing.T) { + val, ok := Get[string](m, "name") + assert.True(t, ok) + assert.Equal(t, "test", val) + }) + + t.Run("missing key", func(t *testing.T) { + _, ok := Get[string](m, "missing") + assert.False(t, ok) + }) + + t.Run("wrong type", func(t *testing.T) { + _, ok := Get[int](m, "name") + assert.False(t, ok) + }) +} + +func TestGetOr(t *testing.T) { + m := map[string]dbus.Variant{ + "name": dbus.MakeVariant("test"), + } + + t.Run("exists", func(t *testing.T) { + val := GetOr(m, "name", "default") + assert.Equal(t, "test", val) + }) + + t.Run("missing uses default", func(t *testing.T) { + val := GetOr(m, "missing", "default") + assert.Equal(t, "default", val) + }) + + t.Run("wrong type uses default", func(t *testing.T) { + val := GetOr(m, "name", 42) + assert.Equal(t, 42, val) + }) +} + +func TestNormalize(t *testing.T) { + t.Run("variant unwrap", func(t *testing.T) { + v := dbus.MakeVariant("hello") + result := Normalize(v) + assert.Equal(t, "hello", result) + }) + + t.Run("nested variant", func(t *testing.T) { + v := dbus.MakeVariant(dbus.MakeVariant("nested")) + result := Normalize(v) + assert.Equal(t, "nested", result) + }) + + t.Run("object path", func(t *testing.T) { + v := dbus.ObjectPath("/org/test") + result := Normalize(v) + assert.Equal(t, "/org/test", result) + }) + + t.Run("object path slice", func(t *testing.T) { + v := []dbus.ObjectPath{"/org/a", "/org/b"} + result := Normalize(v) + assert.Equal(t, []string{"/org/a", "/org/b"}, result) + }) + + t.Run("variant map", func(t *testing.T) { + v := map[string]dbus.Variant{ + "key": dbus.MakeVariant("value"), + } + result := Normalize(v) + expected := map[string]any{"key": "value"} + assert.Equal(t, expected, result) + }) + + t.Run("any slice", func(t *testing.T) { + v := []any{dbus.MakeVariant("a"), dbus.ObjectPath("/b")} + result := Normalize(v) + expected := []any{"a", "/b"} + assert.Equal(t, expected, result) + }) + + t.Run("passthrough primitives", func(t *testing.T) { + assert.Equal(t, "hello", Normalize("hello")) + assert.Equal(t, 42, Normalize(42)) + assert.Equal(t, true, Normalize(true)) + }) +} + +func TestNormalizeSlice(t *testing.T) { + input := []any{ + dbus.MakeVariant("a"), + dbus.ObjectPath("/b"), + "c", + } + result := NormalizeSlice(input) + expected := []any{"a", "/b", "c"} + assert.Equal(t, expected, result) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/AUTHORS b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/AUTHORS new file mode 100644 index 0000000..7b24eab --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/AUTHORS @@ -0,0 +1,3 @@ +// Keep this sorted + +rajveermalviya diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/LICENSE b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/LICENSE new file mode 100644 index 0000000..4da3300 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/LICENSE @@ -0,0 +1,24 @@ +Copyright 2021 go-wayland authors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/README.md b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/README.md new file mode 100644 index 0000000..9e52d99 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/README.md @@ -0,0 +1,25 @@ +# Wayland implementation in Go + +[![Go Reference](https://pkg.go.dev/badge/github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland.svg)](https://pkg.go.dev/github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland) + +This module contains pure Go implementation of the Wayland protocol. +Currently only wayland-client functionality is supported. + +Go code is generated from protocol XML files using +[`go-wayland-scanner`](cmd/go-wayland-scanner/scanner.go). + +To load cursor, minimal port of `wayland-cursor` & `xcursor` in pure Go +is located at [`wayland/cursor`](wayland/cursor) & [`wayland/cursor/xcursor`](wayland/cursor/xcursor) +respectively. + +To demonstrate the functionality of this module +[`examples/imageviewer`](examples/imageviewer) contains a simple image +viewer. It demos displaying a top-level window, resizing of window, +cursor themes, pointer and keyboard. Because it's in pure Go, it can be +compiled without CGO. You can try it using the following commands: + +```sh +CGO_ENABLED=0 go install github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/examples/imageviewer@latest + +imageviewer file.jpg +``` diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/generate b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/generate new file mode 100755 index 0000000..02ad975 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/generate @@ -0,0 +1,4 @@ +#!/bin/sh + +cd ./wayland +go generate -x ./... diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/generatep b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/generatep new file mode 100755 index 0000000..bd660d7 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/generatep @@ -0,0 +1,9 @@ +#!/usr/bin/env sh + +# Runs go generate for each directory, but in parallel. Any arguments are appended to the +# go generate command. +# Usage: $ ./generatep [go generate arguments] +# Print all generate commands: $ ./generatep -x + +cd ./wayland || exit 1 +find . -type f -name '*.go' -exec dirname {} \; | sort -u | parallel -j 0 go generate "$1" {}/. diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/client.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/client.go new file mode 100644 index 0000000..fd49c20 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/client.go @@ -0,0 +1,7542 @@ +// Generated by go-wayland-scanner +// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner +// XML file : https://gitlab.freedesktop.org/wayland/wayland/-/raw/1.24.0/protocol/wayland.xml?ref_type=tags +// +// wayland Protocol Copyright: +// +// Copyright © 2008-2011 Kristian Høgsberg +// Copyright © 2010-2011 Intel Corporation +// Copyright © 2012-2013 Collabora, Ltd. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation files +// (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, +// and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice (including the +// next paragraph) shall be included in all copies or substantial +// portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +// BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package client + +import "golang.org/x/sys/unix" + +// DisplayInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const DisplayInterfaceName = "wl_display" + +// Display : core global object +// +// The core global object. This is a special singleton object. It +// is used for internal Wayland protocol features. +type Display struct { + BaseProxy + errorHandler DisplayErrorHandlerFunc + deleteIdHandler DisplayDeleteIdHandlerFunc +} + +// NewDisplay : core global object +// +// The core global object. This is a special singleton object. It +// is used for internal Wayland protocol features. +func NewDisplay(ctx *Context) *Display { + wlDisplay := &Display{} + ctx.Register(wlDisplay) + return wlDisplay +} + +// Sync : asynchronous roundtrip +// +// The sync request asks the server to emit the 'done' event +// on the returned wl_callback object. Since requests are +// handled in-order and events are delivered in-order, this can +// be used as a barrier to ensure all previous requests and the +// resulting events have been handled. +// +// The object returned by this request will be destroyed by the +// compositor after the callback is fired and as such the client must not +// attempt to use it after that point. +// +// The callback_data passed in the callback is undefined and should be ignored. +func (i *Display) Sync() (*Callback, error) { + callback := NewCallback(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], callback.ID()) + err := i.Context().WriteMsg(_reqBuf[:], nil) + return callback, err +} + +// GetRegistry : get global registry object +// +// This request creates a registry object that allows the client +// to list and bind the global objects available from the +// compositor. +// +// It should be noted that the server side resources consumed in +// response to a get_registry request can only be released when the +// client disconnects, not when the client side proxy is destroyed. +// Therefore, clients should invoke get_registry as infrequently as +// possible to avoid wasting memory. +func (i *Display) GetRegistry() (*Registry, error) { + registry := NewRegistry(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], registry.ID()) + err := i.Context().WriteMsg(_reqBuf[:], nil) + return registry, err +} + +func (i *Display) Destroy() error { + i.MarkZombie() + return nil +} + +type DisplayError uint32 + +// DisplayError : global error values +// +// These errors are global and can be emitted in response to any +// server request. +const ( + // DisplayErrorInvalidObject : server couldn't find object + DisplayErrorInvalidObject DisplayError = 0 + // DisplayErrorInvalidMethod : method doesn't exist on the specified interface or malformed request + DisplayErrorInvalidMethod DisplayError = 1 + // DisplayErrorNoMemory : server is out of memory + DisplayErrorNoMemory DisplayError = 2 + // DisplayErrorImplementation : implementation error in compositor + DisplayErrorImplementation DisplayError = 3 +) + +func (e DisplayError) Name() string { + switch e { + case DisplayErrorInvalidObject: + return "invalid_object" + case DisplayErrorInvalidMethod: + return "invalid_method" + case DisplayErrorNoMemory: + return "no_memory" + case DisplayErrorImplementation: + return "implementation" + default: + return "" + } +} + +func (e DisplayError) Value() string { + switch e { + case DisplayErrorInvalidObject: + return "0" + case DisplayErrorInvalidMethod: + return "1" + case DisplayErrorNoMemory: + return "2" + case DisplayErrorImplementation: + return "3" + default: + return "" + } +} + +func (e DisplayError) String() string { + return e.Name() + "=" + e.Value() +} + +// DisplayErrorEvent : fatal error event +// +// The error event is sent out when a fatal (non-recoverable) +// error has occurred. The object_id argument is the object +// where the error occurred, most often in response to a request +// to that object. The code identifies the error and is defined +// by the object interface. As such, each interface defines its +// own set of error codes. The message is a brief description +// of the error, for (debugging) convenience. +type DisplayErrorEvent struct { + ObjectId Proxy + Code uint32 + Message string +} +type DisplayErrorHandlerFunc func(DisplayErrorEvent) + +// SetErrorHandler : sets handler for DisplayErrorEvent +func (i *Display) SetErrorHandler(f DisplayErrorHandlerFunc) { + i.errorHandler = f +} + +// DisplayDeleteIdEvent : acknowledge object ID deletion +// +// This event is used internally by the object ID management +// logic. When a client deletes an object that it had created, +// the server will send this event to acknowledge that it has +// seen the delete request. When the client receives this event, +// it will know that it can safely reuse the object ID. +type DisplayDeleteIdEvent struct { + Id uint32 +} +type DisplayDeleteIdHandlerFunc func(DisplayDeleteIdEvent) + +// SetDeleteIdHandler : sets handler for DisplayDeleteIdEvent +func (i *Display) SetDeleteIdHandler(f DisplayDeleteIdHandlerFunc) { + i.deleteIdHandler = f +} + +func (i *Display) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.errorHandler == nil { + return + } + var e DisplayErrorEvent + l := 0 + e.ObjectId = i.Context().GetProxy(Uint32(data[l : l+4])) + l += 4 + e.Code = Uint32(data[l : l+4]) + l += 4 + messageLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.Message = String(data[l : l+messageLen]) + + i.errorHandler(e) + case 1: + var e DisplayDeleteIdEvent + l := 0 + e.Id = Uint32(data[l : l+4]) + l += 4 + + i.Context().DeleteID(e.Id) + + if i.deleteIdHandler != nil { + i.deleteIdHandler(e) + } + } +} + +// RegistryInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const RegistryInterfaceName = "wl_registry" + +// Registry : global registry object +// +// The singleton global registry object. The server has a number of +// global objects that are available to all clients. These objects +// typically represent an actual object in the server (for example, +// an input device) or they are singleton objects that provide +// extension functionality. +// +// When a client creates a registry object, the registry object +// will emit a global event for each global currently in the +// registry. Globals come and go as a result of device or +// monitor hotplugs, reconfiguration or other events, and the +// registry will send out global and global_remove events to +// keep the client up to date with the changes. To mark the end +// of the initial burst of events, the client can use the +// wl_display.sync request immediately after calling +// wl_display.get_registry. +// +// A client can bind to a global object by using the bind +// request. This creates a client-side handle that lets the object +// emit events to the client and lets the client invoke requests on +// the object. +type Registry struct { + BaseProxy + globalHandler RegistryGlobalHandlerFunc + globalRemoveHandler RegistryGlobalRemoveHandlerFunc +} + +// NewRegistry : global registry object +// +// The singleton global registry object. The server has a number of +// global objects that are available to all clients. These objects +// typically represent an actual object in the server (for example, +// an input device) or they are singleton objects that provide +// extension functionality. +// +// When a client creates a registry object, the registry object +// will emit a global event for each global currently in the +// registry. Globals come and go as a result of device or +// monitor hotplugs, reconfiguration or other events, and the +// registry will send out global and global_remove events to +// keep the client up to date with the changes. To mark the end +// of the initial burst of events, the client can use the +// wl_display.sync request immediately after calling +// wl_display.get_registry. +// +// A client can bind to a global object by using the bind +// request. This creates a client-side handle that lets the object +// emit events to the client and lets the client invoke requests on +// the object. +func NewRegistry(ctx *Context) *Registry { + wlRegistry := &Registry{} + ctx.Register(wlRegistry) + return wlRegistry +} + +// Bind : bind an object to the display +// +// Binds a new, client-created object to the server using the +// specified name as the identifier. +// +// name: unique numeric name of the object +func (i *Registry) Bind(name uint32, iface string, version uint32, id Proxy) error { + const opcode = 0 + ifaceLen := PaddedLen(len(iface) + 1) + _reqBufLen := 8 + 4 + (4 + ifaceLen) + 4 + 4 + _reqBuf := make([]byte, _reqBufLen) + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(name)) + l += 4 + PutString(_reqBuf[l:l+(4+ifaceLen)], iface) + l += (4 + ifaceLen) + PutUint32(_reqBuf[l:l+4], uint32(version)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf, nil) + return err +} + +func (i *Registry) Destroy() error { + i.MarkZombie() + return nil +} + +// RegistryGlobalEvent : announce global object +// +// Notify the client of global objects. +// +// The event notifies the client that a global object with +// the given name is now available, and it implements the +// given version of the given interface. +type RegistryGlobalEvent struct { + Name uint32 + Interface string + Version uint32 +} +type RegistryGlobalHandlerFunc func(RegistryGlobalEvent) + +// SetGlobalHandler : sets handler for RegistryGlobalEvent +func (i *Registry) SetGlobalHandler(f RegistryGlobalHandlerFunc) { + i.globalHandler = f +} + +// RegistryGlobalRemoveEvent : announce removal of global object +// +// Notify the client of removed global objects. +// +// This event notifies the client that the global identified +// by name is no longer available. If the client bound to +// the global using the bind request, the client should now +// destroy that object. +// +// The object remains valid and requests to the object will be +// ignored until the client destroys it, to avoid races between +// the global going away and a client sending a request to it. +type RegistryGlobalRemoveEvent struct { + Name uint32 +} +type RegistryGlobalRemoveHandlerFunc func(RegistryGlobalRemoveEvent) + +// SetGlobalRemoveHandler : sets handler for RegistryGlobalRemoveEvent +func (i *Registry) SetGlobalRemoveHandler(f RegistryGlobalRemoveHandlerFunc) { + i.globalRemoveHandler = f +} + +func (i *Registry) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.globalHandler == nil { + return + } + var e RegistryGlobalEvent + l := 0 + e.Name = Uint32(data[l : l+4]) + l += 4 + interfaceLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.Interface = String(data[l : l+interfaceLen]) + l += interfaceLen + e.Version = Uint32(data[l : l+4]) + l += 4 + + i.globalHandler(e) + case 1: + if i.globalRemoveHandler == nil { + return + } + var e RegistryGlobalRemoveEvent + l := 0 + e.Name = Uint32(data[l : l+4]) + l += 4 + + i.globalRemoveHandler(e) + } +} + +// CallbackInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const CallbackInterfaceName = "wl_callback" + +// Callback : callback object +// +// Clients can handle the 'done' event to get notified when +// the related request is done. +// +// Note, because wl_callback objects are created from multiple independent +// factory interfaces, the wl_callback interface is frozen at version 1. +type Callback struct { + BaseProxy + doneHandler CallbackDoneHandlerFunc +} + +// NewCallback : callback object +// +// Clients can handle the 'done' event to get notified when +// the related request is done. +// +// Note, because wl_callback objects are created from multiple independent +// factory interfaces, the wl_callback interface is frozen at version 1. +func NewCallback(ctx *Context) *Callback { + wlCallback := &Callback{} + ctx.Register(wlCallback) + return wlCallback +} + +func (i *Callback) Destroy() error { + i.MarkZombie() + return nil +} + +// CallbackDoneEvent : done event +// +// Notify the client when the related request is done. +type CallbackDoneEvent struct { + CallbackData uint32 +} +type CallbackDoneHandlerFunc func(CallbackDoneEvent) + +// SetDoneHandler : sets handler for CallbackDoneEvent +func (i *Callback) SetDoneHandler(f CallbackDoneHandlerFunc) { + i.doneHandler = f +} + +func (i *Callback) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.doneHandler == nil { + return + } + var e CallbackDoneEvent + l := 0 + e.CallbackData = Uint32(data[l : l+4]) + l += 4 + + i.doneHandler(e) + } +} + +// CompositorInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const CompositorInterfaceName = "wl_compositor" + +// Compositor : the compositor singleton +// +// A compositor. This object is a singleton global. The +// compositor is in charge of combining the contents of multiple +// surfaces into one displayable output. +type Compositor struct { + BaseProxy +} + +// NewCompositor : the compositor singleton +// +// A compositor. This object is a singleton global. The +// compositor is in charge of combining the contents of multiple +// surfaces into one displayable output. +func NewCompositor(ctx *Context) *Compositor { + wlCompositor := &Compositor{} + ctx.Register(wlCompositor) + return wlCompositor +} + +// CreateSurface : create new surface +// +// Ask the compositor to create a new surface. +func (i *Compositor) CreateSurface() (*Surface, error) { + id := NewSurface(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// CreateRegion : create new region +// +// Ask the compositor to create a new region. +func (i *Compositor) CreateRegion() (*Region, error) { + id := NewRegion(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +func (i *Compositor) Destroy() error { + i.MarkZombie() + return nil +} + +// ShmPoolInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ShmPoolInterfaceName = "wl_shm_pool" + +// ShmPool : a shared memory pool +// +// The wl_shm_pool object encapsulates a piece of memory shared +// between the compositor and client. Through the wl_shm_pool +// object, the client can allocate shared memory wl_buffer objects. +// All objects created through the same pool share the same +// underlying mapped memory. Reusing the mapped memory avoids the +// setup/teardown overhead and is useful when interactively resizing +// a surface or for many small buffers. +type ShmPool struct { + BaseProxy +} + +// NewShmPool : a shared memory pool +// +// The wl_shm_pool object encapsulates a piece of memory shared +// between the compositor and client. Through the wl_shm_pool +// object, the client can allocate shared memory wl_buffer objects. +// All objects created through the same pool share the same +// underlying mapped memory. Reusing the mapped memory avoids the +// setup/teardown overhead and is useful when interactively resizing +// a surface or for many small buffers. +func NewShmPool(ctx *Context) *ShmPool { + wlShmPool := &ShmPool{} + ctx.Register(wlShmPool) + return wlShmPool +} + +// CreateBuffer : create a buffer from the pool +// +// Create a wl_buffer object from the pool. +// +// The buffer is created offset bytes into the pool and has +// width and height as specified. The stride argument specifies +// the number of bytes from the beginning of one row to the beginning +// of the next. The format is the pixel format of the buffer and +// must be one of those advertised through the wl_shm.format event. +// +// A buffer will keep a reference to the pool it was created from +// so it is valid to destroy the pool immediately after creating +// a buffer from it. +// +// offset: buffer byte offset within the pool +// width: buffer width, in pixels +// height: buffer height, in pixels +// stride: number of bytes from the beginning of one row to the beginning of the next row +// format: buffer pixel format +func (i *ShmPool) CreateBuffer(offset, width, height, stride int32, format uint32) (*Buffer, error) { + id := NewBuffer(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(offset)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(stride)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(format)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// Destroy : destroy the pool +// +// Destroy the shared memory pool. +// +// The mmapped memory will be released when all +// buffers that have been created from this pool +// are gone. +func (i *ShmPool) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Resize : change the size of the pool mapping +// +// This request will cause the server to remap the backing memory +// for the pool from the file descriptor passed when the pool was +// created, but using the new size. This request can only be +// used to make the pool bigger. +// +// This request only changes the amount of bytes that are mmapped +// by the server and does not touch the file corresponding to the +// file descriptor passed at creation time. It is the client's +// responsibility to ensure that the file is at least as big as +// the new pool size. +// +// size: new size of the pool, in bytes +func (i *ShmPool) Resize(size int32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(size)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// ShmInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ShmInterfaceName = "wl_shm" + +// Shm : shared memory support +// +// A singleton global object that provides support for shared +// memory. +// +// Clients can create wl_shm_pool objects using the create_pool +// request. +// +// On binding the wl_shm object one or more format events +// are emitted to inform clients about the valid pixel formats +// that can be used for buffers. +type Shm struct { + BaseProxy + formatHandler ShmFormatHandlerFunc +} + +// NewShm : shared memory support +// +// A singleton global object that provides support for shared +// memory. +// +// Clients can create wl_shm_pool objects using the create_pool +// request. +// +// On binding the wl_shm object one or more format events +// are emitted to inform clients about the valid pixel formats +// that can be used for buffers. +func NewShm(ctx *Context) *Shm { + wlShm := &Shm{} + ctx.Register(wlShm) + return wlShm +} + +// CreatePool : create a shm pool +// +// Create a new wl_shm_pool object. +// +// The pool can be used to create shared memory based buffer +// objects. The server will mmap size bytes of the passed file +// descriptor, to use as backing memory for the pool. +// +// fd: file descriptor for the pool +// size: pool size, in bytes +func (i *Shm) CreatePool(fd int, size int32) (*ShmPool, error) { + id := NewShmPool(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(size)) + l += 4 + oob := unix.UnixRights(int(fd)) + err := i.Context().WriteMsg(_reqBuf[:], oob) + return id, err +} + +// Release : release the shm object +// +// Using this request a client can tell the server that it is not going to +// use the shm object anymore. +// +// Objects created via this interface remain unaffected. +func (i *Shm) Release() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type ShmError uint32 + +// ShmError : wl_shm error values +// +// These errors can be emitted in response to wl_shm requests. +const ( + // ShmErrorInvalidFormat : buffer format is not known + ShmErrorInvalidFormat ShmError = 0 + // ShmErrorInvalidStride : invalid size or stride during pool or buffer creation + ShmErrorInvalidStride ShmError = 1 + // ShmErrorInvalidFd : mmapping the file descriptor failed + ShmErrorInvalidFd ShmError = 2 +) + +func (e ShmError) Name() string { + switch e { + case ShmErrorInvalidFormat: + return "invalid_format" + case ShmErrorInvalidStride: + return "invalid_stride" + case ShmErrorInvalidFd: + return "invalid_fd" + default: + return "" + } +} + +func (e ShmError) Value() string { + switch e { + case ShmErrorInvalidFormat: + return "0" + case ShmErrorInvalidStride: + return "1" + case ShmErrorInvalidFd: + return "2" + default: + return "" + } +} + +func (e ShmError) String() string { + return e.Name() + "=" + e.Value() +} + +type ShmFormat uint32 + +// ShmFormat : pixel formats +// +// This describes the memory layout of an individual pixel. +// +// All renderers should support argb8888 and xrgb8888 but any other +// formats are optional and may not be supported by the particular +// renderer in use. +// +// The drm format codes match the macros defined in drm_fourcc.h, except +// argb8888 and xrgb8888. The formats actually supported by the compositor +// will be reported by the format event. +// +// For all wl_shm formats and unless specified in another protocol +// extension, pre-multiplied alpha is used for pixel values. +const ( + // ShmFormatArgb8888 : 32-bit ARGB format, [31:0] A:R:G:B 8:8:8:8 little endian + ShmFormatArgb8888 ShmFormat = 0 + // ShmFormatXrgb8888 : 32-bit RGB format, [31:0] x:R:G:B 8:8:8:8 little endian + ShmFormatXrgb8888 ShmFormat = 1 + // ShmFormatC8 : 8-bit color index format, [7:0] C + ShmFormatC8 ShmFormat = 0x20203843 + // ShmFormatRgb332 : 8-bit RGB format, [7:0] R:G:B 3:3:2 + ShmFormatRgb332 ShmFormat = 0x38424752 + // ShmFormatBgr233 : 8-bit BGR format, [7:0] B:G:R 2:3:3 + ShmFormatBgr233 ShmFormat = 0x38524742 + // ShmFormatXrgb4444 : 16-bit xRGB format, [15:0] x:R:G:B 4:4:4:4 little endian + ShmFormatXrgb4444 ShmFormat = 0x32315258 + // ShmFormatXbgr4444 : 16-bit xBGR format, [15:0] x:B:G:R 4:4:4:4 little endian + ShmFormatXbgr4444 ShmFormat = 0x32314258 + // ShmFormatRgbx4444 : 16-bit RGBx format, [15:0] R:G:B:x 4:4:4:4 little endian + ShmFormatRgbx4444 ShmFormat = 0x32315852 + // ShmFormatBgrx4444 : 16-bit BGRx format, [15:0] B:G:R:x 4:4:4:4 little endian + ShmFormatBgrx4444 ShmFormat = 0x32315842 + // ShmFormatArgb4444 : 16-bit ARGB format, [15:0] A:R:G:B 4:4:4:4 little endian + ShmFormatArgb4444 ShmFormat = 0x32315241 + // ShmFormatAbgr4444 : 16-bit ABGR format, [15:0] A:B:G:R 4:4:4:4 little endian + ShmFormatAbgr4444 ShmFormat = 0x32314241 + // ShmFormatRgba4444 : 16-bit RBGA format, [15:0] R:G:B:A 4:4:4:4 little endian + ShmFormatRgba4444 ShmFormat = 0x32314152 + // ShmFormatBgra4444 : 16-bit BGRA format, [15:0] B:G:R:A 4:4:4:4 little endian + ShmFormatBgra4444 ShmFormat = 0x32314142 + // ShmFormatXrgb1555 : 16-bit xRGB format, [15:0] x:R:G:B 1:5:5:5 little endian + ShmFormatXrgb1555 ShmFormat = 0x35315258 + // ShmFormatXbgr1555 : 16-bit xBGR 1555 format, [15:0] x:B:G:R 1:5:5:5 little endian + ShmFormatXbgr1555 ShmFormat = 0x35314258 + // ShmFormatRgbx5551 : 16-bit RGBx 5551 format, [15:0] R:G:B:x 5:5:5:1 little endian + ShmFormatRgbx5551 ShmFormat = 0x35315852 + // ShmFormatBgrx5551 : 16-bit BGRx 5551 format, [15:0] B:G:R:x 5:5:5:1 little endian + ShmFormatBgrx5551 ShmFormat = 0x35315842 + // ShmFormatArgb1555 : 16-bit ARGB 1555 format, [15:0] A:R:G:B 1:5:5:5 little endian + ShmFormatArgb1555 ShmFormat = 0x35315241 + // ShmFormatAbgr1555 : 16-bit ABGR 1555 format, [15:0] A:B:G:R 1:5:5:5 little endian + ShmFormatAbgr1555 ShmFormat = 0x35314241 + // ShmFormatRgba5551 : 16-bit RGBA 5551 format, [15:0] R:G:B:A 5:5:5:1 little endian + ShmFormatRgba5551 ShmFormat = 0x35314152 + // ShmFormatBgra5551 : 16-bit BGRA 5551 format, [15:0] B:G:R:A 5:5:5:1 little endian + ShmFormatBgra5551 ShmFormat = 0x35314142 + // ShmFormatRgb565 : 16-bit RGB 565 format, [15:0] R:G:B 5:6:5 little endian + ShmFormatRgb565 ShmFormat = 0x36314752 + // ShmFormatBgr565 : 16-bit BGR 565 format, [15:0] B:G:R 5:6:5 little endian + ShmFormatBgr565 ShmFormat = 0x36314742 + // ShmFormatRgb888 : 24-bit RGB format, [23:0] R:G:B little endian + ShmFormatRgb888 ShmFormat = 0x34324752 + // ShmFormatBgr888 : 24-bit BGR format, [23:0] B:G:R little endian + ShmFormatBgr888 ShmFormat = 0x34324742 + // ShmFormatXbgr8888 : 32-bit xBGR format, [31:0] x:B:G:R 8:8:8:8 little endian + ShmFormatXbgr8888 ShmFormat = 0x34324258 + // ShmFormatRgbx8888 : 32-bit RGBx format, [31:0] R:G:B:x 8:8:8:8 little endian + ShmFormatRgbx8888 ShmFormat = 0x34325852 + // ShmFormatBgrx8888 : 32-bit BGRx format, [31:0] B:G:R:x 8:8:8:8 little endian + ShmFormatBgrx8888 ShmFormat = 0x34325842 + // ShmFormatAbgr8888 : 32-bit ABGR format, [31:0] A:B:G:R 8:8:8:8 little endian + ShmFormatAbgr8888 ShmFormat = 0x34324241 + // ShmFormatRgba8888 : 32-bit RGBA format, [31:0] R:G:B:A 8:8:8:8 little endian + ShmFormatRgba8888 ShmFormat = 0x34324152 + // ShmFormatBgra8888 : 32-bit BGRA format, [31:0] B:G:R:A 8:8:8:8 little endian + ShmFormatBgra8888 ShmFormat = 0x34324142 + // ShmFormatXrgb2101010 : 32-bit xRGB format, [31:0] x:R:G:B 2:10:10:10 little endian + ShmFormatXrgb2101010 ShmFormat = 0x30335258 + // ShmFormatXbgr2101010 : 32-bit xBGR format, [31:0] x:B:G:R 2:10:10:10 little endian + ShmFormatXbgr2101010 ShmFormat = 0x30334258 + // ShmFormatRgbx1010102 : 32-bit RGBx format, [31:0] R:G:B:x 10:10:10:2 little endian + ShmFormatRgbx1010102 ShmFormat = 0x30335852 + // ShmFormatBgrx1010102 : 32-bit BGRx format, [31:0] B:G:R:x 10:10:10:2 little endian + ShmFormatBgrx1010102 ShmFormat = 0x30335842 + // ShmFormatArgb2101010 : 32-bit ARGB format, [31:0] A:R:G:B 2:10:10:10 little endian + ShmFormatArgb2101010 ShmFormat = 0x30335241 + // ShmFormatAbgr2101010 : 32-bit ABGR format, [31:0] A:B:G:R 2:10:10:10 little endian + ShmFormatAbgr2101010 ShmFormat = 0x30334241 + // ShmFormatRgba1010102 : 32-bit RGBA format, [31:0] R:G:B:A 10:10:10:2 little endian + ShmFormatRgba1010102 ShmFormat = 0x30334152 + // ShmFormatBgra1010102 : 32-bit BGRA format, [31:0] B:G:R:A 10:10:10:2 little endian + ShmFormatBgra1010102 ShmFormat = 0x30334142 + // ShmFormatYuyv : packed YCbCr format, [31:0] Cr0:Y1:Cb0:Y0 8:8:8:8 little endian + ShmFormatYuyv ShmFormat = 0x56595559 + // ShmFormatYvyu : packed YCbCr format, [31:0] Cb0:Y1:Cr0:Y0 8:8:8:8 little endian + ShmFormatYvyu ShmFormat = 0x55595659 + // ShmFormatUyvy : packed YCbCr format, [31:0] Y1:Cr0:Y0:Cb0 8:8:8:8 little endian + ShmFormatUyvy ShmFormat = 0x59565955 + // ShmFormatVyuy : packed YCbCr format, [31:0] Y1:Cb0:Y0:Cr0 8:8:8:8 little endian + ShmFormatVyuy ShmFormat = 0x59555956 + // ShmFormatAyuv : packed AYCbCr format, [31:0] A:Y:Cb:Cr 8:8:8:8 little endian + ShmFormatAyuv ShmFormat = 0x56555941 + // ShmFormatNv12 : 2 plane YCbCr Cr:Cb format, 2x2 subsampled Cr:Cb plane + ShmFormatNv12 ShmFormat = 0x3231564e + // ShmFormatNv21 : 2 plane YCbCr Cb:Cr format, 2x2 subsampled Cb:Cr plane + ShmFormatNv21 ShmFormat = 0x3132564e + // ShmFormatNv16 : 2 plane YCbCr Cr:Cb format, 2x1 subsampled Cr:Cb plane + ShmFormatNv16 ShmFormat = 0x3631564e + // ShmFormatNv61 : 2 plane YCbCr Cb:Cr format, 2x1 subsampled Cb:Cr plane + ShmFormatNv61 ShmFormat = 0x3136564e + // ShmFormatYuv410 : 3 plane YCbCr format, 4x4 subsampled Cb (1) and Cr (2) planes + ShmFormatYuv410 ShmFormat = 0x39565559 + // ShmFormatYvu410 : 3 plane YCbCr format, 4x4 subsampled Cr (1) and Cb (2) planes + ShmFormatYvu410 ShmFormat = 0x39555659 + // ShmFormatYuv411 : 3 plane YCbCr format, 4x1 subsampled Cb (1) and Cr (2) planes + ShmFormatYuv411 ShmFormat = 0x31315559 + // ShmFormatYvu411 : 3 plane YCbCr format, 4x1 subsampled Cr (1) and Cb (2) planes + ShmFormatYvu411 ShmFormat = 0x31315659 + // ShmFormatYuv420 : 3 plane YCbCr format, 2x2 subsampled Cb (1) and Cr (2) planes + ShmFormatYuv420 ShmFormat = 0x32315559 + // ShmFormatYvu420 : 3 plane YCbCr format, 2x2 subsampled Cr (1) and Cb (2) planes + ShmFormatYvu420 ShmFormat = 0x32315659 + // ShmFormatYuv422 : 3 plane YCbCr format, 2x1 subsampled Cb (1) and Cr (2) planes + ShmFormatYuv422 ShmFormat = 0x36315559 + // ShmFormatYvu422 : 3 plane YCbCr format, 2x1 subsampled Cr (1) and Cb (2) planes + ShmFormatYvu422 ShmFormat = 0x36315659 + // ShmFormatYuv444 : 3 plane YCbCr format, non-subsampled Cb (1) and Cr (2) planes + ShmFormatYuv444 ShmFormat = 0x34325559 + // ShmFormatYvu444 : 3 plane YCbCr format, non-subsampled Cr (1) and Cb (2) planes + ShmFormatYvu444 ShmFormat = 0x34325659 + // ShmFormatR8 : [7:0] R + ShmFormatR8 ShmFormat = 0x20203852 + // ShmFormatR16 : [15:0] R little endian + ShmFormatR16 ShmFormat = 0x20363152 + // ShmFormatRg88 : [15:0] R:G 8:8 little endian + ShmFormatRg88 ShmFormat = 0x38384752 + // ShmFormatGr88 : [15:0] G:R 8:8 little endian + ShmFormatGr88 ShmFormat = 0x38385247 + // ShmFormatRg1616 : [31:0] R:G 16:16 little endian + ShmFormatRg1616 ShmFormat = 0x32334752 + // ShmFormatGr1616 : [31:0] G:R 16:16 little endian + ShmFormatGr1616 ShmFormat = 0x32335247 + // ShmFormatXrgb16161616F : [63:0] x:R:G:B 16:16:16:16 little endian + ShmFormatXrgb16161616F ShmFormat = 0x48345258 + // ShmFormatXbgr16161616F : [63:0] x:B:G:R 16:16:16:16 little endian + ShmFormatXbgr16161616F ShmFormat = 0x48344258 + // ShmFormatArgb16161616F : [63:0] A:R:G:B 16:16:16:16 little endian + ShmFormatArgb16161616F ShmFormat = 0x48345241 + // ShmFormatAbgr16161616F : [63:0] A:B:G:R 16:16:16:16 little endian + ShmFormatAbgr16161616F ShmFormat = 0x48344241 + // ShmFormatXyuv8888 : [31:0] X:Y:Cb:Cr 8:8:8:8 little endian + ShmFormatXyuv8888 ShmFormat = 0x56555958 + // ShmFormatVuy888 : [23:0] Cr:Cb:Y 8:8:8 little endian + ShmFormatVuy888 ShmFormat = 0x34325556 + // ShmFormatVuy101010 : Y followed by U then V, 10:10:10. + ShmFormatVuy101010 ShmFormat = 0x30335556 + // ShmFormatY210 : [63:0] Cr0:0:Y1:0:Cb0:0:Y0:0 10:6:10:6:10:6:10:6 little endian per 2 Y pixels + ShmFormatY210 ShmFormat = 0x30313259 + // ShmFormatY212 : [63:0] Cr0:0:Y1:0:Cb0:0:Y0:0 12:4:12:4:12:4:12:4 little endian per 2 Y pixels + ShmFormatY212 ShmFormat = 0x32313259 + // ShmFormatY216 : [63:0] Cr0:Y1:Cb0:Y0 16:16:16:16 little endian per 2 Y pixels + ShmFormatY216 ShmFormat = 0x36313259 + // ShmFormatY410 : [31:0] A:Cr:Y:Cb 2:10:10:10 little endian + ShmFormatY410 ShmFormat = 0x30313459 + // ShmFormatY412 : [63:0] A:0:Cr:0:Y:0:Cb:0 12:4:12:4:12:4:12:4 little endian + ShmFormatY412 ShmFormat = 0x32313459 + // ShmFormatY416 : [63:0] A:Cr:Y:Cb 16:16:16:16 little endian + ShmFormatY416 ShmFormat = 0x36313459 + // ShmFormatXvyu2101010 : [31:0] X:Cr:Y:Cb 2:10:10:10 little endian + ShmFormatXvyu2101010 ShmFormat = 0x30335658 + // ShmFormatXvyu1216161616 : [63:0] X:0:Cr:0:Y:0:Cb:0 12:4:12:4:12:4:12:4 little endian + ShmFormatXvyu1216161616 ShmFormat = 0x36335658 + // ShmFormatXvyu16161616 : [63:0] X:Cr:Y:Cb 16:16:16:16 little endian + ShmFormatXvyu16161616 ShmFormat = 0x38345658 + // ShmFormatY0L0 : [63:0] A3:A2:Y3:0:Cr0:0:Y2:0:A1:A0:Y1:0:Cb0:0:Y0:0 1:1:8:2:8:2:8:2:1:1:8:2:8:2:8:2 little endian + ShmFormatY0L0 ShmFormat = 0x304c3059 + // ShmFormatX0L0 : [63:0] X3:X2:Y3:0:Cr0:0:Y2:0:X1:X0:Y1:0:Cb0:0:Y0:0 1:1:8:2:8:2:8:2:1:1:8:2:8:2:8:2 little endian + ShmFormatX0L0 ShmFormat = 0x304c3058 + // ShmFormatY0L2 : [63:0] A3:A2:Y3:Cr0:Y2:A1:A0:Y1:Cb0:Y0 1:1:10:10:10:1:1:10:10:10 little endian + ShmFormatY0L2 ShmFormat = 0x324c3059 + // ShmFormatX0L2 : [63:0] X3:X2:Y3:Cr0:Y2:X1:X0:Y1:Cb0:Y0 1:1:10:10:10:1:1:10:10:10 little endian + ShmFormatX0L2 ShmFormat = 0x324c3058 + ShmFormatYuv4208Bit ShmFormat = 0x38305559 + ShmFormatYuv42010Bit ShmFormat = 0x30315559 + ShmFormatXrgb8888A8 ShmFormat = 0x38415258 + ShmFormatXbgr8888A8 ShmFormat = 0x38414258 + ShmFormatRgbx8888A8 ShmFormat = 0x38415852 + ShmFormatBgrx8888A8 ShmFormat = 0x38415842 + ShmFormatRgb888A8 ShmFormat = 0x38413852 + ShmFormatBgr888A8 ShmFormat = 0x38413842 + ShmFormatRgb565A8 ShmFormat = 0x38413552 + ShmFormatBgr565A8 ShmFormat = 0x38413542 + // ShmFormatNv24 : non-subsampled Cr:Cb plane + ShmFormatNv24 ShmFormat = 0x3432564e + // ShmFormatNv42 : non-subsampled Cb:Cr plane + ShmFormatNv42 ShmFormat = 0x3234564e + // ShmFormatP210 : 2x1 subsampled Cr:Cb plane, 10 bit per channel + ShmFormatP210 ShmFormat = 0x30313250 + // ShmFormatP010 : 2x2 subsampled Cr:Cb plane 10 bits per channel + ShmFormatP010 ShmFormat = 0x30313050 + // ShmFormatP012 : 2x2 subsampled Cr:Cb plane 12 bits per channel + ShmFormatP012 ShmFormat = 0x32313050 + // ShmFormatP016 : 2x2 subsampled Cr:Cb plane 16 bits per channel + ShmFormatP016 ShmFormat = 0x36313050 + // ShmFormatAxbxgxrx106106106106 : [63:0] A:x:B:x:G:x:R:x 10:6:10:6:10:6:10:6 little endian + ShmFormatAxbxgxrx106106106106 ShmFormat = 0x30314241 + // ShmFormatNv15 : 2x2 subsampled Cr:Cb plane + ShmFormatNv15 ShmFormat = 0x3531564e + ShmFormatQ410 ShmFormat = 0x30313451 + ShmFormatQ401 ShmFormat = 0x31303451 + // ShmFormatXrgb16161616 : [63:0] x:R:G:B 16:16:16:16 little endian + ShmFormatXrgb16161616 ShmFormat = 0x38345258 + // ShmFormatXbgr16161616 : [63:0] x:B:G:R 16:16:16:16 little endian + ShmFormatXbgr16161616 ShmFormat = 0x38344258 + // ShmFormatArgb16161616 : [63:0] A:R:G:B 16:16:16:16 little endian + ShmFormatArgb16161616 ShmFormat = 0x38345241 + // ShmFormatAbgr16161616 : [63:0] A:B:G:R 16:16:16:16 little endian + ShmFormatAbgr16161616 ShmFormat = 0x38344241 + // ShmFormatC1 : [7:0] C0:C1:C2:C3:C4:C5:C6:C7 1:1:1:1:1:1:1:1 eight pixels/byte + ShmFormatC1 ShmFormat = 0x20203143 + // ShmFormatC2 : [7:0] C0:C1:C2:C3 2:2:2:2 four pixels/byte + ShmFormatC2 ShmFormat = 0x20203243 + // ShmFormatC4 : [7:0] C0:C1 4:4 two pixels/byte + ShmFormatC4 ShmFormat = 0x20203443 + // ShmFormatD1 : [7:0] D0:D1:D2:D3:D4:D5:D6:D7 1:1:1:1:1:1:1:1 eight pixels/byte + ShmFormatD1 ShmFormat = 0x20203144 + // ShmFormatD2 : [7:0] D0:D1:D2:D3 2:2:2:2 four pixels/byte + ShmFormatD2 ShmFormat = 0x20203244 + // ShmFormatD4 : [7:0] D0:D1 4:4 two pixels/byte + ShmFormatD4 ShmFormat = 0x20203444 + // ShmFormatD8 : [7:0] D + ShmFormatD8 ShmFormat = 0x20203844 + // ShmFormatR1 : [7:0] R0:R1:R2:R3:R4:R5:R6:R7 1:1:1:1:1:1:1:1 eight pixels/byte + ShmFormatR1 ShmFormat = 0x20203152 + // ShmFormatR2 : [7:0] R0:R1:R2:R3 2:2:2:2 four pixels/byte + ShmFormatR2 ShmFormat = 0x20203252 + // ShmFormatR4 : [7:0] R0:R1 4:4 two pixels/byte + ShmFormatR4 ShmFormat = 0x20203452 + // ShmFormatR10 : [15:0] x:R 6:10 little endian + ShmFormatR10 ShmFormat = 0x20303152 + // ShmFormatR12 : [15:0] x:R 4:12 little endian + ShmFormatR12 ShmFormat = 0x20323152 + // ShmFormatAvuy8888 : [31:0] A:Cr:Cb:Y 8:8:8:8 little endian + ShmFormatAvuy8888 ShmFormat = 0x59555641 + // ShmFormatXvuy8888 : [31:0] X:Cr:Cb:Y 8:8:8:8 little endian + ShmFormatXvuy8888 ShmFormat = 0x59555658 + // ShmFormatP030 : 2x2 subsampled Cr:Cb plane 10 bits per channel packed + ShmFormatP030 ShmFormat = 0x30333050 +) + +func (e ShmFormat) Name() string { + switch e { + case ShmFormatArgb8888: + return "argb8888" + case ShmFormatXrgb8888: + return "xrgb8888" + case ShmFormatC8: + return "c8" + case ShmFormatRgb332: + return "rgb332" + case ShmFormatBgr233: + return "bgr233" + case ShmFormatXrgb4444: + return "xrgb4444" + case ShmFormatXbgr4444: + return "xbgr4444" + case ShmFormatRgbx4444: + return "rgbx4444" + case ShmFormatBgrx4444: + return "bgrx4444" + case ShmFormatArgb4444: + return "argb4444" + case ShmFormatAbgr4444: + return "abgr4444" + case ShmFormatRgba4444: + return "rgba4444" + case ShmFormatBgra4444: + return "bgra4444" + case ShmFormatXrgb1555: + return "xrgb1555" + case ShmFormatXbgr1555: + return "xbgr1555" + case ShmFormatRgbx5551: + return "rgbx5551" + case ShmFormatBgrx5551: + return "bgrx5551" + case ShmFormatArgb1555: + return "argb1555" + case ShmFormatAbgr1555: + return "abgr1555" + case ShmFormatRgba5551: + return "rgba5551" + case ShmFormatBgra5551: + return "bgra5551" + case ShmFormatRgb565: + return "rgb565" + case ShmFormatBgr565: + return "bgr565" + case ShmFormatRgb888: + return "rgb888" + case ShmFormatBgr888: + return "bgr888" + case ShmFormatXbgr8888: + return "xbgr8888" + case ShmFormatRgbx8888: + return "rgbx8888" + case ShmFormatBgrx8888: + return "bgrx8888" + case ShmFormatAbgr8888: + return "abgr8888" + case ShmFormatRgba8888: + return "rgba8888" + case ShmFormatBgra8888: + return "bgra8888" + case ShmFormatXrgb2101010: + return "xrgb2101010" + case ShmFormatXbgr2101010: + return "xbgr2101010" + case ShmFormatRgbx1010102: + return "rgbx1010102" + case ShmFormatBgrx1010102: + return "bgrx1010102" + case ShmFormatArgb2101010: + return "argb2101010" + case ShmFormatAbgr2101010: + return "abgr2101010" + case ShmFormatRgba1010102: + return "rgba1010102" + case ShmFormatBgra1010102: + return "bgra1010102" + case ShmFormatYuyv: + return "yuyv" + case ShmFormatYvyu: + return "yvyu" + case ShmFormatUyvy: + return "uyvy" + case ShmFormatVyuy: + return "vyuy" + case ShmFormatAyuv: + return "ayuv" + case ShmFormatNv12: + return "nv12" + case ShmFormatNv21: + return "nv21" + case ShmFormatNv16: + return "nv16" + case ShmFormatNv61: + return "nv61" + case ShmFormatYuv410: + return "yuv410" + case ShmFormatYvu410: + return "yvu410" + case ShmFormatYuv411: + return "yuv411" + case ShmFormatYvu411: + return "yvu411" + case ShmFormatYuv420: + return "yuv420" + case ShmFormatYvu420: + return "yvu420" + case ShmFormatYuv422: + return "yuv422" + case ShmFormatYvu422: + return "yvu422" + case ShmFormatYuv444: + return "yuv444" + case ShmFormatYvu444: + return "yvu444" + case ShmFormatR8: + return "r8" + case ShmFormatR16: + return "r16" + case ShmFormatRg88: + return "rg88" + case ShmFormatGr88: + return "gr88" + case ShmFormatRg1616: + return "rg1616" + case ShmFormatGr1616: + return "gr1616" + case ShmFormatXrgb16161616F: + return "xrgb16161616f" + case ShmFormatXbgr16161616F: + return "xbgr16161616f" + case ShmFormatArgb16161616F: + return "argb16161616f" + case ShmFormatAbgr16161616F: + return "abgr16161616f" + case ShmFormatXyuv8888: + return "xyuv8888" + case ShmFormatVuy888: + return "vuy888" + case ShmFormatVuy101010: + return "vuy101010" + case ShmFormatY210: + return "y210" + case ShmFormatY212: + return "y212" + case ShmFormatY216: + return "y216" + case ShmFormatY410: + return "y410" + case ShmFormatY412: + return "y412" + case ShmFormatY416: + return "y416" + case ShmFormatXvyu2101010: + return "xvyu2101010" + case ShmFormatXvyu1216161616: + return "xvyu12_16161616" + case ShmFormatXvyu16161616: + return "xvyu16161616" + case ShmFormatY0L0: + return "y0l0" + case ShmFormatX0L0: + return "x0l0" + case ShmFormatY0L2: + return "y0l2" + case ShmFormatX0L2: + return "x0l2" + case ShmFormatYuv4208Bit: + return "yuv420_8bit" + case ShmFormatYuv42010Bit: + return "yuv420_10bit" + case ShmFormatXrgb8888A8: + return "xrgb8888_a8" + case ShmFormatXbgr8888A8: + return "xbgr8888_a8" + case ShmFormatRgbx8888A8: + return "rgbx8888_a8" + case ShmFormatBgrx8888A8: + return "bgrx8888_a8" + case ShmFormatRgb888A8: + return "rgb888_a8" + case ShmFormatBgr888A8: + return "bgr888_a8" + case ShmFormatRgb565A8: + return "rgb565_a8" + case ShmFormatBgr565A8: + return "bgr565_a8" + case ShmFormatNv24: + return "nv24" + case ShmFormatNv42: + return "nv42" + case ShmFormatP210: + return "p210" + case ShmFormatP010: + return "p010" + case ShmFormatP012: + return "p012" + case ShmFormatP016: + return "p016" + case ShmFormatAxbxgxrx106106106106: + return "axbxgxrx106106106106" + case ShmFormatNv15: + return "nv15" + case ShmFormatQ410: + return "q410" + case ShmFormatQ401: + return "q401" + case ShmFormatXrgb16161616: + return "xrgb16161616" + case ShmFormatXbgr16161616: + return "xbgr16161616" + case ShmFormatArgb16161616: + return "argb16161616" + case ShmFormatAbgr16161616: + return "abgr16161616" + case ShmFormatC1: + return "c1" + case ShmFormatC2: + return "c2" + case ShmFormatC4: + return "c4" + case ShmFormatD1: + return "d1" + case ShmFormatD2: + return "d2" + case ShmFormatD4: + return "d4" + case ShmFormatD8: + return "d8" + case ShmFormatR1: + return "r1" + case ShmFormatR2: + return "r2" + case ShmFormatR4: + return "r4" + case ShmFormatR10: + return "r10" + case ShmFormatR12: + return "r12" + case ShmFormatAvuy8888: + return "avuy8888" + case ShmFormatXvuy8888: + return "xvuy8888" + case ShmFormatP030: + return "p030" + default: + return "" + } +} + +func (e ShmFormat) Value() string { + switch e { + case ShmFormatArgb8888: + return "0" + case ShmFormatXrgb8888: + return "1" + case ShmFormatC8: + return "0x20203843" + case ShmFormatRgb332: + return "0x38424752" + case ShmFormatBgr233: + return "0x38524742" + case ShmFormatXrgb4444: + return "0x32315258" + case ShmFormatXbgr4444: + return "0x32314258" + case ShmFormatRgbx4444: + return "0x32315852" + case ShmFormatBgrx4444: + return "0x32315842" + case ShmFormatArgb4444: + return "0x32315241" + case ShmFormatAbgr4444: + return "0x32314241" + case ShmFormatRgba4444: + return "0x32314152" + case ShmFormatBgra4444: + return "0x32314142" + case ShmFormatXrgb1555: + return "0x35315258" + case ShmFormatXbgr1555: + return "0x35314258" + case ShmFormatRgbx5551: + return "0x35315852" + case ShmFormatBgrx5551: + return "0x35315842" + case ShmFormatArgb1555: + return "0x35315241" + case ShmFormatAbgr1555: + return "0x35314241" + case ShmFormatRgba5551: + return "0x35314152" + case ShmFormatBgra5551: + return "0x35314142" + case ShmFormatRgb565: + return "0x36314752" + case ShmFormatBgr565: + return "0x36314742" + case ShmFormatRgb888: + return "0x34324752" + case ShmFormatBgr888: + return "0x34324742" + case ShmFormatXbgr8888: + return "0x34324258" + case ShmFormatRgbx8888: + return "0x34325852" + case ShmFormatBgrx8888: + return "0x34325842" + case ShmFormatAbgr8888: + return "0x34324241" + case ShmFormatRgba8888: + return "0x34324152" + case ShmFormatBgra8888: + return "0x34324142" + case ShmFormatXrgb2101010: + return "0x30335258" + case ShmFormatXbgr2101010: + return "0x30334258" + case ShmFormatRgbx1010102: + return "0x30335852" + case ShmFormatBgrx1010102: + return "0x30335842" + case ShmFormatArgb2101010: + return "0x30335241" + case ShmFormatAbgr2101010: + return "0x30334241" + case ShmFormatRgba1010102: + return "0x30334152" + case ShmFormatBgra1010102: + return "0x30334142" + case ShmFormatYuyv: + return "0x56595559" + case ShmFormatYvyu: + return "0x55595659" + case ShmFormatUyvy: + return "0x59565955" + case ShmFormatVyuy: + return "0x59555956" + case ShmFormatAyuv: + return "0x56555941" + case ShmFormatNv12: + return "0x3231564e" + case ShmFormatNv21: + return "0x3132564e" + case ShmFormatNv16: + return "0x3631564e" + case ShmFormatNv61: + return "0x3136564e" + case ShmFormatYuv410: + return "0x39565559" + case ShmFormatYvu410: + return "0x39555659" + case ShmFormatYuv411: + return "0x31315559" + case ShmFormatYvu411: + return "0x31315659" + case ShmFormatYuv420: + return "0x32315559" + case ShmFormatYvu420: + return "0x32315659" + case ShmFormatYuv422: + return "0x36315559" + case ShmFormatYvu422: + return "0x36315659" + case ShmFormatYuv444: + return "0x34325559" + case ShmFormatYvu444: + return "0x34325659" + case ShmFormatR8: + return "0x20203852" + case ShmFormatR16: + return "0x20363152" + case ShmFormatRg88: + return "0x38384752" + case ShmFormatGr88: + return "0x38385247" + case ShmFormatRg1616: + return "0x32334752" + case ShmFormatGr1616: + return "0x32335247" + case ShmFormatXrgb16161616F: + return "0x48345258" + case ShmFormatXbgr16161616F: + return "0x48344258" + case ShmFormatArgb16161616F: + return "0x48345241" + case ShmFormatAbgr16161616F: + return "0x48344241" + case ShmFormatXyuv8888: + return "0x56555958" + case ShmFormatVuy888: + return "0x34325556" + case ShmFormatVuy101010: + return "0x30335556" + case ShmFormatY210: + return "0x30313259" + case ShmFormatY212: + return "0x32313259" + case ShmFormatY216: + return "0x36313259" + case ShmFormatY410: + return "0x30313459" + case ShmFormatY412: + return "0x32313459" + case ShmFormatY416: + return "0x36313459" + case ShmFormatXvyu2101010: + return "0x30335658" + case ShmFormatXvyu1216161616: + return "0x36335658" + case ShmFormatXvyu16161616: + return "0x38345658" + case ShmFormatY0L0: + return "0x304c3059" + case ShmFormatX0L0: + return "0x304c3058" + case ShmFormatY0L2: + return "0x324c3059" + case ShmFormatX0L2: + return "0x324c3058" + case ShmFormatYuv4208Bit: + return "0x38305559" + case ShmFormatYuv42010Bit: + return "0x30315559" + case ShmFormatXrgb8888A8: + return "0x38415258" + case ShmFormatXbgr8888A8: + return "0x38414258" + case ShmFormatRgbx8888A8: + return "0x38415852" + case ShmFormatBgrx8888A8: + return "0x38415842" + case ShmFormatRgb888A8: + return "0x38413852" + case ShmFormatBgr888A8: + return "0x38413842" + case ShmFormatRgb565A8: + return "0x38413552" + case ShmFormatBgr565A8: + return "0x38413542" + case ShmFormatNv24: + return "0x3432564e" + case ShmFormatNv42: + return "0x3234564e" + case ShmFormatP210: + return "0x30313250" + case ShmFormatP010: + return "0x30313050" + case ShmFormatP012: + return "0x32313050" + case ShmFormatP016: + return "0x36313050" + case ShmFormatAxbxgxrx106106106106: + return "0x30314241" + case ShmFormatNv15: + return "0x3531564e" + case ShmFormatQ410: + return "0x30313451" + case ShmFormatQ401: + return "0x31303451" + case ShmFormatXrgb16161616: + return "0x38345258" + case ShmFormatXbgr16161616: + return "0x38344258" + case ShmFormatArgb16161616: + return "0x38345241" + case ShmFormatAbgr16161616: + return "0x38344241" + case ShmFormatC1: + return "0x20203143" + case ShmFormatC2: + return "0x20203243" + case ShmFormatC4: + return "0x20203443" + case ShmFormatD1: + return "0x20203144" + case ShmFormatD2: + return "0x20203244" + case ShmFormatD4: + return "0x20203444" + case ShmFormatD8: + return "0x20203844" + case ShmFormatR1: + return "0x20203152" + case ShmFormatR2: + return "0x20203252" + case ShmFormatR4: + return "0x20203452" + case ShmFormatR10: + return "0x20303152" + case ShmFormatR12: + return "0x20323152" + case ShmFormatAvuy8888: + return "0x59555641" + case ShmFormatXvuy8888: + return "0x59555658" + case ShmFormatP030: + return "0x30333050" + default: + return "" + } +} + +func (e ShmFormat) String() string { + return e.Name() + "=" + e.Value() +} + +// ShmFormatEvent : pixel format description +// +// Informs the client about a valid pixel format that +// can be used for buffers. Known formats include +// argb8888 and xrgb8888. +type ShmFormatEvent struct { + Format uint32 +} +type ShmFormatHandlerFunc func(ShmFormatEvent) + +// SetFormatHandler : sets handler for ShmFormatEvent +func (i *Shm) SetFormatHandler(f ShmFormatHandlerFunc) { + i.formatHandler = f +} + +func (i *Shm) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.formatHandler == nil { + return + } + var e ShmFormatEvent + l := 0 + e.Format = Uint32(data[l : l+4]) + l += 4 + + i.formatHandler(e) + } +} + +// BufferInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const BufferInterfaceName = "wl_buffer" + +// Buffer : content for a wl_surface +// +// A buffer provides the content for a wl_surface. Buffers are +// created through factory interfaces such as wl_shm, wp_linux_buffer_params +// (from the linux-dmabuf protocol extension) or similar. It has a width and +// a height and can be attached to a wl_surface, but the mechanism by which a +// client provides and updates the contents is defined by the buffer factory +// interface. +// +// Color channels are assumed to be electrical rather than optical (in other +// words, encoded with a transfer function) unless otherwise specified. If +// the buffer uses a format that has an alpha channel, the alpha channel is +// assumed to be premultiplied into the electrical color channel values +// (after transfer function encoding) unless otherwise specified. +// +// Note, because wl_buffer objects are created from multiple independent +// factory interfaces, the wl_buffer interface is frozen at version 1. +type Buffer struct { + BaseProxy + releaseHandler BufferReleaseHandlerFunc +} + +// NewBuffer : content for a wl_surface +// +// A buffer provides the content for a wl_surface. Buffers are +// created through factory interfaces such as wl_shm, wp_linux_buffer_params +// (from the linux-dmabuf protocol extension) or similar. It has a width and +// a height and can be attached to a wl_surface, but the mechanism by which a +// client provides and updates the contents is defined by the buffer factory +// interface. +// +// Color channels are assumed to be electrical rather than optical (in other +// words, encoded with a transfer function) unless otherwise specified. If +// the buffer uses a format that has an alpha channel, the alpha channel is +// assumed to be premultiplied into the electrical color channel values +// (after transfer function encoding) unless otherwise specified. +// +// Note, because wl_buffer objects are created from multiple independent +// factory interfaces, the wl_buffer interface is frozen at version 1. +func NewBuffer(ctx *Context) *Buffer { + wlBuffer := &Buffer{} + ctx.Register(wlBuffer) + return wlBuffer +} + +// Destroy : destroy a buffer +// +// Destroy a buffer. If and how you need to release the backing +// storage is defined by the buffer factory interface. +// +// For possible side-effects to a surface, see wl_surface.attach. +func (i *Buffer) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// BufferReleaseEvent : compositor releases buffer +// +// Sent when this wl_buffer is no longer used by the compositor. +// +// For more information on when release events may or may not be sent, +// and what consequences it has, please see the description of +// wl_surface.attach. +// +// If a client receives a release event before the frame callback +// requested in the same wl_surface.commit that attaches this +// wl_buffer to a surface, then the client is immediately free to +// reuse the buffer and its backing storage, and does not need a +// second buffer for the next surface content update. Typically +// this is possible, when the compositor maintains a copy of the +// wl_surface contents, e.g. as a GL texture. This is an important +// optimization for GL(ES) compositors with wl_shm clients. +type BufferReleaseEvent struct{} +type BufferReleaseHandlerFunc func(BufferReleaseEvent) + +// SetReleaseHandler : sets handler for BufferReleaseEvent +func (i *Buffer) SetReleaseHandler(f BufferReleaseHandlerFunc) { + i.releaseHandler = f +} + +func (i *Buffer) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.releaseHandler == nil { + return + } + var e BufferReleaseEvent + + i.releaseHandler(e) + } +} + +// DataOfferInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const DataOfferInterfaceName = "wl_data_offer" + +// DataOffer : offer to transfer data +// +// A wl_data_offer represents a piece of data offered for transfer +// by another client (the source client). It is used by the +// copy-and-paste and drag-and-drop mechanisms. The offer +// describes the different mime types that the data can be +// converted to and provides the mechanism for transferring the +// data directly from the source client. +type DataOffer struct { + BaseProxy + offerHandler DataOfferOfferHandlerFunc + sourceActionsHandler DataOfferSourceActionsHandlerFunc + actionHandler DataOfferActionHandlerFunc +} + +// NewDataOffer : offer to transfer data +// +// A wl_data_offer represents a piece of data offered for transfer +// by another client (the source client). It is used by the +// copy-and-paste and drag-and-drop mechanisms. The offer +// describes the different mime types that the data can be +// converted to and provides the mechanism for transferring the +// data directly from the source client. +func NewDataOffer(ctx *Context) *DataOffer { + wlDataOffer := &DataOffer{} + ctx.Register(wlDataOffer) + return wlDataOffer +} + +// Accept : accept one of the offered mime types +// +// Indicate that the client can accept the given mime type, or +// NULL for not accepted. +// +// For objects of version 2 or older, this request is used by the +// client to give feedback whether the client can receive the given +// mime type, or NULL if none is accepted; the feedback does not +// determine whether the drag-and-drop operation succeeds or not. +// +// For objects of version 3 or newer, this request determines the +// final result of the drag-and-drop operation. If the end result +// is that no mime types were accepted, the drag-and-drop operation +// will be cancelled and the corresponding drag source will receive +// wl_data_source.cancelled. Clients may still use this event in +// conjunction with wl_data_source.action for feedback. +// +// serial: serial number of the accept request +// mimeType: mime type accepted by the client +func (i *DataOffer) Accept(serial uint32, mimeType string) error { + const opcode = 0 + mimeTypeLen := PaddedLen(len(mimeType) + 1) + _reqBufLen := 8 + 4 + (4 + mimeTypeLen) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + PutString(_reqBuf[l:l+(4+mimeTypeLen)], mimeType) + l += (4 + mimeTypeLen) + err := i.Context().WriteMsg(_reqBuf, nil) + return err +} + +// Receive : request that the data is transferred +// +// To transfer the offered data, the client issues this request +// and indicates the mime type it wants to receive. The transfer +// happens through the passed file descriptor (typically created +// with the pipe system call). The source client writes the data +// in the mime type representation requested and then closes the +// file descriptor. +// +// The receiving client reads from the read end of the pipe until +// EOF and then closes its end, at which point the transfer is +// complete. +// +// This request may happen multiple times for different mime types, +// both before and after wl_data_device.drop. Drag-and-drop destination +// clients may preemptively fetch data or examine it more closely to +// determine acceptance. +// +// mimeType: mime type desired by receiver +// fd: file descriptor for data transfer +func (i *DataOffer) Receive(mimeType string, fd int) error { + const opcode = 1 + mimeTypeLen := PaddedLen(len(mimeType) + 1) + _reqBufLen := 8 + (4 + mimeTypeLen) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutString(_reqBuf[l:l+(4+mimeTypeLen)], mimeType) + l += (4 + mimeTypeLen) + oob := unix.UnixRights(int(fd)) + err := i.Context().WriteMsg(_reqBuf, oob) + return err +} + +// Destroy : destroy data offer +// +// Destroy the data offer. +func (i *DataOffer) Destroy() error { + defer i.MarkZombie() + const opcode = 2 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Finish : the offer will no longer be used +// +// Notifies the compositor that the drag destination successfully +// finished the drag-and-drop operation. +// +// Upon receiving this request, the compositor will emit +// wl_data_source.dnd_finished on the drag source client. +// +// It is a client error to perform other requests than +// wl_data_offer.destroy after this one. It is also an error to perform +// this request after a NULL mime type has been set in +// wl_data_offer.accept or no action was received through +// wl_data_offer.action. +// +// If wl_data_offer.finish request is received for a non drag and drop +// operation, the invalid_finish protocol error is raised. +func (i *DataOffer) Finish() error { + const opcode = 3 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetActions : set the available/preferred drag-and-drop actions +// +// Sets the actions that the destination side client supports for +// this operation. This request may trigger the emission of +// wl_data_source.action and wl_data_offer.action events if the compositor +// needs to change the selected action. +// +// This request can be called multiple times throughout the +// drag-and-drop operation, typically in response to wl_data_device.enter +// or wl_data_device.motion events. +// +// This request determines the final result of the drag-and-drop +// operation. If the end result is that no action is accepted, +// the drag source will receive wl_data_source.cancelled. +// +// The dnd_actions argument must contain only values expressed in the +// wl_data_device_manager.dnd_actions enum, and the preferred_action +// argument must only contain one of those values set, otherwise it +// will result in a protocol error. +// +// While managing an "ask" action, the destination drag-and-drop client +// may perform further wl_data_offer.receive requests, and is expected +// to perform one last wl_data_offer.set_actions request with a preferred +// action other than "ask" (and optionally wl_data_offer.accept) before +// requesting wl_data_offer.finish, in order to convey the action selected +// by the user. If the preferred action is not in the +// wl_data_offer.source_actions mask, an error will be raised. +// +// If the "ask" action is dismissed (e.g. user cancellation), the client +// is expected to perform wl_data_offer.destroy right away. +// +// This request can only be made on drag-and-drop offers, a protocol error +// will be raised otherwise. +// +// dndActions: actions supported by the destination client +// preferredAction: action preferred by the destination client +func (i *DataOffer) SetActions(dndActions, preferredAction uint32) error { + const opcode = 4 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(dndActions)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(preferredAction)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type DataOfferError uint32 + +// DataOfferError : +const ( + // DataOfferErrorInvalidFinish : finish request was called untimely + DataOfferErrorInvalidFinish DataOfferError = 0 + // DataOfferErrorInvalidActionMask : action mask contains invalid values + DataOfferErrorInvalidActionMask DataOfferError = 1 + // DataOfferErrorInvalidAction : action argument has an invalid value + DataOfferErrorInvalidAction DataOfferError = 2 + // DataOfferErrorInvalidOffer : offer doesn't accept this request + DataOfferErrorInvalidOffer DataOfferError = 3 +) + +func (e DataOfferError) Name() string { + switch e { + case DataOfferErrorInvalidFinish: + return "invalid_finish" + case DataOfferErrorInvalidActionMask: + return "invalid_action_mask" + case DataOfferErrorInvalidAction: + return "invalid_action" + case DataOfferErrorInvalidOffer: + return "invalid_offer" + default: + return "" + } +} + +func (e DataOfferError) Value() string { + switch e { + case DataOfferErrorInvalidFinish: + return "0" + case DataOfferErrorInvalidActionMask: + return "1" + case DataOfferErrorInvalidAction: + return "2" + case DataOfferErrorInvalidOffer: + return "3" + default: + return "" + } +} + +func (e DataOfferError) String() string { + return e.Name() + "=" + e.Value() +} + +// DataOfferOfferEvent : advertise offered mime type +// +// Sent immediately after creating the wl_data_offer object. One +// event per offered mime type. +type DataOfferOfferEvent struct { + MimeType string +} +type DataOfferOfferHandlerFunc func(DataOfferOfferEvent) + +// SetOfferHandler : sets handler for DataOfferOfferEvent +func (i *DataOffer) SetOfferHandler(f DataOfferOfferHandlerFunc) { + i.offerHandler = f +} + +// DataOfferSourceActionsEvent : notify the source-side available actions +// +// This event indicates the actions offered by the data source. It +// will be sent immediately after creating the wl_data_offer object, +// or anytime the source side changes its offered actions through +// wl_data_source.set_actions. +type DataOfferSourceActionsEvent struct { + SourceActions uint32 +} +type DataOfferSourceActionsHandlerFunc func(DataOfferSourceActionsEvent) + +// SetSourceActionsHandler : sets handler for DataOfferSourceActionsEvent +func (i *DataOffer) SetSourceActionsHandler(f DataOfferSourceActionsHandlerFunc) { + i.sourceActionsHandler = f +} + +// DataOfferActionEvent : notify the selected action +// +// This event indicates the action selected by the compositor after +// matching the source/destination side actions. Only one action (or +// none) will be offered here. +// +// This event can be emitted multiple times during the drag-and-drop +// operation in response to destination side action changes through +// wl_data_offer.set_actions. +// +// This event will no longer be emitted after wl_data_device.drop +// happened on the drag-and-drop destination, the client must +// honor the last action received, or the last preferred one set +// through wl_data_offer.set_actions when handling an "ask" action. +// +// Compositors may also change the selected action on the fly, mainly +// in response to keyboard modifier changes during the drag-and-drop +// operation. +// +// The most recent action received is always the valid one. Prior to +// receiving wl_data_device.drop, the chosen action may change (e.g. +// due to keyboard modifiers being pressed). At the time of receiving +// wl_data_device.drop the drag-and-drop destination must honor the +// last action received. +// +// Action changes may still happen after wl_data_device.drop, +// especially on "ask" actions, where the drag-and-drop destination +// may choose another action afterwards. Action changes happening +// at this stage are always the result of inter-client negotiation, the +// compositor shall no longer be able to induce a different action. +// +// Upon "ask" actions, it is expected that the drag-and-drop destination +// may potentially choose a different action and/or mime type, +// based on wl_data_offer.source_actions and finally chosen by the +// user (e.g. popping up a menu with the available options). The +// final wl_data_offer.set_actions and wl_data_offer.accept requests +// must happen before the call to wl_data_offer.finish. +type DataOfferActionEvent struct { + DndAction uint32 +} +type DataOfferActionHandlerFunc func(DataOfferActionEvent) + +// SetActionHandler : sets handler for DataOfferActionEvent +func (i *DataOffer) SetActionHandler(f DataOfferActionHandlerFunc) { + i.actionHandler = f +} + +func (i *DataOffer) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.offerHandler == nil { + return + } + var e DataOfferOfferEvent + l := 0 + mimeTypeLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.MimeType = String(data[l : l+mimeTypeLen]) + l += mimeTypeLen + + i.offerHandler(e) + case 1: + if i.sourceActionsHandler == nil { + return + } + var e DataOfferSourceActionsEvent + l := 0 + e.SourceActions = Uint32(data[l : l+4]) + l += 4 + + i.sourceActionsHandler(e) + case 2: + if i.actionHandler == nil { + return + } + var e DataOfferActionEvent + l := 0 + e.DndAction = Uint32(data[l : l+4]) + l += 4 + + i.actionHandler(e) + } +} + +// DataSourceInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const DataSourceInterfaceName = "wl_data_source" + +// DataSource : offer to transfer data +// +// The wl_data_source object is the source side of a wl_data_offer. +// It is created by the source client in a data transfer and +// provides a way to describe the offered data and a way to respond +// to requests to transfer the data. +type DataSource struct { + BaseProxy + targetHandler DataSourceTargetHandlerFunc + sendHandler DataSourceSendHandlerFunc + cancelledHandler DataSourceCancelledHandlerFunc + dndDropPerformedHandler DataSourceDndDropPerformedHandlerFunc + dndFinishedHandler DataSourceDndFinishedHandlerFunc + actionHandler DataSourceActionHandlerFunc +} + +// NewDataSource : offer to transfer data +// +// The wl_data_source object is the source side of a wl_data_offer. +// It is created by the source client in a data transfer and +// provides a way to describe the offered data and a way to respond +// to requests to transfer the data. +func NewDataSource(ctx *Context) *DataSource { + wlDataSource := &DataSource{} + ctx.Register(wlDataSource) + return wlDataSource +} + +// Offer : add an offered mime type +// +// This request adds a mime type to the set of mime types +// advertised to targets. Can be called several times to offer +// multiple types. +// +// mimeType: mime type offered by the data source +func (i *DataSource) Offer(mimeType string) error { + const opcode = 0 + mimeTypeLen := PaddedLen(len(mimeType) + 1) + _reqBufLen := 8 + (4 + mimeTypeLen) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutString(_reqBuf[l:l+(4+mimeTypeLen)], mimeType) + l += (4 + mimeTypeLen) + err := i.Context().WriteMsg(_reqBuf, nil) + return err +} + +// Destroy : destroy the data source +// +// Destroy the data source. +func (i *DataSource) Destroy() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetActions : set the available drag-and-drop actions +// +// Sets the actions that the source side client supports for this +// operation. This request may trigger wl_data_source.action and +// wl_data_offer.action events if the compositor needs to change the +// selected action. +// +// The dnd_actions argument must contain only values expressed in the +// wl_data_device_manager.dnd_actions enum, otherwise it will result +// in a protocol error. +// +// This request must be made once only, and can only be made on sources +// used in drag-and-drop, so it must be performed before +// wl_data_device.start_drag. Attempting to use the source other than +// for drag-and-drop will raise a protocol error. +// +// dndActions: actions supported by the data source +func (i *DataSource) SetActions(dndActions uint32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(dndActions)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type DataSourceError uint32 + +// DataSourceError : +const ( + // DataSourceErrorInvalidActionMask : action mask contains invalid values + DataSourceErrorInvalidActionMask DataSourceError = 0 + // DataSourceErrorInvalidSource : source doesn't accept this request + DataSourceErrorInvalidSource DataSourceError = 1 +) + +func (e DataSourceError) Name() string { + switch e { + case DataSourceErrorInvalidActionMask: + return "invalid_action_mask" + case DataSourceErrorInvalidSource: + return "invalid_source" + default: + return "" + } +} + +func (e DataSourceError) Value() string { + switch e { + case DataSourceErrorInvalidActionMask: + return "0" + case DataSourceErrorInvalidSource: + return "1" + default: + return "" + } +} + +func (e DataSourceError) String() string { + return e.Name() + "=" + e.Value() +} + +// DataSourceTargetEvent : a target accepts an offered mime type +// +// Sent when a target accepts pointer_focus or motion events. If +// a target does not accept any of the offered types, type is NULL. +// +// Used for feedback during drag-and-drop. +type DataSourceTargetEvent struct { + MimeType string +} +type DataSourceTargetHandlerFunc func(DataSourceTargetEvent) + +// SetTargetHandler : sets handler for DataSourceTargetEvent +func (i *DataSource) SetTargetHandler(f DataSourceTargetHandlerFunc) { + i.targetHandler = f +} + +// DataSourceSendEvent : send the data +// +// Request for data from the client. Send the data as the +// specified mime type over the passed file descriptor, then +// close it. +type DataSourceSendEvent struct { + MimeType string + Fd int +} +type DataSourceSendHandlerFunc func(DataSourceSendEvent) + +// SetSendHandler : sets handler for DataSourceSendEvent +func (i *DataSource) SetSendHandler(f DataSourceSendHandlerFunc) { + i.sendHandler = f +} + +// DataSourceCancelledEvent : selection was cancelled +// +// This data source is no longer valid. There are several reasons why +// this could happen: +// +// - The data source has been replaced by another data source. +// - The drag-and-drop operation was performed, but the drop destination +// did not accept any of the mime types offered through +// wl_data_source.target. +// - The drag-and-drop operation was performed, but the drop destination +// did not select any of the actions present in the mask offered through +// wl_data_source.action. +// - The drag-and-drop operation was performed but didn't happen over a +// surface. +// - The compositor cancelled the drag-and-drop operation (e.g. compositor +// dependent timeouts to avoid stale drag-and-drop transfers). +// +// The client should clean up and destroy this data source. +// +// For objects of version 2 or older, wl_data_source.cancelled will +// only be emitted if the data source was replaced by another data +// source. +type DataSourceCancelledEvent struct{} +type DataSourceCancelledHandlerFunc func(DataSourceCancelledEvent) + +// SetCancelledHandler : sets handler for DataSourceCancelledEvent +func (i *DataSource) SetCancelledHandler(f DataSourceCancelledHandlerFunc) { + i.cancelledHandler = f +} + +// DataSourceDndDropPerformedEvent : the drag-and-drop operation physically finished +// +// The user performed the drop action. This event does not indicate +// acceptance, wl_data_source.cancelled may still be emitted afterwards +// if the drop destination does not accept any mime type. +// +// However, this event might however not be received if the compositor +// cancelled the drag-and-drop operation before this event could happen. +// +// Note that the data_source may still be used in the future and should +// not be destroyed here. +type DataSourceDndDropPerformedEvent struct{} +type DataSourceDndDropPerformedHandlerFunc func(DataSourceDndDropPerformedEvent) + +// SetDndDropPerformedHandler : sets handler for DataSourceDndDropPerformedEvent +func (i *DataSource) SetDndDropPerformedHandler(f DataSourceDndDropPerformedHandlerFunc) { + i.dndDropPerformedHandler = f +} + +// DataSourceDndFinishedEvent : the drag-and-drop operation concluded +// +// The drop destination finished interoperating with this data +// source, so the client is now free to destroy this data source and +// free all associated data. +// +// If the action used to perform the operation was "move", the +// source can now delete the transferred data. +type DataSourceDndFinishedEvent struct{} +type DataSourceDndFinishedHandlerFunc func(DataSourceDndFinishedEvent) + +// SetDndFinishedHandler : sets handler for DataSourceDndFinishedEvent +func (i *DataSource) SetDndFinishedHandler(f DataSourceDndFinishedHandlerFunc) { + i.dndFinishedHandler = f +} + +// DataSourceActionEvent : notify the selected action +// +// This event indicates the action selected by the compositor after +// matching the source/destination side actions. Only one action (or +// none) will be offered here. +// +// This event can be emitted multiple times during the drag-and-drop +// operation, mainly in response to destination side changes through +// wl_data_offer.set_actions, and as the data device enters/leaves +// surfaces. +// +// It is only possible to receive this event after +// wl_data_source.dnd_drop_performed if the drag-and-drop operation +// ended in an "ask" action, in which case the final wl_data_source.action +// event will happen immediately before wl_data_source.dnd_finished. +// +// Compositors may also change the selected action on the fly, mainly +// in response to keyboard modifier changes during the drag-and-drop +// operation. +// +// The most recent action received is always the valid one. The chosen +// action may change alongside negotiation (e.g. an "ask" action can turn +// into a "move" operation), so the effects of the final action must +// always be applied in wl_data_offer.dnd_finished. +// +// Clients can trigger cursor surface changes from this point, so +// they reflect the current action. +type DataSourceActionEvent struct { + DndAction uint32 +} +type DataSourceActionHandlerFunc func(DataSourceActionEvent) + +// SetActionHandler : sets handler for DataSourceActionEvent +func (i *DataSource) SetActionHandler(f DataSourceActionHandlerFunc) { + i.actionHandler = f +} + +func (i *DataSource) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.targetHandler == nil { + return + } + var e DataSourceTargetEvent + l := 0 + mimeTypeLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.MimeType = String(data[l : l+mimeTypeLen]) + l += mimeTypeLen + + i.targetHandler(e) + case 1: + if i.sendHandler == nil { + if fd != -1 { + unix.Close(fd) + } + return + } + var e DataSourceSendEvent + l := 0 + mimeTypeLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.MimeType = String(data[l : l+mimeTypeLen]) + l += mimeTypeLen + e.Fd = fd + + i.sendHandler(e) + case 2: + if i.cancelledHandler == nil { + return + } + var e DataSourceCancelledEvent + + i.cancelledHandler(e) + case 3: + if i.dndDropPerformedHandler == nil { + return + } + var e DataSourceDndDropPerformedEvent + + i.dndDropPerformedHandler(e) + case 4: + if i.dndFinishedHandler == nil { + return + } + var e DataSourceDndFinishedEvent + + i.dndFinishedHandler(e) + case 5: + if i.actionHandler == nil { + return + } + var e DataSourceActionEvent + l := 0 + e.DndAction = Uint32(data[l : l+4]) + l += 4 + + i.actionHandler(e) + } +} + +// DataDeviceInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const DataDeviceInterfaceName = "wl_data_device" + +// DataDevice : data transfer device +// +// There is one wl_data_device per seat which can be obtained +// from the global wl_data_device_manager singleton. +// +// A wl_data_device provides access to inter-client data transfer +// mechanisms such as copy-and-paste and drag-and-drop. +type DataDevice struct { + BaseProxy + dataOfferHandler DataDeviceDataOfferHandlerFunc + enterHandler DataDeviceEnterHandlerFunc + leaveHandler DataDeviceLeaveHandlerFunc + motionHandler DataDeviceMotionHandlerFunc + dropHandler DataDeviceDropHandlerFunc + selectionHandler DataDeviceSelectionHandlerFunc +} + +// NewDataDevice : data transfer device +// +// There is one wl_data_device per seat which can be obtained +// from the global wl_data_device_manager singleton. +// +// A wl_data_device provides access to inter-client data transfer +// mechanisms such as copy-and-paste and drag-and-drop. +func NewDataDevice(ctx *Context) *DataDevice { + wlDataDevice := &DataDevice{} + ctx.Register(wlDataDevice) + return wlDataDevice +} + +// StartDrag : start drag-and-drop operation +// +// This request asks the compositor to start a drag-and-drop +// operation on behalf of the client. +// +// The source argument is the data source that provides the data +// for the eventual data transfer. If source is NULL, enter, leave +// and motion events are sent only to the client that initiated the +// drag and the client is expected to handle the data passing +// internally. If source is destroyed, the drag-and-drop session will be +// cancelled. +// +// The origin surface is the surface where the drag originates and +// the client must have an active implicit grab that matches the +// serial. +// +// The icon surface is an optional (can be NULL) surface that +// provides an icon to be moved around with the cursor. Initially, +// the top-left corner of the icon surface is placed at the cursor +// hotspot, but subsequent wl_surface.offset requests can move the +// relative position. Attach requests must be confirmed with +// wl_surface.commit as usual. The icon surface is given the role of +// a drag-and-drop icon. If the icon surface already has another role, +// it raises a protocol error. +// +// The input region is ignored for wl_surfaces with the role of a +// drag-and-drop icon. +// +// The given source may not be used in any further set_selection or +// start_drag requests. Attempting to reuse a previously-used source +// may send a used_source error. +// +// source: data source for the eventual transfer +// origin: surface where the drag originates +// icon: drag-and-drop icon surface +// serial: serial number of the implicit grab on the origin +func (i *DataDevice) StartDrag(source *DataSource, origin, icon *Surface, serial uint32) error { + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if source == nil { + PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + PutUint32(_reqBuf[l:l+4], source.ID()) + l += 4 + } + PutUint32(_reqBuf[l:l+4], origin.ID()) + l += 4 + if icon == nil { + PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + PutUint32(_reqBuf[l:l+4], icon.ID()) + l += 4 + } + PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetSelection : copy data to the selection +// +// This request asks the compositor to set the selection +// to the data from the source on behalf of the client. +// +// To unset the selection, set the source to NULL. +// +// The given source may not be used in any further set_selection or +// start_drag requests. Attempting to reuse a previously-used source +// may send a used_source error. +// +// source: data source for the selection +// serial: serial number of the event that triggered this request +func (i *DataDevice) SetSelection(source *DataSource, serial uint32) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if source == nil { + PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + PutUint32(_reqBuf[l:l+4], source.ID()) + l += 4 + } + PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Release : destroy data device +// +// This request destroys the data device. +func (i *DataDevice) Release() error { + defer i.MarkZombie() + const opcode = 2 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type DataDeviceError uint32 + +// DataDeviceError : +const ( + // DataDeviceErrorRole : given wl_surface has another role + DataDeviceErrorRole DataDeviceError = 0 + // DataDeviceErrorUsedSource : source has already been used + DataDeviceErrorUsedSource DataDeviceError = 1 +) + +func (e DataDeviceError) Name() string { + switch e { + case DataDeviceErrorRole: + return "role" + case DataDeviceErrorUsedSource: + return "used_source" + default: + return "" + } +} + +func (e DataDeviceError) Value() string { + switch e { + case DataDeviceErrorRole: + return "0" + case DataDeviceErrorUsedSource: + return "1" + default: + return "" + } +} + +func (e DataDeviceError) String() string { + return e.Name() + "=" + e.Value() +} + +// DataDeviceDataOfferEvent : introduce a new wl_data_offer +// +// The data_offer event introduces a new wl_data_offer object, +// which will subsequently be used in either the +// data_device.enter event (for drag-and-drop) or the +// data_device.selection event (for selections). Immediately +// following the data_device.data_offer event, the new data_offer +// object will send out data_offer.offer events to describe the +// mime types it offers. +type DataDeviceDataOfferEvent struct { + Id *DataOffer +} +type DataDeviceDataOfferHandlerFunc func(DataDeviceDataOfferEvent) + +// SetDataOfferHandler : sets handler for DataDeviceDataOfferEvent +func (i *DataDevice) SetDataOfferHandler(f DataDeviceDataOfferHandlerFunc) { + i.dataOfferHandler = f +} + +// DataDeviceEnterEvent : initiate drag-and-drop session +// +// This event is sent when an active drag-and-drop pointer enters +// a surface owned by the client. The position of the pointer at +// enter time is provided by the x and y arguments, in surface-local +// coordinates. +type DataDeviceEnterEvent struct { + Serial uint32 + Surface *Surface + X float64 + Y float64 + Id *DataOffer +} +type DataDeviceEnterHandlerFunc func(DataDeviceEnterEvent) + +// SetEnterHandler : sets handler for DataDeviceEnterEvent +func (i *DataDevice) SetEnterHandler(f DataDeviceEnterHandlerFunc) { + i.enterHandler = f +} + +// DataDeviceLeaveEvent : end drag-and-drop session +// +// This event is sent when the drag-and-drop pointer leaves the +// surface and the session ends. The client must destroy the +// wl_data_offer introduced at enter time at this point. +type DataDeviceLeaveEvent struct{} +type DataDeviceLeaveHandlerFunc func(DataDeviceLeaveEvent) + +// SetLeaveHandler : sets handler for DataDeviceLeaveEvent +func (i *DataDevice) SetLeaveHandler(f DataDeviceLeaveHandlerFunc) { + i.leaveHandler = f +} + +// DataDeviceMotionEvent : drag-and-drop session motion +// +// This event is sent when the drag-and-drop pointer moves within +// the currently focused surface. The new position of the pointer +// is provided by the x and y arguments, in surface-local +// coordinates. +type DataDeviceMotionEvent struct { + Time uint32 + X float64 + Y float64 +} +type DataDeviceMotionHandlerFunc func(DataDeviceMotionEvent) + +// SetMotionHandler : sets handler for DataDeviceMotionEvent +func (i *DataDevice) SetMotionHandler(f DataDeviceMotionHandlerFunc) { + i.motionHandler = f +} + +// DataDeviceDropEvent : end drag-and-drop session successfully +// +// The event is sent when a drag-and-drop operation is ended +// because the implicit grab is removed. +// +// The drag-and-drop destination is expected to honor the last action +// received through wl_data_offer.action, if the resulting action is +// "copy" or "move", the destination can still perform +// wl_data_offer.receive requests, and is expected to end all +// transfers with a wl_data_offer.finish request. +// +// If the resulting action is "ask", the action will not be considered +// final. The drag-and-drop destination is expected to perform one last +// wl_data_offer.set_actions request, or wl_data_offer.destroy in order +// to cancel the operation. +type DataDeviceDropEvent struct{} +type DataDeviceDropHandlerFunc func(DataDeviceDropEvent) + +// SetDropHandler : sets handler for DataDeviceDropEvent +func (i *DataDevice) SetDropHandler(f DataDeviceDropHandlerFunc) { + i.dropHandler = f +} + +// DataDeviceSelectionEvent : advertise new selection +// +// The selection event is sent out to notify the client of a new +// wl_data_offer for the selection for this device. The +// data_device.data_offer and the data_offer.offer events are +// sent out immediately before this event to introduce the data +// offer object. The selection event is sent to a client +// immediately before receiving keyboard focus and when a new +// selection is set while the client has keyboard focus. The +// data_offer is valid until a new data_offer or NULL is received +// or until the client loses keyboard focus. Switching surface with +// keyboard focus within the same client doesn't mean a new selection +// will be sent. The client must destroy the previous selection +// data_offer, if any, upon receiving this event. +type DataDeviceSelectionEvent struct { + Id *DataOffer +} +type DataDeviceSelectionHandlerFunc func(DataDeviceSelectionEvent) + +// SetSelectionHandler : sets handler for DataDeviceSelectionEvent +func (i *DataDevice) SetSelectionHandler(f DataDeviceSelectionHandlerFunc) { + i.selectionHandler = f +} + +func (i *DataDevice) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.dataOfferHandler == nil { + return + } + var e DataDeviceDataOfferEvent + l := 0 + e.Id = i.Context().GetProxy(Uint32(data[l : l+4])).(*DataOffer) + l += 4 + + i.dataOfferHandler(e) + case 1: + if i.enterHandler == nil { + return + } + var e DataDeviceEnterEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.Surface = i.Context().GetProxy(Uint32(data[l : l+4])).(*Surface) + l += 4 + e.X = Fixed(data[l : l+4]) + l += 4 + e.Y = Fixed(data[l : l+4]) + l += 4 + e.Id = i.Context().GetProxy(Uint32(data[l : l+4])).(*DataOffer) + l += 4 + + i.enterHandler(e) + case 2: + if i.leaveHandler == nil { + return + } + var e DataDeviceLeaveEvent + + i.leaveHandler(e) + case 3: + if i.motionHandler == nil { + return + } + var e DataDeviceMotionEvent + l := 0 + e.Time = Uint32(data[l : l+4]) + l += 4 + e.X = Fixed(data[l : l+4]) + l += 4 + e.Y = Fixed(data[l : l+4]) + l += 4 + + i.motionHandler(e) + case 4: + if i.dropHandler == nil { + return + } + var e DataDeviceDropEvent + + i.dropHandler(e) + case 5: + if i.selectionHandler == nil { + return + } + var e DataDeviceSelectionEvent + l := 0 + e.Id = i.Context().GetProxy(Uint32(data[l : l+4])).(*DataOffer) + l += 4 + + i.selectionHandler(e) + } +} + +// DataDeviceManagerInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const DataDeviceManagerInterfaceName = "wl_data_device_manager" + +// DataDeviceManager : data transfer interface +// +// The wl_data_device_manager is a singleton global object that +// provides access to inter-client data transfer mechanisms such as +// copy-and-paste and drag-and-drop. These mechanisms are tied to +// a wl_seat and this interface lets a client get a wl_data_device +// corresponding to a wl_seat. +// +// Depending on the version bound, the objects created from the bound +// wl_data_device_manager object will have different requirements for +// functioning properly. See wl_data_source.set_actions, +// wl_data_offer.accept and wl_data_offer.finish for details. +type DataDeviceManager struct { + BaseProxy +} + +// NewDataDeviceManager : data transfer interface +// +// The wl_data_device_manager is a singleton global object that +// provides access to inter-client data transfer mechanisms such as +// copy-and-paste and drag-and-drop. These mechanisms are tied to +// a wl_seat and this interface lets a client get a wl_data_device +// corresponding to a wl_seat. +// +// Depending on the version bound, the objects created from the bound +// wl_data_device_manager object will have different requirements for +// functioning properly. See wl_data_source.set_actions, +// wl_data_offer.accept and wl_data_offer.finish for details. +func NewDataDeviceManager(ctx *Context) *DataDeviceManager { + wlDataDeviceManager := &DataDeviceManager{} + ctx.Register(wlDataDeviceManager) + return wlDataDeviceManager +} + +// CreateDataSource : create a new data source +// +// Create a new data source. +func (i *DataDeviceManager) CreateDataSource() (*DataSource, error) { + id := NewDataSource(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// GetDataDevice : create a new data device +// +// Create a new data device for a given seat. +// +// seat: seat associated with the data device +func (i *DataDeviceManager) GetDataDevice(seat *Seat) (*DataDevice, error) { + id := NewDataDevice(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], seat.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +func (i *DataDeviceManager) Destroy() error { + i.MarkZombie() + return nil +} + +type DataDeviceManagerDndAction uint32 + +// DataDeviceManagerDndAction : drag and drop actions +// +// This is a bitmask of the available/preferred actions in a +// drag-and-drop operation. +// +// In the compositor, the selected action is a result of matching the +// actions offered by the source and destination sides. "action" events +// with a "none" action will be sent to both source and destination if +// there is no match. All further checks will effectively happen on +// (source actions ∩ destination actions). +// +// In addition, compositors may also pick different actions in +// reaction to key modifiers being pressed. One common design that +// is used in major toolkits (and the behavior recommended for +// compositors) is: +// +// - If no modifiers are pressed, the first match (in bit order) +// will be used. +// - Pressing Shift selects "move", if enabled in the mask. +// - Pressing Control selects "copy", if enabled in the mask. +// +// Behavior beyond that is considered implementation-dependent. +// Compositors may for example bind other modifiers (like Alt/Meta) +// or drags initiated with other buttons than BTN_LEFT to specific +// actions (e.g. "ask"). +const ( + // DataDeviceManagerDndActionNone : no action + DataDeviceManagerDndActionNone DataDeviceManagerDndAction = 0 + // DataDeviceManagerDndActionCopy : copy action + DataDeviceManagerDndActionCopy DataDeviceManagerDndAction = 1 + // DataDeviceManagerDndActionMove : move action + DataDeviceManagerDndActionMove DataDeviceManagerDndAction = 2 + // DataDeviceManagerDndActionAsk : ask action + DataDeviceManagerDndActionAsk DataDeviceManagerDndAction = 4 +) + +func (e DataDeviceManagerDndAction) Name() string { + switch e { + case DataDeviceManagerDndActionNone: + return "none" + case DataDeviceManagerDndActionCopy: + return "copy" + case DataDeviceManagerDndActionMove: + return "move" + case DataDeviceManagerDndActionAsk: + return "ask" + default: + return "" + } +} + +func (e DataDeviceManagerDndAction) Value() string { + switch e { + case DataDeviceManagerDndActionNone: + return "0" + case DataDeviceManagerDndActionCopy: + return "1" + case DataDeviceManagerDndActionMove: + return "2" + case DataDeviceManagerDndActionAsk: + return "4" + default: + return "" + } +} + +func (e DataDeviceManagerDndAction) String() string { + return e.Name() + "=" + e.Value() +} + +// ShellInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ShellInterfaceName = "wl_shell" + +// Shell : create desktop-style surfaces +// +// This interface is implemented by servers that provide +// desktop-style user interfaces. +// +// It allows clients to associate a wl_shell_surface with +// a basic surface. +// +// Note! This protocol is deprecated and not intended for production use. +// For desktop-style user interfaces, use xdg_shell. Compositors and clients +// should not implement this interface. +type Shell struct { + BaseProxy +} + +// NewShell : create desktop-style surfaces +// +// This interface is implemented by servers that provide +// desktop-style user interfaces. +// +// It allows clients to associate a wl_shell_surface with +// a basic surface. +// +// Note! This protocol is deprecated and not intended for production use. +// For desktop-style user interfaces, use xdg_shell. Compositors and clients +// should not implement this interface. +func NewShell(ctx *Context) *Shell { + wlShell := &Shell{} + ctx.Register(wlShell) + return wlShell +} + +// GetShellSurface : create a shell surface from a surface +// +// Create a shell surface for an existing surface. This gives +// the wl_surface the role of a shell surface. If the wl_surface +// already has another role, it raises a protocol error. +// +// Only one shell surface can be associated with a given surface. +// +// surface: surface to be given the shell surface role +func (i *Shell) GetShellSurface(surface *Surface) (*ShellSurface, error) { + id := NewShellSurface(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], surface.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +func (i *Shell) Destroy() error { + i.MarkZombie() + return nil +} + +type ShellError uint32 + +// ShellError : +const ( + // ShellErrorRole : given wl_surface has another role + ShellErrorRole ShellError = 0 +) + +func (e ShellError) Name() string { + switch e { + case ShellErrorRole: + return "role" + default: + return "" + } +} + +func (e ShellError) Value() string { + switch e { + case ShellErrorRole: + return "0" + default: + return "" + } +} + +func (e ShellError) String() string { + return e.Name() + "=" + e.Value() +} + +// ShellSurfaceInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const ShellSurfaceInterfaceName = "wl_shell_surface" + +// ShellSurface : desktop-style metadata interface +// +// An interface that may be implemented by a wl_surface, for +// implementations that provide a desktop-style user interface. +// +// It provides requests to treat surfaces like toplevel, fullscreen +// or popup windows, move, resize or maximize them, associate +// metadata like title and class, etc. +// +// On the server side the object is automatically destroyed when +// the related wl_surface is destroyed. On the client side, +// wl_shell_surface_destroy() must be called before destroying +// the wl_surface object. +type ShellSurface struct { + BaseProxy + pingHandler ShellSurfacePingHandlerFunc + configureHandler ShellSurfaceConfigureHandlerFunc + popupDoneHandler ShellSurfacePopupDoneHandlerFunc +} + +// NewShellSurface : desktop-style metadata interface +// +// An interface that may be implemented by a wl_surface, for +// implementations that provide a desktop-style user interface. +// +// It provides requests to treat surfaces like toplevel, fullscreen +// or popup windows, move, resize or maximize them, associate +// metadata like title and class, etc. +// +// On the server side the object is automatically destroyed when +// the related wl_surface is destroyed. On the client side, +// wl_shell_surface_destroy() must be called before destroying +// the wl_surface object. +func NewShellSurface(ctx *Context) *ShellSurface { + wlShellSurface := &ShellSurface{} + ctx.Register(wlShellSurface) + return wlShellSurface +} + +// Pong : respond to a ping event +// +// A client must respond to a ping event with a pong request or +// the client may be deemed unresponsive. +// +// serial: serial number of the ping event +func (i *ShellSurface) Pong(serial uint32) error { + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Move : start an interactive move +// +// Start a pointer-driven move of the surface. +// +// This request must be used in response to a button press event. +// The server may ignore move requests depending on the state of +// the surface (e.g. fullscreen or maximized). +// +// seat: seat whose pointer is used +// serial: serial number of the implicit grab on the pointer +func (i *ShellSurface) Move(seat *Seat, serial uint32) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], seat.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Resize : start an interactive resize +// +// Start a pointer-driven resizing of the surface. +// +// This request must be used in response to a button press event. +// The server may ignore resize requests depending on the state of +// the surface (e.g. fullscreen or maximized). +// +// seat: seat whose pointer is used +// serial: serial number of the implicit grab on the pointer +// edges: which edge or corner is being dragged +func (i *ShellSurface) Resize(seat *Seat, serial, edges uint32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], seat.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(edges)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetToplevel : make the surface a toplevel surface +// +// Map the surface as a toplevel surface. +// +// A toplevel surface is not fullscreen, maximized or transient. +func (i *ShellSurface) SetToplevel() error { + const opcode = 3 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetTransient : make the surface a transient surface +// +// Map the surface relative to an existing surface. +// +// The x and y arguments specify the location of the upper left +// corner of the surface relative to the upper left corner of the +// parent surface, in surface-local coordinates. +// +// The flags argument controls details of the transient behaviour. +// +// parent: parent surface +// x: surface-local x coordinate +// y: surface-local y coordinate +// flags: transient surface behavior +func (i *ShellSurface) SetTransient(parent *Surface, x, y int32, flags uint32) error { + const opcode = 4 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], parent.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(flags)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetFullscreen : make the surface a fullscreen surface +// +// Map the surface as a fullscreen surface. +// +// If an output parameter is given then the surface will be made +// fullscreen on that output. If the client does not specify the +// output then the compositor will apply its policy - usually +// choosing the output on which the surface has the biggest surface +// area. +// +// The client may specify a method to resolve a size conflict +// between the output size and the surface size - this is provided +// through the method parameter. +// +// The framerate parameter is used only when the method is set +// to "driver", to indicate the preferred framerate. A value of 0 +// indicates that the client does not care about framerate. The +// framerate is specified in mHz, that is framerate of 60000 is 60Hz. +// +// A method of "scale" or "driver" implies a scaling operation of +// the surface, either via a direct scaling operation or a change of +// the output mode. This will override any kind of output scaling, so +// that mapping a surface with a buffer size equal to the mode can +// fill the screen independent of buffer_scale. +// +// A method of "fill" means we don't scale up the buffer, however +// any output scale is applied. This means that you may run into +// an edge case where the application maps a buffer with the same +// size of the output mode but buffer_scale 1 (thus making a +// surface larger than the output). In this case it is allowed to +// downscale the results to fit the screen. +// +// The compositor must reply to this request with a configure event +// with the dimensions for the output on which the surface will +// be made fullscreen. +// +// method: method for resolving size conflict +// framerate: framerate in mHz +// output: output on which the surface is to be fullscreen +func (i *ShellSurface) SetFullscreen(method, framerate uint32, output *Output) error { + const opcode = 5 + const _reqBufLen = 8 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(method)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(framerate)) + l += 4 + if output == nil { + PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + PutUint32(_reqBuf[l:l+4], output.ID()) + l += 4 + } + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetPopup : make the surface a popup surface +// +// Map the surface as a popup. +// +// A popup surface is a transient surface with an added pointer +// grab. +// +// An existing implicit grab will be changed to owner-events mode, +// and the popup grab will continue after the implicit grab ends +// (i.e. releasing the mouse button does not cause the popup to +// be unmapped). +// +// The popup grab continues until the window is destroyed or a +// mouse button is pressed in any other client's window. A click +// in any of the client's surfaces is reported as normal, however, +// clicks in other clients' surfaces will be discarded and trigger +// the callback. +// +// The x and y arguments specify the location of the upper left +// corner of the surface relative to the upper left corner of the +// parent surface, in surface-local coordinates. +// +// seat: seat whose pointer is used +// serial: serial number of the implicit grab on the pointer +// parent: parent surface +// x: surface-local x coordinate +// y: surface-local y coordinate +// flags: transient surface behavior +func (i *ShellSurface) SetPopup(seat *Seat, serial uint32, parent *Surface, x, y int32, flags uint32) error { + const opcode = 6 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], seat.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + PutUint32(_reqBuf[l:l+4], parent.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(flags)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetMaximized : make the surface a maximized surface +// +// Map the surface as a maximized surface. +// +// If an output parameter is given then the surface will be +// maximized on that output. If the client does not specify the +// output then the compositor will apply its policy - usually +// choosing the output on which the surface has the biggest surface +// area. +// +// The compositor will reply with a configure event telling +// the expected new surface size. The operation is completed +// on the next buffer attach to this surface. +// +// A maximized surface typically fills the entire output it is +// bound to, except for desktop elements such as panels. This is +// the main difference between a maximized shell surface and a +// fullscreen shell surface. +// +// The details depend on the compositor implementation. +// +// output: output on which the surface is to be maximized +func (i *ShellSurface) SetMaximized(output *Output) error { + const opcode = 7 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if output == nil { + PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + PutUint32(_reqBuf[l:l+4], output.ID()) + l += 4 + } + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetTitle : set surface title +// +// Set a short title for the surface. +// +// This string may be used to identify the surface in a task bar, +// window list, or other user interface elements provided by the +// compositor. +// +// The string must be encoded in UTF-8. +// +// title: surface title +func (i *ShellSurface) SetTitle(title string) error { + const opcode = 8 + titleLen := PaddedLen(len(title) + 1) + _reqBufLen := 8 + (4 + titleLen) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutString(_reqBuf[l:l+(4+titleLen)], title) + l += (4 + titleLen) + err := i.Context().WriteMsg(_reqBuf, nil) + return err +} + +// SetClass : set surface class +// +// Set a class for the surface. +// +// The surface class identifies the general class of applications +// to which the surface belongs. A common convention is to use the +// file name (or the full path if it is a non-standard location) of +// the application's .desktop file as the class. +// +// class: surface class +func (i *ShellSurface) SetClass(class string) error { + const opcode = 9 + classLen := PaddedLen(len(class) + 1) + _reqBufLen := 8 + (4 + classLen) + _reqBuf := make([]byte, _reqBufLen) + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutString(_reqBuf[l:l+(4+classLen)], class) + l += (4 + classLen) + err := i.Context().WriteMsg(_reqBuf, nil) + return err +} + +func (i *ShellSurface) Destroy() error { + i.MarkZombie() + return nil +} + +type ShellSurfaceResize uint32 + +// ShellSurfaceResize : edge values for resizing +// +// These values are used to indicate which edge of a surface +// is being dragged in a resize operation. The server may +// use this information to adapt its behavior, e.g. choose +// an appropriate cursor image. +const ( + // ShellSurfaceResizeNone : no edge + ShellSurfaceResizeNone ShellSurfaceResize = 0 + // ShellSurfaceResizeTop : top edge + ShellSurfaceResizeTop ShellSurfaceResize = 1 + // ShellSurfaceResizeBottom : bottom edge + ShellSurfaceResizeBottom ShellSurfaceResize = 2 + // ShellSurfaceResizeLeft : left edge + ShellSurfaceResizeLeft ShellSurfaceResize = 4 + // ShellSurfaceResizeTopLeft : top and left edges + ShellSurfaceResizeTopLeft ShellSurfaceResize = 5 + // ShellSurfaceResizeBottomLeft : bottom and left edges + ShellSurfaceResizeBottomLeft ShellSurfaceResize = 6 + // ShellSurfaceResizeRight : right edge + ShellSurfaceResizeRight ShellSurfaceResize = 8 + // ShellSurfaceResizeTopRight : top and right edges + ShellSurfaceResizeTopRight ShellSurfaceResize = 9 + // ShellSurfaceResizeBottomRight : bottom and right edges + ShellSurfaceResizeBottomRight ShellSurfaceResize = 10 +) + +func (e ShellSurfaceResize) Name() string { + switch e { + case ShellSurfaceResizeNone: + return "none" + case ShellSurfaceResizeTop: + return "top" + case ShellSurfaceResizeBottom: + return "bottom" + case ShellSurfaceResizeLeft: + return "left" + case ShellSurfaceResizeTopLeft: + return "top_left" + case ShellSurfaceResizeBottomLeft: + return "bottom_left" + case ShellSurfaceResizeRight: + return "right" + case ShellSurfaceResizeTopRight: + return "top_right" + case ShellSurfaceResizeBottomRight: + return "bottom_right" + default: + return "" + } +} + +func (e ShellSurfaceResize) Value() string { + switch e { + case ShellSurfaceResizeNone: + return "0" + case ShellSurfaceResizeTop: + return "1" + case ShellSurfaceResizeBottom: + return "2" + case ShellSurfaceResizeLeft: + return "4" + case ShellSurfaceResizeTopLeft: + return "5" + case ShellSurfaceResizeBottomLeft: + return "6" + case ShellSurfaceResizeRight: + return "8" + case ShellSurfaceResizeTopRight: + return "9" + case ShellSurfaceResizeBottomRight: + return "10" + default: + return "" + } +} + +func (e ShellSurfaceResize) String() string { + return e.Name() + "=" + e.Value() +} + +type ShellSurfaceTransient uint32 + +// ShellSurfaceTransient : details of transient behaviour +// +// These flags specify details of the expected behaviour +// of transient surfaces. Used in the set_transient request. +const ( + // ShellSurfaceTransientInactive : do not set keyboard focus + ShellSurfaceTransientInactive ShellSurfaceTransient = 0x1 +) + +func (e ShellSurfaceTransient) Name() string { + switch e { + case ShellSurfaceTransientInactive: + return "inactive" + default: + return "" + } +} + +func (e ShellSurfaceTransient) Value() string { + switch e { + case ShellSurfaceTransientInactive: + return "0x1" + default: + return "" + } +} + +func (e ShellSurfaceTransient) String() string { + return e.Name() + "=" + e.Value() +} + +type ShellSurfaceFullscreenMethod uint32 + +// ShellSurfaceFullscreenMethod : different method to set the surface fullscreen +// +// Hints to indicate to the compositor how to deal with a conflict +// between the dimensions of the surface and the dimensions of the +// output. The compositor is free to ignore this parameter. +const ( + // ShellSurfaceFullscreenMethodDefault : no preference, apply default policy + ShellSurfaceFullscreenMethodDefault ShellSurfaceFullscreenMethod = 0 + // ShellSurfaceFullscreenMethodScale : scale, preserve the surface's aspect ratio and center on output + ShellSurfaceFullscreenMethodScale ShellSurfaceFullscreenMethod = 1 + // ShellSurfaceFullscreenMethodDriver : switch output mode to the smallest mode that can fit the surface, add black borders to compensate size mismatch + ShellSurfaceFullscreenMethodDriver ShellSurfaceFullscreenMethod = 2 + // ShellSurfaceFullscreenMethodFill : no upscaling, center on output and add black borders to compensate size mismatch + ShellSurfaceFullscreenMethodFill ShellSurfaceFullscreenMethod = 3 +) + +func (e ShellSurfaceFullscreenMethod) Name() string { + switch e { + case ShellSurfaceFullscreenMethodDefault: + return "default" + case ShellSurfaceFullscreenMethodScale: + return "scale" + case ShellSurfaceFullscreenMethodDriver: + return "driver" + case ShellSurfaceFullscreenMethodFill: + return "fill" + default: + return "" + } +} + +func (e ShellSurfaceFullscreenMethod) Value() string { + switch e { + case ShellSurfaceFullscreenMethodDefault: + return "0" + case ShellSurfaceFullscreenMethodScale: + return "1" + case ShellSurfaceFullscreenMethodDriver: + return "2" + case ShellSurfaceFullscreenMethodFill: + return "3" + default: + return "" + } +} + +func (e ShellSurfaceFullscreenMethod) String() string { + return e.Name() + "=" + e.Value() +} + +// ShellSurfacePingEvent : ping client +// +// Ping a client to check if it is receiving events and sending +// requests. A client is expected to reply with a pong request. +type ShellSurfacePingEvent struct { + Serial uint32 +} +type ShellSurfacePingHandlerFunc func(ShellSurfacePingEvent) + +// SetPingHandler : sets handler for ShellSurfacePingEvent +func (i *ShellSurface) SetPingHandler(f ShellSurfacePingHandlerFunc) { + i.pingHandler = f +} + +// ShellSurfaceConfigureEvent : suggest resize +// +// The configure event asks the client to resize its surface. +// +// The size is a hint, in the sense that the client is free to +// ignore it if it doesn't resize, pick a smaller size (to +// satisfy aspect ratio or resize in steps of NxM pixels). +// +// The edges parameter provides a hint about how the surface +// was resized. The client may use this information to decide +// how to adjust its content to the new size (e.g. a scrolling +// area might adjust its content position to leave the viewable +// content unmoved). +// +// The client is free to dismiss all but the last configure +// event it received. +// +// The width and height arguments specify the size of the window +// in surface-local coordinates. +type ShellSurfaceConfigureEvent struct { + Edges uint32 + Width int32 + Height int32 +} +type ShellSurfaceConfigureHandlerFunc func(ShellSurfaceConfigureEvent) + +// SetConfigureHandler : sets handler for ShellSurfaceConfigureEvent +func (i *ShellSurface) SetConfigureHandler(f ShellSurfaceConfigureHandlerFunc) { + i.configureHandler = f +} + +// ShellSurfacePopupDoneEvent : popup interaction is done +// +// The popup_done event is sent out when a popup grab is broken, +// that is, when the user clicks a surface that doesn't belong +// to the client owning the popup surface. +type ShellSurfacePopupDoneEvent struct{} +type ShellSurfacePopupDoneHandlerFunc func(ShellSurfacePopupDoneEvent) + +// SetPopupDoneHandler : sets handler for ShellSurfacePopupDoneEvent +func (i *ShellSurface) SetPopupDoneHandler(f ShellSurfacePopupDoneHandlerFunc) { + i.popupDoneHandler = f +} + +func (i *ShellSurface) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.pingHandler == nil { + return + } + var e ShellSurfacePingEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + + i.pingHandler(e) + case 1: + if i.configureHandler == nil { + return + } + var e ShellSurfaceConfigureEvent + l := 0 + e.Edges = Uint32(data[l : l+4]) + l += 4 + e.Width = int32(Uint32(data[l : l+4])) + l += 4 + e.Height = int32(Uint32(data[l : l+4])) + l += 4 + + i.configureHandler(e) + case 2: + if i.popupDoneHandler == nil { + return + } + var e ShellSurfacePopupDoneEvent + + i.popupDoneHandler(e) + } +} + +// SurfaceInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const SurfaceInterfaceName = "wl_surface" + +// Surface : an onscreen surface +// +// A surface is a rectangular area that may be displayed on zero +// or more outputs, and shown any number of times at the compositor's +// discretion. They can present wl_buffers, receive user input, and +// define a local coordinate system. +// +// The size of a surface (and relative positions on it) is described +// in surface-local coordinates, which may differ from the buffer +// coordinates of the pixel content, in case a buffer_transform +// or a buffer_scale is used. +// +// A surface without a "role" is fairly useless: a compositor does +// not know where, when or how to present it. The role is the +// purpose of a wl_surface. Examples of roles are a cursor for a +// pointer (as set by wl_pointer.set_cursor), a drag icon +// (wl_data_device.start_drag), a sub-surface +// (wl_subcompositor.get_subsurface), and a window as defined by a +// shell protocol (e.g. wl_shell.get_shell_surface). +// +// A surface can have only one role at a time. Initially a +// wl_surface does not have a role. Once a wl_surface is given a +// role, it is set permanently for the whole lifetime of the +// wl_surface object. Giving the current role again is allowed, +// unless explicitly forbidden by the relevant interface +// specification. +// +// Surface roles are given by requests in other interfaces such as +// wl_pointer.set_cursor. The request should explicitly mention +// that this request gives a role to a wl_surface. Often, this +// request also creates a new protocol object that represents the +// role and adds additional functionality to wl_surface. When a +// client wants to destroy a wl_surface, they must destroy this role +// object before the wl_surface, otherwise a defunct_role_object error is +// sent. +// +// Destroying the role object does not remove the role from the +// wl_surface, but it may stop the wl_surface from "playing the role". +// For instance, if a wl_subsurface object is destroyed, the wl_surface +// it was created for will be unmapped and forget its position and +// z-order. It is allowed to create a wl_subsurface for the same +// wl_surface again, but it is not allowed to use the wl_surface as +// a cursor (cursor is a different role than sub-surface, and role +// switching is not allowed). +type Surface struct { + BaseProxy + enterHandler SurfaceEnterHandlerFunc + leaveHandler SurfaceLeaveHandlerFunc + preferredBufferScaleHandler SurfacePreferredBufferScaleHandlerFunc + preferredBufferTransformHandler SurfacePreferredBufferTransformHandlerFunc +} + +// NewSurface : an onscreen surface +// +// A surface is a rectangular area that may be displayed on zero +// or more outputs, and shown any number of times at the compositor's +// discretion. They can present wl_buffers, receive user input, and +// define a local coordinate system. +// +// The size of a surface (and relative positions on it) is described +// in surface-local coordinates, which may differ from the buffer +// coordinates of the pixel content, in case a buffer_transform +// or a buffer_scale is used. +// +// A surface without a "role" is fairly useless: a compositor does +// not know where, when or how to present it. The role is the +// purpose of a wl_surface. Examples of roles are a cursor for a +// pointer (as set by wl_pointer.set_cursor), a drag icon +// (wl_data_device.start_drag), a sub-surface +// (wl_subcompositor.get_subsurface), and a window as defined by a +// shell protocol (e.g. wl_shell.get_shell_surface). +// +// A surface can have only one role at a time. Initially a +// wl_surface does not have a role. Once a wl_surface is given a +// role, it is set permanently for the whole lifetime of the +// wl_surface object. Giving the current role again is allowed, +// unless explicitly forbidden by the relevant interface +// specification. +// +// Surface roles are given by requests in other interfaces such as +// wl_pointer.set_cursor. The request should explicitly mention +// that this request gives a role to a wl_surface. Often, this +// request also creates a new protocol object that represents the +// role and adds additional functionality to wl_surface. When a +// client wants to destroy a wl_surface, they must destroy this role +// object before the wl_surface, otherwise a defunct_role_object error is +// sent. +// +// Destroying the role object does not remove the role from the +// wl_surface, but it may stop the wl_surface from "playing the role". +// For instance, if a wl_subsurface object is destroyed, the wl_surface +// it was created for will be unmapped and forget its position and +// z-order. It is allowed to create a wl_subsurface for the same +// wl_surface again, but it is not allowed to use the wl_surface as +// a cursor (cursor is a different role than sub-surface, and role +// switching is not allowed). +func NewSurface(ctx *Context) *Surface { + wlSurface := &Surface{} + ctx.Register(wlSurface) + return wlSurface +} + +// Destroy : delete surface +// +// Deletes the surface and invalidates its object ID. +func (i *Surface) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Attach : set the surface contents +// +// Set a buffer as the content of this surface. +// +// The new size of the surface is calculated based on the buffer +// size transformed by the inverse buffer_transform and the +// inverse buffer_scale. This means that at commit time the supplied +// buffer size must be an integer multiple of the buffer_scale. If +// that's not the case, an invalid_size error is sent. +// +// The x and y arguments specify the location of the new pending +// buffer's upper left corner, relative to the current buffer's upper +// left corner, in surface-local coordinates. In other words, the +// x and y, combined with the new surface size define in which +// directions the surface's size changes. Setting anything other than 0 +// as x and y arguments is discouraged, and should instead be replaced +// with using the separate wl_surface.offset request. +// +// When the bound wl_surface version is 5 or higher, passing any +// non-zero x or y is a protocol violation, and will result in an +// 'invalid_offset' error being raised. The x and y arguments are ignored +// and do not change the pending state. To achieve equivalent semantics, +// use wl_surface.offset. +// +// Surface contents are double-buffered state, see wl_surface.commit. +// +// The initial surface contents are void; there is no content. +// wl_surface.attach assigns the given wl_buffer as the pending +// wl_buffer. wl_surface.commit makes the pending wl_buffer the new +// surface contents, and the size of the surface becomes the size +// calculated from the wl_buffer, as described above. After commit, +// there is no pending buffer until the next attach. +// +// Committing a pending wl_buffer allows the compositor to read the +// pixels in the wl_buffer. The compositor may access the pixels at +// any time after the wl_surface.commit request. When the compositor +// will not access the pixels anymore, it will send the +// wl_buffer.release event. Only after receiving wl_buffer.release, +// the client may reuse the wl_buffer. A wl_buffer that has been +// attached and then replaced by another attach instead of committed +// will not receive a release event, and is not used by the +// compositor. +// +// If a pending wl_buffer has been committed to more than one wl_surface, +// the delivery of wl_buffer.release events becomes undefined. A well +// behaved client should not rely on wl_buffer.release events in this +// case. Alternatively, a client could create multiple wl_buffer objects +// from the same backing storage or use a protocol extension providing +// per-commit release notifications. +// +// Destroying the wl_buffer after wl_buffer.release does not change +// the surface contents. Destroying the wl_buffer before wl_buffer.release +// is allowed as long as the underlying buffer storage isn't re-used (this +// can happen e.g. on client process termination). However, if the client +// destroys the wl_buffer before receiving the wl_buffer.release event and +// mutates the underlying buffer storage, the surface contents become +// undefined immediately. +// +// If wl_surface.attach is sent with a NULL wl_buffer, the +// following wl_surface.commit will remove the surface content. +// +// If a pending wl_buffer has been destroyed, the result is not specified. +// Many compositors are known to remove the surface content on the following +// wl_surface.commit, but this behaviour is not universal. Clients seeking to +// maximise compatibility should not destroy pending buffers and should +// ensure that they explicitly remove content from surfaces, even after +// destroying buffers. +// +// buffer: buffer of surface contents +// x: surface-local x coordinate +// y: surface-local y coordinate +func (i *Surface) Attach(buffer *Buffer, x, y int32) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if buffer == nil { + PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + PutUint32(_reqBuf[l:l+4], buffer.ID()) + l += 4 + } + PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Damage : mark part of the surface damaged +// +// This request is used to describe the regions where the pending +// buffer is different from the current surface contents, and where +// the surface therefore needs to be repainted. The compositor +// ignores the parts of the damage that fall outside of the surface. +// +// Damage is double-buffered state, see wl_surface.commit. +// +// The damage rectangle is specified in surface-local coordinates, +// where x and y specify the upper left corner of the damage rectangle. +// +// The initial value for pending damage is empty: no damage. +// wl_surface.damage adds pending damage: the new pending damage +// is the union of old pending damage and the given rectangle. +// +// wl_surface.commit assigns pending damage as the current damage, +// and clears pending damage. The server will clear the current +// damage as it repaints the surface. +// +// Note! New clients should not use this request. Instead damage can be +// posted with wl_surface.damage_buffer which uses buffer coordinates +// instead of surface coordinates. +// +// x: surface-local x coordinate +// y: surface-local y coordinate +// width: width of damage rectangle +// height: height of damage rectangle +func (i *Surface) Damage(x, y, width, height int32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Frame : request a frame throttling hint +// +// Request a notification when it is a good time to start drawing a new +// frame, by creating a frame callback. This is useful for throttling +// redrawing operations, and driving animations. +// +// When a client is animating on a wl_surface, it can use the 'frame' +// request to get notified when it is a good time to draw and commit the +// next frame of animation. If the client commits an update earlier than +// that, it is likely that some updates will not make it to the display, +// and the client is wasting resources by drawing too often. +// +// The frame request will take effect on the next wl_surface.commit. +// The notification will only be posted for one frame unless +// requested again. For a wl_surface, the notifications are posted in +// the order the frame requests were committed. +// +// The server must send the notifications so that a client +// will not send excessive updates, while still allowing +// the highest possible update rate for clients that wait for the reply +// before drawing again. The server should give some time for the client +// to draw and commit after sending the frame callback events to let it +// hit the next output refresh. +// +// A server should avoid signaling the frame callbacks if the +// surface is not visible in any way, e.g. the surface is off-screen, +// or completely obscured by other opaque surfaces. +// +// The object returned by this request will be destroyed by the +// compositor after the callback is fired and as such the client must not +// attempt to use it after that point. +// +// The callback_data passed in the callback is the current time, in +// milliseconds, with an undefined base. +func (i *Surface) Frame() (*Callback, error) { + callback := NewCallback(i.Context()) + const opcode = 3 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], callback.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return callback, err +} + +// SetOpaqueRegion : set opaque region +// +// This request sets the region of the surface that contains +// opaque content. +// +// The opaque region is an optimization hint for the compositor +// that lets it optimize the redrawing of content behind opaque +// regions. Setting an opaque region is not required for correct +// behaviour, but marking transparent content as opaque will result +// in repaint artifacts. +// +// The opaque region is specified in surface-local coordinates. +// +// The compositor ignores the parts of the opaque region that fall +// outside of the surface. +// +// Opaque region is double-buffered state, see wl_surface.commit. +// +// wl_surface.set_opaque_region changes the pending opaque region. +// wl_surface.commit copies the pending region to the current region. +// Otherwise, the pending and current regions are never changed. +// +// The initial value for an opaque region is empty. Setting the pending +// opaque region has copy semantics, and the wl_region object can be +// destroyed immediately. A NULL wl_region causes the pending opaque +// region to be set to empty. +// +// region: opaque region of the surface +func (i *Surface) SetOpaqueRegion(region *Region) error { + const opcode = 4 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if region == nil { + PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + PutUint32(_reqBuf[l:l+4], region.ID()) + l += 4 + } + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetInputRegion : set input region +// +// This request sets the region of the surface that can receive +// pointer and touch events. +// +// Input events happening outside of this region will try the next +// surface in the server surface stack. The compositor ignores the +// parts of the input region that fall outside of the surface. +// +// The input region is specified in surface-local coordinates. +// +// Input region is double-buffered state, see wl_surface.commit. +// +// wl_surface.set_input_region changes the pending input region. +// wl_surface.commit copies the pending region to the current region. +// Otherwise the pending and current regions are never changed, +// except cursor and icon surfaces are special cases, see +// wl_pointer.set_cursor and wl_data_device.start_drag. +// +// The initial value for an input region is infinite. That means the +// whole surface will accept input. Setting the pending input region +// has copy semantics, and the wl_region object can be destroyed +// immediately. A NULL wl_region causes the input region to be set +// to infinite. +// +// region: input region of the surface +func (i *Surface) SetInputRegion(region *Region) error { + const opcode = 5 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if region == nil { + PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + PutUint32(_reqBuf[l:l+4], region.ID()) + l += 4 + } + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Commit : commit pending surface state +// +// Surface state (input, opaque, and damage regions, attached buffers, +// etc.) is double-buffered. Protocol requests modify the pending state, +// as opposed to the active state in use by the compositor. +// +// A commit request atomically creates a content update from the pending +// state, even if the pending state has not been touched. The content +// update is placed in a queue until it becomes active. After commit, the +// new pending state is as documented for each related request. +// +// When the content update is applied, the wl_buffer is applied before all +// other state. This means that all coordinates in double-buffered state +// are relative to the newly attached wl_buffers, except for +// wl_surface.attach itself. If there is no newly attached wl_buffer, the +// coordinates are relative to the previous content update. +// +// All requests that need a commit to become effective are documented +// to affect double-buffered state. +// +// Other interfaces may add further double-buffered surface state. +func (i *Surface) Commit() error { + const opcode = 6 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetBufferTransform : sets the buffer transformation +// +// This request sets the transformation that the client has already applied +// to the content of the buffer. The accepted values for the transform +// parameter are the values for wl_output.transform. +// +// The compositor applies the inverse of this transformation whenever it +// uses the buffer contents. +// +// Buffer transform is double-buffered state, see wl_surface.commit. +// +// A newly created surface has its buffer transformation set to normal. +// +// wl_surface.set_buffer_transform changes the pending buffer +// transformation. wl_surface.commit copies the pending buffer +// transformation to the current one. Otherwise, the pending and current +// values are never changed. +// +// The purpose of this request is to allow clients to render content +// according to the output transform, thus permitting the compositor to +// use certain optimizations even if the display is rotated. Using +// hardware overlays and scanning out a client buffer for fullscreen +// surfaces are examples of such optimizations. Those optimizations are +// highly dependent on the compositor implementation, so the use of this +// request should be considered on a case-by-case basis. +// +// Note that if the transform value includes 90 or 270 degree rotation, +// the width of the buffer will become the surface height and the height +// of the buffer will become the surface width. +// +// If transform is not one of the values from the +// wl_output.transform enum the invalid_transform protocol error +// is raised. +// +// transform: transform for interpreting buffer contents +func (i *Surface) SetBufferTransform(transform int32) error { + const opcode = 7 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(transform)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetBufferScale : sets the buffer scaling factor +// +// This request sets an optional scaling factor on how the compositor +// interprets the contents of the buffer attached to the window. +// +// Buffer scale is double-buffered state, see wl_surface.commit. +// +// A newly created surface has its buffer scale set to 1. +// +// wl_surface.set_buffer_scale changes the pending buffer scale. +// wl_surface.commit copies the pending buffer scale to the current one. +// Otherwise, the pending and current values are never changed. +// +// The purpose of this request is to allow clients to supply higher +// resolution buffer data for use on high resolution outputs. It is +// intended that you pick the same buffer scale as the scale of the +// output that the surface is displayed on. This means the compositor +// can avoid scaling when rendering the surface on that output. +// +// Note that if the scale is larger than 1, then you have to attach +// a buffer that is larger (by a factor of scale in each dimension) +// than the desired surface size. +// +// If scale is not greater than 0 the invalid_scale protocol error is +// raised. +// +// scale: scale for interpreting buffer contents +func (i *Surface) SetBufferScale(scale int32) error { + const opcode = 8 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(scale)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// DamageBuffer : mark part of the surface damaged using buffer coordinates +// +// This request is used to describe the regions where the pending +// buffer is different from the current surface contents, and where +// the surface therefore needs to be repainted. The compositor +// ignores the parts of the damage that fall outside of the surface. +// +// Damage is double-buffered state, see wl_surface.commit. +// +// The damage rectangle is specified in buffer coordinates, +// where x and y specify the upper left corner of the damage rectangle. +// +// The initial value for pending damage is empty: no damage. +// wl_surface.damage_buffer adds pending damage: the new pending +// damage is the union of old pending damage and the given rectangle. +// +// wl_surface.commit assigns pending damage as the current damage, +// and clears pending damage. The server will clear the current +// damage as it repaints the surface. +// +// This request differs from wl_surface.damage in only one way - it +// takes damage in buffer coordinates instead of surface-local +// coordinates. While this generally is more intuitive than surface +// coordinates, it is especially desirable when using wp_viewport +// or when a drawing library (like EGL) is unaware of buffer scale +// and buffer transform. +// +// Note: Because buffer transformation changes and damage requests may +// be interleaved in the protocol stream, it is impossible to determine +// the actual mapping between surface and buffer damage until +// wl_surface.commit time. Therefore, compositors wishing to take both +// kinds of damage into account will have to accumulate damage from the +// two requests separately and only transform from one to the other +// after receiving the wl_surface.commit. +// +// x: buffer-local x coordinate +// y: buffer-local y coordinate +// width: width of damage rectangle +// height: height of damage rectangle +func (i *Surface) DamageBuffer(x, y, width, height int32) error { + const opcode = 9 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Offset : set the surface contents offset +// +// The x and y arguments specify the location of the new pending +// buffer's upper left corner, relative to the current buffer's upper +// left corner, in surface-local coordinates. In other words, the +// x and y, combined with the new surface size define in which +// directions the surface's size changes. +// +// The exact semantics of wl_surface.offset are role-specific. Refer to +// the documentation of specific roles for more information. +// +// Surface location offset is double-buffered state, see +// wl_surface.commit. +// +// This request is semantically equivalent to and the replaces the x and y +// arguments in the wl_surface.attach request in wl_surface versions prior +// to 5. See wl_surface.attach for details. +// +// x: surface-local x coordinate +// y: surface-local y coordinate +func (i *Surface) Offset(x, y int32) error { + const opcode = 10 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type SurfaceError uint32 + +// SurfaceError : wl_surface error values +// +// These errors can be emitted in response to wl_surface requests. +const ( + // SurfaceErrorInvalidScale : buffer scale value is invalid + SurfaceErrorInvalidScale SurfaceError = 0 + // SurfaceErrorInvalidTransform : buffer transform value is invalid + SurfaceErrorInvalidTransform SurfaceError = 1 + // SurfaceErrorInvalidSize : buffer size is invalid + SurfaceErrorInvalidSize SurfaceError = 2 + // SurfaceErrorInvalidOffset : buffer offset is invalid + SurfaceErrorInvalidOffset SurfaceError = 3 + // SurfaceErrorDefunctRoleObject : surface was destroyed before its role object + SurfaceErrorDefunctRoleObject SurfaceError = 4 +) + +func (e SurfaceError) Name() string { + switch e { + case SurfaceErrorInvalidScale: + return "invalid_scale" + case SurfaceErrorInvalidTransform: + return "invalid_transform" + case SurfaceErrorInvalidSize: + return "invalid_size" + case SurfaceErrorInvalidOffset: + return "invalid_offset" + case SurfaceErrorDefunctRoleObject: + return "defunct_role_object" + default: + return "" + } +} + +func (e SurfaceError) Value() string { + switch e { + case SurfaceErrorInvalidScale: + return "0" + case SurfaceErrorInvalidTransform: + return "1" + case SurfaceErrorInvalidSize: + return "2" + case SurfaceErrorInvalidOffset: + return "3" + case SurfaceErrorDefunctRoleObject: + return "4" + default: + return "" + } +} + +func (e SurfaceError) String() string { + return e.Name() + "=" + e.Value() +} + +// SurfaceEnterEvent : surface enters an output +// +// This is emitted whenever a surface's creation, movement, or resizing +// results in some part of it being within the scanout region of an +// output. +// +// Note that a surface may be overlapping with zero or more outputs. +type SurfaceEnterEvent struct { + Output *Output +} +type SurfaceEnterHandlerFunc func(SurfaceEnterEvent) + +// SetEnterHandler : sets handler for SurfaceEnterEvent +func (i *Surface) SetEnterHandler(f SurfaceEnterHandlerFunc) { + i.enterHandler = f +} + +// SurfaceLeaveEvent : surface leaves an output +// +// This is emitted whenever a surface's creation, movement, or resizing +// results in it no longer having any part of it within the scanout region +// of an output. +// +// Clients should not use the number of outputs the surface is on for frame +// throttling purposes. The surface might be hidden even if no leave event +// has been sent, and the compositor might expect new surface content +// updates even if no enter event has been sent. The frame event should be +// used instead. +type SurfaceLeaveEvent struct { + Output *Output +} +type SurfaceLeaveHandlerFunc func(SurfaceLeaveEvent) + +// SetLeaveHandler : sets handler for SurfaceLeaveEvent +func (i *Surface) SetLeaveHandler(f SurfaceLeaveHandlerFunc) { + i.leaveHandler = f +} + +// SurfacePreferredBufferScaleEvent : preferred buffer scale for the surface +// +// This event indicates the preferred buffer scale for this surface. It is +// sent whenever the compositor's preference changes. +// +// Before receiving this event the preferred buffer scale for this surface +// is 1. +// +// It is intended that scaling aware clients use this event to scale their +// content and use wl_surface.set_buffer_scale to indicate the scale they +// have rendered with. This allows clients to supply a higher detail +// buffer. +// +// The compositor shall emit a scale value greater than 0. +type SurfacePreferredBufferScaleEvent struct { + Factor int32 +} +type SurfacePreferredBufferScaleHandlerFunc func(SurfacePreferredBufferScaleEvent) + +// SetPreferredBufferScaleHandler : sets handler for SurfacePreferredBufferScaleEvent +func (i *Surface) SetPreferredBufferScaleHandler(f SurfacePreferredBufferScaleHandlerFunc) { + i.preferredBufferScaleHandler = f +} + +// SurfacePreferredBufferTransformEvent : preferred buffer transform for the surface +// +// This event indicates the preferred buffer transform for this surface. +// It is sent whenever the compositor's preference changes. +// +// Before receiving this event the preferred buffer transform for this +// surface is normal. +// +// Applying this transformation to the surface buffer contents and using +// wl_surface.set_buffer_transform might allow the compositor to use the +// surface buffer more efficiently. +type SurfacePreferredBufferTransformEvent struct { + Transform uint32 +} +type SurfacePreferredBufferTransformHandlerFunc func(SurfacePreferredBufferTransformEvent) + +// SetPreferredBufferTransformHandler : sets handler for SurfacePreferredBufferTransformEvent +func (i *Surface) SetPreferredBufferTransformHandler(f SurfacePreferredBufferTransformHandlerFunc) { + i.preferredBufferTransformHandler = f +} + +func (i *Surface) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.enterHandler == nil { + return + } + var e SurfaceEnterEvent + l := 0 + e.Output = i.Context().GetProxy(Uint32(data[l : l+4])).(*Output) + l += 4 + + i.enterHandler(e) + case 1: + if i.leaveHandler == nil { + return + } + var e SurfaceLeaveEvent + l := 0 + e.Output = i.Context().GetProxy(Uint32(data[l : l+4])).(*Output) + l += 4 + + i.leaveHandler(e) + case 2: + if i.preferredBufferScaleHandler == nil { + return + } + var e SurfacePreferredBufferScaleEvent + l := 0 + e.Factor = int32(Uint32(data[l : l+4])) + l += 4 + + i.preferredBufferScaleHandler(e) + case 3: + if i.preferredBufferTransformHandler == nil { + return + } + var e SurfacePreferredBufferTransformEvent + l := 0 + e.Transform = Uint32(data[l : l+4]) + l += 4 + + i.preferredBufferTransformHandler(e) + } +} + +// SeatInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const SeatInterfaceName = "wl_seat" + +// Seat : group of input devices +// +// A seat is a group of keyboards, pointer and touch devices. This +// object is published as a global during start up, or when such a +// device is hot plugged. A seat typically has a pointer and +// maintains a keyboard focus and a pointer focus. +type Seat struct { + BaseProxy + capabilitiesHandler SeatCapabilitiesHandlerFunc + nameHandler SeatNameHandlerFunc +} + +// NewSeat : group of input devices +// +// A seat is a group of keyboards, pointer and touch devices. This +// object is published as a global during start up, or when such a +// device is hot plugged. A seat typically has a pointer and +// maintains a keyboard focus and a pointer focus. +func NewSeat(ctx *Context) *Seat { + wlSeat := &Seat{} + ctx.Register(wlSeat) + return wlSeat +} + +// GetPointer : return pointer object +// +// The ID provided will be initialized to the wl_pointer interface +// for this seat. +// +// This request only takes effect if the seat has the pointer +// capability, or has had the pointer capability in the past. +// It is a protocol violation to issue this request on a seat that has +// never had the pointer capability. The missing_capability error will +// be sent in this case. +func (i *Seat) GetPointer() (*Pointer, error) { + id := NewPointer(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// GetKeyboard : return keyboard object +// +// The ID provided will be initialized to the wl_keyboard interface +// for this seat. +// +// This request only takes effect if the seat has the keyboard +// capability, or has had the keyboard capability in the past. +// It is a protocol violation to issue this request on a seat that has +// never had the keyboard capability. The missing_capability error will +// be sent in this case. +func (i *Seat) GetKeyboard() (*Keyboard, error) { + id := NewKeyboard(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// GetTouch : return touch object +// +// The ID provided will be initialized to the wl_touch interface +// for this seat. +// +// This request only takes effect if the seat has the touch +// capability, or has had the touch capability in the past. +// It is a protocol violation to issue this request on a seat that has +// never had the touch capability. The missing_capability error will +// be sent in this case. +func (i *Seat) GetTouch() (*Touch, error) { + id := NewTouch(i.Context()) + const opcode = 2 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +// Release : release the seat object +// +// Using this request a client can tell the server that it is not going to +// use the seat object anymore. +func (i *Seat) Release() error { + defer i.MarkZombie() + const opcode = 3 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type SeatCapability uint32 + +// SeatCapability : seat capability bitmask +// +// This is a bitmask of capabilities this seat has; if a member is +// set, then it is present on the seat. +const ( + // SeatCapabilityPointer : the seat has pointer devices + SeatCapabilityPointer SeatCapability = 1 + // SeatCapabilityKeyboard : the seat has one or more keyboards + SeatCapabilityKeyboard SeatCapability = 2 + // SeatCapabilityTouch : the seat has touch devices + SeatCapabilityTouch SeatCapability = 4 +) + +func (e SeatCapability) Name() string { + switch e { + case SeatCapabilityPointer: + return "pointer" + case SeatCapabilityKeyboard: + return "keyboard" + case SeatCapabilityTouch: + return "touch" + default: + return "" + } +} + +func (e SeatCapability) Value() string { + switch e { + case SeatCapabilityPointer: + return "1" + case SeatCapabilityKeyboard: + return "2" + case SeatCapabilityTouch: + return "4" + default: + return "" + } +} + +func (e SeatCapability) String() string { + return e.Name() + "=" + e.Value() +} + +type SeatError uint32 + +// SeatError : wl_seat error values +// +// These errors can be emitted in response to wl_seat requests. +const ( + // SeatErrorMissingCapability : get_pointer, get_keyboard or get_touch called on seat without the matching capability + SeatErrorMissingCapability SeatError = 0 +) + +func (e SeatError) Name() string { + switch e { + case SeatErrorMissingCapability: + return "missing_capability" + default: + return "" + } +} + +func (e SeatError) Value() string { + switch e { + case SeatErrorMissingCapability: + return "0" + default: + return "" + } +} + +func (e SeatError) String() string { + return e.Name() + "=" + e.Value() +} + +// SeatCapabilitiesEvent : seat capabilities changed +// +// This is sent on binding to the seat global or whenever a seat gains +// or loses the pointer, keyboard or touch capabilities. +// The argument is a capability enum containing the complete set of +// capabilities this seat has. +// +// When the pointer capability is added, a client may create a +// wl_pointer object using the wl_seat.get_pointer request. This object +// will receive pointer events until the capability is removed in the +// future. +// +// When the pointer capability is removed, a client should destroy the +// wl_pointer objects associated with the seat where the capability was +// removed, using the wl_pointer.release request. No further pointer +// events will be received on these objects. +// +// In some compositors, if a seat regains the pointer capability and a +// client has a previously obtained wl_pointer object of version 4 or +// less, that object may start sending pointer events again. This +// behavior is considered a misinterpretation of the intended behavior +// and must not be relied upon by the client. wl_pointer objects of +// version 5 or later must not send events if created before the most +// recent event notifying the client of an added pointer capability. +// +// The above behavior also applies to wl_keyboard and wl_touch with the +// keyboard and touch capabilities, respectively. +type SeatCapabilitiesEvent struct { + Capabilities uint32 +} +type SeatCapabilitiesHandlerFunc func(SeatCapabilitiesEvent) + +// SetCapabilitiesHandler : sets handler for SeatCapabilitiesEvent +func (i *Seat) SetCapabilitiesHandler(f SeatCapabilitiesHandlerFunc) { + i.capabilitiesHandler = f +} + +// SeatNameEvent : unique identifier for this seat +// +// In a multi-seat configuration the seat name can be used by clients to +// help identify which physical devices the seat represents. +// +// The seat name is a UTF-8 string with no convention defined for its +// contents. Each name is unique among all wl_seat globals. The name is +// only guaranteed to be unique for the current compositor instance. +// +// The same seat names are used for all clients. Thus, the name can be +// shared across processes to refer to a specific wl_seat global. +// +// The name event is sent after binding to the seat global, and should be sent +// before announcing capabilities. This event only sent once per seat object, +// and the name does not change over the lifetime of the wl_seat global. +// +// Compositors may re-use the same seat name if the wl_seat global is +// destroyed and re-created later. +type SeatNameEvent struct { + Name string +} +type SeatNameHandlerFunc func(SeatNameEvent) + +// SetNameHandler : sets handler for SeatNameEvent +func (i *Seat) SetNameHandler(f SeatNameHandlerFunc) { + i.nameHandler = f +} + +func (i *Seat) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.capabilitiesHandler == nil { + return + } + var e SeatCapabilitiesEvent + l := 0 + e.Capabilities = Uint32(data[l : l+4]) + l += 4 + + i.capabilitiesHandler(e) + case 1: + if i.nameHandler == nil { + return + } + var e SeatNameEvent + l := 0 + nameLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.Name = String(data[l : l+nameLen]) + l += nameLen + + i.nameHandler(e) + } +} + +// PointerInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const PointerInterfaceName = "wl_pointer" + +// Pointer : pointer input device +// +// The wl_pointer interface represents one or more input devices, +// such as mice, which control the pointer location and pointer_focus +// of a seat. +// +// The wl_pointer interface generates motion, enter and leave +// events for the surfaces that the pointer is located over, +// and button and axis events for button presses, button releases +// and scrolling. +type Pointer struct { + BaseProxy + enterHandler PointerEnterHandlerFunc + leaveHandler PointerLeaveHandlerFunc + motionHandler PointerMotionHandlerFunc + buttonHandler PointerButtonHandlerFunc + axisHandler PointerAxisHandlerFunc + frameHandler PointerFrameHandlerFunc + axisSourceHandler PointerAxisSourceHandlerFunc + axisStopHandler PointerAxisStopHandlerFunc + axisDiscreteHandler PointerAxisDiscreteHandlerFunc + axisValue120Handler PointerAxisValue120HandlerFunc + axisRelativeDirectionHandler PointerAxisRelativeDirectionHandlerFunc +} + +// NewPointer : pointer input device +// +// The wl_pointer interface represents one or more input devices, +// such as mice, which control the pointer location and pointer_focus +// of a seat. +// +// The wl_pointer interface generates motion, enter and leave +// events for the surfaces that the pointer is located over, +// and button and axis events for button presses, button releases +// and scrolling. +func NewPointer(ctx *Context) *Pointer { + wlPointer := &Pointer{} + ctx.Register(wlPointer) + return wlPointer +} + +// SetCursor : set the pointer surface +// +// Set the pointer surface, i.e., the surface that contains the +// pointer image (cursor). This request gives the surface the role +// of a cursor. If the surface already has another role, it raises +// a protocol error. +// +// The cursor actually changes only if the pointer +// focus for this device is one of the requesting client's surfaces +// or the surface parameter is the current pointer surface. If +// there was a previous surface set with this request it is +// replaced. If surface is NULL, the pointer image is hidden. +// +// The parameters hotspot_x and hotspot_y define the position of +// the pointer surface relative to the pointer location. Its +// top-left corner is always at (x, y) - (hotspot_x, hotspot_y), +// where (x, y) are the coordinates of the pointer location, in +// surface-local coordinates. +// +// On wl_surface.offset requests to the pointer surface, hotspot_x +// and hotspot_y are decremented by the x and y parameters +// passed to the request. The offset must be applied by +// wl_surface.commit as usual. +// +// The hotspot can also be updated by passing the currently set +// pointer surface to this request with new values for hotspot_x +// and hotspot_y. +// +// The input region is ignored for wl_surfaces with the role of +// a cursor. When the use as a cursor ends, the wl_surface is +// unmapped. +// +// The serial parameter must match the latest wl_pointer.enter +// serial number sent to the client. Otherwise the request will be +// ignored. +// +// serial: serial number of the enter event +// surface: pointer surface +// hotspotX: surface-local x coordinate +// hotspotY: surface-local y coordinate +func (i *Pointer) SetCursor(serial uint32, surface *Surface, hotspotX, hotspotY int32) error { + const opcode = 0 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(serial)) + l += 4 + if surface == nil { + PutUint32(_reqBuf[l:l+4], 0) + l += 4 + } else { + PutUint32(_reqBuf[l:l+4], surface.ID()) + l += 4 + } + PutUint32(_reqBuf[l:l+4], uint32(hotspotX)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(hotspotY)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Release : release the pointer object +// +// Using this request a client can tell the server that it is not going to +// use the pointer object anymore. +// +// This request destroys the pointer proxy object, so clients must not call +// wl_pointer_destroy() after using this request. +func (i *Pointer) Release() error { + defer i.MarkZombie() + const opcode = 1 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type PointerError uint32 + +// PointerError : +const ( + // PointerErrorRole : given wl_surface has another role + PointerErrorRole PointerError = 0 +) + +func (e PointerError) Name() string { + switch e { + case PointerErrorRole: + return "role" + default: + return "" + } +} + +func (e PointerError) Value() string { + switch e { + case PointerErrorRole: + return "0" + default: + return "" + } +} + +func (e PointerError) String() string { + return e.Name() + "=" + e.Value() +} + +type PointerButtonState uint32 + +// PointerButtonState : physical button state +// +// Describes the physical state of a button that produced the button +// event. +const ( + // PointerButtonStateReleased : the button is not pressed + PointerButtonStateReleased PointerButtonState = 0 + // PointerButtonStatePressed : the button is pressed + PointerButtonStatePressed PointerButtonState = 1 +) + +func (e PointerButtonState) Name() string { + switch e { + case PointerButtonStateReleased: + return "released" + case PointerButtonStatePressed: + return "pressed" + default: + return "" + } +} + +func (e PointerButtonState) Value() string { + switch e { + case PointerButtonStateReleased: + return "0" + case PointerButtonStatePressed: + return "1" + default: + return "" + } +} + +func (e PointerButtonState) String() string { + return e.Name() + "=" + e.Value() +} + +type PointerAxis uint32 + +// PointerAxis : axis types +// +// Describes the axis types of scroll events. +const ( + // PointerAxisVerticalScroll : vertical axis + PointerAxisVerticalScroll PointerAxis = 0 + // PointerAxisHorizontalScroll : horizontal axis + PointerAxisHorizontalScroll PointerAxis = 1 +) + +func (e PointerAxis) Name() string { + switch e { + case PointerAxisVerticalScroll: + return "vertical_scroll" + case PointerAxisHorizontalScroll: + return "horizontal_scroll" + default: + return "" + } +} + +func (e PointerAxis) Value() string { + switch e { + case PointerAxisVerticalScroll: + return "0" + case PointerAxisHorizontalScroll: + return "1" + default: + return "" + } +} + +func (e PointerAxis) String() string { + return e.Name() + "=" + e.Value() +} + +type PointerAxisSource uint32 + +// PointerAxisSource : axis source types +// +// Describes the source types for axis events. This indicates to the +// client how an axis event was physically generated; a client may +// adjust the user interface accordingly. For example, scroll events +// from a "finger" source may be in a smooth coordinate space with +// kinetic scrolling whereas a "wheel" source may be in discrete steps +// of a number of lines. +// +// The "continuous" axis source is a device generating events in a +// continuous coordinate space, but using something other than a +// finger. One example for this source is button-based scrolling where +// the vertical motion of a device is converted to scroll events while +// a button is held down. +// +// The "wheel tilt" axis source indicates that the actual device is a +// wheel but the scroll event is not caused by a rotation but a +// (usually sideways) tilt of the wheel. +const ( + // PointerAxisSourceWheel : a physical wheel rotation + PointerAxisSourceWheel PointerAxisSource = 0 + // PointerAxisSourceFinger : finger on a touch surface + PointerAxisSourceFinger PointerAxisSource = 1 + // PointerAxisSourceContinuous : continuous coordinate space + PointerAxisSourceContinuous PointerAxisSource = 2 + // PointerAxisSourceWheelTilt : a physical wheel tilt + PointerAxisSourceWheelTilt PointerAxisSource = 3 +) + +func (e PointerAxisSource) Name() string { + switch e { + case PointerAxisSourceWheel: + return "wheel" + case PointerAxisSourceFinger: + return "finger" + case PointerAxisSourceContinuous: + return "continuous" + case PointerAxisSourceWheelTilt: + return "wheel_tilt" + default: + return "" + } +} + +func (e PointerAxisSource) Value() string { + switch e { + case PointerAxisSourceWheel: + return "0" + case PointerAxisSourceFinger: + return "1" + case PointerAxisSourceContinuous: + return "2" + case PointerAxisSourceWheelTilt: + return "3" + default: + return "" + } +} + +func (e PointerAxisSource) String() string { + return e.Name() + "=" + e.Value() +} + +type PointerAxisRelativeDirection uint32 + +// PointerAxisRelativeDirection : axis relative direction +// +// This specifies the direction of the physical motion that caused a +// wl_pointer.axis event, relative to the wl_pointer.axis direction. +const ( + // PointerAxisRelativeDirectionIdentical : physical motion matches axis direction + PointerAxisRelativeDirectionIdentical PointerAxisRelativeDirection = 0 + // PointerAxisRelativeDirectionInverted : physical motion is the inverse of the axis direction + PointerAxisRelativeDirectionInverted PointerAxisRelativeDirection = 1 +) + +func (e PointerAxisRelativeDirection) Name() string { + switch e { + case PointerAxisRelativeDirectionIdentical: + return "identical" + case PointerAxisRelativeDirectionInverted: + return "inverted" + default: + return "" + } +} + +func (e PointerAxisRelativeDirection) Value() string { + switch e { + case PointerAxisRelativeDirectionIdentical: + return "0" + case PointerAxisRelativeDirectionInverted: + return "1" + default: + return "" + } +} + +func (e PointerAxisRelativeDirection) String() string { + return e.Name() + "=" + e.Value() +} + +// PointerEnterEvent : enter event +// +// Notification that this seat's pointer is focused on a certain +// surface. +// +// When a seat's focus enters a surface, the pointer image +// is undefined and a client should respond to this event by setting +// an appropriate pointer image with the set_cursor request. +type PointerEnterEvent struct { + Serial uint32 + Surface *Surface + SurfaceX float64 + SurfaceY float64 +} +type PointerEnterHandlerFunc func(PointerEnterEvent) + +// SetEnterHandler : sets handler for PointerEnterEvent +func (i *Pointer) SetEnterHandler(f PointerEnterHandlerFunc) { + i.enterHandler = f +} + +// PointerLeaveEvent : leave event +// +// Notification that this seat's pointer is no longer focused on +// a certain surface. +// +// The leave notification is sent before the enter notification +// for the new focus. +type PointerLeaveEvent struct { + Serial uint32 + Surface *Surface +} +type PointerLeaveHandlerFunc func(PointerLeaveEvent) + +// SetLeaveHandler : sets handler for PointerLeaveEvent +func (i *Pointer) SetLeaveHandler(f PointerLeaveHandlerFunc) { + i.leaveHandler = f +} + +// PointerMotionEvent : pointer motion event +// +// Notification of pointer location change. The arguments +// surface_x and surface_y are the location relative to the +// focused surface. +type PointerMotionEvent struct { + Time uint32 + SurfaceX float64 + SurfaceY float64 +} +type PointerMotionHandlerFunc func(PointerMotionEvent) + +// SetMotionHandler : sets handler for PointerMotionEvent +func (i *Pointer) SetMotionHandler(f PointerMotionHandlerFunc) { + i.motionHandler = f +} + +// PointerButtonEvent : pointer button event +// +// Mouse button click and release notifications. +// +// The location of the click is given by the last motion or +// enter event. +// The time argument is a timestamp with millisecond +// granularity, with an undefined base. +// +// The button is a button code as defined in the Linux kernel's +// linux/input-event-codes.h header file, e.g. BTN_LEFT. +// +// Any 16-bit button code value is reserved for future additions to the +// kernel's event code list. All other button codes above 0xFFFF are +// currently undefined but may be used in future versions of this +// protocol. +type PointerButtonEvent struct { + Serial uint32 + Time uint32 + Button uint32 + State uint32 +} +type PointerButtonHandlerFunc func(PointerButtonEvent) + +// SetButtonHandler : sets handler for PointerButtonEvent +func (i *Pointer) SetButtonHandler(f PointerButtonHandlerFunc) { + i.buttonHandler = f +} + +// PointerAxisEvent : axis event +// +// Scroll and other axis notifications. +// +// For scroll events (vertical and horizontal scroll axes), the +// value parameter is the length of a vector along the specified +// axis in a coordinate space identical to those of motion events, +// representing a relative movement along the specified axis. +// +// For devices that support movements non-parallel to axes multiple +// axis events will be emitted. +// +// When applicable, for example for touch pads, the server can +// choose to emit scroll events where the motion vector is +// equivalent to a motion event vector. +// +// When applicable, a client can transform its content relative to the +// scroll distance. +type PointerAxisEvent struct { + Time uint32 + Axis uint32 + Value float64 +} +type PointerAxisHandlerFunc func(PointerAxisEvent) + +// SetAxisHandler : sets handler for PointerAxisEvent +func (i *Pointer) SetAxisHandler(f PointerAxisHandlerFunc) { + i.axisHandler = f +} + +// PointerFrameEvent : end of a pointer event sequence +// +// Indicates the end of a set of events that logically belong together. +// A client is expected to accumulate the data in all events within the +// frame before proceeding. +// +// All wl_pointer events before a wl_pointer.frame event belong +// logically together. For example, in a diagonal scroll motion the +// compositor will send an optional wl_pointer.axis_source event, two +// wl_pointer.axis events (horizontal and vertical) and finally a +// wl_pointer.frame event. The client may use this information to +// calculate a diagonal vector for scrolling. +// +// When multiple wl_pointer.axis events occur within the same frame, +// the motion vector is the combined motion of all events. +// When a wl_pointer.axis and a wl_pointer.axis_stop event occur within +// the same frame, this indicates that axis movement in one axis has +// stopped but continues in the other axis. +// When multiple wl_pointer.axis_stop events occur within the same +// frame, this indicates that these axes stopped in the same instance. +// +// A wl_pointer.frame event is sent for every logical event group, +// even if the group only contains a single wl_pointer event. +// Specifically, a client may get a sequence: motion, frame, button, +// frame, axis, frame, axis_stop, frame. +// +// The wl_pointer.enter and wl_pointer.leave events are logical events +// generated by the compositor and not the hardware. These events are +// also grouped by a wl_pointer.frame. When a pointer moves from one +// surface to another, a compositor should group the +// wl_pointer.leave event within the same wl_pointer.frame. +// However, a client must not rely on wl_pointer.leave and +// wl_pointer.enter being in the same wl_pointer.frame. +// Compositor-specific policies may require the wl_pointer.leave and +// wl_pointer.enter event being split across multiple wl_pointer.frame +// groups. +type PointerFrameEvent struct{} +type PointerFrameHandlerFunc func(PointerFrameEvent) + +// SetFrameHandler : sets handler for PointerFrameEvent +func (i *Pointer) SetFrameHandler(f PointerFrameHandlerFunc) { + i.frameHandler = f +} + +// PointerAxisSourceEvent : axis source event +// +// Source information for scroll and other axes. +// +// This event does not occur on its own. It is sent before a +// wl_pointer.frame event and carries the source information for +// all events within that frame. +// +// The source specifies how this event was generated. If the source is +// wl_pointer.axis_source.finger, a wl_pointer.axis_stop event will be +// sent when the user lifts the finger off the device. +// +// If the source is wl_pointer.axis_source.wheel, +// wl_pointer.axis_source.wheel_tilt or +// wl_pointer.axis_source.continuous, a wl_pointer.axis_stop event may +// or may not be sent. Whether a compositor sends an axis_stop event +// for these sources is hardware-specific and implementation-dependent; +// clients must not rely on receiving an axis_stop event for these +// scroll sources and should treat scroll sequences from these scroll +// sources as unterminated by default. +// +// This event is optional. If the source is unknown for a particular +// axis event sequence, no event is sent. +// Only one wl_pointer.axis_source event is permitted per frame. +// +// The order of wl_pointer.axis_discrete and wl_pointer.axis_source is +// not guaranteed. +type PointerAxisSourceEvent struct { + AxisSource uint32 +} +type PointerAxisSourceHandlerFunc func(PointerAxisSourceEvent) + +// SetAxisSourceHandler : sets handler for PointerAxisSourceEvent +func (i *Pointer) SetAxisSourceHandler(f PointerAxisSourceHandlerFunc) { + i.axisSourceHandler = f +} + +// PointerAxisStopEvent : axis stop event +// +// Stop notification for scroll and other axes. +// +// For some wl_pointer.axis_source types, a wl_pointer.axis_stop event +// is sent to notify a client that the axis sequence has terminated. +// This enables the client to implement kinetic scrolling. +// See the wl_pointer.axis_source documentation for information on when +// this event may be generated. +// +// Any wl_pointer.axis events with the same axis_source after this +// event should be considered as the start of a new axis motion. +// +// The timestamp is to be interpreted identical to the timestamp in the +// wl_pointer.axis event. The timestamp value may be the same as a +// preceding wl_pointer.axis event. +type PointerAxisStopEvent struct { + Time uint32 + Axis uint32 +} +type PointerAxisStopHandlerFunc func(PointerAxisStopEvent) + +// SetAxisStopHandler : sets handler for PointerAxisStopEvent +func (i *Pointer) SetAxisStopHandler(f PointerAxisStopHandlerFunc) { + i.axisStopHandler = f +} + +// PointerAxisDiscreteEvent : axis click event +// +// Discrete step information for scroll and other axes. +// +// This event carries the axis value of the wl_pointer.axis event in +// discrete steps (e.g. mouse wheel clicks). +// +// This event is deprecated with wl_pointer version 8 - this event is not +// sent to clients supporting version 8 or later. +// +// This event does not occur on its own, it is coupled with a +// wl_pointer.axis event that represents this axis value on a +// continuous scale. The protocol guarantees that each axis_discrete +// event is always followed by exactly one axis event with the same +// axis number within the same wl_pointer.frame. Note that the protocol +// allows for other events to occur between the axis_discrete and +// its coupled axis event, including other axis_discrete or axis +// events. A wl_pointer.frame must not contain more than one axis_discrete +// event per axis type. +// +// This event is optional; continuous scrolling devices +// like two-finger scrolling on touchpads do not have discrete +// steps and do not generate this event. +// +// The discrete value carries the directional information. e.g. a value +// of -2 is two steps towards the negative direction of this axis. +// +// The axis number is identical to the axis number in the associated +// axis event. +// +// The order of wl_pointer.axis_discrete and wl_pointer.axis_source is +// not guaranteed. +type PointerAxisDiscreteEvent struct { + Axis uint32 + Discrete int32 +} +type PointerAxisDiscreteHandlerFunc func(PointerAxisDiscreteEvent) + +// SetAxisDiscreteHandler : sets handler for PointerAxisDiscreteEvent +func (i *Pointer) SetAxisDiscreteHandler(f PointerAxisDiscreteHandlerFunc) { + i.axisDiscreteHandler = f +} + +// PointerAxisValue120Event : axis high-resolution scroll event +// +// Discrete high-resolution scroll information. +// +// This event carries high-resolution wheel scroll information, +// with each multiple of 120 representing one logical scroll step +// (a wheel detent). For example, an axis_value120 of 30 is one quarter of +// a logical scroll step in the positive direction, a value120 of +// -240 are two logical scroll steps in the negative direction within the +// same hardware event. +// Clients that rely on discrete scrolling should accumulate the +// value120 to multiples of 120 before processing the event. +// +// The value120 must not be zero. +// +// This event replaces the wl_pointer.axis_discrete event in clients +// supporting wl_pointer version 8 or later. +// +// Where a wl_pointer.axis_source event occurs in the same +// wl_pointer.frame, the axis source applies to this event. +// +// The order of wl_pointer.axis_value120 and wl_pointer.axis_source is +// not guaranteed. +type PointerAxisValue120Event struct { + Axis uint32 + Value120 int32 +} +type PointerAxisValue120HandlerFunc func(PointerAxisValue120Event) + +// SetAxisValue120Handler : sets handler for PointerAxisValue120Event +func (i *Pointer) SetAxisValue120Handler(f PointerAxisValue120HandlerFunc) { + i.axisValue120Handler = f +} + +// PointerAxisRelativeDirectionEvent : axis relative physical direction event +// +// Relative directional information of the entity causing the axis +// motion. +// +// For a wl_pointer.axis event, the wl_pointer.axis_relative_direction +// event specifies the movement direction of the entity causing the +// wl_pointer.axis event. For example: +// - if a user's fingers on a touchpad move down and this +// causes a wl_pointer.axis vertical_scroll down event, the physical +// direction is 'identical' +// - if a user's fingers on a touchpad move down and this causes a +// wl_pointer.axis vertical_scroll up scroll up event ('natural +// scrolling'), the physical direction is 'inverted'. +// +// A client may use this information to adjust scroll motion of +// components. Specifically, enabling natural scrolling causes the +// content to change direction compared to traditional scrolling. +// Some widgets like volume control sliders should usually match the +// physical direction regardless of whether natural scrolling is +// active. This event enables clients to match the scroll direction of +// a widget to the physical direction. +// +// This event does not occur on its own, it is coupled with a +// wl_pointer.axis event that represents this axis value. +// The protocol guarantees that each axis_relative_direction event is +// always followed by exactly one axis event with the same +// axis number within the same wl_pointer.frame. Note that the protocol +// allows for other events to occur between the axis_relative_direction +// and its coupled axis event. +// +// The axis number is identical to the axis number in the associated +// axis event. +// +// The order of wl_pointer.axis_relative_direction, +// wl_pointer.axis_discrete and wl_pointer.axis_source is not +// guaranteed. +type PointerAxisRelativeDirectionEvent struct { + Axis uint32 + Direction uint32 +} +type PointerAxisRelativeDirectionHandlerFunc func(PointerAxisRelativeDirectionEvent) + +// SetAxisRelativeDirectionHandler : sets handler for PointerAxisRelativeDirectionEvent +func (i *Pointer) SetAxisRelativeDirectionHandler(f PointerAxisRelativeDirectionHandlerFunc) { + i.axisRelativeDirectionHandler = f +} + +func (i *Pointer) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.enterHandler == nil { + return + } + var e PointerEnterEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.Surface = i.Context().GetProxy(Uint32(data[l : l+4])).(*Surface) + l += 4 + e.SurfaceX = Fixed(data[l : l+4]) + l += 4 + e.SurfaceY = Fixed(data[l : l+4]) + l += 4 + + i.enterHandler(e) + case 1: + if i.leaveHandler == nil { + return + } + var e PointerLeaveEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.Surface = i.Context().GetProxy(Uint32(data[l : l+4])).(*Surface) + l += 4 + + i.leaveHandler(e) + case 2: + if i.motionHandler == nil { + return + } + var e PointerMotionEvent + l := 0 + e.Time = Uint32(data[l : l+4]) + l += 4 + e.SurfaceX = Fixed(data[l : l+4]) + l += 4 + e.SurfaceY = Fixed(data[l : l+4]) + l += 4 + + i.motionHandler(e) + case 3: + if i.buttonHandler == nil { + return + } + var e PointerButtonEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.Time = Uint32(data[l : l+4]) + l += 4 + e.Button = Uint32(data[l : l+4]) + l += 4 + e.State = Uint32(data[l : l+4]) + l += 4 + + i.buttonHandler(e) + case 4: + if i.axisHandler == nil { + return + } + var e PointerAxisEvent + l := 0 + e.Time = Uint32(data[l : l+4]) + l += 4 + e.Axis = Uint32(data[l : l+4]) + l += 4 + e.Value = Fixed(data[l : l+4]) + l += 4 + + i.axisHandler(e) + case 5: + if i.frameHandler == nil { + return + } + var e PointerFrameEvent + + i.frameHandler(e) + case 6: + if i.axisSourceHandler == nil { + return + } + var e PointerAxisSourceEvent + l := 0 + e.AxisSource = Uint32(data[l : l+4]) + l += 4 + + i.axisSourceHandler(e) + case 7: + if i.axisStopHandler == nil { + return + } + var e PointerAxisStopEvent + l := 0 + e.Time = Uint32(data[l : l+4]) + l += 4 + e.Axis = Uint32(data[l : l+4]) + l += 4 + + i.axisStopHandler(e) + case 8: + if i.axisDiscreteHandler == nil { + return + } + var e PointerAxisDiscreteEvent + l := 0 + e.Axis = Uint32(data[l : l+4]) + l += 4 + e.Discrete = int32(Uint32(data[l : l+4])) + l += 4 + + i.axisDiscreteHandler(e) + case 9: + if i.axisValue120Handler == nil { + return + } + var e PointerAxisValue120Event + l := 0 + e.Axis = Uint32(data[l : l+4]) + l += 4 + e.Value120 = int32(Uint32(data[l : l+4])) + l += 4 + + i.axisValue120Handler(e) + case 10: + if i.axisRelativeDirectionHandler == nil { + return + } + var e PointerAxisRelativeDirectionEvent + l := 0 + e.Axis = Uint32(data[l : l+4]) + l += 4 + e.Direction = Uint32(data[l : l+4]) + l += 4 + + i.axisRelativeDirectionHandler(e) + } +} + +// KeyboardInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const KeyboardInterfaceName = "wl_keyboard" + +// Keyboard : keyboard input device +// +// The wl_keyboard interface represents one or more keyboards +// associated with a seat. +// +// Each wl_keyboard has the following logical state: +// +// - an active surface (possibly null), +// - the keys currently logically down, +// - the active modifiers, +// - the active group. +// +// By default, the active surface is null, the keys currently logically down +// are empty, the active modifiers and the active group are 0. +type Keyboard struct { + BaseProxy + keymapHandler KeyboardKeymapHandlerFunc + enterHandler KeyboardEnterHandlerFunc + leaveHandler KeyboardLeaveHandlerFunc + keyHandler KeyboardKeyHandlerFunc + modifiersHandler KeyboardModifiersHandlerFunc + repeatInfoHandler KeyboardRepeatInfoHandlerFunc +} + +// NewKeyboard : keyboard input device +// +// The wl_keyboard interface represents one or more keyboards +// associated with a seat. +// +// Each wl_keyboard has the following logical state: +// +// - an active surface (possibly null), +// - the keys currently logically down, +// - the active modifiers, +// - the active group. +// +// By default, the active surface is null, the keys currently logically down +// are empty, the active modifiers and the active group are 0. +func NewKeyboard(ctx *Context) *Keyboard { + wlKeyboard := &Keyboard{} + ctx.Register(wlKeyboard) + return wlKeyboard +} + +// Release : release the keyboard object +func (i *Keyboard) Release() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type KeyboardKeymapFormat uint32 + +// KeyboardKeymapFormat : keyboard mapping format +// +// This specifies the format of the keymap provided to the +// client with the wl_keyboard.keymap event. +const ( + // KeyboardKeymapFormatNoKeymap : no keymap; client must understand how to interpret the raw keycode + KeyboardKeymapFormatNoKeymap KeyboardKeymapFormat = 0 + // KeyboardKeymapFormatXkbV1 : libxkbcommon compatible, null-terminated string; to determine the xkb keycode, clients must add 8 to the key event keycode + KeyboardKeymapFormatXkbV1 KeyboardKeymapFormat = 1 +) + +func (e KeyboardKeymapFormat) Name() string { + switch e { + case KeyboardKeymapFormatNoKeymap: + return "no_keymap" + case KeyboardKeymapFormatXkbV1: + return "xkb_v1" + default: + return "" + } +} + +func (e KeyboardKeymapFormat) Value() string { + switch e { + case KeyboardKeymapFormatNoKeymap: + return "0" + case KeyboardKeymapFormatXkbV1: + return "1" + default: + return "" + } +} + +func (e KeyboardKeymapFormat) String() string { + return e.Name() + "=" + e.Value() +} + +type KeyboardKeyState uint32 + +// KeyboardKeyState : physical key state +// +// Describes the physical state of a key that produced the key event. +// +// Since version 10, the key can be in a "repeated" pseudo-state which +// means the same as "pressed", but is used to signal repetition in the +// key event. +// +// The key may only enter the repeated state after entering the pressed +// state and before entering the released state. This event may be +// generated multiple times while the key is down. +const ( + // KeyboardKeyStateReleased : key is not pressed + KeyboardKeyStateReleased KeyboardKeyState = 0 + // KeyboardKeyStatePressed : key is pressed + KeyboardKeyStatePressed KeyboardKeyState = 1 + // KeyboardKeyStateRepeated : key was repeated + KeyboardKeyStateRepeated KeyboardKeyState = 2 +) + +func (e KeyboardKeyState) Name() string { + switch e { + case KeyboardKeyStateReleased: + return "released" + case KeyboardKeyStatePressed: + return "pressed" + case KeyboardKeyStateRepeated: + return "repeated" + default: + return "" + } +} + +func (e KeyboardKeyState) Value() string { + switch e { + case KeyboardKeyStateReleased: + return "0" + case KeyboardKeyStatePressed: + return "1" + case KeyboardKeyStateRepeated: + return "2" + default: + return "" + } +} + +func (e KeyboardKeyState) String() string { + return e.Name() + "=" + e.Value() +} + +// KeyboardKeymapEvent : keyboard mapping +// +// This event provides a file descriptor to the client which can be +// memory-mapped in read-only mode to provide a keyboard mapping +// description. +// +// From version 7 onwards, the fd must be mapped with MAP_PRIVATE by +// the recipient, as MAP_SHARED may fail. +type KeyboardKeymapEvent struct { + Format uint32 + Fd int + Size uint32 +} +type KeyboardKeymapHandlerFunc func(KeyboardKeymapEvent) + +// SetKeymapHandler : sets handler for KeyboardKeymapEvent +func (i *Keyboard) SetKeymapHandler(f KeyboardKeymapHandlerFunc) { + i.keymapHandler = f +} + +// KeyboardEnterEvent : enter event +// +// Notification that this seat's keyboard focus is on a certain +// surface. +// +// The compositor must send the wl_keyboard.modifiers event after this +// event. +// +// In the wl_keyboard logical state, this event sets the active surface to +// the surface argument and the keys currently logically down to the keys +// in the keys argument. The compositor must not send this event if the +// wl_keyboard already had an active surface immediately before this event. +// +// Clients should not use the list of pressed keys to emulate key-press +// events. The order of keys in the list is unspecified. +type KeyboardEnterEvent struct { + Serial uint32 + Surface *Surface + Keys []byte +} +type KeyboardEnterHandlerFunc func(KeyboardEnterEvent) + +// SetEnterHandler : sets handler for KeyboardEnterEvent +func (i *Keyboard) SetEnterHandler(f KeyboardEnterHandlerFunc) { + i.enterHandler = f +} + +// KeyboardLeaveEvent : leave event +// +// Notification that this seat's keyboard focus is no longer on +// a certain surface. +// +// The leave notification is sent before the enter notification +// for the new focus. +// +// In the wl_keyboard logical state, this event resets all values to their +// defaults. The compositor must not send this event if the active surface +// of the wl_keyboard was not equal to the surface argument immediately +// before this event. +type KeyboardLeaveEvent struct { + Serial uint32 + Surface *Surface +} +type KeyboardLeaveHandlerFunc func(KeyboardLeaveEvent) + +// SetLeaveHandler : sets handler for KeyboardLeaveEvent +func (i *Keyboard) SetLeaveHandler(f KeyboardLeaveHandlerFunc) { + i.leaveHandler = f +} + +// KeyboardKeyEvent : key event +// +// A key was pressed or released. +// The time argument is a timestamp with millisecond +// granularity, with an undefined base. +// +// The key is a platform-specific key code that can be interpreted +// by feeding it to the keyboard mapping (see the keymap event). +// +// If this event produces a change in modifiers, then the resulting +// wl_keyboard.modifiers event must be sent after this event. +// +// In the wl_keyboard logical state, this event adds the key to the keys +// currently logically down (if the state argument is pressed) or removes +// the key from the keys currently logically down (if the state argument is +// released). The compositor must not send this event if the wl_keyboard +// did not have an active surface immediately before this event. The +// compositor must not send this event if state is pressed (resp. released) +// and the key was already logically down (resp. was not logically down) +// immediately before this event. +// +// Since version 10, compositors may send key events with the "repeated" +// key state when a wl_keyboard.repeat_info event with a rate argument of +// 0 has been received. This allows the compositor to take over the +// responsibility of key repetition. +type KeyboardKeyEvent struct { + Serial uint32 + Time uint32 + Key uint32 + State uint32 +} +type KeyboardKeyHandlerFunc func(KeyboardKeyEvent) + +// SetKeyHandler : sets handler for KeyboardKeyEvent +func (i *Keyboard) SetKeyHandler(f KeyboardKeyHandlerFunc) { + i.keyHandler = f +} + +// KeyboardModifiersEvent : modifier and group state +// +// Notifies clients that the modifier and/or group state has +// changed, and it should update its local state. +// +// The compositor may send this event without a surface of the client +// having keyboard focus, for example to tie modifier information to +// pointer focus instead. If a modifier event with pressed modifiers is sent +// without a prior enter event, the client can assume the modifier state is +// valid until it receives the next wl_keyboard.modifiers event. In order to +// reset the modifier state again, the compositor can send a +// wl_keyboard.modifiers event with no pressed modifiers. +// +// In the wl_keyboard logical state, this event updates the modifiers and +// group. +type KeyboardModifiersEvent struct { + Serial uint32 + ModsDepressed uint32 + ModsLatched uint32 + ModsLocked uint32 + Group uint32 +} +type KeyboardModifiersHandlerFunc func(KeyboardModifiersEvent) + +// SetModifiersHandler : sets handler for KeyboardModifiersEvent +func (i *Keyboard) SetModifiersHandler(f KeyboardModifiersHandlerFunc) { + i.modifiersHandler = f +} + +// KeyboardRepeatInfoEvent : repeat rate and delay +// +// Informs the client about the keyboard's repeat rate and delay. +// +// This event is sent as soon as the wl_keyboard object has been created, +// and is guaranteed to be received by the client before any key press +// event. +// +// Negative values for either rate or delay are illegal. A rate of zero +// will disable any repeating (regardless of the value of delay). +// +// This event can be sent later on as well with a new value if necessary, +// so clients should continue listening for the event past the creation +// of wl_keyboard. +type KeyboardRepeatInfoEvent struct { + Rate int32 + Delay int32 +} +type KeyboardRepeatInfoHandlerFunc func(KeyboardRepeatInfoEvent) + +// SetRepeatInfoHandler : sets handler for KeyboardRepeatInfoEvent +func (i *Keyboard) SetRepeatInfoHandler(f KeyboardRepeatInfoHandlerFunc) { + i.repeatInfoHandler = f +} + +func (i *Keyboard) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.keymapHandler == nil { + if fd != -1 { + unix.Close(fd) + } + return + } + var e KeyboardKeymapEvent + l := 0 + e.Format = Uint32(data[l : l+4]) + l += 4 + e.Fd = fd + e.Size = Uint32(data[l : l+4]) + l += 4 + + i.keymapHandler(e) + case 1: + if i.enterHandler == nil { + return + } + var e KeyboardEnterEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.Surface = i.Context().GetProxy(Uint32(data[l : l+4])).(*Surface) + l += 4 + keysLen := int(Uint32(data[l : l+4])) + l += 4 + e.Keys = make([]byte, keysLen) + copy(e.Keys, data[l:l+keysLen]) + l += keysLen + + i.enterHandler(e) + case 2: + if i.leaveHandler == nil { + return + } + var e KeyboardLeaveEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.Surface = i.Context().GetProxy(Uint32(data[l : l+4])).(*Surface) + l += 4 + + i.leaveHandler(e) + case 3: + if i.keyHandler == nil { + return + } + var e KeyboardKeyEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.Time = Uint32(data[l : l+4]) + l += 4 + e.Key = Uint32(data[l : l+4]) + l += 4 + e.State = Uint32(data[l : l+4]) + l += 4 + + i.keyHandler(e) + case 4: + if i.modifiersHandler == nil { + return + } + var e KeyboardModifiersEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.ModsDepressed = Uint32(data[l : l+4]) + l += 4 + e.ModsLatched = Uint32(data[l : l+4]) + l += 4 + e.ModsLocked = Uint32(data[l : l+4]) + l += 4 + e.Group = Uint32(data[l : l+4]) + l += 4 + + i.modifiersHandler(e) + case 5: + if i.repeatInfoHandler == nil { + return + } + var e KeyboardRepeatInfoEvent + l := 0 + e.Rate = int32(Uint32(data[l : l+4])) + l += 4 + e.Delay = int32(Uint32(data[l : l+4])) + l += 4 + + i.repeatInfoHandler(e) + } +} + +// TouchInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const TouchInterfaceName = "wl_touch" + +// Touch : touchscreen input device +// +// The wl_touch interface represents a touchscreen +// associated with a seat. +// +// Touch interactions can consist of one or more contacts. +// For each contact, a series of events is generated, starting +// with a down event, followed by zero or more motion events, +// and ending with an up event. Events relating to the same +// contact point can be identified by the ID of the sequence. +type Touch struct { + BaseProxy + downHandler TouchDownHandlerFunc + upHandler TouchUpHandlerFunc + motionHandler TouchMotionHandlerFunc + frameHandler TouchFrameHandlerFunc + cancelHandler TouchCancelHandlerFunc + shapeHandler TouchShapeHandlerFunc + orientationHandler TouchOrientationHandlerFunc +} + +// NewTouch : touchscreen input device +// +// The wl_touch interface represents a touchscreen +// associated with a seat. +// +// Touch interactions can consist of one or more contacts. +// For each contact, a series of events is generated, starting +// with a down event, followed by zero or more motion events, +// and ending with an up event. Events relating to the same +// contact point can be identified by the ID of the sequence. +func NewTouch(ctx *Context) *Touch { + wlTouch := &Touch{} + ctx.Register(wlTouch) + return wlTouch +} + +// Release : release the touch object +func (i *Touch) Release() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// TouchDownEvent : touch down event and beginning of a touch sequence +// +// A new touch point has appeared on the surface. This touch point is +// assigned a unique ID. Future events from this touch point reference +// this ID. The ID ceases to be valid after a touch up event and may be +// reused in the future. +type TouchDownEvent struct { + Serial uint32 + Time uint32 + Surface *Surface + Id int32 + X float64 + Y float64 +} +type TouchDownHandlerFunc func(TouchDownEvent) + +// SetDownHandler : sets handler for TouchDownEvent +func (i *Touch) SetDownHandler(f TouchDownHandlerFunc) { + i.downHandler = f +} + +// TouchUpEvent : end of a touch event sequence +// +// The touch point has disappeared. No further events will be sent for +// this touch point and the touch point's ID is released and may be +// reused in a future touch down event. +type TouchUpEvent struct { + Serial uint32 + Time uint32 + Id int32 +} +type TouchUpHandlerFunc func(TouchUpEvent) + +// SetUpHandler : sets handler for TouchUpEvent +func (i *Touch) SetUpHandler(f TouchUpHandlerFunc) { + i.upHandler = f +} + +// TouchMotionEvent : update of touch point coordinates +// +// A touch point has changed coordinates. +type TouchMotionEvent struct { + Time uint32 + Id int32 + X float64 + Y float64 +} +type TouchMotionHandlerFunc func(TouchMotionEvent) + +// SetMotionHandler : sets handler for TouchMotionEvent +func (i *Touch) SetMotionHandler(f TouchMotionHandlerFunc) { + i.motionHandler = f +} + +// TouchFrameEvent : end of touch frame event +// +// Indicates the end of a set of events that logically belong together. +// A client is expected to accumulate the data in all events within the +// frame before proceeding. +// +// A wl_touch.frame terminates at least one event but otherwise no +// guarantee is provided about the set of events within a frame. A client +// must assume that any state not updated in a frame is unchanged from the +// previously known state. +type TouchFrameEvent struct{} +type TouchFrameHandlerFunc func(TouchFrameEvent) + +// SetFrameHandler : sets handler for TouchFrameEvent +func (i *Touch) SetFrameHandler(f TouchFrameHandlerFunc) { + i.frameHandler = f +} + +// TouchCancelEvent : touch session cancelled +// +// Sent if the compositor decides the touch stream is a global +// gesture. No further events are sent to the clients from that +// particular gesture. Touch cancellation applies to all touch points +// currently active on this client's surface. The client is +// responsible for finalizing the touch points, future touch points on +// this surface may reuse the touch point ID. +// +// No frame event is required after the cancel event. +type TouchCancelEvent struct{} +type TouchCancelHandlerFunc func(TouchCancelEvent) + +// SetCancelHandler : sets handler for TouchCancelEvent +func (i *Touch) SetCancelHandler(f TouchCancelHandlerFunc) { + i.cancelHandler = f +} + +// TouchShapeEvent : update shape of touch point +// +// Sent when a touchpoint has changed its shape. +// +// This event does not occur on its own. It is sent before a +// wl_touch.frame event and carries the new shape information for +// any previously reported, or new touch points of that frame. +// +// Other events describing the touch point such as wl_touch.down, +// wl_touch.motion or wl_touch.orientation may be sent within the +// same wl_touch.frame. A client should treat these events as a single +// logical touch point update. The order of wl_touch.shape, +// wl_touch.orientation and wl_touch.motion is not guaranteed. +// A wl_touch.down event is guaranteed to occur before the first +// wl_touch.shape event for this touch ID but both events may occur within +// the same wl_touch.frame. +// +// A touchpoint shape is approximated by an ellipse through the major and +// minor axis length. The major axis length describes the longer diameter +// of the ellipse, while the minor axis length describes the shorter +// diameter. Major and minor are orthogonal and both are specified in +// surface-local coordinates. The center of the ellipse is always at the +// touchpoint location as reported by wl_touch.down or wl_touch.move. +// +// This event is only sent by the compositor if the touch device supports +// shape reports. The client has to make reasonable assumptions about the +// shape if it did not receive this event. +type TouchShapeEvent struct { + Id int32 + Major float64 + Minor float64 +} +type TouchShapeHandlerFunc func(TouchShapeEvent) + +// SetShapeHandler : sets handler for TouchShapeEvent +func (i *Touch) SetShapeHandler(f TouchShapeHandlerFunc) { + i.shapeHandler = f +} + +// TouchOrientationEvent : update orientation of touch point +// +// Sent when a touchpoint has changed its orientation. +// +// This event does not occur on its own. It is sent before a +// wl_touch.frame event and carries the new shape information for +// any previously reported, or new touch points of that frame. +// +// Other events describing the touch point such as wl_touch.down, +// wl_touch.motion or wl_touch.shape may be sent within the +// same wl_touch.frame. A client should treat these events as a single +// logical touch point update. The order of wl_touch.shape, +// wl_touch.orientation and wl_touch.motion is not guaranteed. +// A wl_touch.down event is guaranteed to occur before the first +// wl_touch.orientation event for this touch ID but both events may occur +// within the same wl_touch.frame. +// +// The orientation describes the clockwise angle of a touchpoint's major +// axis to the positive surface y-axis and is normalized to the -180 to +// +180 degree range. The granularity of orientation depends on the touch +// device, some devices only support binary rotation values between 0 and +// 90 degrees. +// +// This event is only sent by the compositor if the touch device supports +// orientation reports. +type TouchOrientationEvent struct { + Id int32 + Orientation float64 +} +type TouchOrientationHandlerFunc func(TouchOrientationEvent) + +// SetOrientationHandler : sets handler for TouchOrientationEvent +func (i *Touch) SetOrientationHandler(f TouchOrientationHandlerFunc) { + i.orientationHandler = f +} + +func (i *Touch) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.downHandler == nil { + return + } + var e TouchDownEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.Time = Uint32(data[l : l+4]) + l += 4 + e.Surface = i.Context().GetProxy(Uint32(data[l : l+4])).(*Surface) + l += 4 + e.Id = int32(Uint32(data[l : l+4])) + l += 4 + e.X = Fixed(data[l : l+4]) + l += 4 + e.Y = Fixed(data[l : l+4]) + l += 4 + + i.downHandler(e) + case 1: + if i.upHandler == nil { + return + } + var e TouchUpEvent + l := 0 + e.Serial = Uint32(data[l : l+4]) + l += 4 + e.Time = Uint32(data[l : l+4]) + l += 4 + e.Id = int32(Uint32(data[l : l+4])) + l += 4 + + i.upHandler(e) + case 2: + if i.motionHandler == nil { + return + } + var e TouchMotionEvent + l := 0 + e.Time = Uint32(data[l : l+4]) + l += 4 + e.Id = int32(Uint32(data[l : l+4])) + l += 4 + e.X = Fixed(data[l : l+4]) + l += 4 + e.Y = Fixed(data[l : l+4]) + l += 4 + + i.motionHandler(e) + case 3: + if i.frameHandler == nil { + return + } + var e TouchFrameEvent + + i.frameHandler(e) + case 4: + if i.cancelHandler == nil { + return + } + var e TouchCancelEvent + + i.cancelHandler(e) + case 5: + if i.shapeHandler == nil { + return + } + var e TouchShapeEvent + l := 0 + e.Id = int32(Uint32(data[l : l+4])) + l += 4 + e.Major = Fixed(data[l : l+4]) + l += 4 + e.Minor = Fixed(data[l : l+4]) + l += 4 + + i.shapeHandler(e) + case 6: + if i.orientationHandler == nil { + return + } + var e TouchOrientationEvent + l := 0 + e.Id = int32(Uint32(data[l : l+4])) + l += 4 + e.Orientation = Fixed(data[l : l+4]) + l += 4 + + i.orientationHandler(e) + } +} + +// OutputInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const OutputInterfaceName = "wl_output" + +// Output : compositor output region +// +// An output describes part of the compositor geometry. The +// compositor works in the 'compositor coordinate system' and an +// output corresponds to a rectangular area in that space that is +// actually visible. This typically corresponds to a monitor that +// displays part of the compositor space. This object is published +// as global during start up, or when a monitor is hotplugged. +type Output struct { + BaseProxy + geometryHandler OutputGeometryHandlerFunc + modeHandler OutputModeHandlerFunc + doneHandler OutputDoneHandlerFunc + scaleHandler OutputScaleHandlerFunc + nameHandler OutputNameHandlerFunc + descriptionHandler OutputDescriptionHandlerFunc +} + +// NewOutput : compositor output region +// +// An output describes part of the compositor geometry. The +// compositor works in the 'compositor coordinate system' and an +// output corresponds to a rectangular area in that space that is +// actually visible. This typically corresponds to a monitor that +// displays part of the compositor space. This object is published +// as global during start up, or when a monitor is hotplugged. +func NewOutput(ctx *Context) *Output { + wlOutput := &Output{} + ctx.Register(wlOutput) + return wlOutput +} + +// Release : release the output object +// +// Using this request a client can tell the server that it is not going to +// use the output object anymore. +func (i *Output) Release() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type OutputSubpixel uint32 + +// OutputSubpixel : subpixel geometry information +// +// This enumeration describes how the physical +// pixels on an output are laid out. +const ( + // OutputSubpixelUnknown : unknown geometry + OutputSubpixelUnknown OutputSubpixel = 0 + // OutputSubpixelNone : no geometry + OutputSubpixelNone OutputSubpixel = 1 + // OutputSubpixelHorizontalRgb : horizontal RGB + OutputSubpixelHorizontalRgb OutputSubpixel = 2 + // OutputSubpixelHorizontalBgr : horizontal BGR + OutputSubpixelHorizontalBgr OutputSubpixel = 3 + // OutputSubpixelVerticalRgb : vertical RGB + OutputSubpixelVerticalRgb OutputSubpixel = 4 + // OutputSubpixelVerticalBgr : vertical BGR + OutputSubpixelVerticalBgr OutputSubpixel = 5 +) + +func (e OutputSubpixel) Name() string { + switch e { + case OutputSubpixelUnknown: + return "unknown" + case OutputSubpixelNone: + return "none" + case OutputSubpixelHorizontalRgb: + return "horizontal_rgb" + case OutputSubpixelHorizontalBgr: + return "horizontal_bgr" + case OutputSubpixelVerticalRgb: + return "vertical_rgb" + case OutputSubpixelVerticalBgr: + return "vertical_bgr" + default: + return "" + } +} + +func (e OutputSubpixel) Value() string { + switch e { + case OutputSubpixelUnknown: + return "0" + case OutputSubpixelNone: + return "1" + case OutputSubpixelHorizontalRgb: + return "2" + case OutputSubpixelHorizontalBgr: + return "3" + case OutputSubpixelVerticalRgb: + return "4" + case OutputSubpixelVerticalBgr: + return "5" + default: + return "" + } +} + +func (e OutputSubpixel) String() string { + return e.Name() + "=" + e.Value() +} + +type OutputTransform uint32 + +// OutputTransform : transformation applied to buffer contents +// +// This describes transformations that clients and compositors apply to +// buffer contents. +// +// The flipped values correspond to an initial flip around a +// vertical axis followed by rotation. +// +// The purpose is mainly to allow clients to render accordingly and +// tell the compositor, so that for fullscreen surfaces, the +// compositor will still be able to scan out directly from client +// surfaces. +const ( + // OutputTransformNormal : no transform + OutputTransformNormal OutputTransform = 0 + // OutputTransform90 : 90 degrees counter-clockwise + OutputTransform90 OutputTransform = 1 + // OutputTransform180 : 180 degrees counter-clockwise + OutputTransform180 OutputTransform = 2 + // OutputTransform270 : 270 degrees counter-clockwise + OutputTransform270 OutputTransform = 3 + // OutputTransformFlipped : 180 degree flip around a vertical axis + OutputTransformFlipped OutputTransform = 4 + // OutputTransformFlipped90 : flip and rotate 90 degrees counter-clockwise + OutputTransformFlipped90 OutputTransform = 5 + // OutputTransformFlipped180 : flip and rotate 180 degrees counter-clockwise + OutputTransformFlipped180 OutputTransform = 6 + // OutputTransformFlipped270 : flip and rotate 270 degrees counter-clockwise + OutputTransformFlipped270 OutputTransform = 7 +) + +func (e OutputTransform) Name() string { + switch e { + case OutputTransformNormal: + return "normal" + case OutputTransform90: + return "90" + case OutputTransform180: + return "180" + case OutputTransform270: + return "270" + case OutputTransformFlipped: + return "flipped" + case OutputTransformFlipped90: + return "flipped_90" + case OutputTransformFlipped180: + return "flipped_180" + case OutputTransformFlipped270: + return "flipped_270" + default: + return "" + } +} + +func (e OutputTransform) Value() string { + switch e { + case OutputTransformNormal: + return "0" + case OutputTransform90: + return "1" + case OutputTransform180: + return "2" + case OutputTransform270: + return "3" + case OutputTransformFlipped: + return "4" + case OutputTransformFlipped90: + return "5" + case OutputTransformFlipped180: + return "6" + case OutputTransformFlipped270: + return "7" + default: + return "" + } +} + +func (e OutputTransform) String() string { + return e.Name() + "=" + e.Value() +} + +type OutputMode uint32 + +// OutputMode : mode information +// +// These flags describe properties of an output mode. +// They are used in the flags bitfield of the mode event. +const ( + // OutputModeCurrent : indicates this is the current mode + OutputModeCurrent OutputMode = 0x1 + // OutputModePreferred : indicates this is the preferred mode + OutputModePreferred OutputMode = 0x2 +) + +func (e OutputMode) Name() string { + switch e { + case OutputModeCurrent: + return "current" + case OutputModePreferred: + return "preferred" + default: + return "" + } +} + +func (e OutputMode) Value() string { + switch e { + case OutputModeCurrent: + return "0x1" + case OutputModePreferred: + return "0x2" + default: + return "" + } +} + +func (e OutputMode) String() string { + return e.Name() + "=" + e.Value() +} + +// OutputGeometryEvent : properties of the output +// +// The geometry event describes geometric properties of the output. +// The event is sent when binding to the output object and whenever +// any of the properties change. +// +// The physical size can be set to zero if it doesn't make sense for this +// output (e.g. for projectors or virtual outputs). +// +// The geometry event will be followed by a done event (starting from +// version 2). +// +// Clients should use wl_surface.preferred_buffer_transform instead of the +// transform advertised by this event to find the preferred buffer +// transform to use for a surface. +// +// Note: wl_output only advertises partial information about the output +// position and identification. Some compositors, for instance those not +// implementing a desktop-style output layout or those exposing virtual +// outputs, might fake this information. Instead of using x and y, clients +// should use xdg_output.logical_position. Instead of using make and model, +// clients should use name and description. +type OutputGeometryEvent struct { + X int32 + Y int32 + PhysicalWidth int32 + PhysicalHeight int32 + Subpixel int32 + Make string + Model string + Transform int32 +} +type OutputGeometryHandlerFunc func(OutputGeometryEvent) + +// SetGeometryHandler : sets handler for OutputGeometryEvent +func (i *Output) SetGeometryHandler(f OutputGeometryHandlerFunc) { + i.geometryHandler = f +} + +// OutputModeEvent : advertise available modes for the output +// +// The mode event describes an available mode for the output. +// +// The event is sent when binding to the output object and there +// will always be one mode, the current mode. The event is sent +// again if an output changes mode, for the mode that is now +// current. In other words, the current mode is always the last +// mode that was received with the current flag set. +// +// Non-current modes are deprecated. A compositor can decide to only +// advertise the current mode and never send other modes. Clients +// should not rely on non-current modes. +// +// The size of a mode is given in physical hardware units of +// the output device. This is not necessarily the same as +// the output size in the global compositor space. For instance, +// the output may be scaled, as described in wl_output.scale, +// or transformed, as described in wl_output.transform. Clients +// willing to retrieve the output size in the global compositor +// space should use xdg_output.logical_size instead. +// +// The vertical refresh rate can be set to zero if it doesn't make +// sense for this output (e.g. for virtual outputs). +// +// The mode event will be followed by a done event (starting from +// version 2). +// +// Clients should not use the refresh rate to schedule frames. Instead, +// they should use the wl_surface.frame event or the presentation-time +// protocol. +// +// Note: this information is not always meaningful for all outputs. Some +// compositors, such as those exposing virtual outputs, might fake the +// refresh rate or the size. +type OutputModeEvent struct { + Flags uint32 + Width int32 + Height int32 + Refresh int32 +} +type OutputModeHandlerFunc func(OutputModeEvent) + +// SetModeHandler : sets handler for OutputModeEvent +func (i *Output) SetModeHandler(f OutputModeHandlerFunc) { + i.modeHandler = f +} + +// OutputDoneEvent : sent all information about output +// +// This event is sent after all other properties have been +// sent after binding to the output object and after any +// other property changes done after that. This allows +// changes to the output properties to be seen as +// atomic, even if they happen via multiple events. +type OutputDoneEvent struct{} +type OutputDoneHandlerFunc func(OutputDoneEvent) + +// SetDoneHandler : sets handler for OutputDoneEvent +func (i *Output) SetDoneHandler(f OutputDoneHandlerFunc) { + i.doneHandler = f +} + +// OutputScaleEvent : output scaling properties +// +// This event contains scaling geometry information +// that is not in the geometry event. It may be sent after +// binding the output object or if the output scale changes +// later. The compositor will emit a non-zero, positive +// value for scale. If it is not sent, the client should +// assume a scale of 1. +// +// A scale larger than 1 means that the compositor will +// automatically scale surface buffers by this amount +// when rendering. This is used for very high resolution +// displays where applications rendering at the native +// resolution would be too small to be legible. +// +// Clients should use wl_surface.preferred_buffer_scale +// instead of this event to find the preferred buffer +// scale to use for a surface. +// +// The scale event will be followed by a done event. +type OutputScaleEvent struct { + Factor int32 +} +type OutputScaleHandlerFunc func(OutputScaleEvent) + +// SetScaleHandler : sets handler for OutputScaleEvent +func (i *Output) SetScaleHandler(f OutputScaleHandlerFunc) { + i.scaleHandler = f +} + +// OutputNameEvent : name of this output +// +// Many compositors will assign user-friendly names to their outputs, show +// them to the user, allow the user to refer to an output, etc. The client +// may wish to know this name as well to offer the user similar behaviors. +// +// The name is a UTF-8 string with no convention defined for its contents. +// Each name is unique among all wl_output globals. The name is only +// guaranteed to be unique for the compositor instance. +// +// The same output name is used for all clients for a given wl_output +// global. Thus, the name can be shared across processes to refer to a +// specific wl_output global. +// +// The name is not guaranteed to be persistent across sessions, thus cannot +// be used to reliably identify an output in e.g. configuration files. +// +// Examples of names include 'HDMI-A-1', 'WL-1', 'X11-1', etc. However, do +// not assume that the name is a reflection of an underlying DRM connector, +// X11 connection, etc. +// +// The name event is sent after binding the output object. This event is +// only sent once per output object, and the name does not change over the +// lifetime of the wl_output global. +// +// Compositors may re-use the same output name if the wl_output global is +// destroyed and re-created later. Compositors should avoid re-using the +// same name if possible. +// +// The name event will be followed by a done event. +type OutputNameEvent struct { + Name string +} +type OutputNameHandlerFunc func(OutputNameEvent) + +// SetNameHandler : sets handler for OutputNameEvent +func (i *Output) SetNameHandler(f OutputNameHandlerFunc) { + i.nameHandler = f +} + +// OutputDescriptionEvent : human-readable description of this output +// +// Many compositors can produce human-readable descriptions of their +// outputs. The client may wish to know this description as well, e.g. for +// output selection purposes. +// +// The description is a UTF-8 string with no convention defined for its +// contents. The description is not guaranteed to be unique among all +// wl_output globals. Examples might include 'Foocorp 11" Display' or +// 'Virtual X11 output via :1'. +// +// The description event is sent after binding the output object and +// whenever the description changes. The description is optional, and may +// not be sent at all. +// +// The description event will be followed by a done event. +type OutputDescriptionEvent struct { + Description string +} +type OutputDescriptionHandlerFunc func(OutputDescriptionEvent) + +// SetDescriptionHandler : sets handler for OutputDescriptionEvent +func (i *Output) SetDescriptionHandler(f OutputDescriptionHandlerFunc) { + i.descriptionHandler = f +} + +func (i *Output) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.geometryHandler == nil { + return + } + var e OutputGeometryEvent + l := 0 + e.X = int32(Uint32(data[l : l+4])) + l += 4 + e.Y = int32(Uint32(data[l : l+4])) + l += 4 + e.PhysicalWidth = int32(Uint32(data[l : l+4])) + l += 4 + e.PhysicalHeight = int32(Uint32(data[l : l+4])) + l += 4 + e.Subpixel = int32(Uint32(data[l : l+4])) + l += 4 + makeLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.Make = String(data[l : l+makeLen]) + l += makeLen + modelLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.Model = String(data[l : l+modelLen]) + l += modelLen + e.Transform = int32(Uint32(data[l : l+4])) + l += 4 + + i.geometryHandler(e) + case 1: + if i.modeHandler == nil { + return + } + var e OutputModeEvent + l := 0 + e.Flags = Uint32(data[l : l+4]) + l += 4 + e.Width = int32(Uint32(data[l : l+4])) + l += 4 + e.Height = int32(Uint32(data[l : l+4])) + l += 4 + e.Refresh = int32(Uint32(data[l : l+4])) + l += 4 + + i.modeHandler(e) + case 2: + if i.doneHandler == nil { + return + } + var e OutputDoneEvent + + i.doneHandler(e) + case 3: + if i.scaleHandler == nil { + return + } + var e OutputScaleEvent + l := 0 + e.Factor = int32(Uint32(data[l : l+4])) + l += 4 + + i.scaleHandler(e) + case 4: + if i.nameHandler == nil { + return + } + var e OutputNameEvent + l := 0 + nameLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.Name = String(data[l : l+nameLen]) + l += nameLen + + i.nameHandler(e) + case 5: + if i.descriptionHandler == nil { + return + } + var e OutputDescriptionEvent + l := 0 + descriptionLen := PaddedLen(int(Uint32(data[l : l+4]))) + l += 4 + e.Description = String(data[l : l+descriptionLen]) + l += descriptionLen + + i.descriptionHandler(e) + } +} + +// RegionInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const RegionInterfaceName = "wl_region" + +// Region : region interface +// +// A region object describes an area. +// +// Region objects are used to describe the opaque and input +// regions of a surface. +type Region struct { + BaseProxy +} + +// NewRegion : region interface +// +// A region object describes an area. +// +// Region objects are used to describe the opaque and input +// regions of a surface. +func NewRegion(ctx *Context) *Region { + wlRegion := &Region{} + ctx.Register(wlRegion) + return wlRegion +} + +// Destroy : destroy region +// +// Destroy the region. This will invalidate the object ID. +func (i *Region) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Add : add rectangle to region +// +// Add the specified rectangle to the region. +// +// x: region-local x coordinate +// y: region-local y coordinate +// width: rectangle width +// height: rectangle height +func (i *Region) Add(x, y, width, height int32) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Subtract : subtract rectangle from region +// +// Subtract the specified rectangle from the region. +// +// x: region-local x coordinate +// y: region-local y coordinate +// width: rectangle width +// height: rectangle height +func (i *Region) Subtract(x, y, width, height int32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SubcompositorInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const SubcompositorInterfaceName = "wl_subcompositor" + +// Subcompositor : sub-surface compositing +// +// The global interface exposing sub-surface compositing capabilities. +// A wl_surface, that has sub-surfaces associated, is called the +// parent surface. Sub-surfaces can be arbitrarily nested and create +// a tree of sub-surfaces. +// +// The root surface in a tree of sub-surfaces is the main +// surface. The main surface cannot be a sub-surface, because +// sub-surfaces must always have a parent. +// +// A main surface with its sub-surfaces forms a (compound) window. +// For window management purposes, this set of wl_surface objects is +// to be considered as a single window, and it should also behave as +// such. +// +// The aim of sub-surfaces is to offload some of the compositing work +// within a window from clients to the compositor. A prime example is +// a video player with decorations and video in separate wl_surface +// objects. This should allow the compositor to pass YUV video buffer +// processing to dedicated overlay hardware when possible. +type Subcompositor struct { + BaseProxy +} + +// NewSubcompositor : sub-surface compositing +// +// The global interface exposing sub-surface compositing capabilities. +// A wl_surface, that has sub-surfaces associated, is called the +// parent surface. Sub-surfaces can be arbitrarily nested and create +// a tree of sub-surfaces. +// +// The root surface in a tree of sub-surfaces is the main +// surface. The main surface cannot be a sub-surface, because +// sub-surfaces must always have a parent. +// +// A main surface with its sub-surfaces forms a (compound) window. +// For window management purposes, this set of wl_surface objects is +// to be considered as a single window, and it should also behave as +// such. +// +// The aim of sub-surfaces is to offload some of the compositing work +// within a window from clients to the compositor. A prime example is +// a video player with decorations and video in separate wl_surface +// objects. This should allow the compositor to pass YUV video buffer +// processing to dedicated overlay hardware when possible. +func NewSubcompositor(ctx *Context) *Subcompositor { + wlSubcompositor := &Subcompositor{} + ctx.Register(wlSubcompositor) + return wlSubcompositor +} + +// Destroy : unbind from the subcompositor interface +// +// Informs the server that the client will not be using this +// protocol object anymore. This does not affect any other +// objects, wl_subsurface objects included. +func (i *Subcompositor) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// GetSubsurface : give a surface the role sub-surface +// +// Create a sub-surface interface for the given surface, and +// associate it with the given parent surface. This turns a +// plain wl_surface into a sub-surface. +// +// The to-be sub-surface must not already have another role, and it +// must not have an existing wl_subsurface object. Otherwise the +// bad_surface protocol error is raised. +// +// Adding sub-surfaces to a parent is a double-buffered operation on the +// parent (see wl_surface.commit). The effect of adding a sub-surface +// becomes visible on the next time the state of the parent surface is +// applied. +// +// The parent surface must not be one of the child surface's descendants, +// and the parent must be different from the child surface, otherwise the +// bad_parent protocol error is raised. +// +// This request modifies the behaviour of wl_surface.commit request on +// the sub-surface, see the documentation on wl_subsurface interface. +// +// surface: the surface to be turned into a sub-surface +// parent: the parent surface +func (i *Subcompositor) GetSubsurface(surface, parent *Surface) (*Subsurface, error) { + id := NewSubsurface(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], id.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], surface.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], parent.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return id, err +} + +type SubcompositorError uint32 + +// SubcompositorError : +const ( + // SubcompositorErrorBadSurface : the to-be sub-surface is invalid + SubcompositorErrorBadSurface SubcompositorError = 0 + // SubcompositorErrorBadParent : the to-be sub-surface parent is invalid + SubcompositorErrorBadParent SubcompositorError = 1 +) + +func (e SubcompositorError) Name() string { + switch e { + case SubcompositorErrorBadSurface: + return "bad_surface" + case SubcompositorErrorBadParent: + return "bad_parent" + default: + return "" + } +} + +func (e SubcompositorError) Value() string { + switch e { + case SubcompositorErrorBadSurface: + return "0" + case SubcompositorErrorBadParent: + return "1" + default: + return "" + } +} + +func (e SubcompositorError) String() string { + return e.Name() + "=" + e.Value() +} + +// SubsurfaceInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const SubsurfaceInterfaceName = "wl_subsurface" + +// Subsurface : sub-surface interface to a wl_surface +// +// An additional interface to a wl_surface object, which has been +// made a sub-surface. A sub-surface has one parent surface. A +// sub-surface's size and position are not limited to that of the parent. +// Particularly, a sub-surface is not automatically clipped to its +// parent's area. +// +// A sub-surface becomes mapped, when a non-NULL wl_buffer is applied +// and the parent surface is mapped. The order of which one happens +// first is irrelevant. A sub-surface is hidden if the parent becomes +// hidden, or if a NULL wl_buffer is applied. These rules apply +// recursively through the tree of surfaces. +// +// The behaviour of a wl_surface.commit request on a sub-surface +// depends on the sub-surface's mode. The possible modes are +// synchronized and desynchronized, see methods +// wl_subsurface.set_sync and wl_subsurface.set_desync. Synchronized +// mode caches the wl_surface state to be applied when the parent's +// state gets applied, and desynchronized mode applies the pending +// wl_surface state directly. A sub-surface is initially in the +// synchronized mode. +// +// Sub-surfaces also have another kind of state, which is managed by +// wl_subsurface requests, as opposed to wl_surface requests. This +// state includes the sub-surface position relative to the parent +// surface (wl_subsurface.set_position), and the stacking order of +// the parent and its sub-surfaces (wl_subsurface.place_above and +// .place_below). This state is applied when the parent surface's +// wl_surface state is applied, regardless of the sub-surface's mode. +// As the exception, set_sync and set_desync are effective immediately. +// +// The main surface can be thought to be always in desynchronized mode, +// since it does not have a parent in the sub-surfaces sense. +// +// Even if a sub-surface is in desynchronized mode, it will behave as +// in synchronized mode, if its parent surface behaves as in +// synchronized mode. This rule is applied recursively throughout the +// tree of surfaces. This means, that one can set a sub-surface into +// synchronized mode, and then assume that all its child and grand-child +// sub-surfaces are synchronized, too, without explicitly setting them. +// +// Destroying a sub-surface takes effect immediately. If you need to +// synchronize the removal of a sub-surface to the parent surface update, +// unmap the sub-surface first by attaching a NULL wl_buffer, update parent, +// and then destroy the sub-surface. +// +// If the parent wl_surface object is destroyed, the sub-surface is +// unmapped. +// +// A sub-surface never has the keyboard focus of any seat. +// +// The wl_surface.offset request is ignored: clients must use set_position +// instead to move the sub-surface. +type Subsurface struct { + BaseProxy +} + +// NewSubsurface : sub-surface interface to a wl_surface +// +// An additional interface to a wl_surface object, which has been +// made a sub-surface. A sub-surface has one parent surface. A +// sub-surface's size and position are not limited to that of the parent. +// Particularly, a sub-surface is not automatically clipped to its +// parent's area. +// +// A sub-surface becomes mapped, when a non-NULL wl_buffer is applied +// and the parent surface is mapped. The order of which one happens +// first is irrelevant. A sub-surface is hidden if the parent becomes +// hidden, or if a NULL wl_buffer is applied. These rules apply +// recursively through the tree of surfaces. +// +// The behaviour of a wl_surface.commit request on a sub-surface +// depends on the sub-surface's mode. The possible modes are +// synchronized and desynchronized, see methods +// wl_subsurface.set_sync and wl_subsurface.set_desync. Synchronized +// mode caches the wl_surface state to be applied when the parent's +// state gets applied, and desynchronized mode applies the pending +// wl_surface state directly. A sub-surface is initially in the +// synchronized mode. +// +// Sub-surfaces also have another kind of state, which is managed by +// wl_subsurface requests, as opposed to wl_surface requests. This +// state includes the sub-surface position relative to the parent +// surface (wl_subsurface.set_position), and the stacking order of +// the parent and its sub-surfaces (wl_subsurface.place_above and +// .place_below). This state is applied when the parent surface's +// wl_surface state is applied, regardless of the sub-surface's mode. +// As the exception, set_sync and set_desync are effective immediately. +// +// The main surface can be thought to be always in desynchronized mode, +// since it does not have a parent in the sub-surfaces sense. +// +// Even if a sub-surface is in desynchronized mode, it will behave as +// in synchronized mode, if its parent surface behaves as in +// synchronized mode. This rule is applied recursively throughout the +// tree of surfaces. This means, that one can set a sub-surface into +// synchronized mode, and then assume that all its child and grand-child +// sub-surfaces are synchronized, too, without explicitly setting them. +// +// Destroying a sub-surface takes effect immediately. If you need to +// synchronize the removal of a sub-surface to the parent surface update, +// unmap the sub-surface first by attaching a NULL wl_buffer, update parent, +// and then destroy the sub-surface. +// +// If the parent wl_surface object is destroyed, the sub-surface is +// unmapped. +// +// A sub-surface never has the keyboard focus of any seat. +// +// The wl_surface.offset request is ignored: clients must use set_position +// instead to move the sub-surface. +func NewSubsurface(ctx *Context) *Subsurface { + wlSubsurface := &Subsurface{} + ctx.Register(wlSubsurface) + return wlSubsurface +} + +// Destroy : remove sub-surface interface +// +// The sub-surface interface is removed from the wl_surface object +// that was turned into a sub-surface with a +// wl_subcompositor.get_subsurface request. The wl_surface's association +// to the parent is deleted. The wl_surface is unmapped immediately. +func (i *Subsurface) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetPosition : reposition the sub-surface +// +// This schedules a sub-surface position change. +// The sub-surface will be moved so that its origin (top left +// corner pixel) will be at the location x, y of the parent surface +// coordinate system. The coordinates are not restricted to the parent +// surface area. Negative values are allowed. +// +// The scheduled coordinates will take effect whenever the state of the +// parent surface is applied. +// +// If more than one set_position request is invoked by the client before +// the commit of the parent surface, the position of a new request always +// replaces the scheduled position from any previous request. +// +// The initial position is 0, 0. +// +// x: x coordinate in the parent surface +// y: y coordinate in the parent surface +func (i *Subsurface) SetPosition(x, y int32) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(x)) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// PlaceAbove : restack the sub-surface +// +// This sub-surface is taken from the stack, and put back just +// above the reference surface, changing the z-order of the sub-surfaces. +// The reference surface must be one of the sibling surfaces, or the +// parent surface. Using any other surface, including this sub-surface, +// will cause a protocol error. +// +// The z-order is double-buffered. Requests are handled in order and +// applied immediately to a pending state. The final pending state is +// copied to the active state the next time the state of the parent +// surface is applied. +// +// A new sub-surface is initially added as the top-most in the stack +// of its siblings and parent. +// +// sibling: the reference surface +func (i *Subsurface) PlaceAbove(sibling *Surface) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], sibling.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// PlaceBelow : restack the sub-surface +// +// The sub-surface is placed just below the reference surface. +// See wl_subsurface.place_above. +// +// sibling: the reference surface +func (i *Subsurface) PlaceBelow(sibling *Surface) error { + const opcode = 3 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], sibling.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetSync : set sub-surface to synchronized mode +// +// Change the commit behaviour of the sub-surface to synchronized +// mode, also described as the parent dependent mode. +// +// In synchronized mode, wl_surface.commit on a sub-surface will +// accumulate the committed state in a cache, but the state will +// not be applied and hence will not change the compositor output. +// The cached state is applied to the sub-surface immediately after +// the parent surface's state is applied. This ensures atomic +// updates of the parent and all its synchronized sub-surfaces. +// Applying the cached state will invalidate the cache, so further +// parent surface commits do not (re-)apply old state. +// +// See wl_subsurface for the recursive effect of this mode. +func (i *Subsurface) SetSync() error { + const opcode = 4 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// SetDesync : set sub-surface to desynchronized mode +// +// Change the commit behaviour of the sub-surface to desynchronized +// mode, also described as independent or freely running mode. +// +// In desynchronized mode, wl_surface.commit on a sub-surface will +// apply the pending state directly, without caching, as happens +// normally with a wl_surface. Calling wl_surface.commit on the +// parent surface has no effect on the sub-surface's wl_surface +// state. This mode allows a sub-surface to be updated on its own. +// +// If cached state exists when wl_surface.commit is called in +// desynchronized mode, the pending state is added to the cached +// state, and applied as a whole. This invalidates the cache. +// +// Note: even if a sub-surface is set to desynchronized, a parent +// sub-surface may override it to behave as synchronized. For details, +// see wl_subsurface. +// +// If a surface's parent surface behaves as desynchronized, then +// the cached state is applied on set_desync. +func (i *Subsurface) SetDesync() error { + const opcode = 5 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +type SubsurfaceError uint32 + +// SubsurfaceError : +const ( + // SubsurfaceErrorBadSurface : wl_surface is not a sibling or the parent + SubsurfaceErrorBadSurface SubsurfaceError = 0 +) + +func (e SubsurfaceError) Name() string { + switch e { + case SubsurfaceErrorBadSurface: + return "bad_surface" + default: + return "" + } +} + +func (e SubsurfaceError) Value() string { + switch e { + case SubsurfaceErrorBadSurface: + return "0" + default: + return "" + } +} + +func (e SubsurfaceError) String() string { + return e.Name() + "=" + e.Value() +} + +// FixesInterfaceName is the name of the interface as it appears in the [client.Registry]. +// It can be used to match the [client.RegistryGlobalEvent.Interface] in the +// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies. +const FixesInterfaceName = "wl_fixes" + +// Fixes : wayland protocol fixes +// +// This global fixes problems with other core-protocol interfaces that +// cannot be fixed in these interfaces themselves. +type Fixes struct { + BaseProxy +} + +// NewFixes : wayland protocol fixes +// +// This global fixes problems with other core-protocol interfaces that +// cannot be fixed in these interfaces themselves. +func NewFixes(ctx *Context) *Fixes { + wlFixes := &Fixes{} + ctx.Register(wlFixes) + return wlFixes +} + +// Destroy : destroys this object +func (i *Fixes) Destroy() error { + defer i.MarkZombie() + const opcode = 0 + const _reqBufLen = 8 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// DestroyRegistry : destroy a wl_registry +// +// This request destroys a wl_registry object. +// +// The client should no longer use the wl_registry after making this +// request. +// +// The compositor will emit a wl_display.delete_id event with the object ID +// of the registry and will no longer emit any events on the registry. The +// client should re-use the object ID once it receives the +// wl_display.delete_id event. +// +// registry: the registry to destroy +func (i *Fixes) DestroyRegistry(registry *Registry) error { + const opcode = 1 + const _reqBufLen = 8 + 4 + var _reqBuf [_reqBufLen]byte + l := 0 + PutUint32(_reqBuf[l:4], i.ID()) + l += 4 + PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + PutUint32(_reqBuf[l:l+4], registry.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/common.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/common.go new file mode 100644 index 0000000..b97d1b8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/common.go @@ -0,0 +1,55 @@ +package client + +import "sync/atomic" + +type Dispatcher interface { + Dispatch(opcode uint32, fd int, data []byte) +} + +type Proxy interface { + Context() *Context + SetContext(ctx *Context) + ID() uint32 + SetID(id uint32) + IsZombie() bool + MarkZombie() +} + +type WaylandDisplay interface { + Context() *Context + GetRegistry() (*Registry, error) + Roundtrip() error + Destroy() error +} + +var _ WaylandDisplay = (*Display)(nil) + +type BaseProxy struct { + ctx *Context + id uint32 + zombie atomic.Bool +} + +func (p *BaseProxy) ID() uint32 { + return p.id +} + +func (p *BaseProxy) SetID(id uint32) { + p.id = id +} + +func (p *BaseProxy) Context() *Context { + return p.ctx +} + +func (p *BaseProxy) SetContext(ctx *Context) { + p.ctx = ctx +} + +func (p *BaseProxy) IsZombie() bool { + return p.zombie.Load() +} + +func (p *BaseProxy) MarkZombie() { + p.zombie.Store(true) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/context.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/context.go new file mode 100644 index 0000000..4eee2cb --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/context.go @@ -0,0 +1,143 @@ +package client + +import ( + "errors" + "fmt" + "net" + "os" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type Context struct { + conn *net.UnixConn + objects syncmap.Map[uint32, Proxy] // map[uint32]Proxy - thread-safe concurrent map + currentID uint32 + idMu sync.Mutex // protects currentID increment +} + +func (ctx *Context) Register(p Proxy) { + ctx.idMu.Lock() + ctx.currentID++ + id := ctx.currentID + ctx.idMu.Unlock() + + p.SetID(id) + p.SetContext(ctx) + ctx.objects.Store(id, p) +} + +func (ctx *Context) RegisterWithID(p Proxy, id uint32) { + p.SetID(id) + p.SetContext(ctx) + ctx.objects.Store(id, p) +} + +func (ctx *Context) Unregister(p Proxy) { + ctx.objects.Delete(p.ID()) +} + +func (ctx *Context) DeleteID(id uint32) { + ctx.objects.Delete(id) +} + +func (ctx *Context) GetProxy(id uint32) Proxy { + if val, ok := ctx.objects.Load(id); ok { + return val + } + return nil +} + +func (ctx *Context) Close() error { + return ctx.conn.Close() +} + +func (ctx *Context) SetReadDeadline(t time.Time) error { + return ctx.conn.SetReadDeadline(t) +} + +func (ctx *Context) Fd() int { + rawConn, err := ctx.conn.SyscallConn() + if err != nil { + return -1 + } + var fd int + rawConn.Control(func(f uintptr) { + fd = int(f) + }) + return fd +} + +// Dispatch reads and processes incoming messages and calls [client.Dispatcher.Dispatch] on the +// respective wayland protocol. +// Dispatch must be called on the same goroutine as other interactions with the Context. +// If a multi goroutine approach is desired, use [Context.GetDispatch] instead. +// Dispatch blocks if there are no incoming messages. +// A Dispatch loop is usually used to handle incoming messages. +func (ctx *Context) Dispatch() error { + return ctx.GetDispatch()() +} + +var ErrDispatchSenderNotFound = errors.New("dispatch: unable to find sender") +var ErrDispatchSenderUnsupported = errors.New("dispatch: sender does not implement Dispatch method") +var ErrDispatchUnableToReadMsg = errors.New("dispatch: unable to read msg") + +// GetDispatch reads incoming messages and returns the dispatch function which calls +// [client.Dispatcher.Dispatch] on the respective wayland protocol. +// This function is now thread-safe and can be called from multiple goroutines. +// GetDispatch blocks if there are no incoming messages. +func (ctx *Context) GetDispatch() func() error { + senderID, opcode, fd, data, err := ctx.ReadMsg() // Blocks if there are no incoming messages + if err != nil { + return func() error { + return fmt.Errorf("%w: %w", ErrDispatchUnableToReadMsg, err) + } + } + + return func() error { + proxy, ok := ctx.objects.Load(senderID) + if !ok { + return nil // Proxy already deleted via delete_id, silently ignore + } + + if proxy.IsZombie() { + return nil // Zombie proxy, discard late events + } + + sender, ok := proxy.(Dispatcher) + if !ok { + return fmt.Errorf("%w (senderID=%d)", ErrDispatchSenderUnsupported, senderID) + } + + sender.Dispatch(opcode, fd, data) + return nil + } +} + +func Connect(addr string) (*Display, error) { + if addr == "" { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + return nil, errors.New("env XDG_RUNTIME_DIR not set") + } + if addr == "" { + addr = os.Getenv("WAYLAND_DISPLAY") + } + if addr == "" { + addr = "wayland-0" + } + addr = runtimeDir + "/" + addr + } + + ctx := &Context{} + + conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: addr, Net: "unix"}) + if err != nil { + return nil, err + } + ctx.conn = conn + + return NewDisplay(ctx), nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/context_test.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/context_test.go new file mode 100644 index 0000000..380d7da --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/context_test.go @@ -0,0 +1,111 @@ +package client_test + +import ( + "errors" + "fmt" + "log" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +// Shows a dispatch loop that will block the goroutine. +// This approach has no risk of data races but the loop blocks the goroutine when no messages are +// received. This can be a valid approach if there are no more changes that need to be made after +// setting up and starting the loop. +// For a multi goroutine approach, use [client.Context.GetDispatch]. +func ExampleContext_Dispatch() { + display, err := client.Connect("") + if err != nil { + log.Fatalf("Error connecting to Wayland server: %v", err) + } + + registry, err := display.GetRegistry() + if err != nil { + log.Fatalf("Error getting Wayland registry: %v", err) + } + + var seat *client.Seat + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + switch e.Interface { + case client.SeatInterfaceName: + seat = client.NewSeat(display.Context()) + err := registry.Bind(e.Name, e.Interface, e.Version, seat) + if err != nil { + log.Fatalf("unable to bind %s interface: %v", client.SeatInterfaceName, err) + } + } + }) + display.Roundtrip() + display.Roundtrip() + + keyboard, err := seat.GetKeyboard() + if err != nil { + log.Printf("Error getting keyboard: %v", err) + } + log.Printf("Got keyboard: %v\n", keyboard) + + for { + err := display.Context().Dispatch() + if err != nil { + log.Printf("Dispatch error: %v\n", err) + } + } +} + +// Shows how the dispatch loop can be done in another goroutine. +// This prevents the goroutine from being blocked and allows making changes to wayland objects while +// the dispatch loop is blocking another goroutine. +func ExampleContext_GetDispatch() { + display, err := client.Connect("") + if err != nil { + log.Fatalf("Error connecting to Wayland server: %v", err) + } + + registry, err := display.GetRegistry() + if err != nil { + log.Fatalf("Error getting Wayland registry: %v", err) + } + + var seat *client.Seat + registry.SetGlobalHandler(func(e client.RegistryGlobalEvent) { + switch e.Interface { + case client.SeatInterfaceName: + seat = client.NewSeat(display.Context()) + err := registry.Bind(e.Name, e.Interface, e.Version, seat) + if err != nil { + log.Fatalf("unable to bind %s interface: %v", client.SeatInterfaceName, err) + } + } + }) + display.Roundtrip() + display.Roundtrip() + dispatchQueue := make(chan func() error) + + go func() { + for { + dispatchQueue <- display.Context().GetDispatch() + } + }() + + keyboard, err := seat.GetKeyboard() + if err != nil { + log.Printf("Error getting keyboard: %v", err) + } + log.Printf("Got keyboard: %v\n", keyboard) + + err = errors.Join(keyboard.Release(), seat.Release(), display.Context().Close()) + if err != nil { + fmt.Printf("Error cleaning up: %v\n", err) + } + + for { + select { + // Add other cases here to do other things + case dispatchFunc := <-dispatchQueue: + err := dispatchFunc() + if err != nil { + log.Printf("Dispatch error: %v\n", err) + } + } + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/display.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/display.go new file mode 100644 index 0000000..fa2af6f --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/display.go @@ -0,0 +1,37 @@ +package client + +import ( + "fmt" + "log" +) + +// Roundtrip blocks until all pending request are processed by the server. +// It is the implementation of [wl_display_roundtrip]. +// +// [wl_display_roundtrip]: https://wayland.freedesktop.org/docs/html/apb.html#Client-classwl__display_1ab60f38c2f80980ac84f347e932793390 +func (i *Display) Roundtrip() error { + callback, err := i.Sync() + if err != nil { + return fmt.Errorf("unable to get sync callback: %w", err) + } + defer func() { + if err2 := callback.Destroy(); err2 != nil { + log.Printf("unable to destroy callback: %v\n", err2) + } + }() + + done := false + callback.SetDoneHandler(func(_ CallbackDoneEvent) { + done = true + }) + + // Wait for callback to return + for !done { + err := i.Context().GetDispatch()() + if err != nil { + return fmt.Errorf("roundtrip: failed to dispatch: %w", err) + } + } + + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/doc.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/doc.go new file mode 100644 index 0000000..91137f9 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/doc.go @@ -0,0 +1,6 @@ +// Package client is Go port of wayland-client library +// for writing pure Go GUI software for wayland supported +// platforms. +package client + +//go:generate go run github.com/yaslama/go-wayland/cmd/go-wayland-scanner -pkg client -prefix wl -o client.go -i https://gitlab.freedesktop.org/wayland/wayland/-/raw/1.23.0/protocol/wayland.xml?ref_type=tags diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/event.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/event.go new file mode 100644 index 0000000..e7b5cb8 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/event.go @@ -0,0 +1,120 @@ +package client + +import ( + "bytes" + "fmt" + "unsafe" + + "golang.org/x/sys/unix" + + _ "unsafe" +) + +var oobSpace = unix.CmsgSpace(4) + +func (ctx *Context) ReadMsg() (senderID uint32, opcode uint32, fd int, msg []byte, err error) { + fd = -1 + + oob := make([]byte, oobSpace) + header := make([]byte, 8) + + n, oobn, _, _, err := ctx.conn.ReadMsgUnix(header, oob) + if err != nil { + return senderID, opcode, fd, msg, err + } + if n != 8 { + return senderID, opcode, fd, msg, fmt.Errorf("ctx.ReadMsg: incorrect number of bytes read for header (n=%d)", n) + } + + if oobn > 0 { + fds, err := getFdsFromOob(oob, oobn, "header") + if err != nil { + return senderID, opcode, fd, msg, fmt.Errorf("ctx.ReadMsg: %w", err) + } + + if len(fds) > 0 { + fd = fds[0] + } + } + + senderID = Uint32(header[:4]) + opcodeAndSize := Uint32(header[4:8]) + opcode = opcodeAndSize & 0xffff + size := opcodeAndSize >> 16 + + msgSize := int(size) - 8 + if msgSize == 0 { + return senderID, opcode, fd, nil, nil + } + + msg = make([]byte, msgSize) + + if fd == -1 { + // if something was read before, then zero it out + if oobn > 0 { + oob = make([]byte, oobSpace) + } + + n, oobn, _, _, err = ctx.conn.ReadMsgUnix(msg, oob) + } else { + n, err = ctx.conn.Read(msg) + } + if err != nil { + return senderID, opcode, fd, msg, fmt.Errorf("ctx.ReadMsg: %w", err) + } + if n != msgSize { + return senderID, opcode, fd, msg, fmt.Errorf("ctx.ReadMsg: incorrect number of bytes read for msg (n=%d, msgSize=%d)", n, msgSize) + } + + if fd == -1 && oobn > 0 { + fds, err := getFdsFromOob(oob, oobn, "msg") + if err != nil { + return senderID, opcode, fd, msg, fmt.Errorf("ctx.ReadMsg: %w", err) + } + + if len(fds) > 0 { + fd = fds[0] + } + } + + return senderID, opcode, fd, msg, nil +} + +func getFdsFromOob(oob []byte, oobn int, source string) ([]int, error) { + if oobn > len(oob) { + return nil, fmt.Errorf("getFdsFromOob: incorrect number of bytes read from %s for oob (oobn=%d)", source, oobn) + } + scms, err := unix.ParseSocketControlMessage(oob) + if err != nil { + return nil, fmt.Errorf("getFdsFromOob: unable to parse control message from %s: %w", source, err) + } + + var fdsRet []int + for _, scm := range scms { + fds, err := unix.ParseUnixRights(&scm) + if err != nil { + return nil, fmt.Errorf("getFdsFromOob: unable to parse unix rights from %s: %w", source, err) + } + + fdsRet = append(fdsRet, fds...) + } + + return fdsRet, nil +} + +func Uint32(src []byte) uint32 { + _ = src[3] + return *(*uint32)(unsafe.Pointer(&src[0])) +} + +func String(src []byte) string { + idx := bytes.IndexByte(src, 0) + src = src[:idx:idx] + return *(*string)(unsafe.Pointer(&src)) +} + +func Fixed(src []byte) float64 { + _ = src[3] + fx := *(*int32)(unsafe.Pointer(&src[0])) + return fixedToFloat64(fx) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/request.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/request.go new file mode 100644 index 0000000..3ca2317 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/request.go @@ -0,0 +1,44 @@ +package client + +import ( + "fmt" + "unsafe" +) + +func (ctx *Context) WriteMsg(b []byte, oob []byte) error { + n, oobn, err := ctx.conn.WriteMsgUnix(b, oob, nil) + if err != nil { + return err + } + if n != len(b) || oobn != len(oob) { + return fmt.Errorf("ctx.WriteMsg: incorrect number of bytes written (n=%d oobn=%d)", n, oobn) + } + + return nil +} + +func PutUint32(dst []byte, v uint32) { + _ = dst[3] + *(*uint32)(unsafe.Pointer(&dst[0])) = v +} + +func PutFixed(dst []byte, f float64) { + fx := fixedFromfloat64(f) + _ = dst[3] + *(*int32)(unsafe.Pointer(&dst[0])) = fx +} + +// PutString places a string in Wayland's wire format on the destination buffer. +// It first places the length of the string (plus one for the null terminator) and then the string +// followed by a null byte. +// The length of dst must be equal to, or greater than, len(v) + 5. +func PutString(dst []byte, v string) { + PutUint32(dst[:4], uint32(len(v)+1)) + copy(dst[4:], v) + dst[4+len(v)] = '\x00' // To cause panic if dst is not large enough +} + +func PutArray(dst []byte, a []byte) { + PutUint32(dst[:4], uint32(len(a))) + copy(dst[4:], a) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/util.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/util.go new file mode 100644 index 0000000..ba3d43a --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/client/util.go @@ -0,0 +1,24 @@ +package client + +import "math" + +// From wayland/wayland-util.h + +func fixedToFloat64(f int32) float64 { + u_i := (1023+44)<<52 + (1 << 51) + int64(f) + u_d := math.Float64frombits(uint64(u_i)) + return u_d - (3 << 43) +} + +func fixedFromfloat64(d float64) int32 { + u_d := d + (3 << (51 - 8)) + u_i := int64(math.Float64bits(u_d)) + return int32(u_i) +} + +func PaddedLen(l int) int { + if (l & 0x3) != 0 { + return l + (4 - (l & 0x3)) + } + return l +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/stable/xdg-shell/xdg_shell.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/stable/xdg-shell/xdg_shell.go new file mode 100644 index 0000000..19a203b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/go-wayland/wayland/stable/xdg-shell/xdg_shell.go @@ -0,0 +1,13 @@ +package xdg_shell + +import "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + +type Popup struct { + client.BaseProxy +} + +func NewPopup(ctx *client.Context) *Popup { + p := &Popup{} + ctx.Register(p) + return p +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/CREDITS.MD b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/CREDITS.MD new file mode 100644 index 0000000..b7d28cc --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/CREDITS.MD @@ -0,0 +1,5 @@ +# Credits + +* Original socket adapter code is mostly taken from [korylprince/printer-manager-cups](https://github.com/korylprince/printer-manager-cups) +([MIT](https://github.com/korylprince/printer-manager-cups/blob/v1.0.9/LICENSE) licensed): +[conn.go](https://github.com/korylprince/printer-manager-cups/blob/v1.0.9/cups/conn.go) diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/LICENSE b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/adapter-http.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/adapter-http.go new file mode 100644 index 0000000..56ababb --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/adapter-http.go @@ -0,0 +1,132 @@ +package ipp + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "strconv" + "time" +) + +type HttpAdapter struct { + host string + port int + username string + password string + useTLS bool + client *http.Client +} + +func NewHttpAdapter(host string, port int, username, password string, useTLS bool) *HttpAdapter { + httpClient := http.Client{ + Timeout: 0, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + ResponseHeaderTimeout: 90 * time.Second, + IdleConnTimeout: 120 * time.Second, + }, + } + + return &HttpAdapter{ + host: host, + port: port, + username: username, + password: password, + useTLS: useTLS, + client: &httpClient, + } +} + +func (h *HttpAdapter) SendRequest(url string, req *Request, additionalResponseData io.Writer) (*Response, error) { + payload, err := req.Encode() + if err != nil { + return nil, err + } + + size := len(payload) + var body io.Reader + if req.File != nil && req.FileSize != -1 { + size += req.FileSize + body = io.MultiReader(bytes.NewBuffer(payload), req.File) + } else { + body = bytes.NewBuffer(payload) + } + + httpReq, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Content-Length", strconv.Itoa(size)) + httpReq.Header.Set("Content-Type", ContentTypeIPP) + + if h.username != "" && h.password != "" { + httpReq.SetBasicAuth(h.username, h.password) + } + + httpResp, err := h.client.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != 200 { + return nil, HTTPError{ + Code: httpResp.StatusCode, + } + } + + // buffer response to avoid read issues + buf := new(bytes.Buffer) + if httpResp.ContentLength > 0 { + buf.Grow(int(httpResp.ContentLength)) + } + if _, err := io.Copy(buf, httpResp.Body); err != nil { + return nil, fmt.Errorf("unable to buffer response: %w", err) + } + + ippResp, err := NewResponseDecoder(buf).Decode(additionalResponseData) + if err != nil { + return nil, err + } + + if err = ippResp.CheckForErrors(); err != nil { + return nil, fmt.Errorf("received error IPP response: %w", err) + } + + return ippResp, nil +} + +func (h *HttpAdapter) GetHttpUri(namespace string, object any) string { + proto := "http" + if h.useTLS { + proto = "https" + } + + uri := fmt.Sprintf("%s://%s:%d", proto, h.host, h.port) + + if namespace != "" { + uri = fmt.Sprintf("%s/%s", uri, namespace) + } + + if object != nil { + uri = fmt.Sprintf("%s/%v", uri, object) + } + + return uri +} + +func (h *HttpAdapter) TestConnection() error { + conn, err := net.Dial("tcp", net.JoinHostPort(h.host, fmt.Sprintf("%d", h.port))) + if err != nil { + return err + } + conn.Close() + + return nil +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/adapter.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/adapter.go new file mode 100644 index 0000000..8af0520 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/adapter.go @@ -0,0 +1,9 @@ +package ipp + +import "io" + +type Adapter interface { + SendRequest(url string, req *Request, additionalResponseData io.Writer) (*Response, error) + GetHttpUri(namespace string, object any) string + TestConnection() error +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/attribute.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/attribute.go new file mode 100644 index 0000000..295f521 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/attribute.go @@ -0,0 +1,528 @@ +package ipp + +import ( + "encoding/binary" + "fmt" + "io" +) + +const ( + sizeInteger = int16(4) + sizeBoolean = int16(1) +) + +// AttributeEncoder encodes attribute to a io.Writer +type AttributeEncoder struct { + writer io.Writer +} + +// NewAttributeEncoder returns a new encoder that writes to w +func NewAttributeEncoder(w io.Writer) *AttributeEncoder { + return &AttributeEncoder{w} +} + +// Encode encodes a attribute and its value to a io.Writer +// the tag is determined by the AttributeTagMapping map +func (e *AttributeEncoder) Encode(attribute string, value any) error { + tag, ok := AttributeTagMapping[attribute] + if !ok { + return fmt.Errorf("cannot get tag of attribute %s", attribute) + } + + switch v := value.(type) { + case int: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + if err := e.encodeTag(tag); err != nil { + return err + } + + if err := e.encodeString(attribute); err != nil { + return err + } + + if err := e.encodeInteger(int32(v)); err != nil { + return err + } + case int16: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + if err := e.encodeTag(tag); err != nil { + return err + } + + if err := e.encodeString(attribute); err != nil { + return err + } + + if err := e.encodeInteger(int32(v)); err != nil { + return err + } + case int8: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + if err := e.encodeTag(tag); err != nil { + return err + } + + if err := e.encodeString(attribute); err != nil { + return err + } + + if err := e.encodeInteger(int32(v)); err != nil { + return err + } + case int32: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + if err := e.encodeTag(tag); err != nil { + return err + } + + if err := e.encodeString(attribute); err != nil { + return err + } + + if err := e.encodeInteger(v); err != nil { + return err + } + case int64: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + if err := e.encodeTag(tag); err != nil { + return err + } + + if err := e.encodeString(attribute); err != nil { + return err + } + + if err := e.encodeInteger(int32(v)); err != nil { + return err + } + case []int: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + for index, val := range v { + if err := e.encodeTag(tag); err != nil { + return err + } + + if index == 0 { + if err := e.encodeString(attribute); err != nil { + return err + } + } else { + if err := e.writeNullByte(); err != nil { + return err + } + } + + if err := e.encodeInteger(int32(val)); err != nil { + return err + } + } + case []int16: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + for index, val := range v { + if err := e.encodeTag(tag); err != nil { + return err + } + + if index == 0 { + if err := e.encodeString(attribute); err != nil { + return err + } + } else { + if err := e.writeNullByte(); err != nil { + return err + } + } + + if err := e.encodeInteger(int32(val)); err != nil { + return err + } + } + case []int8: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + for index, val := range v { + if err := e.encodeTag(tag); err != nil { + return err + } + + if index == 0 { + if err := e.encodeString(attribute); err != nil { + return err + } + } else { + if err := e.writeNullByte(); err != nil { + return err + } + } + + if err := e.encodeInteger(int32(val)); err != nil { + return err + } + } + case []int32: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + for index, val := range v { + if err := e.encodeTag(tag); err != nil { + return err + } + + if index == 0 { + if err := e.encodeString(attribute); err != nil { + return err + } + } else { + if err := e.writeNullByte(); err != nil { + return err + } + } + + if err := e.encodeInteger(val); err != nil { + return err + } + } + case []int64: + if tag != TagInteger && tag != TagEnum { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + for index, val := range v { + if err := e.encodeTag(tag); err != nil { + return err + } + + if index == 0 { + if err := e.encodeString(attribute); err != nil { + return err + } + } else { + if err := e.writeNullByte(); err != nil { + return err + } + } + + if err := e.encodeInteger(int32(val)); err != nil { + return err + } + } + case bool: + if tag != TagBoolean { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + if err := e.encodeTag(tag); err != nil { + return err + } + + if err := e.encodeString(attribute); err != nil { + return err + } + + if err := e.encodeBoolean(v); err != nil { + return err + } + case []bool: + if tag != TagBoolean { + return fmt.Errorf("tag for attribute %s does not match with value type", attribute) + } + + for index, val := range v { + if err := e.encodeTag(tag); err != nil { + return err + } + + if index == 0 { + if err := e.encodeString(attribute); err != nil { + return err + } + } else { + if err := e.writeNullByte(); err != nil { + return err + } + } + + if err := e.encodeBoolean(val); err != nil { + return err + } + } + case string: + if err := e.encodeTag(tag); err != nil { + return err + } + + if err := e.encodeString(attribute); err != nil { + return err + } + + if err := e.encodeString(v); err != nil { + return err + } + case []string: + for index, val := range v { + if err := e.encodeTag(tag); err != nil { + return err + } + + if index == 0 { + if err := e.encodeString(attribute); err != nil { + return err + } + } else { + if err := e.writeNullByte(); err != nil { + return err + } + } + + if err := e.encodeString(val); err != nil { + return err + } + } + default: + return fmt.Errorf("type %T is not supported", value) + } + + return nil +} + +func (e *AttributeEncoder) encodeString(s string) error { + if err := binary.Write(e.writer, binary.BigEndian, int16(len(s))); err != nil { + return err + } + + _, err := e.writer.Write([]byte(s)) + return err +} + +func (e *AttributeEncoder) encodeInteger(i int32) error { + if err := binary.Write(e.writer, binary.BigEndian, sizeInteger); err != nil { + return err + } + + return binary.Write(e.writer, binary.BigEndian, i) +} + +func (e *AttributeEncoder) encodeBoolean(b bool) error { + if err := binary.Write(e.writer, binary.BigEndian, sizeBoolean); err != nil { + return err + } + + return binary.Write(e.writer, binary.BigEndian, b) +} + +func (e *AttributeEncoder) encodeTag(t int8) error { + return binary.Write(e.writer, binary.BigEndian, t) +} + +func (e *AttributeEncoder) writeNullByte() error { + return binary.Write(e.writer, binary.BigEndian, int16(0)) +} + +// Attribute defines an ipp attribute +type Attribute struct { + Tag int8 + Name string + Value any +} + +// Resolution defines the resolution attribute +type Resolution struct { + Height int32 + Width int32 + Depth int8 +} + +// AttributeDecoder reads and decodes ipp from an input stream +type AttributeDecoder struct { + reader io.Reader +} + +// NewAttributeDecoder returns a new decoder that reads from r +func NewAttributeDecoder(r io.Reader) *AttributeDecoder { + return &AttributeDecoder{r} +} + +// Decode reads the next ipp attribute into a attribute struct. the type is identified by a tag passed as an argument +func (d *AttributeDecoder) Decode(tag int8) (*Attribute, error) { + attr := Attribute{Tag: tag} + + name, err := d.decodeString() + if err != nil { + return nil, err + } + attr.Name = name + + switch attr.Tag { + case TagEnum, TagInteger: + val, err := d.decodeInteger() + if err != nil { + return nil, err + } + attr.Value = val + case TagBoolean: + val, err := d.decodeBool() + if err != nil { + return nil, err + } + attr.Value = val + case TagDate: + val, err := d.decodeDate() + if err != nil { + return nil, err + } + attr.Value = val + case TagRange: + val, err := d.decodeRange() + if err != nil { + return nil, err + } + attr.Value = val + case TagResolution: + val, err := d.decodeResolution() + if err != nil { + return nil, err + } + attr.Value = val + default: + val, err := d.decodeString() + if err != nil { + return nil, err + } + attr.Value = val + } + + return &attr, nil +} + +func (d *AttributeDecoder) decodeBool() (b bool, err error) { + if _, err = d.readValueLength(); err != nil { + return + } + + if err = binary.Read(d.reader, binary.BigEndian, &b); err != nil { + return + } + + return +} + +func (d *AttributeDecoder) decodeInteger() (i int, err error) { + if _, err = d.readValueLength(); err != nil { + return + } + + var reti int32 + if err = binary.Read(d.reader, binary.BigEndian, &reti); err != nil { + return + } + + return int(reti), nil +} + +func (d *AttributeDecoder) decodeString() (string, error) { + length, err := d.readValueLength() + if err != nil { + return "", err + } + + if length == 0 { + return "", nil + } + + bs := make([]byte, length) + if _, err := d.reader.Read(bs); err != nil { + return "", nil + } + + return string(bs), nil +} + +func (d *AttributeDecoder) decodeDate() ([]int, error) { + length, err := d.readValueLength() + if err != nil { + return nil, err + } + + is := make([]int, length) + var ti int8 + + for i := int16(0); i < length; i++ { + if err = binary.Read(d.reader, binary.BigEndian, &ti); err != nil { + return nil, err + } + is[i] = int(ti) + } + + return is, nil +} + +func (d *AttributeDecoder) decodeRange() ([]int32, error) { + length, err := d.readValueLength() + if err != nil { + return nil, err + } + + // initialize range element count (c) and range slice (r) + c := length / 4 + r := make([]int32, c) + + for i := int16(0); i < c; i++ { + var ti int32 + if err = binary.Read(d.reader, binary.BigEndian, &ti); err != nil { + return nil, err + } + r[i] = ti + } + + return r, nil +} + +func (d *AttributeDecoder) decodeResolution() (res Resolution, err error) { + _, err = d.readValueLength() + if err != nil { + return + } + + if err = binary.Read(d.reader, binary.BigEndian, &res.Height); err != nil { + return + } + + if err = binary.Read(d.reader, binary.BigEndian, &res.Width); err != nil { + return + } + + if err = binary.Read(d.reader, binary.BigEndian, &res.Depth); err != nil { + return + } + + return +} + +func (d *AttributeDecoder) readValueLength() (length int16, err error) { + err = binary.Read(d.reader, binary.BigEndian, &length) + return +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/constants.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/constants.go new file mode 100644 index 0000000..83bf41b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/constants.go @@ -0,0 +1,450 @@ +package ipp + +// ipp status codes +const ( + StatusCupsInvalid int16 = -1 + StatusOk int16 = 0x0000 + StatusOkIgnoredOrSubstituted int16 = 0x0001 + StatusOkConflicting int16 = 0x0002 + StatusOkIgnoredSubscriptions int16 = 0x0003 + StatusOkIgnoredNotifications int16 = 0x0004 + StatusOkTooManyEvents int16 = 0x0005 + StatusOkButCancelSubscription int16 = 0x0006 + StatusOkEventsComplete int16 = 0x0007 + StatusRedirectionOtherSite int16 = 0x0200 + StatusCupsSeeOther int16 = 0x0280 + StatusErrorBadRequest int16 = 0x0400 + StatusErrorForbidden int16 = 0x0401 + StatusErrorNotAuthenticated int16 = 0x0402 + StatusErrorNotAuthorized int16 = 0x0403 + StatusErrorNotPossible int16 = 0x0404 + StatusErrorTimeout int16 = 0x0405 + StatusErrorNotFound int16 = 0x0406 + StatusErrorGone int16 = 0x0407 + StatusErrorRequestEntity int16 = 0x0408 + StatusErrorRequestValue int16 = 0x0409 + StatusErrorDocumentFormatNotSupported int16 = 0x040a + StatusErrorAttributesOrValues int16 = 0x040b + StatusErrorUriScheme int16 = 0x040c + StatusErrorCharset int16 = 0x040d + StatusErrorConflicting int16 = 0x040e + StatusErrorCompressionError int16 = 0x040f + StatusErrorDocumentFormatError int16 = 0x0410 + StatusErrorDocumentAccess int16 = 0x0411 + StatusErrorAttributesNotSettable int16 = 0x0412 + StatusErrorIgnoredAllSubscriptions int16 = 0x0413 + StatusErrorTooManySubscriptions int16 = 0x0414 + StatusErrorIgnoredAllNotifications int16 = 0x0415 + StatusErrorPrintSupportFileNotFound int16 = 0x0416 + StatusErrorDocumentPassword int16 = 0x0417 + StatusErrorDocumentPermission int16 = 0x0418 + StatusErrorDocumentSecurity int16 = 0x0419 + StatusErrorDocumentUnprintable int16 = 0x041a + StatusErrorAccountInfoNeeded int16 = 0x041b + StatusErrorAccountClosed int16 = 0x041c + StatusErrorAccountLimitReached int16 = 0x041d + StatusErrorAccountAuthorizationFailed int16 = 0x041e + StatusErrorNotFetchable int16 = 0x041f + StatusErrorCupsAccountInfoNeeded int16 = 0x049C + StatusErrorCupsAccountClosed int16 = 0x049d + StatusErrorCupsAccountLimitReached int16 = 0x049e + StatusErrorCupsAccountAuthorizationFailed int16 = 0x049f + StatusErrorInternal int16 = 0x0500 + StatusErrorOperationNotSupported int16 = 0x0501 + StatusErrorServiceUnavailable int16 = 0x0502 + StatusErrorVersionNotSupported int16 = 0x0503 + StatusErrorDevice int16 = 0x0504 + StatusErrorTemporary int16 = 0x0505 + StatusErrorNotAcceptingJobs int16 = 0x0506 + StatusErrorBusy int16 = 0x0507 + StatusErrorJobCanceled int16 = 0x0508 + StatusErrorMultipleJobsNotSupported int16 = 0x0509 + StatusErrorPrinterIsDeactivated int16 = 0x050a + StatusErrorTooManyJobs int16 = 0x050b + StatusErrorTooManyDocuments int16 = 0x050c + StatusErrorCupsAuthenticationCanceled int16 = 0x1000 + StatusErrorCupsPki int16 = 0x1001 + StatusErrorCupsUpgradeRequired int16 = 0x1002 +) + +// ipp operations +const ( + OperationCupsInvalid int16 = -0x0001 + OperationCupsNone int16 = 0x0000 + OperationPrintJob int16 = 0x0002 + OperationPrintUri int16 = 0x0003 + OperationValidateJob int16 = 0x0004 + OperationCreateJob int16 = 0x0005 + OperationSendDocument int16 = 0x0006 + OperationSendUri int16 = 0x0007 + OperationCancelJob int16 = 0x0008 + OperationGetJobAttributes int16 = 0x0009 + OperationGetJobs int16 = 0x000a + OperationGetPrinterAttributes int16 = 0x000b + OperationHoldJob int16 = 0x000c + OperationReleaseJob int16 = 0x000d + OperationRestartJob int16 = 0x000e + OperationPausePrinter int16 = 0x0010 + OperationResumePrinter int16 = 0x0011 + OperationPurgeJobs int16 = 0x0012 + OperationSetPrinterAttributes int16 = 0x0013 + OperationSetJobAttributes int16 = 0x0014 + OperationGetPrinterSupportedValues int16 = 0x0015 + OperationCreatePrinterSubscriptions int16 = 0x0016 + OperationCreateJobSubscriptions int16 = 0x0017 + OperationGetSubscriptionAttributes int16 = 0x0018 + OperationGetSubscriptions int16 = 0x0019 + OperationRenewSubscription int16 = 0x001a + OperationCancelSubscription int16 = 0x001b + OperationGetNotifications int16 = 0x001c + OperationSendNotifications int16 = 0x001d + OperationGetResourceAttributes int16 = 0x001e + OperationGetResourceData int16 = 0x001f + OperationGetResources int16 = 0x0020 + OperationGetPrintSupportFiles int16 = 0x0021 + OperationEnablePrinter int16 = 0x0022 + OperationDisablePrinter int16 = 0x0023 + OperationPausePrinterAfterCurrentJob int16 = 0x0024 + OperationHoldNewJobs int16 = 0x0025 + OperationReleaseHeldNewJobs int16 = 0x0026 + OperationDeactivatePrinter int16 = 0x0027 + OperationActivatePrinter int16 = 0x0028 + OperationRestartPrinter int16 = 0x0029 + OperationShutdownPrinter int16 = 0x002a + OperationStartupPrinter int16 = 0x002b + OperationReprocessJob int16 = 0x002c + OperationCancelCurrentJob int16 = 0x002d + OperationSuspendCurrentJob int16 = 0x002e + OperationResumeJob int16 = 0x002f + OperationOperationPromoteJob int16 = 0x0030 + OperationScheduleJobAfter int16 = 0x0031 + OperationCancelDocument int16 = 0x0033 + OperationGetDocumentAttributes int16 = 0x0034 + OperationGetDocuments int16 = 0x0035 + OperationDeleteDocument int16 = 0x0036 + OperationSetDocumentAttributes int16 = 0x0037 + OperationCancelJobs int16 = 0x0038 + OperationCancelMyJobs int16 = 0x0039 + OperationResubmitJob int16 = 0x003a + OperationCloseJob int16 = 0x003b + OperationIdentifyPrinter int16 = 0x003c + OperationValidateDocument int16 = 0x003d + OperationAddDocumentImages int16 = 0x003e + OperationAcknowledgeDocument int16 = 0x003f + OperationAcknowledgeIdentifyPrinter int16 = 0x0040 + OperationAcknowledgeJob int16 = 0x0041 + OperationFetchDocument int16 = 0x0042 + OperationFetchJob int16 = 0x0043 + OperationGetOutputDeviceAttributes int16 = 0x0044 + OperationUpdateActiveJobs int16 = 0x0045 + OperationDeregisterOutputDevice int16 = 0x0046 + OperationUpdateDocumentStatus int16 = 0x0047 + OperationUpdateJobStatus int16 = 0x0048 + OperationUpdateOutputDeviceAttributes int16 = 0x0049 + OperationGetNextDocumentData int16 = 0x004a + OperationAllocatePrinterResources int16 = 0x004b + OperationCreatePrinter int16 = 0x004c + OperationDeallocatePrinterResources int16 = 0x004d + OperationDeletePrinter int16 = 0x004e + OperationGetPrinters int16 = 0x004f + OperationShutdownOnePrinter int16 = 0x0050 + OperationStartupOnePrinter int16 = 0x0051 + OperationCancelResource int16 = 0x0052 + OperationCreateResource int16 = 0x0053 + OperationInstallResource int16 = 0x0054 + OperationSendResourceData int16 = 0x0055 + OperationSetResourceAttributes int16 = 0x0056 + OperationCreateResourceSubscriptions int16 = 0x0057 + OperationCreateSystemSubscriptions int16 = 0x0058 + OperationDisableAllPrinters int16 = 0x0059 + OperationEnableAllPrinters int16 = 0x005a + OperationGetSystemAttributes int16 = 0x005b + OperationGetSystemSupportedValues int16 = 0x005c + OperationPauseAllPrinters int16 = 0x005d + OperationPauseAllPrintersAfterCurrentJob int16 = 0x005e + OperationRegisterOutputDevice int16 = 0x005f + OperationRestartSystem int16 = 0x0060 + OperationResumeAllPrinters int16 = 0x0061 + OperationSetSystemAttributes int16 = 0x0062 + OperationShutdownAllPrinter int16 = 0x0063 + OperationStartupAllPrinters int16 = 0x0064 + OperationPrivate int16 = 0x4000 + OperationCupsGetDefault int16 = 0x4001 + OperationCupsGetPrinters int16 = 0x4002 + OperationCupsAddModifyPrinter int16 = 0x4003 + OperationCupsDeletePrinter int16 = 0x4004 + OperationCupsGetClasses int16 = 0x4005 + OperationCupsAddModifyClass int16 = 0x4006 + OperationCupsDeleteClass int16 = 0x4007 + OperationCupsAcceptJobs int16 = 0x4008 + OperationCupsRejectJobs int16 = 0x4009 + OperationCupsSetDefault int16 = 0x400a + OperationCupsGetDevices int16 = 0x400b + OperationCupsGetPPDs int16 = 0x400c + OperationCupsMoveJob int16 = 0x400d + OperationCupsAuthenticateJob int16 = 0x400e + OperationCupsGetPpd int16 = 0x400f + OperationCupsGetDocument int16 = 0x4027 + OperationCupsCreateLocalPrinter int16 = 0x4028 +) + +// ipp tags +const ( + TagCupsInvalid int8 = -1 + TagZero int8 = 0x00 + TagOperation int8 = 0x01 + TagJob int8 = 0x02 + TagEnd int8 = 0x03 + TagPrinter int8 = 0x04 + TagUnsupportedGroup int8 = 0x05 + TagSubscription int8 = 0x06 + TagEventNotification int8 = 0x07 + TagResource int8 = 0x08 + TagDocument int8 = 0x09 + TagSystem int8 = 0x0a + TagUnsupportedValue int8 = 0x10 + TagDefault int8 = 0x11 + TagUnknown int8 = 0x12 + TagNoValue int8 = 0x13 + TagNotSettable int8 = 0x15 + TagDeleteAttr int8 = 0x16 + TagAdminDefine int8 = 0x17 + TagInteger int8 = 0x21 + TagBoolean int8 = 0x22 + TagEnum int8 = 0x23 + TagString int8 = 0x30 + TagDate int8 = 0x31 + TagResolution int8 = 0x32 + TagRange int8 = 0x33 + TagBeginCollection int8 = 0x34 + TagTextLang int8 = 0x35 + TagNameLang int8 = 0x36 + TagEndCollection int8 = 0x37 + TagText int8 = 0x41 + TagName int8 = 0x42 + TagReservedString int8 = 0x43 + TagKeyword int8 = 0x44 + TagUri int8 = 0x45 + TagUriScheme int8 = 0x46 + TagCharset int8 = 0x47 + TagLanguage int8 = 0x48 + TagMimeType int8 = 0x49 + TagMemberName int8 = 0x4a + TagExtension int8 = 0x7f +) + +// job states +const ( + JobStatePending int8 = 0x03 + JobStateHeld int8 = 0x04 + JobStateProcessing int8 = 0x05 + JobStateStopped int8 = 0x06 + JobStateCanceled int8 = 0x07 + JobStateAborted int8 = 0x08 + JobStateCompleted int8 = 0x09 +) + +// document states +const ( + DocumentStatePending int8 = 0x03 + DocumentStateProcessing int8 = 0x05 + DocumentStateCanceled int8 = 0x07 + DocumentStateAborted int8 = 0x08 + DocumentStateCompleted int8 = 0x08 +) + +// printer states +const ( + PrinterStateIdle int8 = 0x0003 + PrinterStateProcessing int8 = 0x0004 + PrinterStateStopped int8 = 0x0005 +) + +// job state filter +const ( + JobStateFilterNotCompleted = "not-completed" + JobStateFilterCompleted = "completed" + JobStateFilterAll = "all" +) + +// error policies +const ( + ErrorPolicyRetryJob = "retry-job" + ErrorPolicyAbortJob = "abort-job" + ErrorPolicyRetryCurrentJob = "retry-current-job" + ErrorPolicyStopPrinter = "stop-printer" +) + +// ipp defaults +const ( + CharsetLanguage = "en-US" + Charset = "utf-8" + ProtocolVersionMajor = int8(2) + ProtocolVersionMinor = int8(0) + + DefaultJobPriority = 50 +) + +// useful mime types for ipp +const ( + MimeTypePostscript = "application/postscript" + MimeTypePDF = "application/pdf" + MimeTypeOctetStream = "application/octet-stream" +) + +// ipp content types +const ( + ContentTypeIPP = "application/ipp" +) + +// known ipp attributes +const ( + AttributeCopies = "copies" + AttributeDocumentFormat = "document-format" + AttributeDocumentName = "document-name" + AttributeJobID = "job-id" + AttributeJobName = "job-name" + AttributeJobPriority = "job-priority" + AttributeJobURI = "job-uri" + AttributeLastDocument = "last-document" + AttributeMyJobs = "my-jobs" + AttributePPDName = "ppd-name" + AttributePPDMakeAndModel = "ppd-make-and-model" + AttributePrinterIsShared = "printer-is-shared" + AttributePrinterIsTemporary = "printer-is-temporary" + AttributePrinterURI = "printer-uri" + AttributePurgeJobs = "purge-jobs" + AttributeRequestedAttributes = "requested-attributes" + AttributeRequestingUserName = "requesting-user-name" + AttributeWhichJobs = "which-jobs" + AttributeFirstJobID = "first-job-id" + AttributeLimit = "limit" + AttributeStatusMessage = "status-message" + AttributeCharset = "attributes-charset" + AttributeNaturalLanguage = "attributes-natural-language" + AttributeDeviceURI = "device-uri" + AttributeHoldJobUntil = "job-hold-until" + AttributePrinterErrorPolicy = "printer-error-policy" + AttributePrinterInfo = "printer-info" + AttributePrinterLocation = "printer-location" + AttributePrinterName = "printer-name" + AttributePrinterStateReasons = "printer-state-reasons" + AttributeJobPrinterURI = "job-printer-uri" + AttributeMemberURIs = "member-uris" + AttributeDocumentNumber = "document-number" + AttributeDocumentState = "document-state" + AttributeFinishings = "finishings" + AttributeJobHoldUntil = "hold-job-until" + AttributeJobSheets = "job-sheets" + AttributeJobState = "job-state" + AttributeJobStateReason = "job-state-reason" + AttributeMedia = "media" + AttributeSides = "sides" + AttributeNumberUp = "number-up" + AttributeOrientationRequested = "orientation-requested" + AttributePrintQuality = "print-quality" + AttributePrinterIsAcceptingJobs = "printer-is-accepting-jobs" + AttributePrinterResolution = "printer-resolution" + AttributePrinterState = "printer-state" + AttributeMemberNames = "member-names" + AttributePrinterType = "printer-type" + AttributePrinterMakeAndModel = "printer-make-and-model" + AttributePrinterStateMessage = "printer-state-message" + AttributePrinterUriSupported = "printer-uri-supported" + AttributeJobMediaProgress = "job-media-progress" + AttributeJobKilobyteOctets = "job-k-octets" + AttributeNumberOfDocuments = "number-of-documents" + AttributeJobOriginatingUserName = "job-originating-user-name" + AttributeOutputOrder = "outputorder" + AttributeJobStateReasons = "job-state-reasons" + AttributeJobStateMessage = "job-state-message" + AttributeJobPrinterStateReasons = "job-printer-state-reasons" + AttributeJobPrinterStateMessage = "job-printer-state-message" + AttributeJobImpressionsCompleted = "job-impressions-completed" + AttributePrintScaling = "print-scaling" +) + +// Default attributes +var ( + DefaultClassAttributes = []string{AttributePrinterName, AttributeMemberNames} + DefaultPrinterAttributes = []string{ + AttributePrinterName, AttributePrinterType, AttributePrinterLocation, AttributePrinterInfo, + AttributePrinterMakeAndModel, AttributePrinterState, AttributePrinterStateMessage, AttributePrinterStateReasons, + AttributePrinterUriSupported, AttributeDeviceURI, AttributePrinterIsShared, + } + DefaultJobAttributes = []string{ + AttributeJobID, AttributeJobName, AttributePrinterURI, AttributeJobState, AttributeJobStateReason, + AttributeJobHoldUntil, AttributeJobMediaProgress, AttributeJobKilobyteOctets, AttributeNumberOfDocuments, AttributeCopies, + AttributeJobOriginatingUserName, + } +) + +// Attribute to tag mapping +var ( + AttributeTagMapping = map[string]int8{ + AttributeCharset: TagCharset, + AttributeNaturalLanguage: TagLanguage, + AttributeCopies: TagInteger, + AttributeDeviceURI: TagUri, + AttributeDocumentFormat: TagMimeType, + AttributeDocumentName: TagName, + AttributeDocumentNumber: TagInteger, + AttributeDocumentState: TagEnum, + AttributeFinishings: TagEnum, + AttributeJobHoldUntil: TagKeyword, + AttributeHoldJobUntil: TagKeyword, + AttributeJobID: TagInteger, + AttributeJobName: TagName, + AttributeJobPrinterURI: TagUri, + AttributeJobPriority: TagInteger, + AttributeJobSheets: TagName, + AttributeJobState: TagEnum, + AttributeJobStateReason: TagKeyword, + AttributeJobURI: TagUri, + AttributeLastDocument: TagBoolean, + AttributeMedia: TagKeyword, + AttributeSides: TagKeyword, + AttributeMemberURIs: TagUri, + AttributeMyJobs: TagBoolean, + AttributeNumberUp: TagInteger, + AttributeOrientationRequested: TagEnum, + AttributePPDName: TagName, + AttributePPDMakeAndModel: TagText, + AttributeNumberOfDocuments: TagInteger, + AttributePrintQuality: TagEnum, + AttributePrinterErrorPolicy: TagName, + AttributePrinterInfo: TagText, + AttributePrinterIsAcceptingJobs: TagBoolean, + AttributePrinterIsShared: TagBoolean, + AttributePrinterIsTemporary: TagBoolean, + AttributePrinterName: TagName, + AttributePrinterLocation: TagText, + AttributePrinterResolution: TagResolution, + AttributePrinterState: TagEnum, + AttributePrinterStateReasons: TagKeyword, + AttributePrinterURI: TagUri, + AttributePurgeJobs: TagBoolean, + AttributeRequestedAttributes: TagKeyword, + AttributeRequestingUserName: TagName, + AttributeWhichJobs: TagKeyword, + AttributeFirstJobID: TagInteger, + AttributeStatusMessage: TagText, + AttributeLimit: TagInteger, + AttributeOutputOrder: TagName, + AttributeJobStateReasons: TagString, + AttributeJobStateMessage: TagString, + AttributeJobPrinterStateReasons: TagString, + AttributeJobPrinterStateMessage: TagString, + AttributeJobImpressionsCompleted: TagInteger, + AttributePrintScaling: TagKeyword, + // IPP Subscription/Notification attributes (added for dankdots) + "notify-events": TagKeyword, + "notify-pull-method": TagKeyword, + "notify-lease-duration": TagInteger, + "notify-subscription-id": TagInteger, + "notify-subscription-ids": TagInteger, + "notify-sequence-numbers": TagInteger, + "notify-wait": TagBoolean, + "notify-recipient-uri": TagUri, + } +) diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/cups-client.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/cups-client.go new file mode 100644 index 0000000..68d60ce --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/cups-client.go @@ -0,0 +1,313 @@ +package ipp + +import ( + "io" + "strings" +) + +// CUPSClient implements a ipp client with specific cups operations +type CUPSClient struct { + *IPPClient +} + +// NewCUPSClient creates a new cups ipp client (used HttpAdapter internally) +func NewCUPSClient(host string, port int, username, password string, useTLS bool) *CUPSClient { + ippClient := NewIPPClient(host, port, username, password, useTLS) + return &CUPSClient{ippClient} +} + +// NewCUPSClientWithAdapter creates a new cups ipp client with given Adapter +func NewCUPSClientWithAdapter(username string, adapter Adapter) *CUPSClient { + ippClient := NewIPPClientWithAdapter(username, adapter) + return &CUPSClient{ippClient} +} + +// GetDevices returns a map of device uris and printer attributes +func (c *CUPSClient) GetDevices() (map[string]Attributes, error) { + req := NewRequest(OperationCupsGetDevices, 1) + + resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil) + if err != nil { + return nil, err + } + + printerNameMap := make(map[string]Attributes) + + for _, printerAttributes := range resp.PrinterAttributes { + printerNameMap[printerAttributes[AttributeDeviceURI][0].Value.(string)] = printerAttributes + } + + return printerNameMap, nil +} + +// MoveJob moves a job to a other printer +func (c *CUPSClient) MoveJob(jobID int, destPrinter string) error { + req := NewRequest(OperationCupsMoveJob, 1) + req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID) + req.PrinterAttributes[AttributeJobPrinterURI] = c.getPrinterUri(destPrinter) + + _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil) + return err +} + +// MoveAllJob moves all job from a printer to a other printer +func (c *CUPSClient) MoveAllJob(srcPrinter, destPrinter string) error { + req := NewRequest(OperationCupsMoveJob, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(srcPrinter) + req.PrinterAttributes[AttributeJobPrinterURI] = c.getPrinterUri(destPrinter) + + _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil) + return err +} + +// GetPPDs returns a map of ppd names and attributes +func (c *CUPSClient) GetPPDs() (map[string]Attributes, error) { + req := NewRequest(OperationCupsGetPPDs, 1) + + resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil) + if err != nil { + return nil, err + } + + ppdNameMap := make(map[string]Attributes) + + for _, printerAttributes := range resp.PrinterAttributes { + ppdNameMap[printerAttributes[AttributePPDName][0].Value.(string)] = printerAttributes + } + + return ppdNameMap, nil +} + +// AcceptJobs lets a printer accept jobs again +func (c *CUPSClient) AcceptJobs(printer string) error { + req := NewRequest(OperationCupsAcceptJobs, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// RejectJobs does not let a printer accept jobs +func (c *CUPSClient) RejectJobs(printer string) error { + req := NewRequest(OperationCupsRejectJobs, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// AddPrinterToClass adds a printer to a class, if the class does not exists it will be created +func (c *CUPSClient) AddPrinterToClass(class, printer string) error { + attributes, err := c.GetPrinterAttributes(class, []string{AttributeMemberURIs}) + if err != nil && !IsNotExistsError(err) { + return err + } + + memberURIList := make([]string, 0) + + if !IsNotExistsError(err) { + for _, member := range attributes[AttributeMemberURIs] { + memberString := strings.Split(member.Value.(string), "/") + printerName := memberString[len(memberString)-1] + + if printerName == printer { + return nil + } + + memberURIList = append(memberURIList, member.Value.(string)) + } + } + + memberURIList = append(memberURIList, c.getPrinterUri(printer)) + + req := NewRequest(OperationCupsAddModifyClass, 1) + req.OperationAttributes[AttributePrinterURI] = c.getClassUri(class) + req.PrinterAttributes[AttributeMemberURIs] = memberURIList + + _, err = c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// DeletePrinterFromClass removes a printer from a class, if a class has no more printer it will be deleted +func (c *CUPSClient) DeletePrinterFromClass(class, printer string) error { + attributes, err := c.GetPrinterAttributes(class, []string{AttributeMemberURIs}) + if err != nil { + return err + } + + memberURIList := make([]string, 0) + + for _, member := range attributes[AttributeMemberURIs] { + memberString := strings.Split(member.Value.(string), "/") + printerName := memberString[len(memberString)-1] + + if printerName != printer { + memberURIList = append(memberURIList, member.Value.(string)) + } + } + + if len(memberURIList) == 0 { + return c.DeleteClass(class) + } + + req := NewRequest(OperationCupsAddModifyClass, 1) + req.OperationAttributes[AttributePrinterURI] = c.getClassUri(class) + req.PrinterAttributes[AttributeMemberURIs] = memberURIList + + _, err = c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// DeleteClass deletes a class +func (c *CUPSClient) DeleteClass(class string) error { + req := NewRequest(OperationCupsDeleteClass, 1) + req.OperationAttributes[AttributePrinterURI] = c.getClassUri(class) + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// CreatePrinter creates a new printer +func (c *CUPSClient) CreatePrinter(name, deviceURI, ppd string, shared bool, errorPolicy string, information, location string) error { + req := NewRequest(OperationCupsAddModifyPrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(name) + req.OperationAttributes[AttributePPDName] = ppd + req.OperationAttributes[AttributePrinterIsShared] = shared + req.PrinterAttributes[AttributePrinterStateReasons] = "none" + req.PrinterAttributes[AttributeDeviceURI] = deviceURI + req.PrinterAttributes[AttributePrinterInfo] = information + req.PrinterAttributes[AttributePrinterLocation] = location + req.PrinterAttributes[AttributePrinterErrorPolicy] = errorPolicy + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// SetPrinterPPD sets the ppd for a printer +func (c *CUPSClient) SetPrinterPPD(printer, ppd string) error { + req := NewRequest(OperationCupsAddModifyPrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + req.OperationAttributes[AttributePPDName] = ppd + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// SetPrinterDeviceURI sets the device uri for a printer +func (c *CUPSClient) SetPrinterDeviceURI(printer, deviceURI string) error { + req := NewRequest(OperationCupsAddModifyPrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + req.PrinterAttributes[AttributeDeviceURI] = deviceURI + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// SetPrinterIsShared shares or unshares a printer in the network +func (c *CUPSClient) SetPrinterIsShared(printer string, shared bool) error { + req := NewRequest(OperationCupsAddModifyPrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + req.OperationAttributes[AttributePrinterIsShared] = shared + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// SetPrinterErrorPolicy sets the error policy for a printer +func (c *CUPSClient) SetPrinterErrorPolicy(printer string, errorPolicy string) error { + req := NewRequest(OperationCupsAddModifyPrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + req.PrinterAttributes[AttributePrinterErrorPolicy] = errorPolicy + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// SetPrinterInformation sets general printer information +func (c *CUPSClient) SetPrinterInformation(printer, information string) error { + req := NewRequest(OperationCupsAddModifyPrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + req.PrinterAttributes[AttributePrinterInfo] = information + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// SetPrinterLocation sets the printer location +func (c *CUPSClient) SetPrinterLocation(printer, location string) error { + req := NewRequest(OperationCupsAddModifyPrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + req.PrinterAttributes[AttributePrinterLocation] = location + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// DeletePrinter deletes a printer +func (c *CUPSClient) DeletePrinter(printer string) error { + req := NewRequest(OperationCupsDeletePrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// GetPrinters returns a map of printer names and attributes +func (c *CUPSClient) GetPrinters(attributes []string) (map[string]Attributes, error) { + req := NewRequest(OperationCupsGetPrinters, 1) + + if attributes == nil { + req.OperationAttributes[AttributeRequestedAttributes] = DefaultPrinterAttributes + } else { + req.OperationAttributes[AttributeRequestedAttributes] = append(attributes, AttributePrinterName) + } + + resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil) + if err != nil { + return nil, err + } + + printerNameMap := make(map[string]Attributes) + + for _, printerAttributes := range resp.PrinterAttributes { + printerNameMap[printerAttributes[AttributePrinterName][0].Value.(string)] = printerAttributes + } + + return printerNameMap, nil +} + +// GetClasses returns a map of class names and attributes +func (c *CUPSClient) GetClasses(attributes []string) (map[string]Attributes, error) { + req := NewRequest(OperationCupsGetClasses, 1) + + if attributes == nil { + req.OperationAttributes[AttributeRequestedAttributes] = DefaultClassAttributes + } else { + req.OperationAttributes[AttributeRequestedAttributes] = append(attributes, AttributePrinterName) + } + + resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil) + if err != nil { + return nil, err + } + + printerNameMap := make(map[string]Attributes) + + for _, printerAttributes := range resp.PrinterAttributes { + printerNameMap[printerAttributes[AttributePrinterName][0].Value.(string)] = printerAttributes + } + + return printerNameMap, nil +} + +// PrintTestPage prints a test page using the provided PDF data +func (c *CUPSClient) PrintTestPage(printer string, testPageData io.Reader, size int) (int, error) { + return c.PrintJob(Document{ + Document: testPageData, + Name: "Test Page", + Size: size, + MimeType: MimeTypePDF, + }, printer, map[string]any{ + AttributeJobName: "Test Page", + }) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/error.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/error.go new file mode 100644 index 0000000..e4edf5e --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/error.go @@ -0,0 +1,31 @@ +package ipp + +import "fmt" + +// IsNotExistsError checks a given error whether a printer or class does not exist +func IsNotExistsError(err error) bool { + if err == nil { + return false + } + + return err.Error() == "The printer or class does not exist." +} + +// IPPError used for non ok ipp status codes +type IPPError struct { + Status int16 + Message string +} + +func (e IPPError) Error() string { + return fmt.Sprintf("ipp status: %d, message: %s", e.Status, e.Message) +} + +// HTTPError used for non 200 http codes +type HTTPError struct { + Code int +} + +func (e HTTPError) Error() string { + return fmt.Sprintf("got http code %d", e.Code) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/ipp-client.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/ipp-client.go new file mode 100644 index 0000000..a21dd54 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/ipp-client.go @@ -0,0 +1,329 @@ +package ipp + +import ( + "errors" + "fmt" + "io" + "os" + "path" +) + +// Document wraps an io.Reader with more information, needed for encoding +type Document struct { + Document io.Reader + Size int + Name string + MimeType string +} + +// IPPClient implements a generic ipp client +type IPPClient struct { + username string + adapter Adapter +} + +// NewIPPClient creates a new generic ipp client (used HttpAdapter internally) +func NewIPPClient(host string, port int, username, password string, useTLS bool) *IPPClient { + adapter := NewHttpAdapter(host, port, username, password, useTLS) + + return &IPPClient{ + username: username, + adapter: adapter, + } +} + +// NewIPPClientWithAdapter creates a new generic ipp client with given Adapter +func NewIPPClientWithAdapter(username string, adapter Adapter) *IPPClient { + return &IPPClient{ + username: username, + adapter: adapter, + } +} + +func (c *IPPClient) getPrinterUri(printer string) string { + return fmt.Sprintf("ipp://localhost/printers/%s", printer) +} + +func (c *IPPClient) getJobUri(jobID int) string { + return fmt.Sprintf("ipp://localhost/jobs/%d", jobID) +} + +func (c *IPPClient) getClassUri(printer string) string { + return fmt.Sprintf("ipp://localhost/classes/%s", printer) +} + +// SendRequest sends a request to a remote uri end returns the response +func (c *IPPClient) SendRequest(url string, req *Request, additionalResponseData io.Writer) (*Response, error) { + if _, ok := req.OperationAttributes[AttributeRequestingUserName]; !ok { + req.OperationAttributes[AttributeRequestingUserName] = c.username + } + + return c.adapter.SendRequest(url, req, additionalResponseData) +} + +// PrintDocuments prints one or more documents using a Create-Job operation followed by one or more Send-Document operation(s). custom job settings can be specified via the jobAttributes parameter +func (c *IPPClient) PrintDocuments(docs []Document, printer string, jobAttributes map[string]any) (int, error) { + printerURI := c.getPrinterUri(printer) + + req := NewRequest(OperationCreateJob, 1) + req.OperationAttributes[AttributePrinterURI] = printerURI + req.OperationAttributes[AttributeRequestingUserName] = c.username + + // set defaults for some attributes, may get overwritten + req.OperationAttributes[AttributeJobName] = docs[0].Name + req.OperationAttributes[AttributeCopies] = 1 + req.OperationAttributes[AttributeJobPriority] = DefaultJobPriority + + for key, value := range jobAttributes { + req.JobAttributes[key] = value + } + + resp, err := c.SendRequest(c.adapter.GetHttpUri("printers", printer), req, nil) + if err != nil { + return -1, err + } + + if len(resp.JobAttributes) == 0 { + return 0, errors.New("server doesn't returned a job id") + } + + jobID := resp.JobAttributes[0][AttributeJobID][0].Value.(int) + + documentCount := len(docs) - 1 + + for docID, doc := range docs { + req = NewRequest(OperationSendDocument, 2) + req.OperationAttributes[AttributePrinterURI] = printerURI + req.OperationAttributes[AttributeRequestingUserName] = c.username + req.OperationAttributes[AttributeJobID] = jobID + req.OperationAttributes[AttributeDocumentName] = doc.Name + req.OperationAttributes[AttributeDocumentFormat] = doc.MimeType + req.OperationAttributes[AttributeLastDocument] = docID == documentCount + req.File = doc.Document + req.FileSize = doc.Size + + _, err = c.SendRequest(c.adapter.GetHttpUri("printers", printer), req, nil) + if err != nil { + return -1, err + } + } + + return jobID, nil +} + +// PrintJob prints a document using a Print-Job operation. custom job settings can be specified via the jobAttributes parameter +func (c *IPPClient) PrintJob(doc Document, printer string, jobAttributes map[string]any) (int, error) { + printerURI := c.getPrinterUri(printer) + + req := NewRequest(OperationPrintJob, 1) + req.OperationAttributes[AttributePrinterURI] = printerURI + req.OperationAttributes[AttributeRequestingUserName] = c.username + req.OperationAttributes[AttributeJobName] = doc.Name + req.OperationAttributes[AttributeDocumentFormat] = doc.MimeType + + // set defaults for some attributes, may get overwritten + req.OperationAttributes[AttributeCopies] = 1 + req.OperationAttributes[AttributeJobPriority] = DefaultJobPriority + + for key, value := range jobAttributes { + req.JobAttributes[key] = value + } + + req.File = doc.Document + req.FileSize = doc.Size + + resp, err := c.SendRequest(c.adapter.GetHttpUri("printers", printer), req, nil) + if err != nil { + return -1, err + } + + if len(resp.JobAttributes) == 0 { + return 0, errors.New("server doesn't returned a job id") + } + + jobID := resp.JobAttributes[0][AttributeJobID][0].Value.(int) + + return jobID, nil +} + +// PrintFile prints a local file on the file system. custom job settings can be specified via the jobAttributes parameter +func (c *IPPClient) PrintFile(filePath, printer string, jobAttributes map[string]any) (int, error) { + fileStats, err := os.Stat(filePath) + if os.IsNotExist(err) { + return -1, err + } + + fileName := path.Base(filePath) + + document, err := os.Open(filePath) + if err != nil { + return 0, err + } + defer document.Close() + + jobAttributes[AttributeJobName] = fileName + + return c.PrintDocuments([]Document{ + { + Document: document, + Name: fileName, + Size: int(fileStats.Size()), + MimeType: MimeTypeOctetStream, + }, + }, printer, jobAttributes) +} + +// GetPrinterAttributes returns the requested attributes for the specified printer, if attributes is nil the default attributes will be used +func (c *IPPClient) GetPrinterAttributes(printer string, attributes []string) (Attributes, error) { + req := NewRequest(OperationGetPrinterAttributes, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + req.OperationAttributes[AttributeRequestingUserName] = c.username + + if attributes == nil { + req.OperationAttributes[AttributeRequestedAttributes] = DefaultPrinterAttributes + } else { + req.OperationAttributes[AttributeRequestedAttributes] = attributes + } + + resp, err := c.SendRequest(c.adapter.GetHttpUri("printers", printer), req, nil) + if err != nil { + return nil, err + } + + if len(resp.PrinterAttributes) == 0 { + return nil, errors.New("server doesn't return any printer attributes") + } + + return resp.PrinterAttributes[0], nil +} + +// ResumePrinter resumes a printer +func (c *IPPClient) ResumePrinter(printer string) error { + req := NewRequest(OperationResumePrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// PausePrinter pauses a printer +func (c *IPPClient) PausePrinter(printer string) error { + req := NewRequest(OperationPausePrinter, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// GetJobAttributes returns the requested attributes for the specified job, if attributes is nil the default job will be used +func (c *IPPClient) GetJobAttributes(jobID int, attributes []string) (Attributes, error) { + req := NewRequest(OperationGetJobAttributes, 1) + req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID) + + if attributes == nil { + req.OperationAttributes[AttributeRequestedAttributes] = DefaultJobAttributes + } else { + req.OperationAttributes[AttributeRequestedAttributes] = attributes + } + + resp, err := c.SendRequest(c.adapter.GetHttpUri("jobs", jobID), req, nil) + if err != nil { + return nil, err + } + + if len(resp.JobAttributes) == 0 { + return nil, errors.New("server doesn't return any job attributes") + } + + return resp.JobAttributes[0], nil +} + +// GetJobs returns jobs from a printer or class +func (c *IPPClient) GetJobs(printer, class string, whichJobs string, myJobs bool, firstJobId, limit int, attributes []string) (map[int]Attributes, error) { + req := NewRequest(OperationGetJobs, 1) + req.OperationAttributes[AttributeWhichJobs] = whichJobs + req.OperationAttributes[AttributeMyJobs] = myJobs + + if printer != "" { + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + } else if class != "" { + req.OperationAttributes[AttributePrinterURI] = c.getClassUri(printer) + } else { + req.OperationAttributes[AttributePrinterURI] = "ipp://localhost/" + } + + if firstJobId > 0 { + req.OperationAttributes[AttributeFirstJobID] = firstJobId + } + + if limit > 0 { + req.OperationAttributes[AttributeLimit] = limit + } + + if myJobs { + req.OperationAttributes[AttributeRequestingUserName] = c.username + } + + if attributes == nil { + req.OperationAttributes[AttributeRequestedAttributes] = DefaultJobAttributes + } else { + req.OperationAttributes[AttributeRequestedAttributes] = append(attributes, AttributeJobID) + } + + resp, err := c.SendRequest(c.adapter.GetHttpUri("", nil), req, nil) + if err != nil { + return nil, err + } + + jobIDMap := make(map[int]Attributes) + + for _, jobAttributes := range resp.JobAttributes { + jobIDMap[jobAttributes[AttributeJobID][0].Value.(int)] = jobAttributes + } + + return jobIDMap, nil +} + +// CancelJob cancels a job. if purge is true, the job will also be removed +func (c *IPPClient) CancelJob(jobID int, purge bool) error { + req := NewRequest(OperationCancelJob, 1) + req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID) + req.OperationAttributes[AttributePurgeJobs] = purge + + _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil) + return err +} + +// CancelAllJob cancels all jobs for a specified printer. if purge is true, the jobs will also be removed +func (c *IPPClient) CancelAllJob(printer string, purge bool) error { + req := NewRequest(OperationCancelJobs, 1) + req.OperationAttributes[AttributePrinterURI] = c.getPrinterUri(printer) + req.OperationAttributes[AttributePurgeJobs] = purge + + _, err := c.SendRequest(c.adapter.GetHttpUri("admin", ""), req, nil) + return err +} + +// RestartJob restarts a job +func (c *IPPClient) RestartJob(jobID int) error { + req := NewRequest(OperationRestartJob, 1) + req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID) + + _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil) + return err +} + +// HoldJobUntil holds a job +func (c *IPPClient) HoldJobUntil(jobID int, holdUntil string) error { + req := NewRequest(OperationRestartJob, 1) + req.OperationAttributes[AttributeJobURI] = c.getJobUri(jobID) + req.JobAttributes[AttributeHoldJobUntil] = holdUntil + + _, err := c.SendRequest(c.adapter.GetHttpUri("jobs", ""), req, nil) + return err +} + +// TestConnection tests if a tcp connection to the remote server is possible +func (c *IPPClient) TestConnection() error { + return c.adapter.TestConnection() +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/request.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/request.go new file mode 100644 index 0000000..3207038 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/request.go @@ -0,0 +1,299 @@ +package ipp + +import ( + "bytes" + "encoding/binary" + "io" +) + +// Request defines a ipp request +type Request struct { + ProtocolVersionMajor int8 + ProtocolVersionMinor int8 + + Operation int16 + RequestId int32 + + OperationAttributes map[string]any + JobAttributes map[string]any + PrinterAttributes map[string]any + SubscriptionAttributes map[string]any // Added for subscription operations + + File io.Reader + FileSize int +} + +// NewRequest creates a new ipp request +func NewRequest(op int16, reqID int32) *Request { + return &Request{ + ProtocolVersionMajor: ProtocolVersionMajor, + ProtocolVersionMinor: ProtocolVersionMinor, + Operation: op, + RequestId: reqID, + OperationAttributes: make(map[string]any), + JobAttributes: make(map[string]any), + PrinterAttributes: make(map[string]any), + SubscriptionAttributes: make(map[string]any), + File: nil, + FileSize: -1, + } +} + +// Encode encodes the request to a byte slice +func (r *Request) Encode() ([]byte, error) { + buf := new(bytes.Buffer) + enc := NewAttributeEncoder(buf) + + if err := binary.Write(buf, binary.BigEndian, r.ProtocolVersionMajor); err != nil { + return nil, err + } + + if err := binary.Write(buf, binary.BigEndian, r.ProtocolVersionMinor); err != nil { + return nil, err + } + + if err := binary.Write(buf, binary.BigEndian, r.Operation); err != nil { + return nil, err + } + + if err := binary.Write(buf, binary.BigEndian, r.RequestId); err != nil { + return nil, err + } + + if err := binary.Write(buf, binary.BigEndian, TagOperation); err != nil { + return nil, err + } + + if r.OperationAttributes == nil { + r.OperationAttributes = make(map[string]any, 2) + } + + if _, found := r.OperationAttributes[AttributeCharset]; !found { + r.OperationAttributes[AttributeCharset] = Charset + } + + if _, found := r.OperationAttributes[AttributeNaturalLanguage]; !found { + r.OperationAttributes[AttributeNaturalLanguage] = CharsetLanguage + } + + if err := r.encodeOperationAttributes(enc); err != nil { + return nil, err + } + + if len(r.JobAttributes) > 0 { + if err := binary.Write(buf, binary.BigEndian, TagJob); err != nil { + return nil, err + } + for attr, value := range r.JobAttributes { + if err := enc.Encode(attr, value); err != nil { + return nil, err + } + } + } + + if len(r.PrinterAttributes) > 0 { + if err := binary.Write(buf, binary.BigEndian, TagPrinter); err != nil { + return nil, err + } + for attr, value := range r.PrinterAttributes { + if err := enc.Encode(attr, value); err != nil { + return nil, err + } + } + } + + if len(r.SubscriptionAttributes) > 0 { + if err := binary.Write(buf, binary.BigEndian, TagSubscription); err != nil { + return nil, err + } + if err := r.encodeSubscriptionAttributes(enc); err != nil { + return nil, err + } + } + + if err := binary.Write(buf, binary.BigEndian, TagEnd); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func (r *Request) encodeOperationAttributes(enc *AttributeEncoder) error { + ordered := []string{ + AttributeCharset, + AttributeNaturalLanguage, + AttributePrinterURI, + AttributeJobID, + } + + for _, attr := range ordered { + if value, ok := r.OperationAttributes[attr]; ok { + delete(r.OperationAttributes, attr) + if err := enc.Encode(attr, value); err != nil { + return err + } + } + } + + for attr, value := range r.OperationAttributes { + if err := enc.Encode(attr, value); err != nil { + return err + } + } + + return nil +} + +func (r *Request) encodeSubscriptionAttributes(enc *AttributeEncoder) error { + // Encode subscription attributes in proper order + // notify-pull-method and notify-lease-duration must come before notify-events + ordered := []string{ + "notify-pull-method", + "notify-lease-duration", + "notify-events", + } + + for _, attr := range ordered { + if value, ok := r.SubscriptionAttributes[attr]; ok { + delete(r.SubscriptionAttributes, attr) + if err := enc.Encode(attr, value); err != nil { + return err + } + } + } + + // Encode any remaining subscription attributes + for attr, value := range r.SubscriptionAttributes { + if err := enc.Encode(attr, value); err != nil { + return err + } + } + + return nil +} + +// RequestDecoder reads and decodes a request from a stream +type RequestDecoder struct { + reader io.Reader +} + +// NewRequestDecoder returns a new decoder that reads from r +func NewRequestDecoder(r io.Reader) *RequestDecoder { + return &RequestDecoder{ + reader: r, + } +} + +// Decode decodes a ipp request into a request struct. additional data will be written to an io.Writer if data is not nil +func (d *RequestDecoder) Decode(data io.Writer) (*Request, error) { + req := new(Request) + + if err := binary.Read(d.reader, binary.BigEndian, &req.ProtocolVersionMajor); err != nil { + return nil, err + } + + if err := binary.Read(d.reader, binary.BigEndian, &req.ProtocolVersionMinor); err != nil { + return nil, err + } + + if err := binary.Read(d.reader, binary.BigEndian, &req.Operation); err != nil { + return nil, err + } + + if err := binary.Read(d.reader, binary.BigEndian, &req.RequestId); err != nil { + return nil, err + } + + startByteSlice := make([]byte, 1) + + tag := TagCupsInvalid + previousAttributeName := "" + tagSet := false + + attribDecoder := NewAttributeDecoder(d.reader) + + // decode attribute buffer + for { + if _, err := d.reader.Read(startByteSlice); err != nil { + // when we read from a stream, we may get an EOF if we want to read the end tag + // all data should be read and we can ignore the error + if err == io.EOF { + break + } + return nil, err + } + + startByte := int8(startByteSlice[0]) + + // check if attributes are completed + if startByte == TagEnd { + break + } + + if startByte == TagOperation { + if req.OperationAttributes == nil { + req.OperationAttributes = make(map[string]any) + } + + tag = TagOperation + tagSet = true + + } + + if startByte == TagJob { + if req.JobAttributes == nil { + req.JobAttributes = make(map[string]any) + } + tag = TagJob + tagSet = true + } + + if startByte == TagPrinter { + if req.PrinterAttributes == nil { + req.PrinterAttributes = make(map[string]any) + } + tag = TagPrinter + tagSet = true + } + + if tagSet { + if _, err := d.reader.Read(startByteSlice); err != nil { + return nil, err + } + startByte = int8(startByteSlice[0]) + } + + attrib, err := attribDecoder.Decode(startByte) + if err != nil { + return nil, err + } + + if attrib.Name != "" { + appendAttributeToRequest(req, tag, attrib.Name, attrib.Value) + previousAttributeName = attrib.Name + } else { + appendAttributeToRequest(req, tag, previousAttributeName, attrib.Value) + } + + tagSet = false + } + + if data != nil { + if _, err := io.Copy(data, d.reader); err != nil { + return nil, err + } + } + + return req, nil +} + +func appendAttributeToRequest(req *Request, tag int8, name string, value any) { + switch tag { + case TagOperation: + req.OperationAttributes[name] = value + case TagPrinter: + req.PrinterAttributes[name] = value + case TagJob: + req.JobAttributes[name] = value + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/response.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/response.go new file mode 100644 index 0000000..e7cbd75 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/response.go @@ -0,0 +1,383 @@ +package ipp + +import ( + "bytes" + "encoding/binary" + "io" +) + +// Attributes is a wrapper for a set of attributes +type Attributes map[string][]Attribute + +// Response defines a ipp response +type Response struct { + ProtocolVersionMajor int8 + ProtocolVersionMinor int8 + + StatusCode int16 + RequestId int32 + + OperationAttributes Attributes + PrinterAttributes []Attributes + JobAttributes []Attributes + SubscriptionAttributes []Attributes // Added for subscription responses +} + +// CheckForErrors checks the status code and returns a error if it is not zero. it also returns the status message if provided by the server +func (r *Response) CheckForErrors() error { + if r.StatusCode != StatusOk { + err := IPPError{ + Status: r.StatusCode, + Message: "no status message returned", + } + + if len(r.OperationAttributes["status-message"]) > 0 { + err.Message = r.OperationAttributes["status-message"][0].Value.(string) + } + + return err + } + + return nil +} + +// NewResponse creates a new ipp response +func NewResponse(statusCode int16, reqID int32) *Response { + return &Response{ + ProtocolVersionMajor: ProtocolVersionMajor, + ProtocolVersionMinor: ProtocolVersionMinor, + StatusCode: statusCode, + RequestId: reqID, + OperationAttributes: make(Attributes), + PrinterAttributes: make([]Attributes, 0), + JobAttributes: make([]Attributes, 0), + } +} + +// Encode encodes the response to a byte slice +func (r *Response) Encode() ([]byte, error) { + buf := new(bytes.Buffer) + enc := NewAttributeEncoder(buf) + + if err := binary.Write(buf, binary.BigEndian, r.ProtocolVersionMajor); err != nil { + return nil, err + } + + if err := binary.Write(buf, binary.BigEndian, r.ProtocolVersionMinor); err != nil { + return nil, err + } + + if err := binary.Write(buf, binary.BigEndian, r.StatusCode); err != nil { + return nil, err + } + + if err := binary.Write(buf, binary.BigEndian, r.RequestId); err != nil { + return nil, err + } + + if err := binary.Write(buf, binary.BigEndian, TagOperation); err != nil { + return nil, err + } + + if r.OperationAttributes == nil { + r.OperationAttributes = make(Attributes, 0) + } + + if _, found := r.OperationAttributes[AttributeCharset]; !found { + r.OperationAttributes[AttributeCharset] = []Attribute{ + { + Value: Charset, + }, + } + } + + if _, found := r.OperationAttributes[AttributeNaturalLanguage]; !found { + r.OperationAttributes[AttributeNaturalLanguage] = []Attribute{ + { + Value: CharsetLanguage, + }, + } + } + + if err := r.encodeOperationAttributes(enc); err != nil { + return nil, err + } + + if len(r.PrinterAttributes) > 0 { + for _, printerAttr := range r.PrinterAttributes { + if err := binary.Write(buf, binary.BigEndian, TagPrinter); err != nil { + return nil, err + } + + for name, attr := range printerAttr { + if len(attr) == 0 { + continue + } + + values := make([]any, len(attr)) + for i, v := range attr { + values[i] = v.Value + } + + if len(values) == 1 { + if err := enc.Encode(name, values[0]); err != nil { + return nil, err + } + } else { + if err := enc.Encode(name, values); err != nil { + return nil, err + } + } + } + } + } + + if len(r.JobAttributes) > 0 { + for _, jobAttr := range r.JobAttributes { + if err := binary.Write(buf, binary.BigEndian, TagJob); err != nil { + return nil, err + } + + for name, attr := range jobAttr { + if len(attr) == 0 { + continue + } + + values := make([]any, len(attr)) + for i, v := range attr { + values[i] = v.Value + } + + if len(values) == 1 { + if err := enc.Encode(name, values[0]); err != nil { + return nil, err + } + } else { + if err := enc.Encode(name, values); err != nil { + return nil, err + } + } + } + } + } + + if err := binary.Write(buf, binary.BigEndian, TagEnd); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func (r *Response) encodeOperationAttributes(enc *AttributeEncoder) error { + ordered := []string{ + AttributeCharset, + AttributeNaturalLanguage, + AttributePrinterURI, + AttributeJobID, + } + + for _, name := range ordered { + if attr, ok := r.OperationAttributes[name]; ok { + delete(r.OperationAttributes, name) + if err := encodeOperationAttribute(enc, name, attr); err != nil { + return err + } + } + } + + for name, attr := range r.OperationAttributes { + if err := encodeOperationAttribute(enc, name, attr); err != nil { + return err + } + } + + return nil +} + +func encodeOperationAttribute(enc *AttributeEncoder, name string, attr []Attribute) error { + if len(attr) == 0 { + return nil + } + + values := make([]any, len(attr)) + for i, v := range attr { + values[i] = v.Value + } + + if len(values) == 1 { + return enc.Encode(name, values[0]) + } + + return enc.Encode(name, values) +} + +// ResponseDecoder reads and decodes a response from a stream +type ResponseDecoder struct { + reader io.Reader +} + +// NewResponseDecoder returns a new decoder that reads from r +func NewResponseDecoder(r io.Reader) *ResponseDecoder { + return &ResponseDecoder{ + reader: r, + } +} + +// Decode decodes a ipp response into a response struct. additional data will be written to an io.Writer if data is not nil +func (d *ResponseDecoder) Decode(data io.Writer) (*Response, error) { + /* + 1 byte: Protocol Major Version - b + 1 byte: Protocol Minor Version - b + 2 byte: Status ID - h + 4 byte: Request ID - i + 1 byte: Operation Attribute Byte (\0x01) + N times: Attributes + 1 byte: Attribute End Byte (\0x03) + */ + + resp := new(Response) + + // wrap the reader so we have more functionality + // reader := bufio.NewReader(d.reader) + + if err := binary.Read(d.reader, binary.BigEndian, &resp.ProtocolVersionMajor); err != nil { + return nil, err + } + + if err := binary.Read(d.reader, binary.BigEndian, &resp.ProtocolVersionMinor); err != nil { + return nil, err + } + + if err := binary.Read(d.reader, binary.BigEndian, &resp.StatusCode); err != nil { + return nil, err + } + + if err := binary.Read(d.reader, binary.BigEndian, &resp.RequestId); err != nil { + return nil, err + } + + startByteSlice := make([]byte, 1) + + tag := TagCupsInvalid + previousAttributeName := "" + tempAttributes := make(Attributes) + tagSet := false + + attribDecoder := NewAttributeDecoder(d.reader) + + // decode attribute buffer + for { + if _, err := d.reader.Read(startByteSlice); err != nil { + // when we read from a stream, we may get an EOF if we want to read the end tag + // all data should be read and we can ignore the error + if err == io.EOF { + break + } + return nil, err + } + + startByte := int8(startByteSlice[0]) + + // check if attributes are completed + if startByte == TagEnd { + break + } + + if startByte == TagOperation { + if len(tempAttributes) > 0 && tag != TagCupsInvalid { + appendAttributeToResponse(resp, tag, tempAttributes) + tempAttributes = make(Attributes) + } + + tag = TagOperation + tagSet = true + } + + if startByte == TagJob { + if len(tempAttributes) > 0 && tag != TagCupsInvalid { + appendAttributeToResponse(resp, tag, tempAttributes) + tempAttributes = make(Attributes) + } + + tag = TagJob + tagSet = true + } + + if startByte == TagPrinter { + if len(tempAttributes) > 0 && tag != TagCupsInvalid { + appendAttributeToResponse(resp, tag, tempAttributes) + tempAttributes = make(Attributes) + } + + tag = TagPrinter + tagSet = true + } + + if startByte == TagSubscription { + if len(tempAttributes) > 0 && tag != TagCupsInvalid { + appendAttributeToResponse(resp, tag, tempAttributes) + tempAttributes = make(Attributes) + } + + tag = TagSubscription + tagSet = true + } + + if startByte == TagEventNotification { + if len(tempAttributes) > 0 && tag != TagCupsInvalid { + appendAttributeToResponse(resp, tag, tempAttributes) + tempAttributes = make(Attributes) + } + + tag = TagEventNotification + tagSet = true + } + + if tagSet { + if _, err := d.reader.Read(startByteSlice); err != nil { + return nil, err + } + startByte = int8(startByteSlice[0]) + } + + attrib, err := attribDecoder.Decode(startByte) + if err != nil { + return nil, err + } + + if attrib.Name != "" { + tempAttributes[attrib.Name] = append(tempAttributes[attrib.Name], *attrib) + previousAttributeName = attrib.Name + } else { + tempAttributes[previousAttributeName] = append(tempAttributes[previousAttributeName], *attrib) + } + + tagSet = false + } + + if len(tempAttributes) > 0 && tag != TagCupsInvalid { + appendAttributeToResponse(resp, tag, tempAttributes) + } + + if data != nil { + if _, err := io.Copy(data, d.reader); err != nil { + return nil, err + } + } + + return resp, nil +} + +func appendAttributeToResponse(resp *Response, tag int8, attr map[string][]Attribute) { + switch tag { + case TagOperation: + resp.OperationAttributes = attr + case TagPrinter: + resp.PrinterAttributes = append(resp.PrinterAttributes, attr) + case TagJob: + resp.JobAttributes = append(resp.JobAttributes, attr) + case TagSubscription, TagEventNotification: + // Both subscription and event notification attributes go to SubscriptionAttributes + resp.SubscriptionAttributes = append(resp.SubscriptionAttributes, attr) + } +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/utils.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/utils.go new file mode 100644 index 0000000..62689a3 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/ipp/utils.go @@ -0,0 +1,28 @@ +package ipp + +import ( + "fmt" + "os" + "path" +) + +// ParseControlFile reads and decodes a cups control file into a response +func ParseControlFile(jobID int, spoolDirectory string) (*Response, error) { + if spoolDirectory == "" { + spoolDirectory = "/var/spool/cups" + } + + controlFilePath := path.Join(spoolDirectory, fmt.Sprintf("c%d", jobID)) + + if _, err := os.Stat(controlFilePath); err != nil { + return nil, err + } + + controlFile, err := os.Open(controlFilePath) + if err != nil { + return nil, err + } + defer controlFile.Close() + + return NewResponseDecoder(controlFile).Decode(nil) +} diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/syncmap/LICENSE b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/syncmap/LICENSE new file mode 100644 index 0000000..edba275 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/syncmap/LICENSE @@ -0,0 +1,28 @@ +Copyright 2009 The Go Authors. +Copyright 2024 Zachary Olstein. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/syncmap/syncmap.go b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/syncmap/syncmap.go new file mode 100644 index 0000000..27d9458 --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/core/pkg/syncmap/syncmap.go @@ -0,0 +1,537 @@ +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package syncmap + +import ( + "sync" + "sync/atomic" + "unsafe" +) + +// Map is like a Go map[K]V but is safe for concurrent use +// by multiple goroutines without additional locking or coordination. +// Loads, stores, and deletes run in amortized constant time. +// +// The Map type is specialized. Most code should use a plain Go map instead, +// with separate locking or coordination, for better type safety and to make it +// easier to maintain other invariants along with the map content. +// +// The Map type is optimized for two common use cases: (1) when the entry for a given +// key is only ever written once but read many times, as in caches that only grow, +// or (2) when multiple goroutines read, write, and overwrite entries for disjoint +// sets of keys. In these two cases, use of a Map may significantly reduce lock +// contention compared to a Go map paired with a separate [Mutex] or [RWMutex]. +// +// The zero Map is empty and ready for use. A Map must not be copied after first use. +// +// In the terminology of [the Go memory model], Map arranges that a write operation +// “synchronizes before” any read operation that observes the effect of the write, where +// read and write operations are defined as follows. +// [Map.Load], [Map.LoadAndDelete], [Map.LoadOrStore], and [Map.Swap] are read operations; +// [Map.Delete], [Map.LoadAndDelete], [Map.Store], and [Map.Swap] are write operations; +// [Map.LoadOrStore] is a write operation when it returns loaded set to false. +// +// [the Go memory model]: https://go.dev/ref/mem +type Map[K comparable, V any] struct { + mu sync.Mutex + + // read contains the portion of the map's contents that are safe for + // concurrent access (with or without mu held). + // + // The read field itself is always safe to load, but must only be stored with + // mu held. + // + // Entries stored in read may be updated concurrently without mu, but updating + // a previously-expunged entry requires that the entry be copied to the dirty + // map and unexpunged with mu held. + read atomic.Pointer[readOnly[K, V]] + + // dirty contains the portion of the map's contents that require mu to be + // held. To ensure that the dirty map can be promoted to the read map quickly, + // it also includes all of the non-expunged entries in the read map. + // + // Expunged entries are not stored in the dirty map. An expunged entry in the + // clean map must be unexpunged and added to the dirty map before a new value + // can be stored to it. + // + // If the dirty map is nil, the next write to the map will initialize it by + // making a shallow copy of the clean map, omitting stale entries. + dirty map[K]*entry[V] + + // misses counts the number of loads since the read map was last updated that + // needed to lock mu to determine whether the key was present. + // + // Once enough misses have occurred to cover the cost of copying the dirty + // map, the dirty map will be promoted to the read map (in the unamended + // state) and the next store to the map will make a new dirty copy. + misses int +} + +// readOnly is an immutable struct stored atomically in the Map.read field. +type readOnly[K comparable, V any] struct { + m map[K]*entry[V] + amended bool // true if the dirty map contains some key not in m. +} + +// expunged is an arbitrary pointer that marks entries which have been deleted +// from the dirty map. +// Because the same expunged pointer is used regardless of the Map's value type, +// value pointers read from the map must be compared against expunged BEFORE +// casting the pointer to *V. +var expunged = unsafe.Pointer(new(int)) + +// An entry is a slot in the map corresponding to a particular key. +type entry[V any] struct { + // p points to the value stored for the entry. + // + // If p == nil, the entry has been deleted, and either m.dirty == nil or + // m.dirty[key] is e. + // + // If p == expunged, the entry has been deleted, m.dirty != nil, and the entry + // is missing from m.dirty. + // + // Otherwise, the entry is valid and recorded in m.read.m[key] and, if m.dirty + // != nil, in m.dirty[key]. + // + // If p != expunged, it is always safe to cast it to (*V). + // + // An entry can be deleted by atomic replacement with nil: when m.dirty is + // next created, it will atomically replace nil with expunged and leave + // m.dirty[key] unset. + // + // An entry's associated value can be updated by atomic replacement, provided + // p != expunged. If p == expunged, an entry's associated value can be updated + // only after first setting m.dirty[key] = e so that lookups using the dirty + // map find the entry. + p unsafe.Pointer +} + +func newEntry[V any](i V) *entry[V] { + e := &entry[V]{} + atomic.StorePointer(&e.p, unsafe.Pointer(&i)) + return e +} + +func (m *Map[K, V]) loadReadOnly() readOnly[K, V] { + if p := m.read.Load(); p != nil { + return *p + } + return readOnly[K, V]{} +} + +// Load returns the value stored in the map for a key, or nil if no +// value is present. +// The ok result indicates whether value was found in the map. +func (m *Map[K, V]) Load(key K) (value V, ok bool) { + read := m.loadReadOnly() + e, ok := read.m[key] + if !ok && read.amended { + m.mu.Lock() + // Avoid reporting a spurious miss if m.dirty got promoted while we were + // blocked on m.mu. (If further loads of the same key will not miss, it's + // not worth copying the dirty map for this key.) + read = m.loadReadOnly() + e, ok = read.m[key] + if !ok && read.amended { + e, ok = m.dirty[key] + // Regardless of whether the entry was present, record a miss: this key + // will take the slow path until the dirty map is promoted to the read + // map. + m.missLocked() + } + m.mu.Unlock() + } + if !ok { + return value, false + } + return e.load() +} + +func (e *entry[V]) load() (value V, ok bool) { + p := atomic.LoadPointer(&e.p) + if p == nil || p == expunged { + return value, false + } + return *(*V)(p), true +} + +// Store sets the value for a key. +func (m *Map[K, V]) Store(key K, value V) { + _, _ = m.Swap(key, value) +} + +// unexpungeLocked ensures that the entry is not marked as expunged. +// +// If the entry was previously expunged, it must be added to the dirty map +// before m.mu is unlocked. +func (e *entry[V]) unexpungeLocked() (wasExpunged bool) { + return atomic.CompareAndSwapPointer(&e.p, expunged, nil) +} + +// swapLocked unconditionally swaps a value into the entry. +// +// The entry must be known not to be expunged. +func (e *entry[V]) swapLocked(i *V) *V { + return (*V)(atomic.SwapPointer(&e.p, unsafe.Pointer(i))) +} + +// LoadOrStore returns the existing value for the key if present. +// Otherwise, it stores and returns the given value. +// The loaded result is true if the value was loaded, false if stored. +func (m *Map[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { + // Avoid locking if it's a clean hit. + read := m.loadReadOnly() + if e, ok := read.m[key]; ok { + actual, loaded, ok := e.tryLoadOrStore(value) + if ok { + return actual, loaded + } + } + + m.mu.Lock() + read = m.loadReadOnly() + if e, ok := read.m[key]; ok { + if e.unexpungeLocked() { + m.dirty[key] = e + } + actual, loaded, _ = e.tryLoadOrStore(value) + } else if e, ok := m.dirty[key]; ok { + actual, loaded, _ = e.tryLoadOrStore(value) + m.missLocked() + } else { + if !read.amended { + // We're adding the first new key to the dirty map. + // Make sure it is allocated and mark the read-only map as incomplete. + m.dirtyLocked() + m.read.Store(&readOnly[K, V]{m: read.m, amended: true}) + } + m.dirty[key] = newEntry(value) + actual, loaded = value, false + } + m.mu.Unlock() + + return actual, loaded +} + +// tryLoadOrStore atomically loads or stores a value if the entry is not +// expunged. +// +// If the entry is expunged, tryLoadOrStore leaves the entry unchanged and +// returns with ok==false. +func (e *entry[V]) tryLoadOrStore(i V) (actual V, loaded, ok bool) { + ptr := atomic.LoadPointer(&e.p) + if ptr == expunged { + return actual, false, false + } + p := (*V)(ptr) + if p != nil { + return *p, true, true + } + + // Copy the interface after the first load to make this method more amenable + // to escape analysis: if we hit the "load" path or the entry is expunged, we + // shouldn't bother heap-allocating. + ic := i + for { + if atomic.CompareAndSwapPointer(&e.p, nil, unsafe.Pointer(&ic)) { + return i, false, true + } + ptr = atomic.LoadPointer(&e.p) + if ptr == expunged { + return actual, false, false + } + p = (*V)(ptr) + if p != nil { + return *p, true, true + } + } +} + +// LoadAndDelete deletes the value for a key, returning the previous value if any. +// The loaded result reports whether the key was present. +func (m *Map[K, V]) LoadAndDelete(key K) (value V, loaded bool) { + read := m.loadReadOnly() + e, ok := read.m[key] + if !ok && read.amended { + m.mu.Lock() + read = m.loadReadOnly() + e, ok = read.m[key] + if !ok && read.amended { + e, ok = m.dirty[key] + delete(m.dirty, key) + // Regardless of whether the entry was present, record a miss: this key + // will take the slow path until the dirty map is promoted to the read + // map. + m.missLocked() + } + m.mu.Unlock() + } + if ok { + return e.delete() + } + return value, false +} + +// Delete deletes the value for a key. +func (m *Map[K, V]) Delete(key K) { + m.LoadAndDelete(key) +} + +func (e *entry[V]) delete() (value V, ok bool) { + for { + p := atomic.LoadPointer(&e.p) + if p == nil || p == expunged { + return value, false + } + if atomic.CompareAndSwapPointer(&e.p, p, nil) { + return *(*V)(p), true + } + } +} + +// trySwap swaps a value if the entry has not been expunged. +// +// If the entry is expunged, trySwap returns false and leaves the entry +// unchanged. +func (e *entry[V]) trySwap(i *V) (*V, bool) { + for { + p := atomic.LoadPointer(&e.p) + if p == expunged { + return nil, false + } + if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) { + return (*V)(p), true + } + } +} + +// Swap swaps the value for a key and returns the previous value if any. +// The loaded result reports whether the key was present. +func (m *Map[K, V]) Swap(key K, value V) (previous V, loaded bool) { + read := m.loadReadOnly() + if e, ok := read.m[key]; ok { + if v, ok := e.trySwap(&value); ok { + if v == nil { + return previous, false + } + return *v, true + } + } + + m.mu.Lock() + read = m.loadReadOnly() + if e, ok := read.m[key]; ok { + if e.unexpungeLocked() { + // The entry was previously expunged, which implies that there is a + // non-nil dirty map and this entry is not in it. + m.dirty[key] = e + } + if v := e.swapLocked(&value); v != nil { + loaded = true + previous = *v + } + } else if e, ok := m.dirty[key]; ok { + if v := e.swapLocked(&value); v != nil { + loaded = true + previous = *v + } + } else { + if !read.amended { + // We're adding the first new key to the dirty map. + // Make sure it is allocated and mark the read-only map as incomplete. + m.dirtyLocked() + m.read.Store(&readOnly[K, V]{m: read.m, amended: true}) + } + m.dirty[key] = newEntry(value) + } + m.mu.Unlock() + return previous, loaded +} + +// Range calls f sequentially for each key and value present in the map. +// If f returns false, range stops the iteration. +// +// Range does not necessarily correspond to any consistent snapshot of the Map's +// contents: no key will be visited more than once, but if the value for any key +// is stored or deleted concurrently (including by f), Range may reflect any +// mapping for that key from any point during the Range call. Range does not +// block other methods on the receiver; even f itself may call any method on m. +// +// Range may be O(N) with the number of elements in the map even if f returns +// false after a constant number of calls. +func (m *Map[K, V]) Range(f func(key K, value V) bool) { + // We need to be able to iterate over all of the keys that were already + // present at the start of the call to Range. + // If read.amended is false, then read.m satisfies that property without + // requiring us to hold m.mu for a long time. + read := m.loadReadOnly() + if read.amended { + // m.dirty contains keys not in read.m. Fortunately, Range is already O(N) + // (assuming the caller does not break out early), so a call to Range + // amortizes an entire copy of the map: we can promote the dirty copy + // immediately! + m.mu.Lock() + read = m.loadReadOnly() + if read.amended { + read = readOnly[K, V]{m: m.dirty} + copyRead := read + m.read.Store(©Read) + m.dirty = nil + m.misses = 0 + } + m.mu.Unlock() + } + + for k, e := range read.m { + v, ok := e.load() + if !ok { + continue + } + if !f(k, v) { + break + } + } +} + +// CompareAndSwap swaps the old and new values for key +// if the value stored in the map is equal to old. +// The old value must be of a comparable type. +func CompareAndSwap[K comparable, V comparable](m *Map[K, V], key K, old, new V) (swapped bool) { + read := m.loadReadOnly() + if e, ok := read.m[key]; ok { + return tryCompareAndSwap(e, old, new) + } else if !read.amended { + return false // No existing value for key. + } + + m.mu.Lock() + defer m.mu.Unlock() + read = m.loadReadOnly() + swapped = false + if e, ok := read.m[key]; ok { + swapped = tryCompareAndSwap(e, old, new) + } else if e, ok := m.dirty[key]; ok { + swapped = tryCompareAndSwap(e, old, new) + // We needed to lock mu in order to load the entry for key, + // and the operation didn't change the set of keys in the map + // (so it would be made more efficient by promoting the dirty + // map to read-only). + // Count it as a miss so that we will eventually switch to the + // more efficient steady state. + m.missLocked() + } + return swapped +} + +// CompareAndDelete deletes the entry for key if its value is equal to old. +// The old value must be of a comparable type. +// +// If there is no current value for key in the map, CompareAndDelete +// returns false (even if the old value is the zero value of V). +func CompareAndDelete[K comparable, V comparable](m *Map[K, V], key K, old V) (deleted bool) { + read := m.loadReadOnly() + e, ok := read.m[key] + if !ok && read.amended { + m.mu.Lock() + read = m.loadReadOnly() + e, ok = read.m[key] + if !ok && read.amended { + e, ok = m.dirty[key] + // Don't delete key from m.dirty: we still need to do the “compare” part + // of the operation. The entry will eventually be expunged when the + // dirty map is promoted to the read map. + // + // Regardless of whether the entry was present, record a miss: this key + // will take the slow path until the dirty map is promoted to the read + // map. + m.missLocked() + } + m.mu.Unlock() + } + for ok { + ptr := atomic.LoadPointer(&e.p) + if ptr == nil || ptr == expunged { + return false + } + p := (*V)(ptr) + if *p != old { + return false + } + if atomic.CompareAndSwapPointer(&e.p, ptr, nil) { + return true + } + } + return false +} + +// tryCompareAndSwap compare the entry with the given old value and swaps +// it with a new value if the entry is equal to the old value, and the entry +// has not been expunged. +// +// If the entry is expunged, tryCompareAndSwap returns false and leaves +// the entry unchanged. +func tryCompareAndSwap[V comparable](e *entry[V], old, new V) bool { + ptr := atomic.LoadPointer(&e.p) + if ptr == nil || ptr == expunged { + return false + } + p := (*V)(ptr) + if *p != old { + return false + } + + // Copy the interface after the first load to make this method more amenable + // to escape analysis: if the comparison fails from the start, we shouldn't + // bother heap-allocating an interface value to store. + nc := new + for { + if atomic.CompareAndSwapPointer(&e.p, ptr, unsafe.Pointer(&nc)) { + return true + } + ptr = atomic.LoadPointer(&e.p) + if ptr == nil || ptr == expunged { + return false + } + p = (*V)(ptr) + if *p != old { + return false + } + } +} + +func (m *Map[K, V]) missLocked() { + m.misses++ + if m.misses < len(m.dirty) { + return + } + m.read.Store(&readOnly[K, V]{m: m.dirty}) + m.dirty = nil + m.misses = 0 +} + +func (m *Map[K, V]) dirtyLocked() { + if m.dirty != nil { + return + } + + read := m.loadReadOnly() + m.dirty = make(map[K]*entry[V], len(read.m)) + for k, e := range read.m { + if !e.tryExpungeLocked() { + m.dirty[k] = e + } + } +} + +func (e *entry[V]) tryExpungeLocked() (isExpunged bool) { + p := atomic.LoadPointer(&e.p) + for p == nil { + if atomic.CompareAndSwapPointer(&e.p, nil, expunged) { + return true + } + p = atomic.LoadPointer(&e.p) + } + return p == expunged +} -- cgit v1.3