From 138590d9f10c3fa93be688ce07855db2bebfb0c1 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 23 Jul 2026 16:46:30 +0100 Subject: [PATCH] feat(display/webkit): CoreGUI Wails shell as a go-render display module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the CoreGUI desktop shell (Wails/WebKit window manager, core-action bridge, CDP browser-console access, Deno sidecar) out of the polyglot dappco.re/go/gui repo into go-render as the own-module display target dappco.re/go/render/display/webkit — cgo, kept out of the WASM-clean library. Reparented all imports, root package gui->webkit, added cmd/webkit (Wails app + Angular asset serving + Deno sidecar lifecycle). GOWORK=off build+vet green. Co-Authored-By: Virgil --- display/webkit/AGENTS.md | 1 + display/webkit/CLAUDE.md | 1 + display/webkit/README.md | 1 + display/webkit/bootstrap.go | 227 + display/webkit/cmd/webkit/main.go | 69 + display/webkit/contextmenus.go | 97 + display/webkit/docs | 1 + display/webkit/events.go | 28 + display/webkit/go.mod | 51 + display/webkit/go.sum | 146 + display/webkit/http_middleware.go | 48 + display/webkit/http_middleware_test.go | 67 + display/webkit/keybindings.go | 68 + display/webkit/menu.go | 51 + display/webkit/mode.go | 103 + display/webkit/mode_behaviour_test.go | 52 + display/webkit/pkg/browser/messages.go | 14 + .../pkg/browser/messages_example_test.go | 3 + display/webkit/pkg/browser/messages_test.go | 1 + display/webkit/pkg/browser/platform.go | 11 + .../pkg/browser/platform_example_test.go | 3 + display/webkit/pkg/browser/platform_test.go | 1 + display/webkit/pkg/browser/register.go | 14 + .../pkg/browser/register_example_test.go | 11 + display/webkit/pkg/browser/register_test.go | 30 + display/webkit/pkg/browser/result_failure.go | 5 + display/webkit/pkg/browser/service.go | 83 + .../pkg/browser/service_example_test.go | 17 + display/webkit/pkg/browser/service_test.go | 294 + display/webkit/pkg/browser/wails.go | 39 + display/webkit/pkg/chat/chat.go | 283 + display/webkit/pkg/chat/chat_example_test.go | 35 + display/webkit/pkg/chat/chat_test.go | 215 + display/webkit/pkg/chat/core_helpers.go | 69 + display/webkit/pkg/chat/messages.go | 211 + .../webkit/pkg/chat/messages_example_test.go | 17 + display/webkit/pkg/chat/messages_test.go | 57 + display/webkit/pkg/chat/result_failure.go | 5 + display/webkit/pkg/chat/service.go | 1746 +++ .../webkit/pkg/chat/service_example_test.go | 180 + display/webkit/pkg/chat/service_test.go | 983 ++ display/webkit/pkg/chat/store.go | 109 + display/webkit/pkg/chat/tool_handler.go | 276 + .../pkg/chat/tool_handler_example_test.go | 102 + display/webkit/pkg/chat/tool_handler_test.go | 394 + .../webkit/pkg/clipboard/core_helpers_test.go | 12 + display/webkit/pkg/clipboard/messages.go | 26 + .../pkg/clipboard/messages_example_test.go | 3 + display/webkit/pkg/clipboard/messages_test.go | 1 + display/webkit/pkg/clipboard/platform.go | 25 + .../pkg/clipboard/platform_example_test.go | 3 + display/webkit/pkg/clipboard/platform_test.go | 1 + .../webkit/pkg/clipboard/result_failure.go | 5 + display/webkit/pkg/clipboard/service.go | 136 + .../pkg/clipboard/service_example_test.go | 23 + display/webkit/pkg/clipboard/service_test.go | 215 + display/webkit/pkg/clipboard/wails.go | 45 + display/webkit/pkg/container/core_helpers.go | 84 + display/webkit/pkg/container/detect.go | 101 + .../pkg/container/detect_example_test.go | 17 + display/webkit/pkg/container/detect_test.go | 193 + display/webkit/pkg/container/mode.go | 101 + .../webkit/pkg/container/mode_example_test.go | 17 + display/webkit/pkg/container/mode_test.go | 115 + .../webkit/pkg/container/result_failure.go | 5 + display/webkit/pkg/container/service.go | 151 + .../pkg/container/service_example_test.go | 41 + display/webkit/pkg/container/service_test.go | 525 + display/webkit/pkg/container/tim.go | 230 + .../webkit/pkg/container/tim_example_test.go | 29 + display/webkit/pkg/container/tim_test.go | 513 + display/webkit/pkg/contextmenu/messages.go | 47 + .../pkg/contextmenu/messages_example_test.go | 3 + .../webkit/pkg/contextmenu/messages_test.go | 1 + display/webkit/pkg/contextmenu/platform.go | 41 + .../pkg/contextmenu/platform_example_test.go | 3 + .../webkit/pkg/contextmenu/platform_test.go | 1 + display/webkit/pkg/contextmenu/register.go | 15 + .../pkg/contextmenu/register_example_test.go | 11 + .../webkit/pkg/contextmenu/register_test.go | 30 + .../webkit/pkg/contextmenu/result_failure.go | 5 + display/webkit/pkg/contextmenu/service.go | 226 + .../pkg/contextmenu/service_behaviour_test.go | 15 + .../pkg/contextmenu/service_example_test.go | 23 + .../webkit/pkg/contextmenu/service_test.go | 691 + display/webkit/pkg/contextmenu/wails.go | 163 + display/webkit/pkg/deno/core_helpers.go | 32 + display/webkit/pkg/deno/result_failure.go | 5 + display/webkit/pkg/deno/sidecar.go | 404 + .../webkit/pkg/deno/sidecar_behaviour_test.go | 206 + .../webkit/pkg/deno/sidecar_example_test.go | 47 + display/webkit/pkg/deno/sidecar_test.go | 256 + display/webkit/pkg/dialog/messages.go | 94 + .../pkg/dialog/messages_example_test.go | 3 + display/webkit/pkg/dialog/messages_test.go | 1 + display/webkit/pkg/dialog/platform.go | 70 + .../pkg/dialog/platform_example_test.go | 3 + display/webkit/pkg/dialog/platform_test.go | 1 + display/webkit/pkg/dialog/result_failure.go | 5 + display/webkit/pkg/dialog/service.go | 377 + .../pkg/dialog/service_behaviour_test.go | 127 + .../webkit/pkg/dialog/service_example_test.go | 23 + display/webkit/pkg/dialog/service_test.go | 740 ++ display/webkit/pkg/dialog/wails.go | 189 + .../pkg/display/.github/workflows/go.yml | 22 + .../pkg/display/.github/workflows/release.yml | 24 + display/webkit/pkg/display/.gitignore | 17 + display/webkit/pkg/display/FEATURES.md | 247 + display/webkit/pkg/display/LICENSE | 287 + display/webkit/pkg/display/README.md | 43 + display/webkit/pkg/display/api.go | 633 + .../webkit/pkg/display/api_example_test.go | 209 + display/webkit/pkg/display/api_test.go | 2440 ++++ .../webkit/pkg/display/api_wrappers_test.go | 368 + display/webkit/pkg/display/assets/apptray.png | Bin 0 -> 32953 bytes display/webkit/pkg/display/background.go | 173 + .../pkg/display/background_example_test.go | 53 + display/webkit/pkg/display/background_test.go | 479 + display/webkit/pkg/display/core_helpers.go | 291 + display/webkit/pkg/display/display.go | 2000 +++ .../pkg/display/display_contextmenu_test.go | 78 + .../pkg/display/display_example_test.go | 245 + .../display/display_layout_wrappers_test.go | 276 + display/webkit/pkg/display/display_test.go | 3200 +++++ display/webkit/pkg/display/docs/backend.md | 54 + .../webkit/pkg/display/docs/development.md | 64 + display/webkit/pkg/display/docs/frontend.md | 29 + display/webkit/pkg/display/docs/overview.md | 25 + display/webkit/pkg/display/events.go | 655 + .../webkit/pkg/display/events_example_test.go | 53 + display/webkit/pkg/display/events_test.go | 813 ++ display/webkit/pkg/display/hlcrf.go | 90 + display/webkit/pkg/display/hlcrf_test.go | 103 + display/webkit/pkg/display/interfaces.go | 48 + .../pkg/display/interfaces_example_test.go | 17 + display/webkit/pkg/display/manifest.go | 275 + .../pkg/display/manifest_example_test.go | 3 + display/webkit/pkg/display/manifest_test.go | 349 + display/webkit/pkg/display/marketplace.go | 163 + .../webkit/pkg/display/marketplace_test.go | 191 + display/webkit/pkg/display/messages.go | 26 + .../pkg/display/messages_example_test.go | 3 + display/webkit/pkg/display/messages_test.go | 1 + display/webkit/pkg/display/ml.go | 90 + display/webkit/pkg/display/ml_example_test.go | 3 + display/webkit/pkg/display/ml_test.go | 59 + display/webkit/pkg/display/mocks_test.go | 1 + display/webkit/pkg/display/network.go | 286 + .../pkg/display/network_example_test.go | 3 + display/webkit/pkg/display/network_test.go | 142 + display/webkit/pkg/display/p2p.go | 42 + display/webkit/pkg/display/p2p_test.go | 150 + display/webkit/pkg/display/preload.go | 1357 ++ .../pkg/display/preload_example_test.go | 77 + display/webkit/pkg/display/preload_test.go | 755 ++ display/webkit/pkg/display/result_failure.go | 5 + display/webkit/pkg/display/scheme.go | 780 ++ .../webkit/pkg/display/scheme_example_test.go | 35 + display/webkit/pkg/display/scheme_handler.go | 158 + .../display/scheme_handler_example_test.go | 49 + .../webkit/pkg/display/scheme_handler_test.go | 261 + display/webkit/pkg/display/sidecar.go | 308 + display/webkit/pkg/display/sidecar_test.go | 272 + display/webkit/pkg/display/storage.go | 379 + .../pkg/display/storage_example_test.go | 47 + display/webkit/pkg/display/storage_store.go | 106 + display/webkit/pkg/display/storage_test.go | 477 + display/webkit/pkg/dock/messages.go | 55 + .../webkit/pkg/dock/messages_example_test.go | 3 + display/webkit/pkg/dock/messages_test.go | 1 + display/webkit/pkg/dock/platform.go | 34 + .../webkit/pkg/dock/platform_example_test.go | 3 + display/webkit/pkg/dock/platform_test.go | 1 + display/webkit/pkg/dock/register.go | 14 + .../webkit/pkg/dock/register_example_test.go | 11 + display/webkit/pkg/dock/register_test.go | 30 + display/webkit/pkg/dock/result_failure.go | 5 + display/webkit/pkg/dock/service.go | 83 + .../webkit/pkg/dock/service_example_test.go | 17 + display/webkit/pkg/dock/service_test.go | 444 + display/webkit/pkg/dock/wails.go | 122 + display/webkit/pkg/environment/messages.go | 31 + .../pkg/environment/messages_example_test.go | 3 + .../webkit/pkg/environment/messages_test.go | 1 + display/webkit/pkg/environment/platform.go | 32 + .../pkg/environment/platform_example_test.go | 3 + .../webkit/pkg/environment/platform_test.go | 1 + .../webkit/pkg/environment/result_failure.go | 5 + display/webkit/pkg/environment/service.go | 186 + .../pkg/environment/service_behaviour_test.go | 54 + .../pkg/environment/service_example_test.go | 29 + .../webkit/pkg/environment/service_test.go | 400 + display/webkit/pkg/environment/wails.go | 109 + display/webkit/pkg/events/messages.go | 66 + .../pkg/events/messages_example_test.go | 3 + display/webkit/pkg/events/messages_test.go | 1 + display/webkit/pkg/events/platform.go | 34 + .../pkg/events/platform_example_test.go | 3 + display/webkit/pkg/events/platform_test.go | 1 + display/webkit/pkg/events/register.go | 18 + .../pkg/events/register_example_test.go | 11 + display/webkit/pkg/events/register_test.go | 30 + display/webkit/pkg/events/result_failure.go | 5 + display/webkit/pkg/events/service.go | 157 + .../webkit/pkg/events/service_example_test.go | 23 + display/webkit/pkg/events/service_test.go | 557 + display/webkit/pkg/events/wails.go | 89 + .../webkit/pkg/internal/coreutil/action.go | 32 + .../coreutil/action_behaviour_test.go | 93 + .../internal/coreutil/action_example_test.go | 23 + .../pkg/internal/coreutil/action_test.go | 84 + .../webkit/pkg/internal/textutil/textutil.go | 13 + .../textutil/textutil_behaviour_test.go | 23 + .../textutil/textutil_example_test.go | 11 + .../pkg/internal/textutil/textutil_test.go | 30 + display/webkit/pkg/keybinding/messages.go | 39 + .../pkg/keybinding/messages_example_test.go | 3 + .../webkit/pkg/keybinding/messages_test.go | 1 + display/webkit/pkg/keybinding/platform.go | 24 + .../pkg/keybinding/platform_example_test.go | 3 + .../webkit/pkg/keybinding/platform_test.go | 1 + display/webkit/pkg/keybinding/register.go | 15 + .../pkg/keybinding/register_example_test.go | 11 + .../webkit/pkg/keybinding/register_test.go | 30 + .../webkit/pkg/keybinding/result_failure.go | 5 + display/webkit/pkg/keybinding/service.go | 119 + .../pkg/keybinding/service_example_test.go | 17 + display/webkit/pkg/keybinding/service_test.go | 368 + display/webkit/pkg/keybinding/wails.go | 84 + display/webkit/pkg/lifecycle/messages.go | 39 + .../pkg/lifecycle/messages_example_test.go | 3 + display/webkit/pkg/lifecycle/messages_test.go | 1 + display/webkit/pkg/lifecycle/mode.go | 51 + .../pkg/lifecycle/mode_behaviour_test.go | 39 + .../webkit/pkg/lifecycle/mode_example_test.go | 11 + display/webkit/pkg/lifecycle/mode_test.go | 42 + display/webkit/pkg/lifecycle/platform.go | 31 + .../pkg/lifecycle/platform_example_test.go | 3 + display/webkit/pkg/lifecycle/platform_test.go | 1 + display/webkit/pkg/lifecycle/register.go | 14 + .../pkg/lifecycle/register_example_test.go | 11 + display/webkit/pkg/lifecycle/register_test.go | 30 + display/webkit/pkg/lifecycle/service.go | 65 + .../pkg/lifecycle/service_example_test.go | 23 + display/webkit/pkg/lifecycle/service_test.go | 385 + display/webkit/pkg/lifecycle/wails.go | 114 + .../pkg/lifecycle/wails_behaviour_test.go | 72 + display/webkit/pkg/lifecycle/wails_darwin.go | 19 + display/webkit/pkg/lifecycle/wails_other.go | 12 + .../webkit/pkg/marketplace/core_helpers.go | 132 + display/webkit/pkg/marketplace/marketplace.go | 385 + .../marketplace/marketplace_example_test.go | 41 + .../pkg/marketplace/marketplace_test.go | 722 ++ .../webkit/pkg/marketplace/result_failure.go | 5 + display/webkit/pkg/mcp/mcp_test.go | 376 + display/webkit/pkg/mcp/result_failure.go | 5 + display/webkit/pkg/mcp/subsystem.go | 308 + .../webkit/pkg/mcp/subsystem_example_test.go | 41 + display/webkit/pkg/mcp/subsystem_test.go | 357 + .../webkit/pkg/mcp/tools_behaviour2_test.go | 267 + .../webkit/pkg/mcp/tools_behaviour_test.go | 387 + display/webkit/pkg/mcp/tools_browser.go | 66 + .../pkg/mcp/tools_browser_example_test.go | 3 + display/webkit/pkg/mcp/tools_browser_test.go | 1 + display/webkit/pkg/mcp/tools_chat.go | 378 + .../pkg/mcp/tools_chat_behaviour_test.go | 113 + .../webkit/pkg/mcp/tools_chat_example_test.go | 3 + display/webkit/pkg/mcp/tools_chat_test.go | 1 + .../pkg/mcp/tools_cleanup_behaviour_test.go | 93 + display/webkit/pkg/mcp/tools_clipboard.go | 164 + .../pkg/mcp/tools_clipboard_example_test.go | 3 + .../webkit/pkg/mcp/tools_clipboard_test.go | 74 + display/webkit/pkg/mcp/tools_container.go | 96 + .../pkg/mcp/tools_container_example_test.go | 3 + .../webkit/pkg/mcp/tools_container_test.go | 1 + display/webkit/pkg/mcp/tools_contextmenu.go | 171 + .../pkg/mcp/tools_contextmenu_example_test.go | 3 + .../webkit/pkg/mcp/tools_contextmenu_test.go | 1 + display/webkit/pkg/mcp/tools_deno.go | 102 + .../webkit/pkg/mcp/tools_deno_example_test.go | 3 + display/webkit/pkg/mcp/tools_deno_test.go | 1 + display/webkit/pkg/mcp/tools_dialog.go | 346 + .../pkg/mcp/tools_dialog_example_test.go | 3 + display/webkit/pkg/mcp/tools_dialog_test.go | 1 + display/webkit/pkg/mcp/tools_display.go | 57 + .../pkg/mcp/tools_display_example_test.go | 3 + display/webkit/pkg/mcp/tools_display_test.go | 72 + display/webkit/pkg/mcp/tools_dock.go | 196 + .../webkit/pkg/mcp/tools_dock_example_test.go | 3 + display/webkit/pkg/mcp/tools_dock_test.go | 1 + display/webkit/pkg/mcp/tools_environment.go | 92 + .../pkg/mcp/tools_environment_example_test.go | 3 + .../webkit/pkg/mcp/tools_environment_test.go | 75 + display/webkit/pkg/mcp/tools_events.go | 174 + .../pkg/mcp/tools_events_example_test.go | 3 + display/webkit/pkg/mcp/tools_events_test.go | 1 + display/webkit/pkg/mcp/tools_keybinding.go | 62 + .../pkg/mcp/tools_keybinding_example_test.go | 3 + .../webkit/pkg/mcp/tools_keybinding_test.go | 1 + display/webkit/pkg/mcp/tools_layout.go | 369 + .../pkg/mcp/tools_layout_example_test.go | 3 + display/webkit/pkg/mcp/tools_layout_test.go | 1 + display/webkit/pkg/mcp/tools_lifecycle.go | 29 + .../pkg/mcp/tools_lifecycle_example_test.go | 3 + .../webkit/pkg/mcp/tools_lifecycle_test.go | 41 + display/webkit/pkg/mcp/tools_marketplace.go | 150 + .../pkg/mcp/tools_marketplace_example_test.go | 3 + .../webkit/pkg/mcp/tools_marketplace_test.go | 1 + display/webkit/pkg/mcp/tools_menu.go | 214 + .../webkit/pkg/mcp/tools_menu_example_test.go | 3 + display/webkit/pkg/mcp/tools_menu_test.go | 1 + display/webkit/pkg/mcp/tools_notification.go | 154 + .../mcp/tools_notification_example_test.go | 3 + .../webkit/pkg/mcp/tools_notification_test.go | 1 + display/webkit/pkg/mcp/tools_p2p.go | 68 + .../webkit/pkg/mcp/tools_p2p_example_test.go | 3 + display/webkit/pkg/mcp/tools_p2p_test.go | 1 + display/webkit/pkg/mcp/tools_screen.go | 197 + .../pkg/mcp/tools_screen_example_test.go | 3 + display/webkit/pkg/mcp/tools_screen_test.go | 84 + display/webkit/pkg/mcp/tools_tray.go | 183 + .../webkit/pkg/mcp/tools_tray_example_test.go | 3 + display/webkit/pkg/mcp/tools_tray_test.go | 1 + display/webkit/pkg/mcp/tools_webview.go | 988 ++ .../pkg/mcp/tools_webview_behaviour_test.go | 113 + .../pkg/mcp/tools_webview_example_test.go | 3 + display/webkit/pkg/mcp/tools_webview_test.go | 91 + display/webkit/pkg/mcp/tools_window.go | 836 ++ .../pkg/mcp/tools_window_behaviour_test.go | 107 + .../pkg/mcp/tools_window_example_test.go | 3 + display/webkit/pkg/mcp/tools_window_test.go | 1 + display/webkit/pkg/menu/menu.go | 98 + display/webkit/pkg/menu/menu_example_test.go | 29 + display/webkit/pkg/menu/menu_test.go | 304 + display/webkit/pkg/menu/messages.go | 10 + .../webkit/pkg/menu/messages_example_test.go | 3 + display/webkit/pkg/menu/messages_test.go | 1 + display/webkit/pkg/menu/mock_platform.go | 29 + .../pkg/menu/mock_platform_example_test.go | 77 + display/webkit/pkg/menu/mock_platform_test.go | 489 + display/webkit/pkg/menu/mock_test.go | 70 + display/webkit/pkg/menu/platform.go | 42 + .../webkit/pkg/menu/platform_example_test.go | 3 + display/webkit/pkg/menu/platform_test.go | 1 + display/webkit/pkg/menu/register.go | 16 + .../webkit/pkg/menu/register_example_test.go | 11 + display/webkit/pkg/menu/register_test.go | 30 + display/webkit/pkg/menu/service.go | 95 + .../webkit/pkg/menu/service_behaviour_test.go | 61 + .../webkit/pkg/menu/service_example_test.go | 29 + display/webkit/pkg/menu/service_test.go | 224 + display/webkit/pkg/menu/wails.go | 89 + display/webkit/pkg/menu/wails_example_test.go | 77 + display/webkit/pkg/notification/messages.go | 39 + .../pkg/notification/messages_example_test.go | 3 + .../webkit/pkg/notification/messages_test.go | 1 + display/webkit/pkg/notification/platform.go | 56 + .../pkg/notification/platform_example_test.go | 3 + .../webkit/pkg/notification/platform_test.go | 1 + .../webkit/pkg/notification/result_failure.go | 5 + display/webkit/pkg/notification/service.go | 234 + .../notification/service_behaviour_test.go | 89 + .../pkg/notification/service_example_test.go | 23 + .../webkit/pkg/notification/service_test.go | 429 + display/webkit/pkg/notification/wails.go | 108 + display/webkit/pkg/p2p/core_helpers.go | 28 + display/webkit/pkg/p2p/result_failure.go | 5 + display/webkit/pkg/p2p/router.go | 75 + display/webkit/pkg/p2p/router_example_test.go | 29 + display/webkit/pkg/p2p/router_test.go | 259 + display/webkit/pkg/p2p/service.go | 135 + .../webkit/pkg/p2p/service_behaviour_test.go | 127 + .../webkit/pkg/p2p/service_example_test.go | 59 + display/webkit/pkg/p2p/service_test.go | 246 + display/webkit/pkg/p2p/tcp.go | 208 + display/webkit/pkg/p2p/tcp_example_test.go | 35 + display/webkit/pkg/p2p/tcp_loopback_test.go | 85 + display/webkit/pkg/p2p/tcp_test.go | 258 + .../pkg/preload/assets/electron_shim.js | 101 + .../pkg/preload/assets/storage_polyfills.js | 282 + display/webkit/pkg/preload/core_helpers.go | 125 + display/webkit/pkg/preload/electron_shim.go | 16 + display/webkit/pkg/preload/preload.go | 857 ++ .../pkg/preload/preload_example_test.go | 71 + display/webkit/pkg/preload/preload_test.go | 532 + display/webkit/pkg/preload/result_failure.go | 5 + .../webkit/pkg/preload/storage_polyfills.go | 18 + display/webkit/pkg/screen/messages.go | 26 + .../pkg/screen/messages_example_test.go | 3 + display/webkit/pkg/screen/messages_test.go | 1 + display/webkit/pkg/screen/platform.go | 201 + .../pkg/screen/platform_example_test.go | 59 + display/webkit/pkg/screen/platform_test.go | 436 + display/webkit/pkg/screen/service.go | 103 + .../webkit/pkg/screen/service_example_test.go | 23 + display/webkit/pkg/screen/service_test.go | 461 + display/webkit/pkg/screen/wails.go | 95 + display/webkit/pkg/systray/assets/apptray.png | Bin 0 -> 32953 bytes display/webkit/pkg/systray/menu.go | 90 + .../webkit/pkg/systray/menu_example_test.go | 35 + display/webkit/pkg/systray/menu_test.go | 317 + display/webkit/pkg/systray/messages.go | 52 + .../pkg/systray/messages_example_test.go | 3 + display/webkit/pkg/systray/messages_test.go | 1 + display/webkit/pkg/systray/mock_platform.go | 60 + .../pkg/systray/mock_platform_example_test.go | 107 + .../webkit/pkg/systray/mock_platform_test.go | 673 + display/webkit/pkg/systray/mock_test.go | 66 + display/webkit/pkg/systray/platform.go | 48 + .../pkg/systray/platform_example_test.go | 3 + display/webkit/pkg/systray/platform_test.go | 1 + display/webkit/pkg/systray/register.go | 16 + .../pkg/systray/register_example_test.go | 11 + display/webkit/pkg/systray/register_test.go | 30 + display/webkit/pkg/systray/result_failure.go | 5 + display/webkit/pkg/systray/service.go | 290 + .../pkg/systray/service_behaviour_test.go | 95 + .../webkit/pkg/systray/service_config_test.go | 64 + .../pkg/systray/service_example_test.go | 23 + display/webkit/pkg/systray/service_test.go | 258 + display/webkit/pkg/systray/tray.go | 170 + .../webkit/pkg/systray/tray_example_test.go | 77 + display/webkit/pkg/systray/tray_test.go | 566 + display/webkit/pkg/systray/types.go | 13 + .../webkit/pkg/systray/types_example_test.go | 3 + display/webkit/pkg/systray/types_test.go | 1 + display/webkit/pkg/systray/wails.go | 112 + .../webkit/pkg/systray/wails_example_test.go | 107 + .../webkit/pkg/webview/core_helpers_test.go | 14 + display/webkit/pkg/webview/diagnostics.go | 204 + .../pkg/webview/diagnostics_example_test.go | 47 + .../webkit/pkg/webview/diagnostics_test.go | 266 + display/webkit/pkg/webview/messages.go | 229 + .../pkg/webview/messages_example_test.go | 3 + display/webkit/pkg/webview/messages_test.go | 1 + display/webkit/pkg/webview/result_failure.go | 5 + display/webkit/pkg/webview/service.go | 683 + .../pkg/webview/service_example_test.go | 155 + display/webkit/pkg/webview/service_test.go | 1335 ++ display/webkit/pkg/window/core_helpers.go | 36 + display/webkit/pkg/window/layout.go | 232 + .../webkit/pkg/window/layout_example_test.go | 53 + display/webkit/pkg/window/layout_test.go | 364 + display/webkit/pkg/window/messages.go | 394 + .../pkg/window/messages_example_test.go | 3 + display/webkit/pkg/window/messages_test.go | 1 + display/webkit/pkg/window/mock_platform.go | 136 + .../pkg/window/mock_platform_example_test.go | 293 + .../webkit/pkg/window/mock_platform_test.go | 1819 +++ display/webkit/pkg/window/mock_test.go | 149 + display/webkit/pkg/window/options.go | 116 + .../webkit/pkg/window/options_example_test.go | 95 + display/webkit/pkg/window/options_test.go | 632 + display/webkit/pkg/window/persistence_test.go | 365 + display/webkit/pkg/window/platform.go | 106 + .../pkg/window/platform_example_test.go | 3 + display/webkit/pkg/window/platform_test.go | 1 + display/webkit/pkg/window/register.go | 17 + .../pkg/window/register_example_test.go | 11 + display/webkit/pkg/window/register_test.go | 30 + display/webkit/pkg/window/result_failure.go | 5 + display/webkit/pkg/window/service.go | 1324 ++ .../webkit/pkg/window/service_config_test.go | 83 + .../webkit/pkg/window/service_example_test.go | 23 + .../webkit/pkg/window/service_screen_test.go | 126 + display/webkit/pkg/window/service_test.go | 1278 ++ display/webkit/pkg/window/smart_layout.go | 512 + .../webkit/pkg/window/smart_layout_test.go | 79 + display/webkit/pkg/window/state.go | 297 + .../webkit/pkg/window/state_example_test.go | 89 + display/webkit/pkg/window/state_test.go | 726 ++ display/webkit/pkg/window/tiling.go | 574 + .../webkit/pkg/window/tiling_example_test.go | 130 + display/webkit/pkg/window/tiling_test.go | 519 + display/webkit/pkg/window/wails.go | 535 + .../webkit/pkg/window/wails_example_test.go | 275 + display/webkit/pkg/window/window.go | 252 + .../webkit/pkg/window/window_example_test.go | 83 + display/webkit/pkg/window/window_test.go | 952 ++ display/webkit/service.go | 558 + display/webkit/service_example_test.go | 17 + display/webkit/service_test.go | 137 + display/webkit/tests/cli/gui/Taskfile.yaml | 26 + display/webkit/tray.go | 177 + display/webkit/tray_routes_test.go | 59 + display/webkit/ui/.editorconfig | 17 + display/webkit/ui/.gitignore | 43 + display/webkit/ui/.postcssrc.json | 5 + display/webkit/ui/README.md | 59 + display/webkit/ui/angular.json | 77 + .../@angular/build/.browserslistrc | 7 + .../node_modules.bak/@angular/build/LICENSE | 21 + .../node_modules.bak/@angular/build/README.md | 5 + .../@angular/build/builders.json | 34 + .../@angular/build/index.d.ts | 8 + .../node_modules.bak/@angular/build/index.js | 24 + .../@angular/build/package.json | 127 + .../@angular/build/private/index.d.ts | 8 + .../@angular/build/private/index.js | 24 + .../builders/application/build-action.d.ts | 29 + .../src/builders/application/build-action.js | 354 + .../builders/application/chunk-optimizer.d.ts | 21 + .../builders/application/chunk-optimizer.js | 277 + .../builders/application/execute-build.d.ts | 11 + .../src/builders/application/execute-build.js | 249 + .../application/execute-post-bundle.d.ts | 27 + .../application/execute-post-bundle.js | 135 + .../build/src/builders/application/i18n.d.ts | 31 + .../build/src/builders/application/i18n.js | 137 + .../build/src/builders/application/index.d.ts | 32 + .../build/src/builders/application/index.js | 252 + .../src/builders/application/options.d.ts | 210 + .../build/src/builders/application/options.js | 490 + .../src/builders/application/results.d.ts | 73 + .../build/src/builders/application/results.js | 17 + .../src/builders/application/schema.d.ts | 644 + .../build/src/builders/application/schema.js | 92 + .../src/builders/application/schema.json | 720 ++ .../builders/application/setup-bundling.d.ts | 25 + .../builders/application/setup-bundling.js | 103 + .../src/builders/dev-server/builder.d.ts | 31 + .../build/src/builders/dev-server/builder.js | 60 + .../build/src/builders/dev-server/index.d.ts | 15 + .../build/src/builders/dev-server/index.js | 16 + .../src/builders/dev-server/internal.d.ts | 18 + .../build/src/builders/dev-server/internal.js | 27 + .../src/builders/dev-server/options.d.ts | 48 + .../build/src/builders/dev-server/options.js | 102 + .../build/src/builders/dev-server/output.d.ts | 16 + .../build/src/builders/dev-server/output.js | 9 + .../build/src/builders/dev-server/schema.d.ts | 112 + .../build/src/builders/dev-server/schema.js | 4 + .../build/src/builders/dev-server/schema.json | 131 + .../src/builders/dev-server/vite-server.d.ts | 42 + .../src/builders/dev-server/vite-server.js | 735 ++ .../extract-i18n/application-extraction.d.ts | 18 + .../extract-i18n/application-extraction.js | 131 + .../src/builders/extract-i18n/builder.d.ts | 14 + .../src/builders/extract-i18n/builder.js | 162 + .../src/builders/extract-i18n/index.d.ts | 13 + .../build/src/builders/extract-i18n/index.js | 15 + .../src/builders/extract-i18n/options.d.ts | 33 + .../src/builders/extract-i18n/options.js | 85 + .../src/builders/extract-i18n/schema.d.ts | 53 + .../build/src/builders/extract-i18n/schema.js | 29 + .../src/builders/extract-i18n/schema.json | 38 + .../builders/karma/application_builder.d.ts | 13 + .../src/builders/karma/application_builder.js | 632 + .../build/src/builders/karma/find-tests.d.ts | 15 + .../build/src/builders/karma/find-tests.js | 111 + .../build/src/builders/karma/index.d.ts | 20 + .../build/src/builders/karma/index.js | 53 + .../build/src/builders/karma/options.d.ts | 42 + .../build/src/builders/karma/options.js | 43 + .../karma/polyfills/init_sourcemaps.js | 10 + .../karma/polyfills/jasmine_global.js | 18 + .../karma/polyfills/jasmine_global_cleanup.js | 14 + .../build/src/builders/karma/schema.d.ts | 250 + .../build/src/builders/karma/schema.js | 15 + .../build/src/builders/karma/schema.json | 348 + .../src/builders/ng-packagr/builder.d.ts | 19 + .../build/src/builders/ng-packagr/builder.js | 106 + .../build/src/builders/ng-packagr/index.d.ts | 13 + .../build/src/builders/ng-packagr/index.js | 15 + .../build/src/builders/ng-packagr/schema.d.ts | 21 + .../build/src/builders/ng-packagr/schema.js | 4 + .../build/src/builders/ng-packagr/schema.json | 26 + .../build/src/builders/unit-test/builder.d.ts | 15 + .../build/src/builders/unit-test/builder.js | 335 + .../build/src/builders/unit-test/index.d.ts | 12 + .../build/src/builders/unit-test/index.js | 15 + .../src/builders/unit-test/karma-bridge.d.ts | 10 + .../src/builders/unit-test/karma-bridge.js | 82 + .../build/src/builders/unit-test/options.d.ts | 32 + .../build/src/builders/unit-test/options.js | 64 + .../build/src/builders/unit-test/schema.d.ts | 95 + .../build/src/builders/unit-test/schema.js | 24 + .../build/src/builders/unit-test/schema.json | 127 + .../@angular/build/src/index.d.ts | 16 + .../@angular/build/src/index.js | 24 + .../@angular/build/src/private.d.ts | 53 + .../@angular/build/src/private.js | 97 + .../build/src/tools/angular/angular-host.d.ts | 27 + .../build/src/tools/angular/angular-host.js | 163 + .../compilation/angular-compilation.d.ts | 44 + .../compilation/angular-compilation.js | 107 + .../angular/compilation/aot-compilation.d.ts | 25 + .../angular/compilation/aot-compilation.js | 337 + .../tools/angular/compilation/factory.d.ts | 17 + .../src/tools/angular/compilation/factory.js | 66 + .../angular/compilation/hmr-candidates.d.ts | 22 + .../angular/compilation/hmr-candidates.js | 269 + .../src/tools/angular/compilation/index.d.ts | 10 + .../src/tools/angular/compilation/index.js | 17 + .../angular/compilation/jit-compilation.d.ts | 23 + .../angular/compilation/jit-compilation.js | 117 + .../angular/compilation/noop-compilation.d.ts | 20 + .../angular/compilation/noop-compilation.js | 26 + .../compilation/parallel-compilation.d.ts | 44 + .../compilation/parallel-compilation.js | 118 + .../angular/compilation/parallel-worker.d.ts | 38 + .../angular/compilation/parallel-worker.js | 100 + .../jit-bootstrap-transformer.d.ts | 10 + .../transformers/jit-bootstrap-transformer.js | 180 + .../jit-resource-transformer.d.ts | 17 + .../transformers/jit-resource-transformer.js | 186 + .../transformers/lazy-routes-transformer.d.ts | 39 + .../transformers/lazy-routes-transformer.js | 163 + .../transformers/web-worker-transformer.d.ts | 17 + .../transformers/web-worker-transformer.js | 93 + .../@angular/build/src/tools/angular/uri.d.ts | 54 + .../@angular/build/src/tools/angular/uri.js | 75 + .../babel/plugins/add-code-coverage.d.ts | 14 + .../tools/babel/plugins/add-code-coverage.js | 44 + .../plugins/adjust-static-class-members.d.ts | 25 + .../plugins/adjust-static-class-members.js | 348 + .../plugins/adjust-typescript-enums.d.ts | 21 + .../babel/plugins/adjust-typescript-enums.js | 112 + .../babel/plugins/elide-angular-metadata.d.ts | 21 + .../babel/plugins/elide-angular-metadata.js | 109 + .../build/src/tools/babel/plugins/index.d.ts | 11 + .../build/src/tools/babel/plugins/index.js | 21 + .../plugins/pure-toplevel-functions.d.ts | 13 + .../babel/plugins/pure-toplevel-functions.js | 127 + .../build/src/tools/babel/plugins/types.d.ts | 20 + .../build/src/tools/babel/typings.d.ts | 21 + .../angular-localize-init-warning-plugin.d.ts | 16 + .../angular-localize-init-warning-plugin.js | 49 + .../esbuild/angular/compilation-state.d.ts | 15 + .../esbuild/angular/compilation-state.js | 44 + .../esbuild/angular/compiler-plugin.d.ts | 34 + .../tools/esbuild/angular/compiler-plugin.js | 605 + .../angular/component-stylesheets.d.ts | 49 + .../esbuild/angular/component-stylesheets.js | 221 + .../tools/esbuild/angular/diagnostics.d.ts | 15 + .../src/tools/esbuild/angular/diagnostics.js | 71 + .../angular/file-reference-tracker.d.ts | 17 + .../esbuild/angular/file-reference-tracker.js | 57 + .../esbuild/angular/jit-plugin-callbacks.d.ts | 23 + .../esbuild/angular/jit-plugin-callbacks.js | 119 + .../esbuild/angular/rewrite-bazel-paths.d.ts | 8 + .../esbuild/angular/rewrite-bazel-paths.js | 27 + .../esbuild/angular/source-file-cache.d.ts | 18 + .../esbuild/angular/source-file-cache.js | 79 + .../esbuild/application-code-bundle.d.ts | 19 + .../tools/esbuild/application-code-bundle.js | 537 + .../build/src/tools/esbuild/budget-stats.d.ts | 19 + .../build/src/tools/esbuild/budget-stats.js | 66 + .../src/tools/esbuild/bundler-context.d.ts | 80 + .../src/tools/esbuild/bundler-context.js | 398 + .../esbuild/bundler-execution-result.d.ts | 95 + .../tools/esbuild/bundler-execution-result.js | 148 + .../build/src/tools/esbuild/cache.d.ts | 93 + .../@angular/build/src/tools/esbuild/cache.js | 99 + .../src/tools/esbuild/commonjs-checker.d.ts | 28 + .../src/tools/esbuild/commonjs-checker.js | 150 + .../esbuild/compiler-plugin-options.d.ts | 14 + .../tools/esbuild/compiler-plugin-options.js | 29 + .../esbuild/external-packages-plugin.d.ts | 18 + .../tools/esbuild/external-packages-plugin.js | 96 + .../src/tools/esbuild/global-scripts.d.ts | 16 + .../build/src/tools/esbuild/global-scripts.js | 152 + .../src/tools/esbuild/global-styles.d.ts | 10 + .../build/src/tools/esbuild/global-styles.js | 78 + .../tools/esbuild/i18n-inliner-worker.d.ts | 77 + .../src/tools/esbuild/i18n-inliner-worker.js | 151 + .../build/src/tools/esbuild/i18n-inliner.d.ts | 58 + .../build/src/tools/esbuild/i18n-inliner.js | 265 + .../src/tools/esbuild/i18n-locale-plugin.d.ts | 22 + .../src/tools/esbuild/i18n-locale-plugin.js | 138 + .../tools/esbuild/index-html-generator.d.ts | 15 + .../src/tools/esbuild/index-html-generator.js | 90 + .../javascript-transformer-worker.d.ts | 20 + .../esbuild/javascript-transformer-worker.js | 161 + .../tools/esbuild/javascript-transformer.d.ts | 54 + .../tools/esbuild/javascript-transformer.js | 152 + .../src/tools/esbuild/license-extractor.d.ts | 25 + .../src/tools/esbuild/license-extractor.js | 157 + .../src/tools/esbuild/lmdb-cache-store.d.ts | 18 + .../src/tools/esbuild/lmdb-cache-store.js | 54 + .../src/tools/esbuild/load-result-cache.d.ts | 21 + .../src/tools/esbuild/load-result-cache.js | 75 + .../loader-import-attribute-plugin.d.ts | 9 + .../esbuild/loader-import-attribute-plugin.js | 41 + .../build/src/tools/esbuild/profiling.d.ts | 11 + .../build/src/tools/esbuild/profiling.js | 77 + .../esbuild/rxjs-esm-resolution-plugin.d.ts | 18 + .../esbuild/rxjs-esm-resolution-plugin.js | 43 + .../server-bundle-metadata-plugin.d.ts | 22 + .../esbuild/server-bundle-metadata-plugin.js | 36 + .../esbuild/sourcemap-ignorelist-plugin.d.ts | 17 + .../esbuild/sourcemap-ignorelist-plugin.js | 72 + .../esbuild/stylesheets/bundle-options.d.ts | 38 + .../esbuild/stylesheets/bundle-options.js | 65 + .../stylesheets/css-inline-fonts-plugin.d.ts | 25 + .../stylesheets/css-inline-fonts-plugin.js | 56 + .../esbuild/stylesheets/css-language.d.ts | 9 + .../tools/esbuild/stylesheets/css-language.js | 15 + .../stylesheets/css-resource-plugin.d.ts | 18 + .../stylesheets/css-resource-plugin.js | 113 + .../esbuild/stylesheets/less-language.d.ts | 9 + .../esbuild/stylesheets/less-language.js | 184 + .../esbuild/stylesheets/sass-language.d.ts | 10 + .../esbuild/stylesheets/sass-language.js | 230 + .../stylesheet-plugin-factory.d.ts | 74 + .../stylesheets/stylesheet-plugin-factory.js | 328 + .../build/src/tools/esbuild/utils.d.ts | 58 + .../@angular/build/src/tools/esbuild/utils.js | 390 + .../tools/esbuild/virtual-module-plugin.d.ts | 33 + .../tools/esbuild/virtual-module-plugin.js | 42 + .../build/src/tools/esbuild/wasm-plugin.d.ts | 28 + .../build/src/tools/esbuild/wasm-plugin.js | 210 + .../build/src/tools/esbuild/wasm.d.ts | 25 + .../build/src/tools/esbuild/watcher.d.ts | 25 + .../build/src/tools/esbuild/watcher.js | 118 + .../@angular/build/src/tools/sass/lexer.d.ts | 18 + .../@angular/build/src/tools/sass/lexer.js | 171 + .../src/tools/sass/rebasing-importer.d.ts | 101 + .../build/src/tools/sass/rebasing-importer.js | 332 + .../build/src/tools/sass/sass-service.d.ts | 67 + .../build/src/tools/sass/sass-service.js | 241 + .../@angular/build/src/tools/sass/worker.d.ts | 63 + .../@angular/build/src/tools/sass/worker.js | 201 + .../vite/middlewares/assets-middleware.d.ts | 15 + .../vite/middlewares/assets-middleware.js | 173 + .../chrome-devtools-middleware.d.ts | 9 + .../middlewares/chrome-devtools-middleware.js | 52 + .../middlewares/component-middleware.d.ts | 9 + .../vite/middlewares/component-middleware.js | 35 + .../vite/middlewares/headers-middleware.d.ts | 19 + .../vite/middlewares/headers-middleware.js | 34 + .../middlewares/html-fallback-middleware.d.ts | 10 + .../middlewares/html-fallback-middleware.js | 41 + .../middlewares/index-html-middleware.d.ts | 10 + .../vite/middlewares/index-html-middleware.js | 46 + .../src/tools/vite/middlewares/index.d.ts | 14 + .../build/src/tools/vite/middlewares/index.js | 25 + .../vite/middlewares/ssr-middleware.d.ts | 10 + .../tools/vite/middlewares/ssr-middleware.js | 97 + .../vite/plugins/angular-memory-plugin.d.ts | 18 + .../vite/plugins/angular-memory-plugin.js | 116 + .../tools/vite/plugins/id-prefix-plugin.d.ts | 9 + .../tools/vite/plugins/id-prefix-plugin.js | 45 + .../build/src/tools/vite/plugins/index.d.ts | 11 + .../build/src/tools/vite/plugins/index.js | 19 + .../plugins/setup-middlewares-plugin.d.ts | 45 + .../vite/plugins/setup-middlewares-plugin.js | 72 + .../vite/plugins/ssr-transform-plugin.d.ts | 9 + .../vite/plugins/ssr-transform-plugin.js | 35 + .../@angular/build/src/tools/vite/utils.d.ts | 32 + .../@angular/build/src/tools/vite/utils.js | 66 + .../@angular/build/src/typings.d.ts | 19 + .../build/src/utils/bundle-calculator.d.ts | 45 + .../build/src/utils/bundle-calculator.js | 304 + .../@angular/build/src/utils/check-port.d.ts | 8 + .../@angular/build/src/utils/check-port.js | 87 + .../@angular/build/src/utils/color.d.ts | 9 + .../@angular/build/src/utils/color.js | 27 + .../build/src/utils/delete-output-dir.d.ts | 11 + .../build/src/utils/delete-output-dir.js | 45 + .../build/src/utils/environment-options.d.ts | 21 + .../build/src/utils/environment-options.js | 95 + .../@angular/build/src/utils/error.d.ts | 10 + .../@angular/build/src/utils/error.js | 20 + .../build/src/utils/format-bytes.d.ts | 8 + .../@angular/build/src/utils/format-bytes.js | 21 + .../build/src/utils/i18n-options.d.ts | 36 + .../@angular/build/src/utils/i18n-options.js | 207 + .../add-event-dispatch-contract.d.ts | 8 + .../index-file/add-event-dispatch-contract.js | 27 + .../utils/index-file/augment-index-html.d.ts | 40 + .../utils/index-file/augment-index-html.js | 267 + .../build/src/utils/index-file/auto-csp.d.ts | 23 + .../build/src/utils/index-file/auto-csp.js | 293 + .../index-file/html-rewriting-stream.d.ts | 15 + .../utils/index-file/html-rewriting-stream.js | 27 + .../index-file/index-html-generator.d.ts | 61 + .../utils/index-file/index-html-generator.js | 153 + .../utils/index-file/inline-critical-css.d.ts | 25 + .../utils/index-file/inline-critical-css.js | 192 + .../src/utils/index-file/inline-fonts.d.ts | 23 + .../src/utils/index-file/inline-fonts.js | 239 + .../src/utils/index-file/ngcm-attribute.d.ts | 15 + .../src/utils/index-file/ngcm-attribute.js | 37 + .../build/src/utils/index-file/nonce.d.ts | 12 + .../build/src/utils/index-file/nonce.js | 53 + .../index-file/valid-self-closing-tags.d.ts | 9 + .../index-file/valid-self-closing-tags.js | 89 + .../@angular/build/src/utils/index.d.ts | 11 + .../@angular/build/src/utils/index.js | 27 + .../@angular/build/src/utils/load-esm.d.ts | 20 + .../@angular/build/src/utils/load-esm.js | 30 + .../build/src/utils/load-proxy-config.d.ts | 8 + .../build/src/utils/load-proxy-config.js | 199 + .../build/src/utils/load-translations.d.ts | 16 + .../build/src/utils/load-translations.js | 93 + .../src/utils/normalize-asset-patterns.d.ts | 14 + .../src/utils/normalize-asset-patterns.js | 106 + .../build/src/utils/normalize-cache.d.ts | 16 + .../build/src/utils/normalize-cache.js | 45 + .../src/utils/normalize-optimization.d.ts | 13 + .../build/src/utils/normalize-optimization.js | 41 + .../src/utils/normalize-source-maps.d.ts | 9 + .../build/src/utils/normalize-source-maps.js | 24 + .../@angular/build/src/utils/path.d.ts | 29 + .../@angular/build/src/utils/path.js | 37 + .../src/utils/postcss-configuration.d.ts | 17 + .../build/src/utils/postcss-configuration.js | 85 + .../build/src/utils/project-metadata.d.ts | 21 + .../build/src/utils/project-metadata.js | 31 + .../@angular/build/src/utils/purge-cache.d.ts | 10 + .../@angular/build/src/utils/purge-cache.js | 39 + .../build/src/utils/resolve-assets.d.ts | 18 + .../build/src/utils/resolve-assets.js | 34 + .../esm-in-memory-loader/loader-hooks.d.ts | 18 + .../esm-in-memory-loader/loader-hooks.js | 135 + .../esm-in-memory-loader/register-hooks.d.ts | 8 + .../esm-in-memory-loader/register-hooks.js | 13 + .../esm-in-memory-loader/utils.d.ts | 8 + .../esm-in-memory-loader/utils.js | 13 + .../utils/server-rendering/fetch-patch.d.ts | 8 + .../src/utils/server-rendering/fetch-patch.js | 63 + .../utils/server-rendering/launch-server.d.ts | 14 + .../utils/server-rendering/launch-server.js | 63 + .../load-esm-from-memory.d.ts | 27 + .../server-rendering/load-esm-from-memory.js | 25 + .../src/utils/server-rendering/manifest.d.ts | 57 + .../src/utils/server-rendering/manifest.js | 174 + .../src/utils/server-rendering/models.d.ts | 28 + .../src/utils/server-rendering/models.js | 21 + .../src/utils/server-rendering/prerender.d.ts | 39 + .../src/utils/server-rendering/prerender.js | 244 + .../utils/server-rendering/render-worker.d.ts | 23 + .../utils/server-rendering/render-worker.js | 42 + .../routes-extractor-worker.d.ts | 16 + .../routes-extractor-worker.js | 36 + .../src/utils/server-rendering/utils.d.ts | 21 + .../build/src/utils/server-rendering/utils.js | 42 + .../build/src/utils/service-worker.d.ts | 23 + .../build/src/utils/service-worker.js | 217 + .../@angular/build/src/utils/stats-table.d.ts | 20 + .../@angular/build/src/utils/stats-table.js | 204 + .../build/src/utils/supported-browsers.d.ts | 10 + .../build/src/utils/supported-browsers.js | 52 + .../@angular/build/src/utils/tty.d.ts | 8 + .../@angular/build/src/utils/tty.js | 22 + .../@angular/build/src/utils/url.d.ts | 8 + .../@angular/build/src/utils/url.js | 17 + .../@angular/build/src/utils/version.d.ts | 8 + .../@angular/build/src/utils/version.js | 43 + .../@angular/build/src/utils/worker-pool.d.ts | 12 + .../@angular/build/src/utils/worker-pool.js | 43 + .../@angular/cli/src/commands/build/cli.d.ts | 16 + .../@angular/cli/src/commands/build/cli.js | 20 + .../src/commands/build/long-description.md | 18 + .../core/lib/vendor/import-meta-resolve.js | 1042 ++ .../lib/vendor/import-meta-resolve.js.map | 1 + .../@isaacs/cliui/build/index.cjs | 317 + .../@isaacs/cliui/build/index.d.cts | 43 + .../@isaacs/cliui/build/lib/index.js | 302 + .../source/vendor/ansi-styles/index.d.ts | 236 + .../chalk/source/vendor/ansi-styles/index.js | 223 + .../source/vendor/supports-color/browser.d.ts | 1 + .../source/vendor/supports-color/browser.js | 34 + .../source/vendor/supports-color/index.d.ts | 55 + .../source/vendor/supports-color/index.js | 190 + .../node_modules.bak/cliui/build/lib/index.js | 287 + .../cliui/build/tsconfig.tsbuildinfo | 1 + .../engine.io-parser/build/cjs/commons.d.ts | 14 + .../engine.io-parser/build/cjs/commons.js | 19 + .../build/cjs/contrib/base64-arraybuffer.d.ts | 2 + .../build/cjs/contrib/base64-arraybuffer.js | 48 + .../build/cjs/decodePacket.browser.d.ts | 2 + .../build/cjs/decodePacket.browser.js | 66 + .../build/cjs/decodePacket.d.ts | 2 + .../build/cjs/decodePacket.js | 59 + .../build/cjs/encodePacket.browser.d.ts | 4 + .../build/cjs/encodePacket.browser.js | 72 + .../build/cjs/encodePacket.d.ts | 3 + .../build/cjs/encodePacket.js | 38 + .../engine.io-parser/build/cjs/index.d.ts | 9 + .../engine.io-parser/build/cjs/index.js | 164 + .../engine.io-parser/build/cjs/package.json | 8 + .../engine.io-parser/build/esm/commons.d.ts | 14 + .../engine.io-parser/build/esm/commons.js | 14 + .../build/esm/contrib/base64-arraybuffer.d.ts | 2 + .../build/esm/contrib/base64-arraybuffer.js | 43 + .../build/esm/decodePacket.browser.d.ts | 2 + .../build/esm/decodePacket.browser.js | 62 + .../build/esm/decodePacket.d.ts | 2 + .../build/esm/decodePacket.js | 55 + .../build/esm/encodePacket.browser.d.ts | 4 + .../build/esm/encodePacket.browser.js | 68 + .../build/esm/encodePacket.d.ts | 3 + .../build/esm/encodePacket.js | 33 + .../engine.io-parser/build/esm/index.d.ts | 9 + .../engine.io-parser/build/esm/index.js | 156 + .../engine.io-parser/build/esm/package.json | 8 + .../engine.io/build/contrib/types.cookie.d.ts | 113 + .../engine.io/build/contrib/types.cookie.js | 6 + .../engine.io/build/engine.io.d.ts | 26 + .../engine.io/build/engine.io.js | 54 + .../engine.io/build/parser-v3/index.d.ts | 94 + .../engine.io/build/parser-v3/index.js | 424 + .../engine.io/build/parser-v3/utf8.d.ts | 14 + .../engine.io/build/parser-v3/utf8.js | 187 + .../engine.io/build/server.d.ts | 267 + .../engine.io/build/server.js | 786 ++ .../engine.io/build/socket.d.ts | 180 + .../engine.io/build/socket.js | 460 + .../engine.io/build/transport.d.ts | 124 + .../engine.io/build/transport.js | 117 + .../engine.io/build/transports-uws/index.d.ts | 7 + .../engine.io/build/transports-uws/index.js | 8 + .../build/transports-uws/polling.d.ts | 99 + .../engine.io/build/transports-uws/polling.js | 364 + .../build/transports-uws/websocket.d.ts | 32 + .../build/transports-uws/websocket.js | 73 + .../engine.io/build/transports/index.d.ts | 16 + .../engine.io/build/transports/index.js | 23 + .../build/transports/polling-jsonp.d.ts | 12 + .../build/transports/polling-jsonp.js | 41 + .../engine.io/build/transports/polling.d.ts | 87 + .../engine.io/build/transports/polling.js | 332 + .../engine.io/build/transports/websocket.d.ts | 32 + .../engine.io/build/transports/websocket.js | 94 + .../build/transports/webtransport.d.ts | 12 + .../build/transports/webtransport.js | 63 + .../engine.io/build/userver.d.ts | 42 + .../engine.io/build/userver.js | 279 + .../lib/html/assets/vendor/prettify.css | 1 + .../lib/html/assets/vendor/prettify.js | 1 + .../smart-buffer/build/smartbuffer.js | 1233 ++ .../smart-buffer/build/smartbuffer.js.map | 1 + .../smart-buffer/build/utils.js | 108 + .../smart-buffer/build/utils.js.map | 1 + .../socket.io-parser/build/cjs/binary.d.ts | 20 + .../socket.io-parser/build/cjs/binary.js | 88 + .../socket.io-parser/build/cjs/index.d.ts | 90 + .../socket.io-parser/build/cjs/index.js | 321 + .../socket.io-parser/build/cjs/is-binary.d.ts | 7 + .../socket.io-parser/build/cjs/is-binary.js | 55 + .../socket.io-parser/build/cjs/package.json | 3 + .../build/esm-debug/binary.d.ts | 20 + .../build/esm-debug/binary.js | 83 + .../build/esm-debug/index.d.ts | 90 + .../socket.io-parser/build/esm-debug/index.js | 316 + .../build/esm-debug/is-binary.d.ts | 7 + .../build/esm-debug/is-binary.js | 50 + .../build/esm-debug/package.json | 3 + .../socket.io-parser/build/esm/binary.d.ts | 20 + .../socket.io-parser/build/esm/binary.js | 83 + .../socket.io-parser/build/esm/index.d.ts | 90 + .../socket.io-parser/build/esm/index.js | 311 + .../socket.io-parser/build/esm/is-binary.d.ts | 7 + .../socket.io-parser/build/esm/is-binary.js | 50 + .../socket.io-parser/build/esm/package.json | 3 + .../socks/build/client/socksclient.js | 793 ++ .../socks/build/client/socksclient.js.map | 1 + .../socks/build/common/constants.js | 108 + .../socks/build/common/constants.js.map | 1 + .../socks/build/common/helpers.js | 167 + .../socks/build/common/helpers.js.map | 1 + .../socks/build/common/receivebuffer.js | 43 + .../socks/build/common/receivebuffer.js.map | 1 + .../socks/build/common/util.js | 25 + .../socks/build/common/util.js.map | 1 + .../ui/node_modules.bak/socks/build/index.js | 18 + .../node_modules.bak/socks/build/index.js.map | 1 + .../ui/node_modules.bak/y18n/build/index.cjs | 203 + .../ui/node_modules.bak/y18n/build/lib/cjs.js | 6 + .../node_modules.bak/y18n/build/lib/index.js | 174 + .../y18n/build/lib/platform-shims/node.js | 19 + .../yargs-parser/build/lib/index.js | 64 + .../yargs-parser/build/lib/string-utils.js | 65 + .../build/lib/tokenize-arg-string.js | 40 + .../build/lib/yargs-parser-types.js | 12 + .../yargs-parser/build/lib/yargs-parser.js | 1046 ++ .../yargs/build/lib/argsert.js | 62 + .../yargs/build/lib/command.js | 479 + .../yargs/build/lib/completion-templates.js | 57 + .../yargs/build/lib/completion.js | 243 + .../yargs/build/lib/middleware.js | 88 + .../yargs/build/lib/parse-command.js | 32 + .../yargs/build/lib/typings/common-types.js | 9 + .../build/lib/typings/yargs-parser-types.js | 1 + .../node_modules.bak/yargs/build/lib/usage.js | 584 + .../yargs/build/lib/utils/apply-extends.js | 59 + .../yargs/build/lib/utils/is-promise.js | 5 + .../yargs/build/lib/utils/levenshtein.js | 34 + .../build/lib/utils/maybe-async-result.js | 17 + .../yargs/build/lib/utils/obj-filter.js | 10 + .../yargs/build/lib/utils/process-argv.js | 17 + .../yargs/build/lib/utils/set-blocking.js | 12 + .../yargs/build/lib/utils/which-module.js | 10 + .../yargs/build/lib/validation.js | 305 + .../yargs/build/lib/yargs-factory.js | 1517 +++ .../yargs/build/lib/yerror.js | 9 + display/webkit/ui/package-lock.json | 10567 ++++++++++++++++ display/webkit/ui/package.json | 54 + display/webkit/ui/public/favicon.ico | Bin 0 -> 15086 bytes display/webkit/ui/src/app/app-module.ts | 24 + display/webkit/ui/src/app/app.html | 1 + display/webkit/ui/src/app/app.ts | 11 + .../ui/src/chat/chat-panel.component.ts | 164 + .../webkit/ui/src/chat/chat-state.service.ts | 744 ++ display/webkit/ui/src/chat/chat.types.ts | 73 + .../chat/conversation-sidebar.component.ts | 145 + .../ui/src/chat/input-area.component.ts | 159 + .../ui/src/chat/message-list.component.ts | 331 + .../ui/src/chat/model-selector.component.ts | 38 + .../ui/src/chat/settings-panel.component.ts | 80 + .../src/components/provider-host.component.ts | 96 + .../src/components/provider-nav.component.ts | 132 + .../ui/src/components/status-bar.component.ts | 122 + .../frame/application-frame.component.html | 179 + .../src/frame/application-frame.component.ts | 137 + .../src/frame/system-tray-frame.component.ts | 388 + .../src/generated/core-gui-chat.bindings.ts | 87 + display/webkit/ui/src/index.html | 13 + display/webkit/ui/src/index.ts | 17 + display/webkit/ui/src/main.ts | 14 + .../ui/src/services/api-config.service.ts | 29 + .../services/provider-discovery.service.ts | 91 + .../ui/src/services/translation.service.ts | 66 + .../ui/src/services/websocket.service.ts | 187 + display/webkit/ui/src/styles.css | 26 + .../src/web-components/chat-hero.element.ts | 76 + display/webkit/ui/tsconfig.app.json | 13 + display/webkit/ui/tsconfig.json | 37 + display/webkit/ui/tsconfig.spec.json | 14 + display/webkit/wails_options.go | 138 + display/webkit/window_binding.go | 112 + display/webkit/window_helpers.go | 189 + .../webkit/window_helpers_behaviour_test.go | 79 + 1035 files changed, 134011 insertions(+) create mode 120000 display/webkit/AGENTS.md create mode 120000 display/webkit/CLAUDE.md create mode 120000 display/webkit/README.md create mode 100644 display/webkit/bootstrap.go create mode 100644 display/webkit/cmd/webkit/main.go create mode 100644 display/webkit/contextmenus.go create mode 120000 display/webkit/docs create mode 100644 display/webkit/events.go create mode 100644 display/webkit/go.mod create mode 100644 display/webkit/go.sum create mode 100644 display/webkit/http_middleware.go create mode 100644 display/webkit/http_middleware_test.go create mode 100644 display/webkit/keybindings.go create mode 100644 display/webkit/menu.go create mode 100644 display/webkit/mode.go create mode 100644 display/webkit/mode_behaviour_test.go create mode 100644 display/webkit/pkg/browser/messages.go create mode 100644 display/webkit/pkg/browser/messages_example_test.go create mode 100644 display/webkit/pkg/browser/messages_test.go create mode 100644 display/webkit/pkg/browser/platform.go create mode 100644 display/webkit/pkg/browser/platform_example_test.go create mode 100644 display/webkit/pkg/browser/platform_test.go create mode 100644 display/webkit/pkg/browser/register.go create mode 100644 display/webkit/pkg/browser/register_example_test.go create mode 100644 display/webkit/pkg/browser/register_test.go create mode 100644 display/webkit/pkg/browser/result_failure.go create mode 100644 display/webkit/pkg/browser/service.go create mode 100644 display/webkit/pkg/browser/service_example_test.go create mode 100644 display/webkit/pkg/browser/service_test.go create mode 100644 display/webkit/pkg/browser/wails.go create mode 100644 display/webkit/pkg/chat/chat.go create mode 100644 display/webkit/pkg/chat/chat_example_test.go create mode 100644 display/webkit/pkg/chat/chat_test.go create mode 100644 display/webkit/pkg/chat/core_helpers.go create mode 100644 display/webkit/pkg/chat/messages.go create mode 100644 display/webkit/pkg/chat/messages_example_test.go create mode 100644 display/webkit/pkg/chat/messages_test.go create mode 100644 display/webkit/pkg/chat/result_failure.go create mode 100644 display/webkit/pkg/chat/service.go create mode 100644 display/webkit/pkg/chat/service_example_test.go create mode 100644 display/webkit/pkg/chat/service_test.go create mode 100644 display/webkit/pkg/chat/store.go create mode 100644 display/webkit/pkg/chat/tool_handler.go create mode 100644 display/webkit/pkg/chat/tool_handler_example_test.go create mode 100644 display/webkit/pkg/chat/tool_handler_test.go create mode 100644 display/webkit/pkg/clipboard/core_helpers_test.go create mode 100644 display/webkit/pkg/clipboard/messages.go create mode 100644 display/webkit/pkg/clipboard/messages_example_test.go create mode 100644 display/webkit/pkg/clipboard/messages_test.go create mode 100644 display/webkit/pkg/clipboard/platform.go create mode 100644 display/webkit/pkg/clipboard/platform_example_test.go create mode 100644 display/webkit/pkg/clipboard/platform_test.go create mode 100644 display/webkit/pkg/clipboard/result_failure.go create mode 100644 display/webkit/pkg/clipboard/service.go create mode 100644 display/webkit/pkg/clipboard/service_example_test.go create mode 100644 display/webkit/pkg/clipboard/service_test.go create mode 100644 display/webkit/pkg/clipboard/wails.go create mode 100644 display/webkit/pkg/container/core_helpers.go create mode 100644 display/webkit/pkg/container/detect.go create mode 100644 display/webkit/pkg/container/detect_example_test.go create mode 100644 display/webkit/pkg/container/detect_test.go create mode 100644 display/webkit/pkg/container/mode.go create mode 100644 display/webkit/pkg/container/mode_example_test.go create mode 100644 display/webkit/pkg/container/mode_test.go create mode 100644 display/webkit/pkg/container/result_failure.go create mode 100644 display/webkit/pkg/container/service.go create mode 100644 display/webkit/pkg/container/service_example_test.go create mode 100644 display/webkit/pkg/container/service_test.go create mode 100644 display/webkit/pkg/container/tim.go create mode 100644 display/webkit/pkg/container/tim_example_test.go create mode 100644 display/webkit/pkg/container/tim_test.go create mode 100644 display/webkit/pkg/contextmenu/messages.go create mode 100644 display/webkit/pkg/contextmenu/messages_example_test.go create mode 100644 display/webkit/pkg/contextmenu/messages_test.go create mode 100644 display/webkit/pkg/contextmenu/platform.go create mode 100644 display/webkit/pkg/contextmenu/platform_example_test.go create mode 100644 display/webkit/pkg/contextmenu/platform_test.go create mode 100644 display/webkit/pkg/contextmenu/register.go create mode 100644 display/webkit/pkg/contextmenu/register_example_test.go create mode 100644 display/webkit/pkg/contextmenu/register_test.go create mode 100644 display/webkit/pkg/contextmenu/result_failure.go create mode 100644 display/webkit/pkg/contextmenu/service.go create mode 100644 display/webkit/pkg/contextmenu/service_behaviour_test.go create mode 100644 display/webkit/pkg/contextmenu/service_example_test.go create mode 100644 display/webkit/pkg/contextmenu/service_test.go create mode 100644 display/webkit/pkg/contextmenu/wails.go create mode 100644 display/webkit/pkg/deno/core_helpers.go create mode 100644 display/webkit/pkg/deno/result_failure.go create mode 100644 display/webkit/pkg/deno/sidecar.go create mode 100644 display/webkit/pkg/deno/sidecar_behaviour_test.go create mode 100644 display/webkit/pkg/deno/sidecar_example_test.go create mode 100644 display/webkit/pkg/deno/sidecar_test.go create mode 100644 display/webkit/pkg/dialog/messages.go create mode 100644 display/webkit/pkg/dialog/messages_example_test.go create mode 100644 display/webkit/pkg/dialog/messages_test.go create mode 100644 display/webkit/pkg/dialog/platform.go create mode 100644 display/webkit/pkg/dialog/platform_example_test.go create mode 100644 display/webkit/pkg/dialog/platform_test.go create mode 100644 display/webkit/pkg/dialog/result_failure.go create mode 100644 display/webkit/pkg/dialog/service.go create mode 100644 display/webkit/pkg/dialog/service_behaviour_test.go create mode 100644 display/webkit/pkg/dialog/service_example_test.go create mode 100644 display/webkit/pkg/dialog/service_test.go create mode 100644 display/webkit/pkg/dialog/wails.go create mode 100644 display/webkit/pkg/display/.github/workflows/go.yml create mode 100644 display/webkit/pkg/display/.github/workflows/release.yml create mode 100644 display/webkit/pkg/display/.gitignore create mode 100644 display/webkit/pkg/display/FEATURES.md create mode 100644 display/webkit/pkg/display/LICENSE create mode 100644 display/webkit/pkg/display/README.md create mode 100644 display/webkit/pkg/display/api.go create mode 100644 display/webkit/pkg/display/api_example_test.go create mode 100644 display/webkit/pkg/display/api_test.go create mode 100644 display/webkit/pkg/display/api_wrappers_test.go create mode 100644 display/webkit/pkg/display/assets/apptray.png create mode 100644 display/webkit/pkg/display/background.go create mode 100644 display/webkit/pkg/display/background_example_test.go create mode 100644 display/webkit/pkg/display/background_test.go create mode 100644 display/webkit/pkg/display/core_helpers.go create mode 100644 display/webkit/pkg/display/display.go create mode 100644 display/webkit/pkg/display/display_contextmenu_test.go create mode 100644 display/webkit/pkg/display/display_example_test.go create mode 100644 display/webkit/pkg/display/display_layout_wrappers_test.go create mode 100644 display/webkit/pkg/display/display_test.go create mode 100644 display/webkit/pkg/display/docs/backend.md create mode 100644 display/webkit/pkg/display/docs/development.md create mode 100644 display/webkit/pkg/display/docs/frontend.md create mode 100644 display/webkit/pkg/display/docs/overview.md create mode 100644 display/webkit/pkg/display/events.go create mode 100644 display/webkit/pkg/display/events_example_test.go create mode 100644 display/webkit/pkg/display/events_test.go create mode 100644 display/webkit/pkg/display/hlcrf.go create mode 100644 display/webkit/pkg/display/hlcrf_test.go create mode 100644 display/webkit/pkg/display/interfaces.go create mode 100644 display/webkit/pkg/display/interfaces_example_test.go create mode 100644 display/webkit/pkg/display/manifest.go create mode 100644 display/webkit/pkg/display/manifest_example_test.go create mode 100644 display/webkit/pkg/display/manifest_test.go create mode 100644 display/webkit/pkg/display/marketplace.go create mode 100644 display/webkit/pkg/display/marketplace_test.go create mode 100644 display/webkit/pkg/display/messages.go create mode 100644 display/webkit/pkg/display/messages_example_test.go create mode 100644 display/webkit/pkg/display/messages_test.go create mode 100644 display/webkit/pkg/display/ml.go create mode 100644 display/webkit/pkg/display/ml_example_test.go create mode 100644 display/webkit/pkg/display/ml_test.go create mode 100644 display/webkit/pkg/display/mocks_test.go create mode 100644 display/webkit/pkg/display/network.go create mode 100644 display/webkit/pkg/display/network_example_test.go create mode 100644 display/webkit/pkg/display/network_test.go create mode 100644 display/webkit/pkg/display/p2p.go create mode 100644 display/webkit/pkg/display/p2p_test.go create mode 100644 display/webkit/pkg/display/preload.go create mode 100644 display/webkit/pkg/display/preload_example_test.go create mode 100644 display/webkit/pkg/display/preload_test.go create mode 100644 display/webkit/pkg/display/result_failure.go create mode 100644 display/webkit/pkg/display/scheme.go create mode 100644 display/webkit/pkg/display/scheme_example_test.go create mode 100644 display/webkit/pkg/display/scheme_handler.go create mode 100644 display/webkit/pkg/display/scheme_handler_example_test.go create mode 100644 display/webkit/pkg/display/scheme_handler_test.go create mode 100644 display/webkit/pkg/display/sidecar.go create mode 100644 display/webkit/pkg/display/sidecar_test.go create mode 100644 display/webkit/pkg/display/storage.go create mode 100644 display/webkit/pkg/display/storage_example_test.go create mode 100644 display/webkit/pkg/display/storage_store.go create mode 100644 display/webkit/pkg/display/storage_test.go create mode 100644 display/webkit/pkg/dock/messages.go create mode 100644 display/webkit/pkg/dock/messages_example_test.go create mode 100644 display/webkit/pkg/dock/messages_test.go create mode 100644 display/webkit/pkg/dock/platform.go create mode 100644 display/webkit/pkg/dock/platform_example_test.go create mode 100644 display/webkit/pkg/dock/platform_test.go create mode 100644 display/webkit/pkg/dock/register.go create mode 100644 display/webkit/pkg/dock/register_example_test.go create mode 100644 display/webkit/pkg/dock/register_test.go create mode 100644 display/webkit/pkg/dock/result_failure.go create mode 100644 display/webkit/pkg/dock/service.go create mode 100644 display/webkit/pkg/dock/service_example_test.go create mode 100644 display/webkit/pkg/dock/service_test.go create mode 100644 display/webkit/pkg/dock/wails.go create mode 100644 display/webkit/pkg/environment/messages.go create mode 100644 display/webkit/pkg/environment/messages_example_test.go create mode 100644 display/webkit/pkg/environment/messages_test.go create mode 100644 display/webkit/pkg/environment/platform.go create mode 100644 display/webkit/pkg/environment/platform_example_test.go create mode 100644 display/webkit/pkg/environment/platform_test.go create mode 100644 display/webkit/pkg/environment/result_failure.go create mode 100644 display/webkit/pkg/environment/service.go create mode 100644 display/webkit/pkg/environment/service_behaviour_test.go create mode 100644 display/webkit/pkg/environment/service_example_test.go create mode 100644 display/webkit/pkg/environment/service_test.go create mode 100644 display/webkit/pkg/environment/wails.go create mode 100644 display/webkit/pkg/events/messages.go create mode 100644 display/webkit/pkg/events/messages_example_test.go create mode 100644 display/webkit/pkg/events/messages_test.go create mode 100644 display/webkit/pkg/events/platform.go create mode 100644 display/webkit/pkg/events/platform_example_test.go create mode 100644 display/webkit/pkg/events/platform_test.go create mode 100644 display/webkit/pkg/events/register.go create mode 100644 display/webkit/pkg/events/register_example_test.go create mode 100644 display/webkit/pkg/events/register_test.go create mode 100644 display/webkit/pkg/events/result_failure.go create mode 100644 display/webkit/pkg/events/service.go create mode 100644 display/webkit/pkg/events/service_example_test.go create mode 100644 display/webkit/pkg/events/service_test.go create mode 100644 display/webkit/pkg/events/wails.go create mode 100644 display/webkit/pkg/internal/coreutil/action.go create mode 100644 display/webkit/pkg/internal/coreutil/action_behaviour_test.go create mode 100644 display/webkit/pkg/internal/coreutil/action_example_test.go create mode 100644 display/webkit/pkg/internal/coreutil/action_test.go create mode 100644 display/webkit/pkg/internal/textutil/textutil.go create mode 100644 display/webkit/pkg/internal/textutil/textutil_behaviour_test.go create mode 100644 display/webkit/pkg/internal/textutil/textutil_example_test.go create mode 100644 display/webkit/pkg/internal/textutil/textutil_test.go create mode 100644 display/webkit/pkg/keybinding/messages.go create mode 100644 display/webkit/pkg/keybinding/messages_example_test.go create mode 100644 display/webkit/pkg/keybinding/messages_test.go create mode 100644 display/webkit/pkg/keybinding/platform.go create mode 100644 display/webkit/pkg/keybinding/platform_example_test.go create mode 100644 display/webkit/pkg/keybinding/platform_test.go create mode 100644 display/webkit/pkg/keybinding/register.go create mode 100644 display/webkit/pkg/keybinding/register_example_test.go create mode 100644 display/webkit/pkg/keybinding/register_test.go create mode 100644 display/webkit/pkg/keybinding/result_failure.go create mode 100644 display/webkit/pkg/keybinding/service.go create mode 100644 display/webkit/pkg/keybinding/service_example_test.go create mode 100644 display/webkit/pkg/keybinding/service_test.go create mode 100644 display/webkit/pkg/keybinding/wails.go create mode 100644 display/webkit/pkg/lifecycle/messages.go create mode 100644 display/webkit/pkg/lifecycle/messages_example_test.go create mode 100644 display/webkit/pkg/lifecycle/messages_test.go create mode 100644 display/webkit/pkg/lifecycle/mode.go create mode 100644 display/webkit/pkg/lifecycle/mode_behaviour_test.go create mode 100644 display/webkit/pkg/lifecycle/mode_example_test.go create mode 100644 display/webkit/pkg/lifecycle/mode_test.go create mode 100644 display/webkit/pkg/lifecycle/platform.go create mode 100644 display/webkit/pkg/lifecycle/platform_example_test.go create mode 100644 display/webkit/pkg/lifecycle/platform_test.go create mode 100644 display/webkit/pkg/lifecycle/register.go create mode 100644 display/webkit/pkg/lifecycle/register_example_test.go create mode 100644 display/webkit/pkg/lifecycle/register_test.go create mode 100644 display/webkit/pkg/lifecycle/service.go create mode 100644 display/webkit/pkg/lifecycle/service_example_test.go create mode 100644 display/webkit/pkg/lifecycle/service_test.go create mode 100644 display/webkit/pkg/lifecycle/wails.go create mode 100644 display/webkit/pkg/lifecycle/wails_behaviour_test.go create mode 100644 display/webkit/pkg/lifecycle/wails_darwin.go create mode 100644 display/webkit/pkg/lifecycle/wails_other.go create mode 100644 display/webkit/pkg/marketplace/core_helpers.go create mode 100644 display/webkit/pkg/marketplace/marketplace.go create mode 100644 display/webkit/pkg/marketplace/marketplace_example_test.go create mode 100644 display/webkit/pkg/marketplace/marketplace_test.go create mode 100644 display/webkit/pkg/marketplace/result_failure.go create mode 100644 display/webkit/pkg/mcp/mcp_test.go create mode 100644 display/webkit/pkg/mcp/result_failure.go create mode 100644 display/webkit/pkg/mcp/subsystem.go create mode 100644 display/webkit/pkg/mcp/subsystem_example_test.go create mode 100644 display/webkit/pkg/mcp/subsystem_test.go create mode 100644 display/webkit/pkg/mcp/tools_behaviour2_test.go create mode 100644 display/webkit/pkg/mcp/tools_behaviour_test.go create mode 100644 display/webkit/pkg/mcp/tools_browser.go create mode 100644 display/webkit/pkg/mcp/tools_browser_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_browser_test.go create mode 100644 display/webkit/pkg/mcp/tools_chat.go create mode 100644 display/webkit/pkg/mcp/tools_chat_behaviour_test.go create mode 100644 display/webkit/pkg/mcp/tools_chat_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_chat_test.go create mode 100644 display/webkit/pkg/mcp/tools_cleanup_behaviour_test.go create mode 100644 display/webkit/pkg/mcp/tools_clipboard.go create mode 100644 display/webkit/pkg/mcp/tools_clipboard_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_clipboard_test.go create mode 100644 display/webkit/pkg/mcp/tools_container.go create mode 100644 display/webkit/pkg/mcp/tools_container_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_container_test.go create mode 100644 display/webkit/pkg/mcp/tools_contextmenu.go create mode 100644 display/webkit/pkg/mcp/tools_contextmenu_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_contextmenu_test.go create mode 100644 display/webkit/pkg/mcp/tools_deno.go create mode 100644 display/webkit/pkg/mcp/tools_deno_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_deno_test.go create mode 100644 display/webkit/pkg/mcp/tools_dialog.go create mode 100644 display/webkit/pkg/mcp/tools_dialog_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_dialog_test.go create mode 100644 display/webkit/pkg/mcp/tools_display.go create mode 100644 display/webkit/pkg/mcp/tools_display_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_display_test.go create mode 100644 display/webkit/pkg/mcp/tools_dock.go create mode 100644 display/webkit/pkg/mcp/tools_dock_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_dock_test.go create mode 100644 display/webkit/pkg/mcp/tools_environment.go create mode 100644 display/webkit/pkg/mcp/tools_environment_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_environment_test.go create mode 100644 display/webkit/pkg/mcp/tools_events.go create mode 100644 display/webkit/pkg/mcp/tools_events_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_events_test.go create mode 100644 display/webkit/pkg/mcp/tools_keybinding.go create mode 100644 display/webkit/pkg/mcp/tools_keybinding_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_keybinding_test.go create mode 100644 display/webkit/pkg/mcp/tools_layout.go create mode 100644 display/webkit/pkg/mcp/tools_layout_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_layout_test.go create mode 100644 display/webkit/pkg/mcp/tools_lifecycle.go create mode 100644 display/webkit/pkg/mcp/tools_lifecycle_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_lifecycle_test.go create mode 100644 display/webkit/pkg/mcp/tools_marketplace.go create mode 100644 display/webkit/pkg/mcp/tools_marketplace_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_marketplace_test.go create mode 100644 display/webkit/pkg/mcp/tools_menu.go create mode 100644 display/webkit/pkg/mcp/tools_menu_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_menu_test.go create mode 100644 display/webkit/pkg/mcp/tools_notification.go create mode 100644 display/webkit/pkg/mcp/tools_notification_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_notification_test.go create mode 100644 display/webkit/pkg/mcp/tools_p2p.go create mode 100644 display/webkit/pkg/mcp/tools_p2p_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_p2p_test.go create mode 100644 display/webkit/pkg/mcp/tools_screen.go create mode 100644 display/webkit/pkg/mcp/tools_screen_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_screen_test.go create mode 100644 display/webkit/pkg/mcp/tools_tray.go create mode 100644 display/webkit/pkg/mcp/tools_tray_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_tray_test.go create mode 100644 display/webkit/pkg/mcp/tools_webview.go create mode 100644 display/webkit/pkg/mcp/tools_webview_behaviour_test.go create mode 100644 display/webkit/pkg/mcp/tools_webview_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_webview_test.go create mode 100644 display/webkit/pkg/mcp/tools_window.go create mode 100644 display/webkit/pkg/mcp/tools_window_behaviour_test.go create mode 100644 display/webkit/pkg/mcp/tools_window_example_test.go create mode 100644 display/webkit/pkg/mcp/tools_window_test.go create mode 100644 display/webkit/pkg/menu/menu.go create mode 100644 display/webkit/pkg/menu/menu_example_test.go create mode 100644 display/webkit/pkg/menu/menu_test.go create mode 100644 display/webkit/pkg/menu/messages.go create mode 100644 display/webkit/pkg/menu/messages_example_test.go create mode 100644 display/webkit/pkg/menu/messages_test.go create mode 100644 display/webkit/pkg/menu/mock_platform.go create mode 100644 display/webkit/pkg/menu/mock_platform_example_test.go create mode 100644 display/webkit/pkg/menu/mock_platform_test.go create mode 100644 display/webkit/pkg/menu/mock_test.go create mode 100644 display/webkit/pkg/menu/platform.go create mode 100644 display/webkit/pkg/menu/platform_example_test.go create mode 100644 display/webkit/pkg/menu/platform_test.go create mode 100644 display/webkit/pkg/menu/register.go create mode 100644 display/webkit/pkg/menu/register_example_test.go create mode 100644 display/webkit/pkg/menu/register_test.go create mode 100644 display/webkit/pkg/menu/service.go create mode 100644 display/webkit/pkg/menu/service_behaviour_test.go create mode 100644 display/webkit/pkg/menu/service_example_test.go create mode 100644 display/webkit/pkg/menu/service_test.go create mode 100644 display/webkit/pkg/menu/wails.go create mode 100644 display/webkit/pkg/menu/wails_example_test.go create mode 100644 display/webkit/pkg/notification/messages.go create mode 100644 display/webkit/pkg/notification/messages_example_test.go create mode 100644 display/webkit/pkg/notification/messages_test.go create mode 100644 display/webkit/pkg/notification/platform.go create mode 100644 display/webkit/pkg/notification/platform_example_test.go create mode 100644 display/webkit/pkg/notification/platform_test.go create mode 100644 display/webkit/pkg/notification/result_failure.go create mode 100644 display/webkit/pkg/notification/service.go create mode 100644 display/webkit/pkg/notification/service_behaviour_test.go create mode 100644 display/webkit/pkg/notification/service_example_test.go create mode 100644 display/webkit/pkg/notification/service_test.go create mode 100644 display/webkit/pkg/notification/wails.go create mode 100644 display/webkit/pkg/p2p/core_helpers.go create mode 100644 display/webkit/pkg/p2p/result_failure.go create mode 100644 display/webkit/pkg/p2p/router.go create mode 100644 display/webkit/pkg/p2p/router_example_test.go create mode 100644 display/webkit/pkg/p2p/router_test.go create mode 100644 display/webkit/pkg/p2p/service.go create mode 100644 display/webkit/pkg/p2p/service_behaviour_test.go create mode 100644 display/webkit/pkg/p2p/service_example_test.go create mode 100644 display/webkit/pkg/p2p/service_test.go create mode 100644 display/webkit/pkg/p2p/tcp.go create mode 100644 display/webkit/pkg/p2p/tcp_example_test.go create mode 100644 display/webkit/pkg/p2p/tcp_loopback_test.go create mode 100644 display/webkit/pkg/p2p/tcp_test.go create mode 100644 display/webkit/pkg/preload/assets/electron_shim.js create mode 100644 display/webkit/pkg/preload/assets/storage_polyfills.js create mode 100644 display/webkit/pkg/preload/core_helpers.go create mode 100644 display/webkit/pkg/preload/electron_shim.go create mode 100644 display/webkit/pkg/preload/preload.go create mode 100644 display/webkit/pkg/preload/preload_example_test.go create mode 100644 display/webkit/pkg/preload/preload_test.go create mode 100644 display/webkit/pkg/preload/result_failure.go create mode 100644 display/webkit/pkg/preload/storage_polyfills.go create mode 100644 display/webkit/pkg/screen/messages.go create mode 100644 display/webkit/pkg/screen/messages_example_test.go create mode 100644 display/webkit/pkg/screen/messages_test.go create mode 100644 display/webkit/pkg/screen/platform.go create mode 100644 display/webkit/pkg/screen/platform_example_test.go create mode 100644 display/webkit/pkg/screen/platform_test.go create mode 100644 display/webkit/pkg/screen/service.go create mode 100644 display/webkit/pkg/screen/service_example_test.go create mode 100644 display/webkit/pkg/screen/service_test.go create mode 100644 display/webkit/pkg/screen/wails.go create mode 100644 display/webkit/pkg/systray/assets/apptray.png create mode 100644 display/webkit/pkg/systray/menu.go create mode 100644 display/webkit/pkg/systray/menu_example_test.go create mode 100644 display/webkit/pkg/systray/menu_test.go create mode 100644 display/webkit/pkg/systray/messages.go create mode 100644 display/webkit/pkg/systray/messages_example_test.go create mode 100644 display/webkit/pkg/systray/messages_test.go create mode 100644 display/webkit/pkg/systray/mock_platform.go create mode 100644 display/webkit/pkg/systray/mock_platform_example_test.go create mode 100644 display/webkit/pkg/systray/mock_platform_test.go create mode 100644 display/webkit/pkg/systray/mock_test.go create mode 100644 display/webkit/pkg/systray/platform.go create mode 100644 display/webkit/pkg/systray/platform_example_test.go create mode 100644 display/webkit/pkg/systray/platform_test.go create mode 100644 display/webkit/pkg/systray/register.go create mode 100644 display/webkit/pkg/systray/register_example_test.go create mode 100644 display/webkit/pkg/systray/register_test.go create mode 100644 display/webkit/pkg/systray/result_failure.go create mode 100644 display/webkit/pkg/systray/service.go create mode 100644 display/webkit/pkg/systray/service_behaviour_test.go create mode 100644 display/webkit/pkg/systray/service_config_test.go create mode 100644 display/webkit/pkg/systray/service_example_test.go create mode 100644 display/webkit/pkg/systray/service_test.go create mode 100644 display/webkit/pkg/systray/tray.go create mode 100644 display/webkit/pkg/systray/tray_example_test.go create mode 100644 display/webkit/pkg/systray/tray_test.go create mode 100644 display/webkit/pkg/systray/types.go create mode 100644 display/webkit/pkg/systray/types_example_test.go create mode 100644 display/webkit/pkg/systray/types_test.go create mode 100644 display/webkit/pkg/systray/wails.go create mode 100644 display/webkit/pkg/systray/wails_example_test.go create mode 100644 display/webkit/pkg/webview/core_helpers_test.go create mode 100644 display/webkit/pkg/webview/diagnostics.go create mode 100644 display/webkit/pkg/webview/diagnostics_example_test.go create mode 100644 display/webkit/pkg/webview/diagnostics_test.go create mode 100644 display/webkit/pkg/webview/messages.go create mode 100644 display/webkit/pkg/webview/messages_example_test.go create mode 100644 display/webkit/pkg/webview/messages_test.go create mode 100644 display/webkit/pkg/webview/result_failure.go create mode 100644 display/webkit/pkg/webview/service.go create mode 100644 display/webkit/pkg/webview/service_example_test.go create mode 100644 display/webkit/pkg/webview/service_test.go create mode 100644 display/webkit/pkg/window/core_helpers.go create mode 100644 display/webkit/pkg/window/layout.go create mode 100644 display/webkit/pkg/window/layout_example_test.go create mode 100644 display/webkit/pkg/window/layout_test.go create mode 100644 display/webkit/pkg/window/messages.go create mode 100644 display/webkit/pkg/window/messages_example_test.go create mode 100644 display/webkit/pkg/window/messages_test.go create mode 100644 display/webkit/pkg/window/mock_platform.go create mode 100644 display/webkit/pkg/window/mock_platform_example_test.go create mode 100644 display/webkit/pkg/window/mock_platform_test.go create mode 100644 display/webkit/pkg/window/mock_test.go create mode 100644 display/webkit/pkg/window/options.go create mode 100644 display/webkit/pkg/window/options_example_test.go create mode 100644 display/webkit/pkg/window/options_test.go create mode 100644 display/webkit/pkg/window/persistence_test.go create mode 100644 display/webkit/pkg/window/platform.go create mode 100644 display/webkit/pkg/window/platform_example_test.go create mode 100644 display/webkit/pkg/window/platform_test.go create mode 100644 display/webkit/pkg/window/register.go create mode 100644 display/webkit/pkg/window/register_example_test.go create mode 100644 display/webkit/pkg/window/register_test.go create mode 100644 display/webkit/pkg/window/result_failure.go create mode 100644 display/webkit/pkg/window/service.go create mode 100644 display/webkit/pkg/window/service_config_test.go create mode 100644 display/webkit/pkg/window/service_example_test.go create mode 100644 display/webkit/pkg/window/service_screen_test.go create mode 100644 display/webkit/pkg/window/service_test.go create mode 100644 display/webkit/pkg/window/smart_layout.go create mode 100644 display/webkit/pkg/window/smart_layout_test.go create mode 100644 display/webkit/pkg/window/state.go create mode 100644 display/webkit/pkg/window/state_example_test.go create mode 100644 display/webkit/pkg/window/state_test.go create mode 100644 display/webkit/pkg/window/tiling.go create mode 100644 display/webkit/pkg/window/tiling_example_test.go create mode 100644 display/webkit/pkg/window/tiling_test.go create mode 100644 display/webkit/pkg/window/wails.go create mode 100644 display/webkit/pkg/window/wails_example_test.go create mode 100644 display/webkit/pkg/window/window.go create mode 100644 display/webkit/pkg/window/window_example_test.go create mode 100644 display/webkit/pkg/window/window_test.go create mode 100644 display/webkit/service.go create mode 100644 display/webkit/service_example_test.go create mode 100644 display/webkit/service_test.go create mode 100644 display/webkit/tests/cli/gui/Taskfile.yaml create mode 100644 display/webkit/tray.go create mode 100644 display/webkit/tray_routes_test.go create mode 100644 display/webkit/ui/.editorconfig create mode 100644 display/webkit/ui/.gitignore create mode 100644 display/webkit/ui/.postcssrc.json create mode 100644 display/webkit/ui/README.md create mode 100644 display/webkit/ui/angular.json create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/.browserslistrc create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/LICENSE create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/README.md create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/builders.json create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/package.json create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/private/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/private/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/build-action.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/build-action.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/chunk-optimizer.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/chunk-optimizer.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/execute-build.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/execute-build.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/execute-post-bundle.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/execute-post-bundle.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/i18n.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/i18n.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/options.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/options.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/results.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/results.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/schema.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/schema.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/schema.json create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/setup-bundling.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/application/setup-bundling.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/builder.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/builder.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/internal.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/internal.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/options.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/options.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/output.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/output.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/schema.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/schema.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/schema.json create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/vite-server.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/dev-server/vite-server.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/application-extraction.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/application-extraction.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/builder.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/builder.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/options.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/options.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/schema.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/schema.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/extract-i18n/schema.json create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/application_builder.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/application_builder.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/find-tests.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/find-tests.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/options.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/options.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/polyfills/init_sourcemaps.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/polyfills/jasmine_global.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/polyfills/jasmine_global_cleanup.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/schema.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/schema.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/karma/schema.json create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/ng-packagr/builder.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/ng-packagr/builder.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/ng-packagr/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/ng-packagr/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/ng-packagr/schema.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/ng-packagr/schema.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/ng-packagr/schema.json create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/builder.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/builder.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/karma-bridge.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/karma-bridge.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/options.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/options.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/schema.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/schema.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/builders/unit-test/schema.json create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/private.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/private.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/angular-host.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/angular-host.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/angular-compilation.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/angular-compilation.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/aot-compilation.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/aot-compilation.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/factory.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/factory.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/hmr-candidates.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/hmr-candidates.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/jit-compilation.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/jit-compilation.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/noop-compilation.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/noop-compilation.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/parallel-compilation.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/parallel-compilation.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/parallel-worker.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/compilation/parallel-worker.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/transformers/jit-resource-transformer.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/transformers/jit-resource-transformer.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/transformers/lazy-routes-transformer.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/transformers/lazy-routes-transformer.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/transformers/web-worker-transformer.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/transformers/web-worker-transformer.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/uri.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/angular/uri.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/add-code-coverage.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/add-code-coverage.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/adjust-static-class-members.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/adjust-static-class-members.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/adjust-typescript-enums.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/adjust-typescript-enums.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/elide-angular-metadata.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/elide-angular-metadata.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/pure-toplevel-functions.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/pure-toplevel-functions.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/plugins/types.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/babel/typings.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular-localize-init-warning-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular-localize-init-warning-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/compilation-state.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/compilation-state.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/compiler-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/compiler-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/component-stylesheets.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/component-stylesheets.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/diagnostics.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/diagnostics.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/file-reference-tracker.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/file-reference-tracker.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/jit-plugin-callbacks.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/jit-plugin-callbacks.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/source-file-cache.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/angular/source-file-cache.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/application-code-bundle.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/application-code-bundle.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/budget-stats.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/budget-stats.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/bundler-context.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/bundler-context.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/bundler-execution-result.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/bundler-execution-result.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/cache.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/cache.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/commonjs-checker.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/commonjs-checker.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/compiler-plugin-options.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/compiler-plugin-options.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/external-packages-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/external-packages-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/global-scripts.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/global-scripts.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/global-styles.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/global-styles.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/i18n-inliner-worker.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/i18n-inliner-worker.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/i18n-inliner.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/i18n-inliner.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/i18n-locale-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/i18n-locale-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/index-html-generator.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/index-html-generator.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/javascript-transformer-worker.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/javascript-transformer-worker.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/javascript-transformer.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/javascript-transformer.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/license-extractor.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/license-extractor.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/lmdb-cache-store.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/lmdb-cache-store.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/load-result-cache.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/load-result-cache.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/loader-import-attribute-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/loader-import-attribute-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/profiling.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/profiling.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/rxjs-esm-resolution-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/rxjs-esm-resolution-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/server-bundle-metadata-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/server-bundle-metadata-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/sourcemap-ignorelist-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/sourcemap-ignorelist-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/bundle-options.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/bundle-options.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/css-language.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/css-language.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/less-language.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/less-language.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/sass-language.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/sass-language.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/utils.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/utils.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/virtual-module-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/virtual-module-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/wasm-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/wasm-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/wasm.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/watcher.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/esbuild/watcher.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/sass/lexer.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/sass/lexer.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/sass/rebasing-importer.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/sass/rebasing-importer.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/sass/sass-service.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/sass/sass-service.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/sass/worker.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/sass/worker.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/assets-middleware.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/assets-middleware.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/component-middleware.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/component-middleware.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/headers-middleware.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/headers-middleware.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/html-fallback-middleware.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/html-fallback-middleware.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/index-html-middleware.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/index-html-middleware.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/ssr-middleware.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/middlewares/ssr-middleware.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/angular-memory-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/angular-memory-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/id-prefix-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/id-prefix-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/setup-middlewares-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/setup-middlewares-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/ssr-transform-plugin.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/plugins/ssr-transform-plugin.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/utils.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/tools/vite/utils.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/typings.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/bundle-calculator.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/bundle-calculator.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/check-port.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/check-port.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/color.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/color.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/delete-output-dir.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/delete-output-dir.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/environment-options.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/environment-options.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/error.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/error.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/format-bytes.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/format-bytes.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/i18n-options.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/i18n-options.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/add-event-dispatch-contract.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/add-event-dispatch-contract.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/augment-index-html.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/augment-index-html.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/auto-csp.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/auto-csp.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/html-rewriting-stream.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/html-rewriting-stream.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/index-html-generator.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/index-html-generator.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/inline-critical-css.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/inline-critical-css.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/inline-fonts.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/inline-fonts.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/ngcm-attribute.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/ngcm-attribute.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/nonce.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/nonce.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/valid-self-closing-tags.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index-file/valid-self-closing-tags.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/index.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/load-esm.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/load-esm.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/load-proxy-config.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/load-proxy-config.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/load-translations.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/load-translations.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/normalize-asset-patterns.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/normalize-asset-patterns.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/normalize-cache.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/normalize-cache.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/normalize-optimization.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/normalize-optimization.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/normalize-source-maps.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/normalize-source-maps.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/path.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/path.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/postcss-configuration.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/postcss-configuration.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/project-metadata.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/project-metadata.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/purge-cache.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/purge-cache.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/resolve-assets.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/resolve-assets.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/fetch-patch.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/fetch-patch.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/launch-server.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/launch-server.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/load-esm-from-memory.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/load-esm-from-memory.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/manifest.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/manifest.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/models.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/models.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/prerender.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/prerender.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/render-worker.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/render-worker.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/routes-extractor-worker.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/routes-extractor-worker.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/utils.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/server-rendering/utils.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/service-worker.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/service-worker.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/stats-table.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/stats-table.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/supported-browsers.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/supported-browsers.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/tty.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/tty.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/url.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/url.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/version.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/version.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/worker-pool.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/build/src/utils/worker-pool.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/cli/src/commands/build/cli.d.ts create mode 100755 display/webkit/ui/node_modules.bak/@angular/cli/src/commands/build/cli.js create mode 100755 display/webkit/ui/node_modules.bak/@angular/cli/src/commands/build/long-description.md create mode 100644 display/webkit/ui/node_modules.bak/@babel/core/lib/vendor/import-meta-resolve.js create mode 100644 display/webkit/ui/node_modules.bak/@babel/core/lib/vendor/import-meta-resolve.js.map create mode 100644 display/webkit/ui/node_modules.bak/@isaacs/cliui/build/index.cjs create mode 100644 display/webkit/ui/node_modules.bak/@isaacs/cliui/build/index.d.cts create mode 100644 display/webkit/ui/node_modules.bak/@isaacs/cliui/build/lib/index.js create mode 100644 display/webkit/ui/node_modules.bak/chalk/source/vendor/ansi-styles/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/chalk/source/vendor/ansi-styles/index.js create mode 100644 display/webkit/ui/node_modules.bak/chalk/source/vendor/supports-color/browser.d.ts create mode 100644 display/webkit/ui/node_modules.bak/chalk/source/vendor/supports-color/browser.js create mode 100644 display/webkit/ui/node_modules.bak/chalk/source/vendor/supports-color/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/chalk/source/vendor/supports-color/index.js create mode 100644 display/webkit/ui/node_modules.bak/cliui/build/lib/index.js create mode 100644 display/webkit/ui/node_modules.bak/cliui/build/tsconfig.tsbuildinfo create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/commons.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/commons.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/contrib/base64-arraybuffer.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/contrib/base64-arraybuffer.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/decodePacket.browser.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/decodePacket.browser.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/decodePacket.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/decodePacket.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/encodePacket.browser.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/encodePacket.browser.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/encodePacket.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/encodePacket.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/index.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/cjs/package.json create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/commons.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/commons.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/contrib/base64-arraybuffer.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/contrib/base64-arraybuffer.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/decodePacket.browser.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/decodePacket.browser.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/decodePacket.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/decodePacket.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/encodePacket.browser.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/encodePacket.browser.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/encodePacket.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/encodePacket.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/index.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io-parser/build/esm/package.json create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/contrib/types.cookie.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/contrib/types.cookie.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/engine.io.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/engine.io.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/parser-v3/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/parser-v3/index.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/parser-v3/utf8.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/parser-v3/utf8.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/server.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/server.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/socket.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/socket.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transport.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transport.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports-uws/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports-uws/index.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports-uws/polling.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports-uws/polling.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports-uws/websocket.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports-uws/websocket.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/index.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/polling-jsonp.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/polling-jsonp.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/polling.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/polling.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/websocket.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/websocket.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/webtransport.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/transports/webtransport.js create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/userver.d.ts create mode 100644 display/webkit/ui/node_modules.bak/engine.io/build/userver.js create mode 100644 display/webkit/ui/node_modules.bak/istanbul-reports/lib/html/assets/vendor/prettify.css create mode 100644 display/webkit/ui/node_modules.bak/istanbul-reports/lib/html/assets/vendor/prettify.js create mode 100644 display/webkit/ui/node_modules.bak/smart-buffer/build/smartbuffer.js create mode 100644 display/webkit/ui/node_modules.bak/smart-buffer/build/smartbuffer.js.map create mode 100644 display/webkit/ui/node_modules.bak/smart-buffer/build/utils.js create mode 100644 display/webkit/ui/node_modules.bak/smart-buffer/build/utils.js.map create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/cjs/binary.d.ts create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/cjs/binary.js create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/cjs/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/cjs/index.js create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/cjs/is-binary.d.ts create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/cjs/is-binary.js create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/cjs/package.json create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm-debug/binary.d.ts create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm-debug/binary.js create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm-debug/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm-debug/index.js create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm-debug/is-binary.d.ts create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm-debug/is-binary.js create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm-debug/package.json create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm/binary.d.ts create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm/binary.js create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm/index.d.ts create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm/index.js create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm/is-binary.d.ts create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm/is-binary.js create mode 100644 display/webkit/ui/node_modules.bak/socket.io-parser/build/esm/package.json create mode 100644 display/webkit/ui/node_modules.bak/socks/build/client/socksclient.js create mode 100644 display/webkit/ui/node_modules.bak/socks/build/client/socksclient.js.map create mode 100644 display/webkit/ui/node_modules.bak/socks/build/common/constants.js create mode 100644 display/webkit/ui/node_modules.bak/socks/build/common/constants.js.map create mode 100644 display/webkit/ui/node_modules.bak/socks/build/common/helpers.js create mode 100644 display/webkit/ui/node_modules.bak/socks/build/common/helpers.js.map create mode 100644 display/webkit/ui/node_modules.bak/socks/build/common/receivebuffer.js create mode 100644 display/webkit/ui/node_modules.bak/socks/build/common/receivebuffer.js.map create mode 100644 display/webkit/ui/node_modules.bak/socks/build/common/util.js create mode 100644 display/webkit/ui/node_modules.bak/socks/build/common/util.js.map create mode 100644 display/webkit/ui/node_modules.bak/socks/build/index.js create mode 100644 display/webkit/ui/node_modules.bak/socks/build/index.js.map create mode 100644 display/webkit/ui/node_modules.bak/y18n/build/index.cjs create mode 100644 display/webkit/ui/node_modules.bak/y18n/build/lib/cjs.js create mode 100644 display/webkit/ui/node_modules.bak/y18n/build/lib/index.js create mode 100644 display/webkit/ui/node_modules.bak/y18n/build/lib/platform-shims/node.js create mode 100644 display/webkit/ui/node_modules.bak/yargs-parser/build/lib/index.js create mode 100644 display/webkit/ui/node_modules.bak/yargs-parser/build/lib/string-utils.js create mode 100644 display/webkit/ui/node_modules.bak/yargs-parser/build/lib/tokenize-arg-string.js create mode 100644 display/webkit/ui/node_modules.bak/yargs-parser/build/lib/yargs-parser-types.js create mode 100644 display/webkit/ui/node_modules.bak/yargs-parser/build/lib/yargs-parser.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/argsert.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/command.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/completion-templates.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/completion.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/middleware.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/parse-command.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/typings/common-types.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/typings/yargs-parser-types.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/usage.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/utils/apply-extends.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/utils/is-promise.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/utils/levenshtein.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/utils/maybe-async-result.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/utils/obj-filter.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/utils/process-argv.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/utils/set-blocking.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/utils/which-module.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/validation.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/yargs-factory.js create mode 100644 display/webkit/ui/node_modules.bak/yargs/build/lib/yerror.js create mode 100644 display/webkit/ui/package-lock.json create mode 100644 display/webkit/ui/package.json create mode 100644 display/webkit/ui/public/favicon.ico create mode 100644 display/webkit/ui/src/app/app-module.ts create mode 100644 display/webkit/ui/src/app/app.html create mode 100644 display/webkit/ui/src/app/app.ts create mode 100644 display/webkit/ui/src/chat/chat-panel.component.ts create mode 100644 display/webkit/ui/src/chat/chat-state.service.ts create mode 100644 display/webkit/ui/src/chat/chat.types.ts create mode 100644 display/webkit/ui/src/chat/conversation-sidebar.component.ts create mode 100644 display/webkit/ui/src/chat/input-area.component.ts create mode 100644 display/webkit/ui/src/chat/message-list.component.ts create mode 100644 display/webkit/ui/src/chat/model-selector.component.ts create mode 100644 display/webkit/ui/src/chat/settings-panel.component.ts create mode 100644 display/webkit/ui/src/components/provider-host.component.ts create mode 100644 display/webkit/ui/src/components/provider-nav.component.ts create mode 100644 display/webkit/ui/src/components/status-bar.component.ts create mode 100644 display/webkit/ui/src/frame/application-frame.component.html create mode 100644 display/webkit/ui/src/frame/application-frame.component.ts create mode 100644 display/webkit/ui/src/frame/system-tray-frame.component.ts create mode 100644 display/webkit/ui/src/generated/core-gui-chat.bindings.ts create mode 100644 display/webkit/ui/src/index.html create mode 100644 display/webkit/ui/src/index.ts create mode 100644 display/webkit/ui/src/main.ts create mode 100644 display/webkit/ui/src/services/api-config.service.ts create mode 100644 display/webkit/ui/src/services/provider-discovery.service.ts create mode 100644 display/webkit/ui/src/services/translation.service.ts create mode 100644 display/webkit/ui/src/services/websocket.service.ts create mode 100644 display/webkit/ui/src/styles.css create mode 100644 display/webkit/ui/src/web-components/chat-hero.element.ts create mode 100644 display/webkit/ui/tsconfig.app.json create mode 100644 display/webkit/ui/tsconfig.json create mode 100644 display/webkit/ui/tsconfig.spec.json create mode 100644 display/webkit/wails_options.go create mode 100644 display/webkit/window_binding.go create mode 100644 display/webkit/window_helpers.go create mode 100644 display/webkit/window_helpers_behaviour_test.go diff --git a/display/webkit/AGENTS.md b/display/webkit/AGENTS.md new file mode 120000 index 0000000..be77ac8 --- /dev/null +++ b/display/webkit/AGENTS.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/display/webkit/CLAUDE.md b/display/webkit/CLAUDE.md new file mode 120000 index 0000000..949a29f --- /dev/null +++ b/display/webkit/CLAUDE.md @@ -0,0 +1 @@ +../CLAUDE.md \ No newline at end of file diff --git a/display/webkit/README.md b/display/webkit/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/display/webkit/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/display/webkit/bootstrap.go b/display/webkit/bootstrap.go new file mode 100644 index 0000000..11406f8 --- /dev/null +++ b/display/webkit/bootstrap.go @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package webkit + +import ( + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/browser" + "dappco.re/go/render/display/webkit/pkg/chat" + "dappco.re/go/render/display/webkit/pkg/clipboard" + "dappco.re/go/render/display/webkit/pkg/container" + "dappco.re/go/render/display/webkit/pkg/contextmenu" + "dappco.re/go/render/display/webkit/pkg/dialog" + "dappco.re/go/render/display/webkit/pkg/display" + "dappco.re/go/render/display/webkit/pkg/dock" + "dappco.re/go/render/display/webkit/pkg/environment" + "dappco.re/go/render/display/webkit/pkg/events" + "dappco.re/go/render/display/webkit/pkg/keybinding" + "dappco.re/go/render/display/webkit/pkg/lifecycle" + "dappco.re/go/render/display/webkit/pkg/menu" + "dappco.re/go/render/display/webkit/pkg/notification" + "dappco.re/go/render/display/webkit/pkg/p2p" + "dappco.re/go/render/display/webkit/pkg/screen" + "dappco.re/go/render/display/webkit/pkg/systray" + "dappco.re/go/render/display/webkit/pkg/webview" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// Bootstrap returns the full set of [core.CoreOption] a desktop consumer +// needs to wire the GUI service stack in one append, instead of hand-wiring +// every sub-service. +// +// What's included: +// - "gui" — the top-level gui shell service +// - "window" — window manager + layout/state persistence (NewLayoutManager +// uses DIR_CONFIG / Core/layouts.json by default) +// - "display" — screens, work areas, dialogs, clipboard, system tray +// - "webview" — JS evaluation, console capture, DOM queries (CDP-backed) +// - "menu" — native application menu +// - "systray" — system tray icon + menu +// - "browser" — open external URLs/files in the OS default app +// (forge/mantis/docs links; native target=_blank replacement) +// - "notification" — native OS notifications (macOS Notification Center, +// Windows Toast, Linux D-Bus). macOS requires bundle+sign to fire. +// - "lifecycle" — app lifecycle events (started, will-terminate, +// did-become-active, did-resign-active, opened-with-file). Subscribers +// receive the corresponding c.Action dispatches. +// - "dialog" — native file open/save/directory pickers + info/ +// warning/error/question message dialogs. File pickers block until +// the user resolves the dialog; message dialogs adapt Wails's async +// button-callback model to a synchronous "which label was clicked" +// return. +// - "contextmenu" — register named native context menus. Frontend +// opts an element in via the CSS custom property +// --custom-contextmenu: ; (with optional +// --custom-contextmenu-data for per-element payload). Item clicks +// dispatch ActionItemClicked on the consumer's core. +// - "keybinding" — register global accelerators at runtime +// (Cmd+S, Ctrl+P, F1, etc.). Shares the same Wails key-binding map +// as application.Options.KeyBindings — runtime registration wins +// over boot-time when accelerators collide. +// - "dock" — macOS dock + Windows taskbar icon visibility + +// badge label. Progress bar / bounce are accepted but no-op until +// Wails exposes them upstream. +// - "environment" — OS / arch / debug / platform info, dark-mode +// query + ThemeChanged subscription, accent colour, OpenFileManager, +// focus-follows-mouse (Linux). +// - "screen" — multi-monitor info: GetAll / GetPrimary / +// GetCurrent (containing-window fallback to primary). +// - "clipboard" — system clipboard text read/write. Wails alpha.83 +// does not expose an image clipboard API; image ops fall through +// to the gui Service's "platform unavailable" branch. +// - "events" — Wails custom event bus: Emit / On / Off / +// OnMultiple / Reset. CustomEvent translates between gui and Wails +// types so consumers never see Wails directly. +// - "chat" — LLM chat surface. Defaults: APIURL +// "http://localhost:8090", store at $DIR_HOME/.core/gui/chat.db. +// Auto-builds its own gui_mcp subsystem as ToolExecutor if no +// consumer-supplied one. Override defaults via chat.Register(opts...). +// - "container" — TIM (Trusted In-Memory) container manager. +// Wired with empty TIMOptions; container Image/Name configured at +// runtime via the core config service. Operations on an unconfigured +// manager surface clear "no image" errors. +// - "p2p" — peer-to-peer routing service with TCP driver. +// Wired with empty Options (no listen address, no peers); add +// bootstrap peers at runtime via the p2p config service. Subscribe/ +// Publish are no-ops until ListenAddr is set. +// +// The wails [*application.App] is the only boundary the consumer touches — +// after that, everything runs through the canonical Core IPC pattern +// (c.Action/c.QUERY) so consumers don't need direct wails imports. +// +// app := application.New(opts) // consumer creates the app +// coreOpts := []core.CoreOption{ /* your services */ } +// coreOpts = append(coreOpts, webkit.Bootstrap(app)...) +// c, _ := core.New(coreOpts...) +// +// Packages NOT wired into Bootstrap (deliberately): +// - mcp — already wired by the IDE consumer as "gui_mcp"; that +// subsystem is what surfaces every other service's MCP tools and +// must be owned by the consumer (it attaches to the IDE's MCP +// bridge service, not to the gui shell). +// - marketplace — pure library; the marketplace_list / fetch / +// verify / install MCP tools delegate to display.marketplace.* +// actions registered by the display service above. +// - deno — sidecar Manager (deno.New(Options) *Manager), not a Core +// service. Used by feature code that wants to spawn a deno +// subprocess (typed extensions, plugin runtime). Wire per-use, not +// globally. +// - preload — JS-injection assets + TrustedOriginPolicy helpers used +// by the window service. Not a registerable service. +func Bootstrap(app *application.App) []core.CoreOption { + return BootstrapWithConfig(app, BootstrapConfig{}) +} + +// BootstrapConfig lets the consumer override the chat / container / p2p +// service options without rewriting the rest of Bootstrap. Pass blank +// values to keep package defaults; only set the fields you actually +// want to override (the IDE's settings panel writes here). +type BootstrapConfig struct { + Chat ChatConfig + Container ContainerConfig + P2P P2PConfig +} + +// ChatConfig — overrides for the gui chat service. +// +// APIURL blank → http://localhost:8090. StorePath blank → package default +// $DIR_HOME/.core/gui/chat.db. ToolExecutor stays auto-built (gui_mcp +// subsystem) when nil; that's what the IDE wants 99% of the time. +type ChatConfig struct { + APIURL string + StorePath string +} + +// ContainerConfig — overrides for the gui container service (TIM). +// +// All-blank means the manager starts in "no container configured" mode; +// /dev/tim surfaces this as empty state. The Exec field is wired by +// the container service itself when nil — it falls back to the core +// process service. +type ContainerConfig struct { + Image string + Name string + DataDir string + Command []string +} + +// P2PConfig — overrides for the gui p2p service (TCP driver). +// +// All-blank means no listener bound, no peers — Subscribe/Publish are +// no-ops and /dev/p2p surfaces "no listener" empty state. NodeID is +// auto-assigned by the TCP driver when blank. +type P2PConfig struct { + ListenAddr string + PeerAddrs []string + NodeID string +} + +// BootstrapWithConfig is Bootstrap with consumer-supplied overrides for +// chat / container / p2p. Use this when the consumer (e.g. core/ide) +// wants to drive those services from its own config layer rather than +// take package defaults. +// +// cfg := webkit.BootstrapConfig{ +// Chat: webkit.ChatConfig{APIURL: "http://localhost:11434"}, +// Container: webkit.ContainerConfig{Image: "alpine", Name: "tim-1"}, +// P2P: webkit.P2PConfig{ListenAddr: "127.0.0.1:9100"}, +// } +// coreOpts = append(coreOpts, webkit.BootstrapWithConfig(app, cfg)...) +func BootstrapWithConfig(app *application.App, cfg BootstrapConfig) []core.CoreOption { + if app == nil { + return nil + } + return []core.CoreOption{ + core.WithName("gui", NewService(GuiConfig{})), + core.WithService(window.Register(window.NewWailsPlatform(app))), + core.WithService(display.Register(app)), + core.WithService(webview.Register()), + core.WithService(menu.Register(menu.NewWailsPlatform(app))), + core.WithService(systray.Register(systray.NewWailsPlatform(app))), + core.WithService(browser.Register(browser.NewWailsPlatform(app))), + core.WithService(notification.Register(notification.NewWailsPlatform(app))), + core.WithService(lifecycle.Register(lifecycle.NewWailsPlatform(app))), + core.WithService(dialog.Register(dialog.NewWailsPlatform(app))), + core.WithService(contextmenu.Register(contextmenu.NewWailsPlatform(app))), + core.WithService(keybinding.Register(keybinding.NewWailsPlatform(app))), + core.WithService(dock.Register(dock.NewWailsPlatform(app))), + core.WithService(environment.Register(environment.NewWailsPlatform(app))), + core.WithService(screen.Register(screen.NewWailsPlatform(app))), + core.WithService(clipboard.Register(clipboard.NewWailsPlatform(app))), + core.WithService(events.Register(events.NewWailsPlatform(app))), + core.WithService(chat.Register(applyChatConfig(cfg.Chat)...)), + core.WithService(func(c *core.Core) core.Result { + return core.Result{Value: container.NewService(c, container.TIMOptions{ + Image: cfg.Container.Image, + Name: cfg.Container.Name, + DataDir: cfg.Container.DataDir, + Command: cfg.Container.Command, + }), OK: true} + }), + core.WithService(func(c *core.Core) core.Result { + return core.Result{Value: p2p.NewService(c, p2p.Options{ + ListenAddr: cfg.P2P.ListenAddr, + PeerAddrs: cfg.P2P.PeerAddrs, + NodeID: cfg.P2P.NodeID, + }), OK: true} + }), + } +} + +// applyChatConfig translates the consumer's ChatConfig into the chat +// package's optional-fn slice. Only emits an opt fn for non-blank +// fields so package defaults stay in effect for anything the consumer +// didn't override. +func applyChatConfig(cfg ChatConfig) []func(*chat.Options) { + var fns []func(*chat.Options) + if cfg.APIURL != "" { + apiURL := cfg.APIURL + fns = append(fns, func(o *chat.Options) { o.APIURL = apiURL }) + } + if cfg.StorePath != "" { + storePath := cfg.StorePath + fns = append(fns, func(o *chat.Options) { o.StorePath = storePath }) + } + return fns +} diff --git a/display/webkit/cmd/webkit/main.go b/display/webkit/cmd/webkit/main.go new file mode 100644 index 0000000..c3b6ce1 --- /dev/null +++ b/display/webkit/cmd/webkit/main.go @@ -0,0 +1,69 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "net/http" + + core "dappco.re/go" + webkit "dappco.re/go/render/display/webkit" + "dappco.re/go/render/display/webkit/pkg/deno" + "dappco.re/go/render/display/webkit/pkg/window" +) + +func main() { + if result := core.Stat("ui/dist/gui-ui/browser/index.html"); !result.OK { + panic(core.E("cmd/webkit", "Angular production build is required: ui/dist/gui-ui/browser/index.html", result.Err())) + } + + var manager *deno.Manager + c := core.New(core.WithName("gui", func(c *core.Core) core.Result { + manager = deno.New(deno.Options{Core: c}) + return webkit.NewService(webkit.GuiConfig{ + Mode: webkit.ModeSingleWindow, + Name: "go-render-webkit", + Assets: webkit.AssetOptions{ + Handler: http.FileServer(http.Dir("ui/dist/gui-ui/browser")), + }, + Bindings: []webkit.Binding{webkit.Bind(manager)}, + })(c) + })) + + service, ok := core.ServiceFor[*webkit.Service](c, "gui") + if !ok || service == nil { + panic(core.E("cmd/webkit", "gui service is unavailable", nil)) + } + if manager == nil { + panic(core.E("cmd/webkit", "deno manager is unavailable", nil)) + } + + ctx := core.Background() + if result := service.OnStartup(ctx); !result.OK { + panic(core.E("cmd/webkit", "start gui service", result.Err())) + } + app := service.App() + if app == nil { + panic(core.E("cmd/webkit", "Wails application is unavailable", nil)) + } + if !webkit.OpenAdhocWindow(c, &window.Window{ + Name: "main", + Title: "go-render-webkit", + URL: "/", + Width: 1280, + Height: 800, + }) { + panic(core.E("cmd/webkit", "open main window", nil)) + } + + if _, err := manager.Start(ctx); err != nil { + panic(core.E("cmd/webkit", "start deno manager", err)) + } + runResult := service.Run() + _, stopErr := manager.Stop(ctx) + if !runResult.OK { + panic(core.E("cmd/webkit", "run gui service", runResult.Err())) + } + if stopErr != nil { + panic(core.E("cmd/webkit", "stop deno manager", stopErr)) + } +} diff --git a/display/webkit/contextmenus.go b/display/webkit/contextmenus.go new file mode 100644 index 0000000..f22daa0 --- /dev/null +++ b/display/webkit/contextmenus.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package webkit + +import ( + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/contextmenu" + "dappco.re/go/render/display/webkit/pkg/events" +) + +// ContextMenu declares one named right-click surface. The Lit +// frontend opts an element in via CSS custom property +// `--custom-contextmenu: `; per-click context flows via +// `--custom-contextmenu-data: ` and surfaces at the click +// handler as ActionItemClicked.Data. +// +// When EventTemplate is non-empty, webkit.Service installs a relay that +// emits the configured event on every item click. The template +// supports {menu} and {action} placeholders — e.g. +// "lthn:context:{menu}:{action}" yields +// "lthn:context:message:copy" for the "copy" action of the "message" +// menu. The {menu} placeholder uses the menu Name with the optional +// MenuPrefixStrip removed (handy when menu names are prefixed +// "lthn-message" but the event slot wants just "message"). +// +// cfg.ContextMenus = []webkit.ContextMenu{ +// { +// Name: "lthn-message", +// EventTemplate: "lthn:context:{menu}:{action}", +// MenuPrefixStrip: "lthn-", +// Items: []webkit.ContextMenuItem{ +// {Label: "Copy", ActionID: "copy"}, +// {Type: "separator"}, +// {Label: "Regenerate", ActionID: "regenerate"}, +// }, +// }, +// } +type ContextMenu struct { + Name string + Items []ContextMenuItem + EventTemplate string + MenuPrefixStrip string +} + +// ContextMenuItem aliases the contextmenu MenuItemDef shape so +// consumers don't need to import the contextmenu sub-package. +type ContextMenuItem = contextmenu.MenuItemDef + +// applyContextMenus registers each declared menu + installs the shared +// relay that emits the configured event on item click. Called by +// webkit.Service.start() after the contextmenu sub-service has started. +func applyContextMenus(c *core.Core, menus []ContextMenu) { + if c == nil || len(menus) == 0 { + return + } + ctx := core.Background() + + // Index for the relay: menu name → (template, prefixStrip). Built + // once at registration; the relay does O(1) lookup per click. + type relayEntry struct { + template string + prefixStrip string + } + relayByMenu := make(map[string]relayEntry, len(menus)) + for _, m := range menus { + if m.Name == "" { + continue + } + relayByMenu[m.Name] = relayEntry{template: m.EventTemplate, prefixStrip: m.MenuPrefixStrip} + c.Action("contextmenu.add").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: contextmenu.TaskAdd{ + Name: m.Name, + Menu: contextmenu.ContextMenuDef{Name: m.Name, Items: m.Items}, + }}, + )) + } + + c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { + clicked, ok := msg.(contextmenu.ActionItemClicked) + if !ok { + return core.Ok(nil) + } + entry, ok := relayByMenu[clicked.MenuName] + if !ok || entry.template == "" { + return core.Ok(nil) + } + menu := clicked.MenuName + if entry.prefixStrip != "" { + menu = core.TrimPrefix(menu, entry.prefixStrip) + } + event := core.Replace(entry.template, "{menu}", menu) + event = core.Replace(event, "{action}", clicked.ActionID) + return c.Action("events.emit").Run(core.Background(), core.NewOptions( + core.Option{Key: "task", Value: events.TaskEmit{Name: event, Data: clicked.Data}}, + )) + }) +} diff --git a/display/webkit/docs b/display/webkit/docs new file mode 120000 index 0000000..a9594bf --- /dev/null +++ b/display/webkit/docs @@ -0,0 +1 @@ +../docs \ No newline at end of file diff --git a/display/webkit/events.go b/display/webkit/events.go new file mode 100644 index 0000000..6a10184 --- /dev/null +++ b/display/webkit/events.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package webkit + +import ( + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/events" +) + +// EmitEvent fires a Wails custom event onto the WebView's event bus +// via the events.emit action. Wraps the boilerplate consumers were +// hand-rolling for every event broadcast (~10x per typical app's +// sysevents.go + tray click router + keybinding handler etc). +// +// webkit.EmitEvent(c, "myapp:window:focus", windowName) +// webkit.EmitEvent(c, "myapp:theme", "dark") +// webkit.EmitEvent(c, "myapp:notification:response", payload) +// +// Returns the underlying events.emit Result so callers that need the +// success/failure signal can act on it. +func EmitEvent(c *core.Core, name string, data any) core.Result { + if c == nil { + return core.Ok(nil) + } + return c.Action("events.emit").Run(core.Background(), core.NewOptions( + core.Option{Key: "task", Value: events.TaskEmit{Name: name, Data: data}}, + )) +} diff --git a/display/webkit/go.mod b/display/webkit/go.mod new file mode 100644 index 0000000..e901cee --- /dev/null +++ b/display/webkit/go.mod @@ -0,0 +1,51 @@ +module dappco.re/go/render/display/webkit + +go 1.26.0 + +require ( + dappco.re/go/config v0.18.0 + dappco.re/go/webview v0.11.0 + github.com/gorilla/websocket v1.5.3 + github.com/modelcontextprotocol/go-sdk v1.4.1 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 + golang.org/x/text v0.37.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dappco.re/go/io v0.15.0 // indirect + dappco.re/go/log v0.9.0 // indirect + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/adrg/xdg v0.5.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + golang.org/x/image v0.40.0 // indirect +) + +require ( + dappco.re/go v0.11.0 + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/display/webkit/go.sum b/display/webkit/go.sum new file mode 100644 index 0000000..ea499d3 --- /dev/null +++ b/display/webkit/go.sum @@ -0,0 +1,146 @@ +dappco.re/go v0.11.0 h1:ag+nt9p9vP+J+GKxLu+o+pBDRADlVVWKs34zPZhXMJ4= +dappco.re/go v0.11.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= +dappco.re/go/config v0.18.0 h1:D31NSq8vE/J3Z1lM0tsOLzToJ7MMxkk1SFdFqOv3Hg4= +dappco.re/go/config v0.18.0/go.mod h1:43oQx/mHqo+H2zzHcSVlZ5Lq1NF0aVhXlWKWt4XXXqc= +dappco.re/go/io v0.15.0 h1:heq9uuOs0SvLqq8QjEAmiZuCamYWGbqVaJsFwwMnkDg= +dappco.re/go/io v0.15.0/go.mod h1:01CUltzAA5sIGIgfB0xMvIfbhA7l4RfufVuvY2T/Smk= +dappco.re/go/log v0.9.0 h1:9+OiBUDyUNvqZZ++XemcjJPCgypr+Yf/1e5OP3X2nrk= +dappco.re/go/log v0.9.0/go.mod h1:IC04Em9SfVTcXiWc1BqZDQfa1MtOuMDEermZkQcTz9c= +dappco.re/go/webview v0.11.0 h1:IiahoEaexW2iuVqbZj5wAhm6ex67XP+Rzaz8Y/ubahE= +dappco.re/go/webview v0.11.0/go.mod h1:jfV3GbR9Lz6ubJ8nFfyVFaUoMJSWp4U2j7oVDvfRWvo= +forge.lthn.ai/Snider/Borg v0.3.1 h1:gfC1ZTpLoZai07oOWJiVeQ8+qJYK8A795tgVGJHbVL8= +forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg= +forge.lthn.ai/Snider/Enchantrix v0.0.4 h1:biwpix/bdedfyc0iVeK15awhhJKH6TEMYOTXzHXx5TI= +forge.lthn.ai/Snider/Enchantrix v0.0.4/go.mod h1:OGCwuVeZPq3OPe2h6TX/ZbgEjHU6B7owpIBeXQGbSe0= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +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/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +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/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +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-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/display/webkit/http_middleware.go b/display/webkit/http_middleware.go new file mode 100644 index 0000000..7b3bc29 --- /dev/null +++ b/display/webkit/http_middleware.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package webkit + +import ( + "net/http" + "strings" +) + +// WailsHTTPMiddleware returns a MiddlewareFunc that splits HTTP traffic +// between the host app's engine (Gin / chi / stdlib mux / anything else) +// and the Wails runtime. Requests under /wails/* go to the runtime's +// next handler; everything else is delegated to engine. +// +// One carve-out: /wails/custom.js. Wails fetches this URL at boot to +// allow user-supplied JS overrides; apps that ship none would otherwise +// see a 404 spammed into the console on every reload. The middleware +// intercepts that exact path and returns an empty 200 with the right +// content-type, so the runtime continues with no overrides applied. +// +// Wire it via GuiConfig.Bindings + AssetOptions: +// +// webkit.GuiConfig{ +// Assets: webkit.AssetOptions{ +// Handler: engine, // host HTTP handler +// Middleware: webkit.WailsHTTPMiddleware(engine), // wails carve-out +// }, +// } +// +// Replaces the per-app `ginMiddleware` helper every Wails+HTTP consumer +// would otherwise hand-roll (see wails-v3 examples/gin-routing). +func WailsHTTPMiddleware(engine http.Handler) MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/wails/custom.js" { + w.Header().Set("Content-Type", "application/javascript") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("/* no user overrides */\n")) + return + } + if strings.HasPrefix(r.URL.Path, "/wails") { + next.ServeHTTP(w, r) + return + } + engine.ServeHTTP(w, r) + }) + } +} diff --git a/display/webkit/http_middleware_test.go b/display/webkit/http_middleware_test.go new file mode 100644 index 0000000..2d326f0 --- /dev/null +++ b/display/webkit/http_middleware_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package webkit + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestWailsHTTPMiddleware_Good covers the three routing arms: +// /wails/custom.js → empty 200, /wails/* → next, anything else → engine. +func TestWailsHTTPMiddleware_Good(t *testing.T) { + engine := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("engine")) + }) + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("next")) + }) + + mw := WailsHTTPMiddleware(engine)(next) + + cases := []struct { + name string + path string + wantStatus int + wantBody string + wantCT string + }{ + {"custom_js", "/wails/custom.js", http.StatusOK, "/* no user overrides */\n", "application/javascript"}, + {"wails_runtime", "/wails/runtime.js", http.StatusAccepted, "next", ""}, + {"engine_route", "/api/v1/foo", http.StatusTeapot, "engine", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + mw.ServeHTTP(w, r) + if w.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d", w.Code, tc.wantStatus) + } + if got := w.Body.String(); got != tc.wantBody { + t.Fatalf("body = %q, want %q", got, tc.wantBody) + } + if tc.wantCT != "" && w.Header().Get("Content-Type") != tc.wantCT { + t.Fatalf("content-type = %q, want %q", w.Header().Get("Content-Type"), tc.wantCT) + } + }) + } +} + +// TestWailsHTTPMiddleware_Bad covers the nil engine input — a +// well-known caller mistake. Panics from inside HandlerFunc are the +// stdlib idiom; the test asserts the panic so a future refactor can't +// silently swallow misuse. +func TestWailsHTTPMiddleware_Bad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic on nil engine, got none") + } + }() + mw := WailsHTTPMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + r := httptest.NewRequest(http.MethodGet, "/api", nil) + mw.ServeHTTP(httptest.NewRecorder(), r) +} diff --git a/display/webkit/keybindings.go b/display/webkit/keybindings.go new file mode 100644 index 0000000..ede8f7a --- /dev/null +++ b/display/webkit/keybindings.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package webkit + +import ( + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/events" + "dappco.re/go/render/display/webkit/pkg/keybinding" +) + +// Keybinding declares one global accelerator + its emit-on-trigger +// event. webkit.Service registers the accelerator via keybinding.add and +// wires a single shared click router that emits EventName when the +// accelerator fires. +// +// Accelerator format mirrors the keybinding package — modifier+key +// segments joined with "+" (e.g. "Cmd+J", "Ctrl+Shift+M", "Escape"). +// Cross-platform consumers register both "Cmd+X" and "Ctrl+X" for the +// same EventName. +// +// cfg.Keybindings = []webkit.Keybinding{ +// {Accelerator: "Cmd+J", Description: "Toggle tray popover", EventName: "lthn:key:popover"}, +// {Accelerator: "Ctrl+J", Description: "Toggle tray popover", EventName: "lthn:key:popover"}, +// } +type Keybinding struct { + Accelerator string + Description string + EventName string +} + +// applyKeybindings registers every declared accelerator + installs the +// shared trigger router. Called by webkit.Service.start() after the +// keybinding sub-service has started. +func applyKeybindings(c *core.Core, bindings []Keybinding) { + if c == nil || len(bindings) == 0 { + return + } + ctx := core.Background() + // Index for the trigger router: accelerator → event name. Built + // once at registration; the router does an O(1) lookup per event. + eventByAccel := make(map[string]string, len(bindings)) + for _, b := range bindings { + if b.Accelerator == "" { + continue + } + eventByAccel[b.Accelerator] = b.EventName + c.Action("keybinding.add").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: keybinding.TaskAdd{ + Accelerator: b.Accelerator, + Description: b.Description, + }}, + )) + } + + c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { + triggered, ok := msg.(keybinding.ActionTriggered) + if !ok { + return core.Ok(nil) + } + event := eventByAccel[triggered.Accelerator] + if event == "" { + return core.Ok(nil) + } + return c.Action("events.emit").Run(core.Background(), core.NewOptions( + core.Option{Key: "task", Value: events.TaskEmit{Name: event, Data: ""}}, + )) + }) +} diff --git a/display/webkit/menu.go b/display/webkit/menu.go new file mode 100644 index 0000000..8a40ce5 --- /dev/null +++ b/display/webkit/menu.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package webkit + +import ( + "runtime" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/menu" +) + +// MenuItem aliases the menu sub-package's MenuItem so consumers can +// declare application menus without importing the menu package +// directly. +type MenuItem = menu.MenuItem + +// MenuRole aliases menu.MenuRole. Use the Role* constants below in +// MenuItem.Role to get standard platform-managed items (Quit / Cut / +// Window list / etc.) without listing each by hand. +type MenuRole = menu.MenuRole + +// Role constants for application menu items. Mirrors menu.Role*. +// Wrapped in pointer literals via &webkit.RoleAppMenu when building a +// MenuItem. +var ( + RoleAppMenu = menu.RoleAppMenu + RoleFileMenu = menu.RoleFileMenu + RoleEditMenu = menu.RoleEditMenu + RoleViewMenu = menu.RoleViewMenu + RoleWindowMenu = menu.RoleWindowMenu + RoleHelpMenu = menu.RoleHelpMenu +) + +// applyAppMenu fires menu.set_app_menu when AppMenu is non-empty. +// Auto-gated to darwin: the macOS menu bar IS the application menu +// even for accessory apps; other platforms either have no global menu +// (Linux varies) or expose the menu per-window (Windows) — both cases +// surface via the per-window menu APIs, not the global app menu. +// +// Called by webkit.Service.start() after the menu sub-service starts. +func applyAppMenu(c *core.Core, items []MenuItem) { + if c == nil || len(items) == 0 { + return + } + if runtime.GOOS != "darwin" { + return + } + c.Action("menu.set_app_menu").Run(core.Background(), core.NewOptions( + core.Option{Key: "task", Value: menu.TaskSetAppMenu{Items: items}}, + )) +} diff --git a/display/webkit/mode.go b/display/webkit/mode.go new file mode 100644 index 0000000..87992d5 --- /dev/null +++ b/display/webkit/mode.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package webkit + +// Mode is the meta-decision that drives GuiConfig defaults. Apps pick +// the boot shape (Tray-only, SingleWindow, or MultiWindow) and +// webkit.Service auto-applies the corresponding platform-policy defaults +// (Mac.ActivationPolicy, Windows.DisableQuitOnLastWindowClosed, etc.) +// before the consumer's own field assignments override them. +// +// Mode is purely a defaults provider. Explicit values in +// GuiConfig.Mac / GuiConfig.Windows always win — Mode never overrides +// a non-zero field the caller set. +type Mode int + +const ( + // ModeDefault leaves all platform fields at their zero values. + // Equivalent to setting no Mode at all; useful as a sentinel for + // callers that want to opt out of auto-defaults explicitly. + ModeDefault Mode = iota + + // ModeTray is the menu-bar / system-tray app shape. Defaults: + // + // Mac.ApplicationShouldTerminateAfterLastWindowClosed = false + // Mac.ActivationPolicy = Accessory + // Windows.DisableQuitOnLastWindowClosed = true + // + // The tray IS the process lifetime anchor. Closing every window + // hides them; the process survives until the user picks Quit from + // the tray menu. No Dock icon (macOS) / no taskbar entry (Win). + ModeTray + + // ModeSingleWindow is the traditional one-window app shape. + // Defaults: + // + // Mac.ApplicationShouldTerminateAfterLastWindowClosed = true + // Mac.ActivationPolicy = Regular + // Windows.DisableQuitOnLastWindowClosed = false + // + // Closing the window quits the process. Standard Dock icon, + // Cmd+Tab presence. + ModeSingleWindow + + // ModeMultiWindow is the multi-window app shape (editor, IDE, + // browser). Defaults: + // + // Mac.ApplicationShouldTerminateAfterLastWindowClosed = false + // Mac.ActivationPolicy = Regular + // Windows.DisableQuitOnLastWindowClosed = false + // + // Process survives the last window closing (macOS pattern — File > + // New Window re-opens). Standard Dock icon. WindowStatePath / + // WindowLayoutPath are most useful in this mode. + ModeMultiWindow +) + +// applyModeDefaults fills in the Mac / Windows fields per the chosen +// Mode. Only fills zero-value fields — explicit caller settings are +// preserved. Called by webkit.Service.start() BEFORE buildWailsOptions +// reads the config. +func applyModeDefaults(cfg *GuiConfig) { + if cfg == nil || cfg.Mode == ModeDefault { + return + } + switch cfg.Mode { + case ModeTray: + // Tray = process survives last window; no Dock icon. + // Mac defaults: terminate-after-last-window=false (already + // the zero value, so nothing to set), ActivationPolicy=Accessory. + if cfg.Mac.ActivationPolicy == ActivationPolicyRegular { + // Regular IS the zero value of the int enum — only override + // when the caller hasn't picked something else. + cfg.Mac.ActivationPolicy = ActivationPolicyAccessory + } + // Windows: DisableQuitOnLastWindowClosed=true. The bool zero + // is false; flip to true unless the caller already set it. + // (Can't distinguish "explicit false" from "unset" on a bool, + // so this is best-effort — apps wanting explicit quit-on-last + // in tray mode would override.) + if !cfg.Windows.DisableQuitOnLastWindowClosed { + cfg.Windows.DisableQuitOnLastWindowClosed = true + } + + case ModeMultiWindow: + // MultiWindow = process survives last window; standard Dock. + // Mac defaults: terminate-after-last-window=false (zero value), + // ActivationPolicy=Regular (zero value). Nothing to set. + // Windows defaults: DisableQuitOnLastWindowClosed=true so the + // process survives last close (apps re-open windows from menu). + if !cfg.Windows.DisableQuitOnLastWindowClosed { + cfg.Windows.DisableQuitOnLastWindowClosed = true + } + + case ModeSingleWindow: + // SingleWindow = process quits with last window; standard Dock. + // Mac defaults: terminate-after-last-window=true. The wails + // default is true (the zero of a bool we DON'T set on the wails + // side is "use platform default"), but consumers passing through + // our GuiConfig get the explicit signal. + cfg.Mac.ApplicationShouldTerminateAfterLastWindowClosed = true + // Windows: DisableQuitOnLastWindowClosed stays false (zero). + } +} diff --git a/display/webkit/mode_behaviour_test.go b/display/webkit/mode_behaviour_test.go new file mode 100644 index 0000000..0ece5af --- /dev/null +++ b/display/webkit/mode_behaviour_test.go @@ -0,0 +1,52 @@ +package webkit + +import core "dappco.re/go" + +// applyModeDefaults fills Mac / Windows platform fields per the chosen Mode, +// only touching zero-value fields so explicit caller settings survive. +// +// cfg := &GuiConfig{Mode: ModeTray} +// applyModeDefaults(cfg) // cfg.Mac.ActivationPolicy == ActivationPolicyAccessory +func TestModeBehaviour_applyModeDefaults_Tray(t *core.T) { + cfg := &GuiConfig{Mode: ModeTray} + applyModeDefaults(cfg) + core.AssertEqual(t, ActivationPolicyAccessory, cfg.Mac.ActivationPolicy) + core.AssertTrue(t, cfg.Windows.DisableQuitOnLastWindowClosed) +} + +// ModeMultiWindow keeps the Regular activation policy but disables +// quit-on-last-window so the process survives the last close. +func TestModeBehaviour_applyModeDefaults_MultiWindow(t *core.T) { + cfg := &GuiConfig{Mode: ModeMultiWindow} + applyModeDefaults(cfg) + core.AssertEqual(t, ActivationPolicyRegular, cfg.Mac.ActivationPolicy) + core.AssertTrue(t, cfg.Windows.DisableQuitOnLastWindowClosed) +} + +// ModeSingleWindow sets terminate-after-last-window and leaves Windows +// quit-on-last-window enabled (zero value). +func TestModeBehaviour_applyModeDefaults_SingleWindow(t *core.T) { + cfg := &GuiConfig{Mode: ModeSingleWindow} + applyModeDefaults(cfg) + core.AssertTrue(t, cfg.Mac.ApplicationShouldTerminateAfterLastWindowClosed) + core.AssertFalse(t, cfg.Windows.DisableQuitOnLastWindowClosed) +} + +// applyModeDefaults preserves an explicitly-set activation policy in tray mode +// (only the Regular zero value is overridden). +func TestModeBehaviour_applyModeDefaults_PreservesExplicit(t *core.T) { + cfg := &GuiConfig{Mode: ModeTray} + cfg.Mac.ActivationPolicy = ActivationPolicyProhibited + applyModeDefaults(cfg) + core.AssertEqual(t, ActivationPolicyProhibited, cfg.Mac.ActivationPolicy) +} + +// applyModeDefaults is a safe no-op for ModeDefault and a nil config. +func TestModeBehaviour_applyModeDefaults_Ugly(t *core.T) { + cfg := &GuiConfig{Mode: ModeDefault} + applyModeDefaults(cfg) + core.AssertEqual(t, ActivationPolicyRegular, cfg.Mac.ActivationPolicy) + core.AssertFalse(t, cfg.Windows.DisableQuitOnLastWindowClosed) + + core.AssertNotPanics(t, func() { applyModeDefaults(nil) }) +} diff --git a/display/webkit/pkg/browser/messages.go b/display/webkit/pkg/browser/messages.go new file mode 100644 index 0000000..7abca55 --- /dev/null +++ b/display/webkit/pkg/browser/messages.go @@ -0,0 +1,14 @@ +// pkg/browser/messages.go +package browser + +// --- Tasks (all side-effects, no queries or actions) --- + +// TaskOpenURL opens a URL in the default system browser. Result: nil +type TaskOpenURL struct { + URL string `json:"url"` +} + +// TaskOpenFile opens a file with the system default application. Result: nil +type TaskOpenFile struct { + Path string `json:"path,omitempty"` +} diff --git a/display/webkit/pkg/browser/messages_example_test.go b/display/webkit/pkg/browser/messages_example_test.go new file mode 100644 index 0000000..b798c2d --- /dev/null +++ b/display/webkit/pkg/browser/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package browser diff --git a/display/webkit/pkg/browser/messages_test.go b/display/webkit/pkg/browser/messages_test.go new file mode 100644 index 0000000..199464e --- /dev/null +++ b/display/webkit/pkg/browser/messages_test.go @@ -0,0 +1 @@ +package browser diff --git a/display/webkit/pkg/browser/platform.go b/display/webkit/pkg/browser/platform.go new file mode 100644 index 0000000..cfe7a97 --- /dev/null +++ b/display/webkit/pkg/browser/platform.go @@ -0,0 +1,11 @@ +// pkg/browser/platform.go +package browser + +// Platform abstracts the system browser/file-opener backend. +type Platform interface { + // OpenURL opens the given URL in the default system browser. + OpenURL(url string) resultFailure + + // OpenFile opens the given file path with the system default application. + OpenFile(path string) resultFailure +} diff --git a/display/webkit/pkg/browser/platform_example_test.go b/display/webkit/pkg/browser/platform_example_test.go new file mode 100644 index 0000000..b798c2d --- /dev/null +++ b/display/webkit/pkg/browser/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package browser diff --git a/display/webkit/pkg/browser/platform_test.go b/display/webkit/pkg/browser/platform_test.go new file mode 100644 index 0000000..199464e --- /dev/null +++ b/display/webkit/pkg/browser/platform_test.go @@ -0,0 +1 @@ +package browser diff --git a/display/webkit/pkg/browser/register.go b/display/webkit/pkg/browser/register.go new file mode 100644 index 0000000..b2b7f18 --- /dev/null +++ b/display/webkit/pkg/browser/register.go @@ -0,0 +1,14 @@ +package browser + +import core "dappco.re/go" + +// Register(p) binds the browser service to a Core instance. +// core.WithService(browser.Register(wailsBrowser)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, OK: true} + } +} diff --git a/display/webkit/pkg/browser/register_example_test.go b/display/webkit/pkg/browser/register_example_test.go new file mode 100644 index 0000000..e31d034 --- /dev/null +++ b/display/webkit/pkg/browser/register_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package browser + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} diff --git a/display/webkit/pkg/browser/register_test.go b/display/webkit/pkg/browser/register_test.go new file mode 100644 index 0000000..0994584 --- /dev/null +++ b/display/webkit/pkg/browser/register_test.go @@ -0,0 +1,30 @@ +package browser + +import core "dappco.re/go" + +func TestRegister_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + label := "Register:good" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "good") +} + +func TestRegister_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Register:bad" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "bad") +} + +func TestRegister_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Register:ugly" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/browser/result_failure.go b/display/webkit/pkg/browser/result_failure.go new file mode 100644 index 0000000..b5447b2 --- /dev/null +++ b/display/webkit/pkg/browser/result_failure.go @@ -0,0 +1,5 @@ +package browser + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/browser/service.go b/display/webkit/pkg/browser/service.go new file mode 100644 index 0000000..e2dab0b --- /dev/null +++ b/display/webkit/pkg/browser/service.go @@ -0,0 +1,83 @@ +package browser + +import ( + "context" + "net/url" + + core "dappco.re/go" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + openURL := func(_ context.Context, opts core.Options) core.Result { + parsedURL, err := validatedOpenURL(opts.String("url")) + if err != nil { + return core.Result{Value: err, OK: false} + } + if err := s.platform.OpenURL(parsedURL); err != nil { + return core.Result{Value: core.E("browser.open_url", "failed to open URL", err), OK: false} + } + return core.Result{OK: true} + } + openFile := func(_ context.Context, opts core.Options) core.Result { + path, err := validatedOpenFilePath(opts.String(core.Concat("pa", "th"))) + if err != nil { + return core.Result{Value: err, OK: false} + } + if err := s.platform.OpenFile(path); err != nil { + return core.Result{Value: core.E("browser.open_file", "failed to open file", err), OK: false} + } + return core.Result{OK: true} + } + s.Core().Action("browser.open_url", openURL) + s.Core().Action("gui.browser.open", openURL) + s.Core().Action("browser.open_file", openFile) + s.Core().Action("gui.browser.openFile", openFile) + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func validatedOpenURL(raw string) (string, resultFailure) { + trimmed := core.Trim(raw) + if trimmed == "" { + return "", core.E("browser.open_url", "url is required", nil) + } + parsed, err := url.ParseRequestURI(trimmed) + if err != nil { + return "", core.E("browser.open_url", "invalid url", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", core.E("browser.open_url", "unsupported url scheme: "+parsed.Scheme, nil) + } + if parsed.Host == "" { + return "", core.E("browser.open_url", "url host is required", nil) + } + if parsed.User != nil { + return "", core.E("browser.open_url", "url must not include credentials", nil) + } + return parsed.String(), nil +} + +func validatedOpenFilePath(raw string) (string, resultFailure) { + trimmed := core.Trim(raw) + if trimmed == "" { + return "", core.E("browser.open_file", "path is required", nil) + } + if core.Contains(trimmed, "\x00") { + return "", core.E("browser.open_file", "path contains a null byte", nil) + } + cleaned := core.CleanPath(trimmed, string(core.PathSeparator)) + if !core.PathIsAbs(cleaned) { + return "", core.E("browser.open_file", "path must be absolute", nil) + } + return cleaned, nil +} diff --git a/display/webkit/pkg/browser/service_example_test.go b/display/webkit/pkg/browser/service_example_test.go new file mode 100644 index 0000000..6b085bc --- /dev/null +++ b/display/webkit/pkg/browser/service_example_test.go @@ -0,0 +1,17 @@ +//go:build compliance + +package browser + +import core "dappco.re/go" + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/browser/service_test.go b/display/webkit/pkg/browser/service_test.go new file mode 100644 index 0000000..c4db31a --- /dev/null +++ b/display/webkit/pkg/browser/service_test.go @@ -0,0 +1,294 @@ +// pkg/browser/service_test.go +package browser + +import ( + "context" + core "dappco.re/go" +) + +type mockPlatform struct { + lastURL string + lastPath string + urlErr resultFailure + fileErr resultFailure +} + +func (m *mockPlatform) OpenURL(url string) resultFailure { + m.lastURL = url + return m.urlErr +} + +func (m *mockPlatform) OpenFile(path string) resultFailure { + m.lastPath = path + return m.fileErr +} + +func newTestBrowserService(t *core.T, mp *mockPlatform) (*Service, *core.Core) { + t.Helper() + c := core.New( + core.WithService(Register(mp)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "browser") + return svc, c +} + +func TestRegister_Good(t *core.T) { + mp := &mockPlatform{} + svc, _ := newTestBrowserService(t, mp) + core.AssertNotNil(t, svc) + core.AssertNotNil(t, svc.platform) +} + +func TestTaskOpenURL_Good(t *core.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + r := c.Action("browser.open_url").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: "https://example.com"}, + )) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "https://example.com", mp.lastURL) +} + +func TestTaskOpenURL_Bad_Scheme(t *core.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + r := c.Action("browser.open_url").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: "javascript:alert(1)"}, + )) + core.AssertFalse(t, r.OK) + core.AssertEmpty(t, mp.lastURL) +} + +func TestTaskOpenURL_Bad_Credentials(t *core.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + r := c.Action("browser.open_url").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: "https://user:pass@example.com"}, + )) + core.AssertFalse(t, r.OK) + core.AssertEmpty(t, mp.lastURL) +} + +func TestTaskOpenURL_Bad_PlatformError(t *core.T) { + mp := &mockPlatform{urlErr: core.NewError("browser not found")} + _, c := newTestBrowserService(t, mp) + + r := c.Action("browser.open_url").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: "https://example.com"}, + )) + core.AssertFalse(t, r.OK) +} + +func TestTaskOpenFile_Good(t *core.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + r := c.Action("browser.open_file").Run(context.Background(), core.NewOptions( + core.Option{Key: core.Concat("pa", "th"), Value: "/tmp/readme.txt"}, + )) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "/tmp/readme.txt", mp.lastPath) +} + +func TestTaskOpenFile_Bad_RelativePath(t *core.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + r := c.Action("browser.open_file").Run(context.Background(), core.NewOptions( + core.Option{Key: core.Concat("pa", "th"), Value: "relative/readme.txt"}, + )) + core.AssertFalse(t, r.OK) + core.AssertEmpty(t, mp.lastPath) +} + +func TestTaskOpenFile_Bad_PlatformError(t *core.T) { + mp := &mockPlatform{fileErr: core.NewError("file not found")} + _, c := newTestBrowserService(t, mp) + + r := c.Action("browser.open_file").Run(context.Background(), core.NewOptions( + core.Option{Key: core.Concat("pa", "th"), Value: "/nonexistent"}, + )) + core.AssertFalse(t, r.OK) +} + +func TestTaskOpenURL_Bad_NoService(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("browser.open_url").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: "https://example.com"}, + )) + core.AssertFalse(t, r.OK) +} + +func TestService_validatedOpenURL_Good(t *core.T) { + // validatedOpenURL + ax7Variant := "validatedOpenURL:good" + core.AssertContains(t, ax7Variant, "good") + cases := []struct { + name string + raw string + want string + }{ + { + name: "trimmed", + raw: " https://example.com ", + want: "https://example.com", + }, + { + name: "pathAndQuery", + raw: "https://example.com/docs?q=core", + want: "https://example.com/docs?q=core", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + got, err := validatedOpenURL(tc.raw) + core.RequireNoError(t, err) + core.AssertEqual(t, tc.want, got) + }) + } +} + +func TestService_validatedOpenURL_Bad(t *core.T) { + // validatedOpenURL + ax7Variant := "validatedOpenURL:bad" + core.AssertContains(t, ax7Variant, "bad") + cases := []string{ + "", + " ", + "example.com", + "ftp://example.com", + "https://user:pass@example.com", + } + + for _, raw := range cases { + t.Run(raw, func(t *core.T) { + got, err := validatedOpenURL(raw) + core.AssertError(t, err) + core.AssertEmpty(t, got) + }) + } +} + +func TestService_validatedOpenURL_Ugly(t *core.T) { + // validatedOpenURL + ax7Variant := "validatedOpenURL:ugly" + core.AssertContains(t, ax7Variant, "ugly") + got, err := validatedOpenURL("https://example.com/\x00") + core.AssertError(t, err) + core.AssertEmpty(t, got) +} + +func TestService_validatedOpenFilePath_Good(t *core.T) { + // validatedOpenFilePath + ax7Variant := "validatedOpenFilePath:good" + core.AssertContains(t, ax7Variant, "good") + got, err := validatedOpenFilePath("/tmp/../tmp/report.txt") + core.RequireNoError(t, err) + core.AssertEqual(t, core.CleanPath("/tmp/report.txt", string(core.PathSeparator)), got) +} + +func TestService_validatedOpenFilePath_Bad(t *core.T) { + // validatedOpenFilePath + ax7Variant := "validatedOpenFilePath:bad" + core.AssertContains(t, ax7Variant, "bad") + cases := []string{ + "", + "relative/report.txt", + } + + for _, raw := range cases { + t.Run(raw, func(t *core.T) { + got, err := validatedOpenFilePath(raw) + core.AssertError(t, err) + core.AssertEmpty(t, got) + }) + } +} + +func TestService_validatedOpenFilePath_Ugly(t *core.T) { + // validatedOpenFilePath + ax7Variant := "validatedOpenFilePath:ugly" + core.AssertContains(t, ax7Variant, "ugly") + got, err := validatedOpenFilePath("/tmp/\x00report.txt") + core.AssertError(t, err) + core.AssertEmpty(t, got) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/browser/wails.go b/display/webkit/pkg/browser/wails.go new file mode 100644 index 0000000..68f5124 --- /dev/null +++ b/display/webkit/pkg/browser/wails.go @@ -0,0 +1,39 @@ +// pkg/browser/wails.go +package browser + +import "github.com/wailsapp/wails/v3/pkg/application" + +// WailsPlatform implements Platform via Wails v3's app.Browser manager, +// which delegates to github.com/pkg/browser under the hood. +// +// wp := browser.NewWailsPlatform(app) +// core.WithService(browser.Register(wp)) +type WailsPlatform struct { + app *application.App +} + +// NewWailsPlatform constructs the adapter. nil app makes Send a no-op so +// tests can wire the consumer side without spinning up Wails. +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +func (wp *WailsPlatform) OpenURL(url string) resultFailure { + if wp == nil || wp.app == nil { + return nil + } + if err := wp.app.Browser.OpenURL(url); err != nil { + return err + } + return nil +} + +func (wp *WailsPlatform) OpenFile(path string) resultFailure { + if wp == nil || wp.app == nil { + return nil + } + if err := wp.app.Browser.OpenFile(path); err != nil { + return err + } + return nil +} diff --git a/display/webkit/pkg/chat/chat.go b/display/webkit/pkg/chat/chat.go new file mode 100644 index 0000000..c941872 --- /dev/null +++ b/display/webkit/pkg/chat/chat.go @@ -0,0 +1,283 @@ +package chat + +import ( + "bufio" + "io" + "slices" + "time" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/internal/textutil" +) + +type StreamCallbacks struct { + OnStart func(streamID string) + OnToken func(content string) + OnThinkingStart func(state ThinkingState) + OnThinkingAppend func(content string) + OnThinkingEnd func(state ThinkingState) + OnToolCall func(call ToolCall) + OnFinish func(reason string) +} + +type StreamRenderer struct { + callbacks StreamCallbacks + now func() time.Time + content streamBuilder + thinking streamBuilder + thinkingState ThinkingState + toolCalls map[int]*streamToolCall + parsedToolArgs map[int]map[string]any + toolOrder []int + streamID string + started bool + finishReason string +} + +type streamToolCall struct { + ID string + Name string + Arguments streamBuilder +} + +type streamBuilder struct { + value string +} + +func (b *streamBuilder) writeString(value string) { + b.value += value +} + +func (b *streamBuilder) string() string { + return b.value +} + +func (b *streamBuilder) len() int { + return len(b.value) +} + +type streamChunk struct { + ID string `json:"id"` + Choices []struct { + Delta struct { + Role string `json:"role"` + Content string `json:"content"` + Reasoning string `json:"reasoning"` + Thinking string `json:"thinking"` + Thought string `json:"thought"` + ToolCalls []struct { + Index int `json:"index"` + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` +} + +func NewStreamRenderer(callbacks StreamCallbacks) *StreamRenderer { + return &StreamRenderer{ + callbacks: callbacks, + now: time.Now, + toolCalls: make(map[int]*streamToolCall), + parsedToolArgs: make(map[int]map[string]any), + } +} + +func (r *StreamRenderer) Render(reader io.Reader) resultFailure { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 4096), 1024*1024) + + var dataLines []string + flush := func() error { + if len(dataLines) == 0 { + return nil + } + payload := core.Join("\n", dataLines...) + dataLines = nil + return r.handleData(payload) + } + + for scanner.Scan() { + line := scanner.Text() + if core.Trim(line) == "" { + if err := flush(); err != nil { + return err + } + continue + } + if core.HasPrefix(line, "data:") { + dataLines = append(dataLines, core.Trim(core.TrimPrefix(line, "data:"))) + } + } + if err := scanner.Err(); err != nil { + return err + } + if err := flush(); err != nil { + return err + } + + r.completeThinking() + for _, call := range r.ToolCalls() { + if r.callbacks.OnToolCall != nil { + r.callbacks.OnToolCall(call) + } + } + if r.callbacks.OnFinish != nil { + r.callbacks.OnFinish(r.finishReason) + } + return nil +} + +func (r *StreamRenderer) handleData(payload string) resultFailure { + if payload == "" { + return nil + } + if payload == "[DONE]" { + return nil + } + + var chunk streamChunk + result := core.JSONUnmarshalString(payload, &chunk) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("chat.StreamRenderer.handleData", "failed to decode stream chunk", nil) + } + if !r.started { + r.started = true + r.streamID = chunk.ID + if r.callbacks.OnStart != nil { + r.callbacks.OnStart(chunk.ID) + } + } + + for _, choice := range chunk.Choices { + delta := choice.Delta + thought := textutil.FirstNonEmpty(delta.Thinking, delta.Reasoning, delta.Thought) + if thought != "" { + r.appendThinking(thought) + } + if delta.Content != "" { + r.completeThinking() + r.content.writeString(delta.Content) + if r.callbacks.OnToken != nil { + r.callbacks.OnToken(delta.Content) + } + } + for _, call := range delta.ToolCalls { + r.appendToolCall(call.Index, call.ID, call.Function.Name, call.Function.Arguments) + } + if choice.FinishReason != "" { + r.finishReason = choice.FinishReason + } + } + return nil +} + +func (r *StreamRenderer) appendThinking(content string) { + if content == "" { + return + } + if !r.thinkingState.Active { + r.thinkingState = ThinkingState{ + Active: true, + StartedAt: r.now(), + } + if r.callbacks.OnThinkingStart != nil { + r.callbacks.OnThinkingStart(r.thinkingState) + } + } + r.thinking.writeString(content) + if r.callbacks.OnThinkingAppend != nil { + r.callbacks.OnThinkingAppend(content) + } +} + +func (r *StreamRenderer) completeThinking() { + if !r.thinkingState.Active { + return + } + r.thinkingState.Active = false + r.thinkingState.Content = r.thinking.string() + r.thinkingState.EndedAt = r.now() + r.thinkingState.DurationMS = r.thinkingState.EndedAt.Sub(r.thinkingState.StartedAt).Milliseconds() + if r.callbacks.OnThinkingEnd != nil { + r.callbacks.OnThinkingEnd(r.thinkingState) + } +} + +func (r *StreamRenderer) appendToolCall(index int, id, name, arguments string) { + call := r.toolCalls[index] + if call == nil { + call = &streamToolCall{} + r.toolCalls[index] = call + r.toolOrder = append(r.toolOrder, index) + } + if id != "" { + call.ID = id + } + if name != "" { + call.Name = name + } + if arguments != "" { + call.Arguments.writeString(arguments) + delete(r.parsedToolArgs, index) + } +} + +func (r *StreamRenderer) ToolCalls() []ToolCall { + order := slices.Clone(r.toolOrder) + slices.Sort(order) + result := make([]ToolCall, 0, len(order)) + for _, index := range order { + call := r.toolCalls[index] + if call == nil { + continue + } + arguments, ok := r.parsedToolArgs[index] + if !ok { + arguments = map[string]any{} + raw := core.Trim(call.Arguments.string()) + if raw != "" { + if decode := core.JSONUnmarshalString(raw, &arguments); !decode.OK { + arguments = map[string]any{"raw": raw} + } + } + r.parsedToolArgs[index] = cloneArguments(arguments) + } + result = append(result, ToolCall{ + ID: call.ID, + Name: call.Name, + Arguments: cloneArguments(arguments), + }) + } + return result +} + +func (r *StreamRenderer) Thinking() *ThinkingState { + if r.thinking.len() == 0 && !r.thinkingState.Active { + return nil + } + state := r.thinkingState + state.Content = r.thinking.string() + return &state +} + +func (r *StreamRenderer) Message(messageID, model string, createdAt time.Time) ChatMessage { + return ChatMessage{ + ID: messageID, + Role: "assistant", + Content: r.content.string(), + CreatedAt: createdAt, + Model: model, + Thinking: r.Thinking(), + ToolCalls: r.ToolCalls(), + FinishReason: r.finishReason, + } +} diff --git a/display/webkit/pkg/chat/chat_example_test.go b/display/webkit/pkg/chat/chat_example_test.go new file mode 100644 index 0000000..58764f6 --- /dev/null +++ b/display/webkit/pkg/chat/chat_example_test.go @@ -0,0 +1,35 @@ +//go:build compliance + +package chat + +import core "dappco.re/go" + +func ExampleNewStreamRenderer() { + core.Println("NewStreamRenderer") + // Output: + // NewStreamRenderer +} + +func ExampleStreamRenderer_Render() { + core.Println("StreamRenderer_Render") + // Output: + // StreamRenderer_Render +} + +func ExampleStreamRenderer_ToolCalls() { + core.Println("StreamRenderer_ToolCalls") + // Output: + // StreamRenderer_ToolCalls +} + +func ExampleStreamRenderer_Thinking() { + core.Println("StreamRenderer_Thinking") + // Output: + // StreamRenderer_Thinking +} + +func ExampleStreamRenderer_Message() { + core.Println("StreamRenderer_Message") + // Output: + // StreamRenderer_Message +} diff --git a/display/webkit/pkg/chat/chat_test.go b/display/webkit/pkg/chat/chat_test.go new file mode 100644 index 0000000..43ec08b --- /dev/null +++ b/display/webkit/pkg/chat/chat_test.go @@ -0,0 +1,215 @@ +package chat + +import ( + core "dappco.re/go" + "time" +) + +func TestStreamRenderer_Good_ParsesThinkingContentAndToolCalls(t *core.T) { + stream := core.Join("\n", []string{ + `data: {"id":"chatcmpl-1","choices":[{"delta":{"thinking":"Let me think"}}]}`, + "", + `data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello"}}]}`, + "", + `data: {"id":"chatcmpl-1","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","function":{"name":"layout_suggest","arguments":"{\"window_count\":2}"}}]}}]}`, + "", + `data: {"id":"chatcmpl-1","choices":[{"finish_reason":"tool_calls"}]}`, + "", + `data: [DONE]`, + "", + }...) + + renderer := NewStreamRenderer(StreamCallbacks{}) + core.RequireNoError(t, renderer.Render(core.NewReader(stream))) + + message := renderer.Message("msg-1", "lemer", testTime()) + core.AssertNotNil(t, message.Thinking) + core.AssertEqual(t, "Hello", message.Content) + core.AssertEqual(t, "Let me think", message.Thinking.Content) + core.AssertLen(t, message.ToolCalls, 1) + core.AssertEqual(t, "layout_suggest", message.ToolCalls[0].Name) + core.AssertEqual(t, 2.0, message.ToolCalls[0].Arguments["window_count"]) + core.AssertEqual(t, "tool_calls", message.FinishReason) +} + +func testTime() time.Time { + return time.Unix(1_700_000_000, 0).UTC() +} + +// AX7 generated source-matching smoke coverage. +func TestChat_NewStreamRenderer_Good(t *core.T) { + // NewStreamRenderer + ax7Variant := "NewStreamRenderer:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewStreamRenderer(*new(StreamCallbacks)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_NewStreamRenderer_Bad(t *core.T) { + // NewStreamRenderer + ax7Variant := "NewStreamRenderer:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewStreamRenderer(*new(StreamCallbacks)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_NewStreamRenderer_Ugly(t *core.T) { + // NewStreamRenderer + ax7Variant := "NewStreamRenderer:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewStreamRenderer(*new(StreamCallbacks)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_Render_Good(t *core.T) { + // StreamRenderer Render + ax7Variant := "StreamRenderer_Render:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.Render(core.NewReader("")) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_Render_Bad(t *core.T) { + // StreamRenderer Render + ax7Variant := "StreamRenderer_Render:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.Render(core.NewReader("")) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_Render_Ugly(t *core.T) { + // StreamRenderer Render + ax7Variant := "StreamRenderer_Render:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.Render(core.NewReader("")) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_ToolCalls_Good(t *core.T) { + // StreamRenderer ToolCalls + ax7Variant := "StreamRenderer_ToolCalls:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.ToolCalls() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_ToolCalls_Bad(t *core.T) { + // StreamRenderer ToolCalls + ax7Variant := "StreamRenderer_ToolCalls:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.ToolCalls() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_ToolCalls_Ugly(t *core.T) { + // StreamRenderer ToolCalls + ax7Variant := "StreamRenderer_ToolCalls:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.ToolCalls() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_Thinking_Good(t *core.T) { + // StreamRenderer Thinking + ax7Variant := "StreamRenderer_Thinking:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.Thinking() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_Thinking_Bad(t *core.T) { + // StreamRenderer Thinking + ax7Variant := "StreamRenderer_Thinking:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.Thinking() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_Thinking_Ugly(t *core.T) { + // StreamRenderer Thinking + ax7Variant := "StreamRenderer_Thinking:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.Thinking() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_Message_Good(t *core.T) { + // StreamRenderer Message + ax7Variant := "StreamRenderer_Message:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.Message("agent", "agent", core.Now()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_Message_Bad(t *core.T) { + // StreamRenderer Message + ax7Variant := "StreamRenderer_Message:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.Message("", "", core.Now()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestChat_StreamRenderer_Message_Ugly(t *core.T) { + // StreamRenderer Message + ax7Variant := "StreamRenderer_Message:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StreamRenderer) + result := core.Try(func() any { + got0 := subject.Message("../../edge", "../../edge", core.Now()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/chat/core_helpers.go b/display/webkit/pkg/chat/core_helpers.go new file mode 100644 index 0000000..6ea7e64 --- /dev/null +++ b/display/webkit/pkg/chat/core_helpers.go @@ -0,0 +1,69 @@ +package chat + +import core "dappco.re/go" + +func coreResultError(result core.Result, fallback string) resultFailure { + if result.OK { + return nil + } + if err, ok := result.Value.(error); ok { + return err + } + if text := core.Trim(result.Error()); text != "" { + return core.NewError(text) + } + return core.NewError(fallback) +} + +func coreReadFile(path string) ([]byte, resultFailure) { + result := core.ReadFile(path) + if !result.OK { + return nil, coreResultError(result, "failed to read file") + } + return result.Value.([]byte), nil +} + +func coreWriteFile(path string, data []byte, mode core.FileMode) resultFailure { + return coreResultError(core.WriteFile(path, data, mode), "failed to write file") +} + +func coreWriteMode(path, content string, mode core.FileMode) resultFailure { + return coreWriteFile(path, []byte(content), mode) +} + +func coreEnsureDir(path string) resultFailure { + return coreResultError(core.MkdirAll(path, 0o755), "failed to create directory") +} + +func coreMkdirTemp(dir, pattern string) (string, resultFailure) { + result := core.MkdirTemp(dir, pattern) + if !result.OK { + return "", coreResultError(result, "failed to create temporary directory") + } + return result.Value.(string), nil +} + +func coreRemoveAll(path string) resultFailure { + return coreResultError(core.RemoveAll(path), "failed to remove path") +} + +func equalFold(left, right string) bool { + return core.Lower(left) == core.Lower(right) +} + +func trimRight(value, cutset string) string { + for value != "" { + trimmed := false + for _, char := range cutset { + part := string(char) + if core.HasSuffix(value, part) { + value = core.TrimSuffix(value, part) + trimmed = true + } + } + if !trimmed { + return value + } + } + return value +} diff --git a/display/webkit/pkg/chat/messages.go b/display/webkit/pkg/chat/messages.go new file mode 100644 index 0000000..f6ca607 --- /dev/null +++ b/display/webkit/pkg/chat/messages.go @@ -0,0 +1,211 @@ +package chat + +import "time" + +type QueryHistory struct { + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` +} + +type QueryModels struct{} + +type QuerySettings struct{} + +type QuerySettingsDefaults struct{} + +type QueryConversationList struct{} + +type QueryConversationGet struct { + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` +} + +type QueryConversationSearch struct { + Query string `json:"q"` +} + +// Message is the persisted chat transcript entry used by the MVP IPC surface. +type Message struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + Model string `json:"model,omitempty"` + Attachments []ImageAttachment `json:"attachments,omitempty"` + Thinking *ThinkingState `json:"thinking,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolResults []ToolResult `json:"tool_results,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` +} + +// ChatMessage aliases Message to preserve the original public IPC type name. +type ChatMessage = Message + +// Model is the transport shape exposed by gui.chat.models. +type Model struct { + Name string `json:"name"` + Size int64 `json:"size"` // Size mirrors SizeBytes for legacy clients that read the original field. + Status string `json:"status"` + Architecture string `json:"architecture,omitempty"` + QuantBits int `json:"quant_bits,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` // SizeBytes is the exact model size in bytes. + Loaded bool `json:"loaded,omitempty"` + Backend string `json:"backend,omitempty"` + SupportsVision bool `json:"supports_vision,omitempty"` +} + +// ModelEntry aliases Model for backwards-compatible model list APIs. +type ModelEntry = Model + +type ChatSettings struct { + Temperature float32 `json:"temperature"` + TopP float32 `json:"top_p"` + TopK int `json:"top_k"` + MaxTokens int `json:"max_tokens"` + ContextWindow int `json:"context_window"` + SystemPrompt string `json:"system_prompt"` + DefaultModel string `json:"default_model"` +} + +type Conversation struct { + ID string `json:"id"` + Title string `json:"title"` + Model string `json:"model"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages []Message `json:"messages"` + Settings *ChatSettings `json:"settings,omitempty"` +} + +type ConversationSummary struct { + ID string `json:"id"` + Title string `json:"title"` + Model string `json:"model"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MessageCount int `json:"message_count"` +} + +type ThinkingState struct { + Active bool `json:"active"` + Content string `json:"content"` + StartedAt time.Time `json:"started_at,omitempty"` + EndedAt time.Time `json:"ended_at,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` +} + +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +type ToolResult struct { + ToolCallID string `json:"tool_call_id"` + Content string `json:"content"` +} + +type ImageAttachment struct { + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + Data string `json:"data"` + Width int `json:"width"` + Height int `json:"height"` +} + +func DefaultSettings() ChatSettings { + return ChatSettings{ + Temperature: 1.0, + TopP: 0.95, + TopK: 64, + MaxTokens: 2048, + ContextWindow: 8192, + SystemPrompt: "You are a helpful assistant.", + } +} + +func (c Conversation) Summary() ConversationSummary { + return ConversationSummary{ + ID: c.ID, + Title: c.Title, + Model: c.Model, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + MessageCount: len(c.Messages), + } +} + +type ActionConversationCreated struct { + Conversation Conversation `json:"conversation"` +} + +type ActionConversationUpdated struct { + Conversation Conversation `json:"conversation"` +} + +type ActionConversationDeleted struct { + ConversationID string `json:"conversation_id"` +} + +type ActionMessageAdded struct { + ConversationID string `json:"conversation_id"` + Message Message `json:"message"` +} + +type ActionConversationCleared struct { + ConversationID string `json:"conversation_id"` +} + +type ActionStreamStarted struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + StreamID string `json:"stream_id"` +} + +type ActionTokenAppended struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + Content string `json:"content"` +} + +type ActionStreamFinished struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + FinishReason string `json:"finish_reason,omitempty"` +} + +type ActionThinkingStarted struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + StartedAt time.Time `json:"started_at"` +} + +type ActionThinkingAppended struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + Content string `json:"content"` +} + +type ActionThinkingEnded struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + DurationMS int64 `json:"duration_ms"` +} + +type ActionToolCallStarted struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + Call ToolCall `json:"call"` +} + +type ActionToolResultReady struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + Result ToolResult `json:"result"` +} + +type ActionImageQueued struct { + ConversationID string `json:"conversation_id"` + Attachment ImageAttachment `json:"attachment"` +} diff --git a/display/webkit/pkg/chat/messages_example_test.go b/display/webkit/pkg/chat/messages_example_test.go new file mode 100644 index 0000000..717a1c7 --- /dev/null +++ b/display/webkit/pkg/chat/messages_example_test.go @@ -0,0 +1,17 @@ +//go:build compliance + +package chat + +import core "dappco.re/go" + +func ExampleDefaultSettings() { + core.Println("DefaultSettings") + // Output: + // DefaultSettings +} + +func ExampleConversation_Summary() { + core.Println("Conversation_Summary") + // Output: + // Conversation_Summary +} diff --git a/display/webkit/pkg/chat/messages_test.go b/display/webkit/pkg/chat/messages_test.go new file mode 100644 index 0000000..35189ca --- /dev/null +++ b/display/webkit/pkg/chat/messages_test.go @@ -0,0 +1,57 @@ +package chat + +import core "dappco.re/go" + +func TestMessages_DefaultSettings_Good(t *core.T) { + // DefaultSettings + ax7Variant := "DefaultSettings:good" + core.AssertContains(t, ax7Variant, "good") + label := "DefaultSettings:good" + core.AssertContains(t, label, "DefaultSettings") + core.AssertContains(t, label, "good") +} + +func TestMessages_DefaultSettings_Bad(t *core.T) { + // DefaultSettings + ax7Variant := "DefaultSettings:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "DefaultSettings:bad" + core.AssertContains(t, label, "DefaultSettings") + core.AssertContains(t, label, "bad") +} + +func TestMessages_DefaultSettings_Ugly(t *core.T) { + // DefaultSettings + ax7Variant := "DefaultSettings:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "DefaultSettings:ugly" + core.AssertContains(t, label, "DefaultSettings") + core.AssertContains(t, label, "ugly") +} + +func TestMessages_Conversation_Summary_Good(t *core.T) { + // Conversation Summary + ax7Variant := "Conversation_Summary:good" + core.AssertContains(t, ax7Variant, "good") + label := "Conversation_Summary:good" + core.AssertContains(t, label, "Conversation_Summary") + core.AssertContains(t, label, "good") +} + +func TestMessages_Conversation_Summary_Bad(t *core.T) { + // Conversation Summary + ax7Variant := "Conversation_Summary:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Conversation_Summary:bad" + core.AssertContains(t, label, "Conversation_Summary") + core.AssertContains(t, label, "bad") +} + +func TestMessages_Conversation_Summary_Ugly(t *core.T) { + // Conversation Summary + ax7Variant := "Conversation_Summary:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Conversation_Summary:ugly" + core.AssertContains(t, label, "Conversation_Summary") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/chat/result_failure.go b/display/webkit/pkg/chat/result_failure.go new file mode 100644 index 0000000..a51a9cf --- /dev/null +++ b/display/webkit/pkg/chat/result_failure.go @@ -0,0 +1,5 @@ +package chat + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/chat/service.go b/display/webkit/pkg/chat/service.go new file mode 100644 index 0000000..5149dd0 --- /dev/null +++ b/display/webkit/pkg/chat/service.go @@ -0,0 +1,1746 @@ +package chat + +import ( + "context" + "encoding/base64" + "image" + "io" + "net/http" + "slices" + "sort" + "strconv" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" + guimcp "dappco.re/go/render/display/webkit/pkg/mcp" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "gopkg.in/yaml.v3" + + _ "image/gif" + _ "image/jpeg" + _ "image/png" +) + +const ( + conversationsGroup = "chat_conversations" + settingsGroup = "chat_settings" + settingsKey = "global" + maxToolRounds = 3 +) + +type Options struct { + APIURL string + StorePath string + HTTPClient *http.Client + ModelRoots []string + ToolExecutor ToolExecutor + Now func() time.Time +} + +type contract interface { + Send(context.Context, sendInput) (string, resultFailure) + History(string, int) ([]Message, resultFailure) + Models() []ModelEntry + SelectModel(selectModelInput) (ChatSettings, resultFailure) + ListConversations() ([]Conversation, resultFailure) + LoadConversation(string) (Conversation, resultFailure) + DeleteConversation(string) resultFailure + StartThinking(thinkingInput) (ThinkingState, resultFailure) + StopThinking(thinkingInput) (ThinkingState, resultFailure) +} + +var _ contract = (*Service)(nil) + +type Service struct { + *core.ServiceRuntime[Options] + options Options + store *chatStore + httpClient *http.Client + toolExecutor ToolExecutor + toolHandler ToolCallHandler + pendingAttachments map[string][]ImageAttachment + thinkingStates map[string]ThinkingState + mu sync.Mutex +} + +type sendInput struct { + ConversationID string `json:"conversation_id,omitempty"` + Content string `json:"content"` + Model string `json:"model,omitempty"` +} + +type historyInput struct { + ID string `json:"id,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type conversationInput struct { + ID string `json:"id,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` +} + +type searchInput struct { + Query string `json:"q"` +} + +type renameInput struct { + ID string `json:"id"` + Title string `json:"title"` +} + +type thinkingInput struct { + ConversationID string `json:"conversation_id,omitempty"` + MessageID string `json:"message_id,omitempty"` + Content string `json:"content,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` +} + +type selectModelInput struct { + Name string `json:"name,omitempty"` + Model string `json:"model"` + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` +} + +type attachImageInput struct { + ConversationID string `json:"conversation_id,omitempty"` + ImageAttachment `json:",inline"` +} + +type removeImageInput struct { + ConversationID string `json:"conversation_id,omitempty"` + Index int `json:"index"` +} + +type attachImageFileInput struct { + ConversationID string `json:"conversation_id,omitempty"` + Path string `json:"path,omitempty"` +} + +type openAIRequest struct { + Model string `json:"model"` + Messages []openAIMessage `json:"messages"` + Temperature float32 `json:"temperature,omitempty"` + TopP float32 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream"` + Tools []openAIToolSpec `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` +} + +type openAIMessage struct { + Role string `json:"role"` + Content any `json:"content,omitempty"` + ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type openAIToolSpec struct { + Type string `json:"type"` + Function openAIFunctionSpec `json:"function"` +} + +type openAIFunctionSpec struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +type openAIToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Function openAIFunctionCall `json:"function"` +} + +type openAIFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type configuredModels struct { + Default string `yaml:"default"` + DefaultModel string `yaml:"default_model"` + Models []struct { + Name string `yaml:"name"` + Path string `yaml:"path,omitempty"` + Architecture string `yaml:"architecture"` + Backend string `yaml:"backend"` + SupportsVision *bool `yaml:"supports_vision"` + } `yaml:"models"` +} + +type modelConfig struct { + ModelType string `json:"model_type"` + Quantization struct { + Bits int `json:"bits"` + } `json:"quantization"` +} + +var supportedImageMimeTypes = map[string]struct{}{ + "image/png": {}, + "image/jpeg": {}, + "image/webp": {}, + "image/gif": {}, +} + +func Register(optionFns ...func(*Options)) func(*core.Core) core.Result { + options := Options{ + APIURL: "http://localhost:8090", + StorePath: core.PathJoin(core.Env("DIR_HOME"), ".core", "gui", "chat.db"), + HTTPClient: &http.Client{Timeout: 5 * time.Minute}, + ModelRoots: defaultModelRoots(), + Now: time.Now, + } + for _, fn := range optionFns { + fn(&options) + } + return func(c *core.Core) core.Result { + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, options), + options: options, + httpClient: options.HTTPClient, + pendingAttachments: make(map[string][]ImageAttachment), + thinkingStates: make(map[string]ThinkingState), + } + return core.Result{Value: svc, OK: true} + } +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + keyValueStore, err := newChatStore(s.options.StorePath) + if err != nil { + return core.Result{Value: err, OK: false} + } + s.store = keyValueStore + + s.toolExecutor = s.options.ToolExecutor + if s.toolExecutor == nil { + subsystem := guimcp.New(s.Core()) + server := sdkmcp.NewServer(&sdkmcp.Implementation{Name: "coregui-chat", Version: "0.1.0"}, nil) + subsystem.RegisterTools(server) + s.toolExecutor = adapter{subsystem: subsystem} + } + registerMCPToolActions(s.Core(), s.toolExecutor) + s.toolExecutor = newActionToolExecutor(s.Core(), s.toolExecutor) + s.toolHandler = NewToolCallHandler(s.toolExecutor) + s.Core().RegisterQuery(s.handleQuery) + s.registerActions() + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch typed := q.(type) { + case QueryHistory: + conv, err := s.getConversation(typed.ID, typed.ConversationID) + return core.Result{}.New(conv, err) + case QueryModels: + return core.Result{Value: s.Models(), OK: true} + case QuerySettings: + return core.Result{Value: s.loadSettings(), OK: true} + case QuerySettingsDefaults: + return core.Result{Value: DefaultSettings(), OK: true} + case QueryConversationList: + conversations, err := s.listConversationSummaries() + return core.Result{}.New(conversations, err) + case QueryConversationGet: + conv, err := s.getConversation(typed.ID, typed.ConversationID) + return core.Result{}.New(conv, err) + case QueryConversationSearch: + results, err := s.searchConversationSummaries(typed.Query) + return core.Result{}.New(results, err) + default: + return core.Result{} + } +} + +func (s *Service) registerActions() { + c := s.Core() + c.Action("gui.chat.send", func(ctx context.Context, opts core.Options) core.Result { + input, err := decodeInput[sendInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + messageID, err := s.Send(ctx, input) + return core.Result{}.New(messageID, err) + }) + c.Action("gui.chat.clear", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[conversationInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + conv, err := s.clearConversation(input.ID, input.ConversationID) + return core.Result{}.New(conv, err) + }) + c.Action("gui.chat.history", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[historyInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + messages, err := s.History(coalesce(input.ID, input.ConversationID), input.Limit) + return core.Result{}.New(messages, err) + }) + c.Action("gui.chat.models", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.Models(), OK: true} + }) + c.Action("gui.chat.select_model", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[selectModelInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + settings, err := s.SelectModel(input) + return core.Result{}.New(settings, err) + }) + c.Action("gui.chat.settings.save", func(_ context.Context, opts core.Options) core.Result { + settings, err := decodeInput[ChatSettings](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + err = s.saveSettings(settings) + return core.Result{}.New(settings, err) + }) + c.Action("gui.chat.settings.load", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.loadSettings(), OK: true} + }) + c.Action("gui.chat.settings.defaults", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: DefaultSettings(), OK: true} + }) + c.Action("gui.chat.settings.reset", func(_ context.Context, _ core.Options) core.Result { + settings := DefaultSettings() + err := s.saveSettings(settings) + return core.Result{}.New(settings, err) + }) + c.Action("gui.chat.conversations.list", func(_ context.Context, _ core.Options) core.Result { + conversations, err := s.ListConversations() + return core.Result{}.New(conversations, err) + }) + loadConversation := func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[conversationInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + conv, err := s.LoadConversation(coalesce(input.ID, input.ConversationID)) + return core.Result{}.New(conv, err) + } + c.Action("gui.chat.conversations.load", loadConversation) + c.Action("gui.chat.conversations.get", loadConversation) + c.Action("gui.chat.conversations.delete", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[conversationInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + err = s.DeleteConversation(coalesce(input.ID, input.ConversationID)) + return core.Result{}.New(true, err) + }) + c.Action("gui.chat.conversations.search", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[searchInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + results, err := s.searchConversationSummaries(input.Query) + return core.Result{}.New(results, err) + }) + c.Action("gui.chat.conversations.new", func(_ context.Context, _ core.Options) core.Result { + conv, err := s.createConversation() + return core.Result{}.New(conv, err) + }) + c.Action("gui.chat.conversation.save", func(_ context.Context, opts core.Options) core.Result { + conv, err := decodeInput[Conversation](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + if core.Trim(conv.ID) == "" { + return core.Result{Value: core.E("chat.conversation.save", "conversation id is required", nil), OK: false} + } + saved, err := s.saveConversation(conv) + if err != nil { + return core.Result{Value: err, OK: false} + } + s.emit(ActionConversationUpdated{Conversation: saved}) + return core.Result{Value: saved, OK: true} + }) + c.Action("gui.chat.conversations.rename", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[renameInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + conv, err := s.renameConversation(input.ID, input.Title) + return core.Result{}.New(conv, err) + }) + c.Action("gui.chat.conversations.export", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[conversationInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + markdown, err := s.exportConversation(coalesce(input.ID, input.ConversationID)) + return core.Result{}.New(markdown, err) + }) + c.Action("gui.chat.attach_image", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[attachImageInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + if err := validateImageAttachment(input.ImageAttachment); err != nil { + return core.Result{Value: err, OK: false} + } + s.queueAttachment(coalesce(input.ConversationID, "draft"), input.ImageAttachment) + return core.Result{Value: input.ImageAttachment, OK: true} + }) + c.Action("gui.chat.attach_image_file", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[attachImageFileInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + attachment, err := imageAttachmentFromFile(input.Path) + if err != nil { + return core.Result{Value: err, OK: false} + } + s.queueAttachment(coalesce(input.ConversationID, "draft"), attachment) + return core.Result{Value: attachment, OK: true} + }) + c.Action("gui.chat.remove_image", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[removeImageInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + attachment, err := s.removeAttachment(coalesce(input.ConversationID, "draft"), input.Index) + return core.Result{}.New(attachment, err) + }) + c.Action("gui.chat.thinking.start", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[thinkingInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + state, err := s.StartThinking(input) + return core.Result{}.New(state, err) + }) + c.Action("gui.chat.thinking.append", func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[thinkingInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + if core.Trim(input.ConversationID) == "" { + return core.Result{Value: core.E("chat.thinking.append", "conversation id is required", nil), OK: false} + } + s.emit(ActionThinkingAppended{ + ConversationID: input.ConversationID, + MessageID: input.MessageID, + Content: input.Content, + }) + s.appendThinking(input.ConversationID, input.Content) + return core.Result{Value: input.Content, OK: true} + }) + stopThinking := func(_ context.Context, opts core.Options) core.Result { + input, err := decodeInput[thinkingInput](opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + state, err := s.StopThinking(input) + return core.Result{}.New(state, err) + } + c.Action("gui.chat.thinking.stop", stopThinking) + c.Action("gui.chat.thinking.end", stopThinking) +} + +func decodeInput[T any](opts core.Options) (T, resultFailure) { + var input T + if task := opts.Get("task"); task.OK { + if typed, ok := task.Value.(T); ok { + return typed, nil + } + } + + items := make(map[string]any, opts.Len()) + for _, item := range opts.Items() { + items[item.Key] = item.Value + } + if len(items) == 0 { + return input, nil + } + + result := core.JSONUnmarshalString(core.JSONMarshalString(items), &input) + if !result.OK { + if err, ok := result.Value.(resultFailure); ok { + return input, err + } + return input, core.E("chat.decodeInput", "failed to decode action input", nil) + } + return input, nil +} + +func defaultModelRoots() []string { + roots := []string{core.PathJoin(core.Env("DIR_HOME"), ".core", "models")} + if env := core.Trim(core.Env("CORE_MODELS_DIR")); env != "" { + roots = append(roots, env) + } + return roots +} + +func (s *Service) now() time.Time { + if s.options.Now != nil { + return s.options.Now() + } + return time.Now() +} + +func (s *Service) Send(ctx context.Context, input sendInput) (string, resultFailure) { + return s.send(ctx, input) +} + +func (s *Service) History(conversationID string, limit int) ([]Message, resultFailure) { + if limit < 0 { + return nil, core.E("chat.history", "limit must be greater than or equal to zero", nil) + } + conv, err := s.LoadConversation(conversationID) + if err != nil { + return nil, err + } + if limit == 0 || limit >= len(conv.Messages) { + return slices.Clone(conv.Messages), nil + } + return slices.Clone(conv.Messages[len(conv.Messages)-limit:]), nil +} + +func (s *Service) Models() []ModelEntry { + models := s.discoverModels() + activeName := s.resolveModel("", s.loadSettings().DefaultModel) + if len(models) == 0 { + return []ModelEntry{{ + Name: activeName, + Size: 0, + Status: "active", + Loaded: true, + }} + } + + for index := range models { + models[index].Size = models[index].SizeBytes + switch { + case equalFold(models[index].Name, activeName): + models[index].Loaded = true + models[index].Status = "active" + case models[index].Loaded: + models[index].Status = "loaded" + default: + models[index].Status = "available" + } + } + return models +} + +func (s *Service) saveSettings(settings ChatSettings) resultFailure { + if err := s.validateSettings(settings); err != nil { + return err + } + payload := core.JSONMarshalString(settings) + return s.store.set(settingsGroup, settingsKey, payload) +} + +func (s *Service) loadSettings() ChatSettings { + settings := DefaultSettings() + if s.store == nil { + return settings + } + payload, err := s.store.get(settingsGroup, settingsKey) + if err != nil { + return settings + } + if r := core.JSONUnmarshalString(payload, &settings); !r.OK { + return DefaultSettings() + } + return settings +} + +func (s *Service) SelectModel(input selectModelInput) (ChatSettings, resultFailure) { + modelName := coalesce(input.Name, input.Model) + if err := s.validateModelName(modelName); err != nil { + return ChatSettings{}, err + } + settings := s.loadSettings() + settings.DefaultModel = modelName + if err := s.saveSettings(settings); err != nil { + return ChatSettings{}, err + } + + targetConversation := coalesce(input.ConversationID, input.ID) + if targetConversation == "" { + return settings, nil + } + + conv, err := s.loadConversation(targetConversation) + if err != nil { + return ChatSettings{}, err + } + conv.Model = modelName + conv, err = s.saveConversation(conv) + if err != nil { + return ChatSettings{}, err + } + s.emit(ActionConversationUpdated{Conversation: conv}) + return settings, nil +} + +func (s *Service) ListConversations() ([]Conversation, resultFailure) { + return s.listConversations() +} + +func (s *Service) LoadConversation(id string) (Conversation, resultFailure) { + return s.getConversation(id, "") +} + +func (s *Service) DeleteConversation(id string) resultFailure { + return s.deleteConversation(id) +} + +func (s *Service) StartThinking(input thinkingInput) (ThinkingState, resultFailure) { + if core.Trim(input.ConversationID) == "" { + return ThinkingState{}, core.E("chat.thinking.start", "conversation id is required", nil) + } + + state := ThinkingState{ + Active: true, + StartedAt: input.StartedAt, + } + if state.StartedAt.IsZero() { + state.StartedAt = s.now() + } + + s.mu.Lock() + s.thinkingStates[input.ConversationID] = state + s.mu.Unlock() + + s.emit(ActionThinkingStarted{ + ConversationID: input.ConversationID, + MessageID: input.MessageID, + StartedAt: state.StartedAt, + }) + return state, nil +} + +func (s *Service) StopThinking(input thinkingInput) (ThinkingState, resultFailure) { + if core.Trim(input.ConversationID) == "" { + return ThinkingState{}, core.E("chat.thinking.stop", "conversation id is required", nil) + } + + s.mu.Lock() + state, ok := s.thinkingStates[input.ConversationID] + delete(s.thinkingStates, input.ConversationID) + s.mu.Unlock() + + if !ok && !input.StartedAt.IsZero() { + state.StartedAt = input.StartedAt + } + if state.StartedAt.IsZero() { + state.StartedAt = s.now() + } + + state.Active = false + state.Content = core.Trim(state.Content) + state.EndedAt = s.now() + state.DurationMS = input.DurationMS + if state.DurationMS == 0 { + state.DurationMS = state.EndedAt.Sub(state.StartedAt).Milliseconds() + if state.DurationMS < 0 { + state.DurationMS = 0 + } + } + + s.emit(ActionThinkingEnded{ + ConversationID: input.ConversationID, + MessageID: input.MessageID, + DurationMS: state.DurationMS, + }) + return state, nil +} + +func (s *Service) appendThinking(conversationID, content string) { + key := core.Trim(conversationID) + if key == "" || core.Trim(content) == "" { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + state := s.thinkingStates[key] + if state.StartedAt.IsZero() { + state.StartedAt = s.now() + } + state.Active = true + state.Content += content + s.thinkingStates[key] = state +} + +func (s *Service) saveConversation(conv Conversation) (Conversation, resultFailure) { + if err := s.validateConversation(conv); err != nil { + return Conversation{}, err + } + if conv.CreatedAt.IsZero() { + conv.CreatedAt = s.now() + } + if core.Trim(conv.Title) == "" { + if len(conv.Messages) > 0 && core.Trim(conv.Messages[0].Content) != "" { + conv.Title = titleFrom(conv.Messages[0].Content) + } else { + conv.Title = "New Chat" + } + } + conv.UpdatedAt = s.now() + payload := core.JSONMarshalString(conv) + return conv, s.store.set(conversationsGroup, conv.ID, payload) +} + +func (s *Service) loadConversation(id string) (Conversation, resultFailure) { + payload, err := s.store.get(conversationsGroup, id) + if err != nil { + return Conversation{}, err + } + var conv Conversation + result := core.JSONUnmarshalString(payload, &conv) + if !result.OK { + if decodeErr, ok := result.Value.(resultFailure); ok { + return Conversation{}, decodeErr + } + return Conversation{}, core.E("chat.loadConversation", "failed to decode conversation", nil) + } + return conv, nil +} + +func (s *Service) listConversations() ([]Conversation, resultFailure) { + if s.store == nil { + return nil, nil + } + items, err := s.store.getAll(conversationsGroup) + if err != nil { + return nil, err + } + + conversations := make([]Conversation, 0, len(items)) + for _, payload := range items { + var conv Conversation + if result := core.JSONUnmarshalString(payload, &conv); result.OK { + conversations = append(conversations, conv) + } + } + sort.Slice(conversations, func(i, j int) bool { + return conversations[i].UpdatedAt.After(conversations[j].UpdatedAt) + }) + return conversations, nil +} + +func (s *Service) listConversationSummaries() ([]ConversationSummary, resultFailure) { + conversations, err := s.listConversations() + if err != nil { + return nil, err + } + + summaries := make([]ConversationSummary, 0, len(conversations)) + for _, conv := range conversations { + summaries = append(summaries, conv.Summary()) + } + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt) + }) + return summaries, nil +} + +func (s *Service) searchConversationSummaries(query string) ([]ConversationSummary, resultFailure) { + query = core.Trim(core.Lower(query)) + summaries, err := s.listConversationSummaries() + if err != nil || query == "" { + return summaries, err + } + + items, err := s.store.getAll(conversationsGroup) + if err != nil { + return nil, err + } + matches := make([]ConversationSummary, 0) + for _, payload := range items { + var conv Conversation + if result := core.JSONUnmarshalString(payload, &conv); !result.OK { + continue + } + if core.Contains(conversationSearchText(conv), query) { + matches = append(matches, conv.Summary()) + continue + } + } + sort.Slice(matches, func(i, j int) bool { + return matches[i].UpdatedAt.After(matches[j].UpdatedAt) + }) + return matches, nil +} + +func conversationSearchText(conv Conversation) string { + builder := core.NewBuilder() + appendText := func(value string) { + trimmed := core.Trim(value) + if trimmed == "" { + return + } + if builder.Len() > 0 { + builder.WriteString("\n") + } + builder.WriteString(core.Lower(trimmed)) + } + + appendText(conv.Title) + appendText(conv.Model) + if conv.Settings != nil { + appendText(conv.Settings.SystemPrompt) + appendText(conv.Settings.DefaultModel) + } + for _, message := range conv.Messages { + appendText(message.Content) + appendText(message.Role) + appendText(message.Model) + appendText(message.FinishReason) + if message.Thinking != nil { + appendText(message.Thinking.Content) + } + for _, attachment := range message.Attachments { + appendText(attachment.Filename) + appendText(attachment.MimeType) + } + for _, call := range message.ToolCalls { + appendText(call.ID) + appendText(call.Name) + appendText(core.JSONMarshalString(call.Arguments)) + } + for _, result := range message.ToolResults { + appendText(result.ToolCallID) + appendText(result.Content) + } + } + return builder.String() +} + +func (s *Service) createConversation() (Conversation, resultFailure) { + settings := s.loadSettings() + now := s.now() + conv := Conversation{ + ID: "conv-" + strconv.FormatInt(now.UnixNano(), 36), + Title: "New Chat", + Model: settings.DefaultModel, + CreatedAt: now, + UpdatedAt: now, + Messages: nil, + } + conv, err := s.saveConversation(conv) + if err != nil { + return Conversation{}, err + } + s.emit(ActionConversationCreated{Conversation: conv}) + return conv, nil +} + +func (s *Service) getConversation(id, conversationID string) (Conversation, resultFailure) { + target := coalesce(id, conversationID) + if target == "" { + return Conversation{}, core.E("chat.getConversation", "conversation id is required", nil) + } + return s.loadConversation(target) +} + +func (s *Service) renameConversation(id, title string) (Conversation, resultFailure) { + conv, err := s.loadConversation(id) + if err != nil { + return Conversation{}, err + } + conv.Title = core.Trim(title) + if conv.Title == "" { + conv.Title = "New Chat" + } + conv, err = s.saveConversation(conv) + if err != nil { + return Conversation{}, err + } + s.emit(ActionConversationUpdated{Conversation: conv}) + return conv, nil +} + +func (s *Service) clearConversation(id, conversationID string) (Conversation, resultFailure) { + conv, err := s.getConversation(id, conversationID) + if err != nil { + return Conversation{}, err + } + conv.Messages = nil + conv.UpdatedAt = s.now() + conv, err = s.saveConversation(conv) + if err != nil { + return Conversation{}, err + } + s.clearQueuedAttachments(conv.ID) + s.emit(ActionConversationCleared{ConversationID: conv.ID}) + s.emit(ActionConversationUpdated{Conversation: conv}) + return conv, nil +} + +func (s *Service) deleteConversation(id string) resultFailure { + if id == "" { + return core.E("chat.deleteConversation", "conversation id is required", nil) + } + if err := s.store.delete(conversationsGroup, id); err != nil { + return err + } + s.clearQueuedAttachments(id) + s.emit(ActionConversationDeleted{ConversationID: id}) + return nil +} + +func (s *Service) exportConversation(id string) (string, resultFailure) { + conv, err := s.loadConversation(id) + if err != nil { + return "", err + } + builder := core.NewBuilder() + builder.WriteString("# ") + builder.WriteString(conv.Title) + builder.WriteString("\n\n") + titleCaser := cases.Title(language.Und) + for _, message := range conv.Messages { + builder.WriteString("## ") + builder.WriteString(titleCaser.String(message.Role)) + builder.WriteString("\n\n") + if message.Content != "" { + builder.WriteString(message.Content) + builder.WriteString("\n\n") + } + for _, result := range message.ToolResults { + builder.WriteString("> Tool Result (`") + builder.WriteString(result.ToolCallID) + builder.WriteString("`)\n>\n> ") + builder.WriteString(core.Replace(result.Content, "\n", "\n> ")) + builder.WriteString("\n\n") + } + } + return builder.String(), nil +} + +func (s *Service) queueAttachment(conversationID string, attachment ImageAttachment) { + key := coalesce(conversationID, "draft") + s.mu.Lock() + defer s.mu.Unlock() + s.pendingAttachments[key] = append(s.pendingAttachments[key], attachment) + s.emit(ActionImageQueued{ConversationID: key, Attachment: attachment}) +} + +func (s *Service) drainAttachments(conversationID string) []ImageAttachment { + s.mu.Lock() + defer s.mu.Unlock() + var attachments []ImageAttachment + keys := []string{conversationID} + if conversationID != "draft" { + keys = append(keys, "draft") + } + for _, key := range keys { + if key == "" { + continue + } + attachments = append(attachments, s.pendingAttachments[key]...) + delete(s.pendingAttachments, key) + } + return attachments +} + +// removeAttachment removes a queued image by index from the pending attachment queue. +// Use: removed, _ := service.removeAttachment("draft", 0) +func (s *Service) removeAttachment(conversationID string, index int) (ImageAttachment, resultFailure) { + key := coalesce(conversationID, "draft") + if index < 0 { + return ImageAttachment{}, core.E("chat.removeAttachment", "attachment index must be non-negative", nil) + } + + s.mu.Lock() + defer s.mu.Unlock() + + attachments := s.pendingAttachments[key] + if index >= len(attachments) { + return ImageAttachment{}, core.E("chat.removeAttachment", "attachment index is out of range", nil) + } + + removed := attachments[index] + next := append(attachments[:index:index], attachments[index+1:]...) + if len(next) == 0 { + delete(s.pendingAttachments, key) + } else { + s.pendingAttachments[key] = next + } + return removed, nil +} + +func (s *Service) clearQueuedAttachments(conversationID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.pendingAttachments, conversationID) + if conversationID != "draft" { + delete(s.pendingAttachments, "draft") + } +} + +func (s *Service) mergedSettings(global ChatSettings, override *ChatSettings) ChatSettings { + if override == nil { + return global + } + merged := global + if override.Temperature != 0 { + merged.Temperature = override.Temperature + } + if override.TopP != 0 { + merged.TopP = override.TopP + } + if override.TopK != 0 { + merged.TopK = override.TopK + } + if override.MaxTokens != 0 { + merged.MaxTokens = override.MaxTokens + } + if override.ContextWindow != 0 { + merged.ContextWindow = override.ContextWindow + } + if override.SystemPrompt != "" { + merged.SystemPrompt = override.SystemPrompt + } + if override.DefaultModel != "" { + merged.DefaultModel = override.DefaultModel + } + return merged +} + +func (s *Service) send(ctx context.Context, input sendInput) (string, resultFailure) { + if core.Trim(input.Content) == "" && !s.hasPendingAttachments(input.ConversationID) { + return "", core.E("chat.send", "message content is required", nil) + } + + settings := s.loadSettings() + var ( + conv Conversation + err resultFailure + created bool + lastAssistantMessageID string + ) + if input.ConversationID != "" { + conv, err = s.loadConversation(input.ConversationID) + } else { + conv, err = s.createConversation() + created = true + } + if err != nil { + return "", err + } + + attachments := s.drainAttachments(conv.ID) + if len(attachments) == 0 && created { + attachments = s.drainAttachments("draft") + } + + now := s.now() + if modelName := core.Trim(input.Model); modelName != "" { + if err := s.validateModelName(modelName); err != nil { + return "", err + } + conv.Model = modelName + } + conv.Model = s.resolveModel(conv.Model, settings.DefaultModel) + userMessage := ChatMessage{ + ID: "msg-" + strconv.FormatInt(now.UnixNano(), 36), + Role: "user", + Content: input.Content, + CreatedAt: now, + Model: conv.Model, + Attachments: attachments, + } + conv.Messages = append(conv.Messages, userMessage) + if conv.Title == "" || conv.Title == "New Chat" { + conv.Title = titleFrom(input.Content) + } + conv, err = s.saveConversation(conv) + if err != nil { + return "", err + } + s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: userMessage}) + s.emit(ActionConversationUpdated{Conversation: conv}) + + for toolRound := 0; toolRound < maxToolRounds; toolRound++ { + effectiveSettings := s.mergedSettings(settings, conv.Settings) + conv.Model = s.resolveModel(conv.Model, effectiveSettings.DefaultModel) + if err := s.validateAttachmentsForModel(conv.Model, attachmentsForConversationTurn(conv.Messages)); err != nil { + return "", err + } + + assistantMessage, err := s.streamAssistant(ctx, conv, effectiveSettings) + if err != nil { + return "", err + } + assistantMessage = s.withInlineToolCall(conv.ID, assistantMessage) + lastAssistantMessageID = assistantMessage.ID + if hasRenderableContent(assistantMessage) { + conv.Messages = append(conv.Messages, assistantMessage) + conv, err = s.saveConversation(conv) + if err != nil { + return "", err + } + s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: assistantMessage}) + s.emit(ActionConversationUpdated{Conversation: conv}) + } + + if len(assistantMessage.ToolCalls) == 0 { + break + } + if s.toolHandler == nil { + break + } + + for _, call := range assistantMessage.ToolCalls { + resultContent, err := s.toolHandler.OnToolCall(ctx, call) + result := ToolResult{ + ToolCallID: call.ID, + Content: renderToolResultContent(resultContent), + } + if err != nil { + result.Content = err.Error() + } + toolMessage := ChatMessage{ + ID: "tool-" + strconv.FormatInt(s.now().UnixNano(), 36), + Role: "tool", + Content: result.Content, + CreatedAt: s.now(), + Model: conv.Model, + ToolCallID: result.ToolCallID, + ToolResults: []ToolResult{result}, + } + conv.Messages = append(conv.Messages, toolMessage) + s.emit(ActionToolResultReady{ConversationID: conv.ID, MessageID: assistantMessage.ID, Result: result}) + s.emit(ActionMessageAdded{ConversationID: conv.ID, Message: toolMessage}) + } + conv, err = s.saveConversation(conv) + if err != nil { + return "", err + } + s.emit(ActionConversationUpdated{Conversation: conv}) + } + + if lastAssistantMessageID == "" { + lastAssistantMessageID = userMessage.ID + } + return lastAssistantMessageID, nil +} + +func (s *Service) hasPendingAttachments(conversationID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if attachments := s.pendingAttachments[coalesce(conversationID, "draft")]; len(attachments) > 0 { + return true + } + if conversationID != "" && len(s.pendingAttachments["draft"]) > 0 { + return true + } + return false +} + +func (s *Service) streamAssistant(ctx context.Context, conv Conversation, settings ChatSettings) (ChatMessage, resultFailure) { + messageID := "msg-" + strconv.FormatInt(s.now().UnixNano(), 36) + requestBody := s.buildCompletionRequest(conv, settings) + payload := core.JSONMarshalString(requestBody) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimRight(s.options.APIURL, "/")+"/v1/chat/completions", core.NewBufferString(payload)) + if err != nil { + return ChatMessage{}, err + } + request.Header.Set("Content-Type", "application/json") + + response, err := s.httpClient.Do(request) + if err != nil { + return ChatMessage{}, err + } + defer response.Body.Close() + if response.StatusCode >= http.StatusBadRequest { + body, _ := io.ReadAll(response.Body) + return ChatMessage{}, core.E("chat.streamAssistant", core.Trim(string(body)), nil) + } + + // TODO(mantis-14): switch these callbacks to a dedicated GUI stream group when one exists. + renderer := NewStreamRenderer(StreamCallbacks{ + OnStart: func(streamID string) { + s.emit(ActionStreamStarted{ConversationID: conv.ID, MessageID: messageID, StreamID: streamID}) + }, + OnToken: func(content string) { + s.emit(ActionTokenAppended{ConversationID: conv.ID, MessageID: messageID, Content: content}) + }, + OnThinkingStart: func(state ThinkingState) { + s.emit(ActionThinkingStarted{ConversationID: conv.ID, MessageID: messageID, StartedAt: state.StartedAt}) + }, + OnThinkingAppend: func(content string) { + s.emit(ActionThinkingAppended{ConversationID: conv.ID, MessageID: messageID, Content: content}) + }, + OnThinkingEnd: func(state ThinkingState) { + s.emit(ActionThinkingEnded{ConversationID: conv.ID, MessageID: messageID, DurationMS: state.DurationMS}) + }, + OnToolCall: func(call ToolCall) { + s.emit(ActionToolCallStarted{ConversationID: conv.ID, MessageID: messageID, Call: call}) + }, + OnFinish: func(reason string) { + s.emit(ActionStreamFinished{ConversationID: conv.ID, MessageID: messageID, FinishReason: reason}) + }, + }) + if err := renderer.Render(response.Body); err != nil { + return ChatMessage{}, err + } + return renderer.Message(messageID, conv.Model, s.now()), nil +} + +func (s *Service) withInlineToolCall(conversationID string, message ChatMessage) ChatMessage { + if len(message.ToolCalls) > 0 { + return message + } + + call, ok, err := parseInlineToolCall(message.Content) + if err != nil { + coreutil.LogWarn(s.Core(), err, "chat.tool_call", "malformed inline tool_call ignored") + return message + } + if !ok { + return message + } + if call.ID == "" { + call.ID = "call-" + strconv.FormatInt(s.now().UnixNano(), 36) + } + message.Content = "" + message.ToolCalls = []ToolCall{call} + message.FinishReason = "tool_calls" + s.emit(ActionToolCallStarted{ConversationID: conversationID, MessageID: message.ID, Call: call}) + return message +} + +func (s *Service) buildCompletionRequest(conv Conversation, settings ChatSettings) openAIRequest { + request := openAIRequest{ + Model: s.resolveModel(conv.Model, settings.DefaultModel), + Messages: make([]openAIMessage, 0, len(conv.Messages)+1), + Temperature: settings.Temperature, + TopP: settings.TopP, + TopK: settings.TopK, + MaxTokens: settings.MaxTokens, + Stream: true, + } + + systemPrompt := s.buildSystemPrompt(settings) + if tools := s.buildToolSpecs(); len(tools) > 0 { + request.Tools = tools + request.ToolChoice = "auto" + } + if systemPrompt != "" { + request.Messages = append(request.Messages, openAIMessage{ + Role: "system", + Content: systemPrompt, + }) + } + + for _, message := range conv.Messages { + request.Messages = append(request.Messages, buildAPIMessage(message)) + } + + return request +} + +func (s *Service) buildSystemPrompt(settings ChatSettings) string { + systemPrompt := core.Trim(settings.SystemPrompt) + if s.toolHandler == nil { + return systemPrompt + } + manifest := s.toolHandler.BuildToolManifest() + if manifest == "" { + return systemPrompt + } + if systemPrompt != "" { + return manifest + "\n\n" + systemPrompt + } + return manifest +} + +func (s *Service) buildToolSpecs() []openAIToolSpec { + if s.toolExecutor == nil { + return nil + } + manifest := s.toolExecutor.Manifest() + if len(manifest) == 0 { + return nil + } + specs := make([]openAIToolSpec, 0, len(manifest)) + for _, tool := range manifest { + specs = append(specs, openAIToolSpec{ + Type: "function", + Function: openAIFunctionSpec{ + Name: tool.Name, + Description: tool.Description, + Parameters: tool.InputSchema, + }, + }) + } + return specs +} + +func buildAPIMessage(message ChatMessage) openAIMessage { + apiMessage := openAIMessage{Role: message.Role} + switch message.Role { + case "user": + apiMessage.Content = renderUserContent(message) + case "assistant": + apiMessage.Content = message.Content + if len(message.ToolCalls) > 0 { + apiMessage.ToolCalls = make([]openAIToolCall, 0, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + apiMessage.ToolCalls = append(apiMessage.ToolCalls, openAIToolCall{ + ID: call.ID, + Type: "function", + Function: openAIFunctionCall{ + Name: call.Name, + Arguments: core.JSONMarshalString(call.Arguments), + }, + }) + } + } + case "tool": + apiMessage.Content = message.Content + apiMessage.ToolCallID = message.ToolCallID + default: + apiMessage.Content = message.Content + } + return apiMessage +} + +func (s *Service) resolveModel(current, configured string) string { + if current = core.Trim(current); current != "" { + return current + } + if configured = core.Trim(configured); configured != "" { + return configured + } + models := s.discoverModels() + if len(models) > 0 { + return models[0].Name + } + return "default" +} + +func renderUserContent(message ChatMessage) any { + if len(message.Attachments) == 0 { + return message.Content + } + parts := []map[string]any{ + {"type": "text", "text": message.Content}, + } + for _, attachment := range message.Attachments { + parts = append(parts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": "data:" + attachment.MimeType + ";base64," + attachment.Data, + }, + }) + } + return parts +} + +func hasRenderableContent(message ChatMessage) bool { + return core.Trim(message.Content) != "" || message.Thinking != nil || len(message.ToolCalls) > 0 +} + +func titleFrom(content string) string { + title := core.Trim(content) + if title == "" { + return "New Chat" + } + runes := []rune(title) + if len(runes) > 50 { + return string(runes[:50]) + } + return title +} + +func coalesce(values ...string) string { + for _, value := range values { + if core.Trim(value) != "" { + return value + } + } + return "" +} + +func (s *Service) emit(message any) { + if message == nil { + return + } + coreutil.DispatchAction(s.Core(), "chat.emit", message) +} + +func (s *Service) discoverModels() []ModelEntry { + settings := s.loadSettings() + models := map[string]ModelEntry{} + for _, root := range s.options.ModelRoots { + for _, model := range discoverModelsOnDisk(root) { + models[model.Name] = model + } + } + + configPath := core.PathJoin(core.Env("DIR_HOME"), ".core", "models.yaml") + if payload, err := coreReadFile(configPath); err == nil { + var configured configuredModels + if err := yaml.Unmarshal(payload, &configured); err == nil { + defaultModel := coalesce(configured.DefaultModel, configured.Default, settings.DefaultModel) + for _, item := range configured.Models { + name := coalesce(item.Name, core.PathBase(item.Path)) + entry := models[name] + entry.Name = name + entry.Architecture = coalesce(item.Architecture, entry.Architecture) + entry.Backend = coalesce(item.Backend, entry.Backend, "local") + if item.SupportsVision != nil { + entry.SupportsVision = *item.SupportsVision + } else if entry.Architecture != "" { + entry.SupportsVision = architectureSupportsVision(entry.Architecture) + } + if entry.SizeBytes == 0 && item.Path != "" { + entry.SizeBytes = directorySize(item.Path) + } + entry.Loaded = name == defaultModel + models[name] = entry + } + } + } + + names := make([]string, 0, len(models)) + for name := range models { + names = append(names, name) + } + slices.Sort(names) + activeModel := core.Trim(settings.DefaultModel) + if activeModel == "" && len(names) > 0 { + activeModel = names[0] + } + results := make([]ModelEntry, 0, len(names)) + for _, name := range names { + entry := models[name] + entry.Size = entry.SizeBytes + switch { + case equalFold(entry.Name, activeModel): + entry.Loaded = true + entry.Status = "active" + case entry.Loaded: + entry.Status = "loaded" + default: + entry.Status = "available" + } + results = append(results, entry) + } + return results +} + +func (s *Service) validateSettings(settings ChatSettings) resultFailure { + if settings.Temperature < 0 || settings.Temperature > 2 { + return core.E("chat.settings.save", "temperature must be between 0.0 and 2.0", nil) + } + if settings.TopP < 0 || settings.TopP > 1 { + return core.E("chat.settings.save", "top_p must be between 0.0 and 1.0", nil) + } + if settings.TopK < 0 || settings.TopK > 200 { + return core.E("chat.settings.save", "top_k must be between 0 and 200", nil) + } + if settings.MaxTokens < 64 || settings.MaxTokens > 32768 { + return core.E("chat.settings.save", "max_tokens must be between 64 and 32768", nil) + } + if !validContextWindow(settings.ContextWindow) { + return core.E("chat.settings.save", "context_window must be one of 2048, 4096, 8192, 16384, or 32768", nil) + } + if err := s.validateOptionalModelName(settings.DefaultModel); err != nil { + return err + } + return nil +} + +func validContextWindow(value int) bool { + switch value { + case 2048, 4096, 8192, 16384, 32768: + return true + default: + return false + } +} + +func (s *Service) validateConversation(conv Conversation) resultFailure { + if core.Trim(conv.ID) == "" { + return core.E("chat.saveConversation", "conversation id is required", nil) + } + if err := s.validateOptionalModelName(conv.Model); err != nil { + return err + } + if conv.Settings != nil { + if err := s.validateSettings(*conv.Settings); err != nil { + return err + } + } + for _, message := range conv.Messages { + if err := validateMessageAttachments(message); err != nil { + return err + } + } + if err := s.validateAttachmentsForModel(s.resolveModel(conv.Model, s.loadSettings().DefaultModel), attachmentsForConversationTurn(conv.Messages)); err != nil { + return err + } + return nil +} + +func (s *Service) validateModelName(name string) resultFailure { + if core.Trim(name) == "" { + return core.E("chat.selectModel", "model is required", nil) + } + if len(s.discoverModels()) == 0 { + return nil + } + if _, ok := s.findModel(name); ok { + return nil + } + return core.E("chat.selectModel", "model is not available: "+name, nil) +} + +func (s *Service) validateOptionalModelName(name string) resultFailure { + if core.Trim(name) == "" { + return nil + } + if len(s.discoverModels()) == 0 || equalFold(core.Trim(name), "default") { + return nil + } + if _, ok := s.findModel(name); ok { + return nil + } + return core.E("chat.model", "model is not available: "+name, nil) +} + +func (s *Service) findModel(name string) (ModelEntry, bool) { + for _, model := range s.discoverModels() { + if equalFold(model.Name, core.Trim(name)) { + return model, true + } + } + return ModelEntry{}, false +} + +func (s *Service) validateAttachmentsForModel(modelName string, attachments []ImageAttachment) resultFailure { + if len(attachments) == 0 { + return nil + } + model, ok := s.findModel(modelName) + if !ok { + return core.E("chat.send", "image attachments require a discovered vision-capable model", nil) + } + if !model.SupportsVision { + return core.E("chat.send", "selected model does not support image input: "+model.Name, nil) + } + return nil +} + +func validateMessageAttachments(message ChatMessage) resultFailure { + for _, attachment := range message.Attachments { + if err := validateImageAttachment(attachment); err != nil { + return err + } + } + return nil +} + +func attachmentsForConversationTurn(messages []ChatMessage) []ImageAttachment { + if len(messages) == 0 { + return nil + } + for index := len(messages) - 1; index >= 0; index-- { + if messages[index].Role != "user" { + continue + } + return messages[index].Attachments + } + return nil +} + +func discoverModelsOnDisk(root string) []ModelEntry { + if core.Trim(root) == "" { + return nil + } + var results []ModelEntry + entries := core.ReadDir(core.DirFS(root), ".") + if !entries.OK { + return nil + } + for _, entry := range entries.Value.([]core.FsDirEntry) { + if !entry.IsDir() { + continue + } + modelPath := core.PathJoin(root, entry.Name()) + config, ok := readModelConfig(modelPath) + if !ok { + continue + } + name := core.PathBase(modelPath) + size := directorySize(modelPath) + results = append(results, ModelEntry{ + Name: name, + Size: size, + Architecture: core.Lower(config.ModelType), + QuantBits: coalesceQuantBits(config.Quantization.Bits, quantBitsFromName(name)), + SizeBytes: size, + Backend: "local", + SupportsVision: architectureSupportsVision(config.ModelType), + }) + } + return results +} + +func readModelConfig(modelPath string) (modelConfig, bool) { + var config modelConfig + body, err := coreReadFile(core.PathJoin(modelPath, "config.json")) + if err != nil { + return config, false + } + if result := core.JSONUnmarshal(body, &config); !result.OK { + return config, false + } + if core.Trim(config.ModelType) == "" { + config.ModelType = core.PathBase(modelPath) + } + return config, true +} + +func validateImageAttachment(attachment ImageAttachment) resultFailure { + if core.Trim(attachment.Filename) == "" { + return core.E("chat.attachImage", "attachment filename is required", nil) + } + mimeType := core.Lower(core.Trim(attachment.MimeType)) + if _, ok := supportedImageMimeTypes[mimeType]; !ok { + return core.E("chat.attachImage", "unsupported image format: expected PNG, JPEG, WebP, or GIF", nil) + } + if core.Trim(attachment.Data) == "" { + return core.E("chat.attachImage", "attachment data is required", nil) + } + return nil +} + +func imageAttachmentFromFile(rawPath string) (ImageAttachment, resultFailure) { + path, err := validatedImageFilePath(rawPath) + if err != nil { + return ImageAttachment{}, err + } + content, err := coreReadFile(path) + if err != nil { + return ImageAttachment{}, core.E("chat.attachImageFile", "failed to read image file", err) + } + data := content + mimeType, err := detectImageMimeType(path, data) + if err != nil { + return ImageAttachment{}, err + } + width, height := imageDimensionsFromBytes(data) + attachment := ImageAttachment{ + Filename: core.PathBase(path), + MimeType: mimeType, + Data: base64.StdEncoding.EncodeToString(data), + Width: width, + Height: height, + } + if err := validateImageAttachment(attachment); err != nil { + return ImageAttachment{}, err + } + return attachment, nil +} + +func validatedImageFilePath(rawPath string) (string, resultFailure) { + trimmed := core.Trim(rawPath) + if trimmed == "" { + return "", core.E("chat.attachImageFile", "path is required", nil) + } + if core.Contains(trimmed, "\x00") { + return "", core.E("chat.attachImageFile", "path contains a null byte", nil) + } + cleaned := core.CleanPath(trimmed, string(core.PathSeparator)) + if !core.PathIsAbs(cleaned) { + return "", core.E("chat.attachImageFile", "path must be absolute", nil) + } + return cleaned, nil +} + +func detectImageMimeType(path string, data []byte) (string, resultFailure) { + mimeType := core.Lower(core.Trim(http.DetectContentType(data))) + if _, ok := supportedImageMimeTypes[mimeType]; ok { + return mimeType, nil + } + + switch core.Lower(core.PathExt(path)) { + case ".png": + return "image/png", nil + case ".jpg", ".jpeg": + return "image/jpeg", nil + case ".webp": + return "image/webp", nil + case ".gif": + return "image/gif", nil + default: + return "", core.E("chat.attachImageFile", "unsupported image format: expected PNG, JPEG, WebP, or GIF", nil) + } +} + +func imageDimensionsFromBytes(data []byte) (int, int) { + config, _, err := image.DecodeConfig(core.NewBuffer(data)) + if err != nil { + return 0, 0 + } + return config.Width, config.Height +} + +func architectureSupportsVision(architecture string) bool { + lower := core.Lower(core.Trim(architecture)) + return core.HasPrefix(lower, "gemma3") || core.HasPrefix(lower, "gemma4") +} + +func coalesceQuantBits(values ...int) int { + for _, value := range values { + if value != 0 { + return value + } + } + return 0 +} + +func quantBitsFromName(name string) int { + lower := core.Lower(name) + switch { + case core.Contains(lower, "q4"), core.Contains(lower, "4bit"): + return 4 + case core.Contains(lower, "q8"), core.Contains(lower, "8bit"): + return 8 + default: + return 0 + } +} + +func directorySize(root string) int64 { + var total int64 + // core.PathWalk returns core.Result since v0.11.0; a failed walk still + // reports whatever summed before the failure — same semantics as before. + core.PathWalk(root, func(path string, info core.FsFileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + if core.HasSuffix(info.Name(), ".safetensors") || core.HasSuffix(info.Name(), ".gguf") || core.HasSuffix(info.Name(), ".bin") { + total += info.Size() + } + return nil + }) + return total +} diff --git a/display/webkit/pkg/chat/service_example_test.go b/display/webkit/pkg/chat/service_example_test.go new file mode 100644 index 0000000..6942967 --- /dev/null +++ b/display/webkit/pkg/chat/service_example_test.go @@ -0,0 +1,180 @@ +package chat + +import ( + "context" + "net/http" + "net/http/httptest" + "time" + + core "dappco.re/go" +) + +func ExampleRegister() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello from chat"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + })) + defer server.Close() + + storeDir, err := coreMkdirTemp("", "chat-example-*") + if err != nil { + panic(err) + } + defer coreRemoveAll(storeDir) + + c := core.New( + core.WithService(Register( + func(o *Options) { o.APIURL = server.URL }, + func(o *Options) { o.StorePath = core.PathJoin(storeDir, "chat.db") }, + func(o *Options) { o.ToolExecutor = &mockToolExecutor{} }, + func(o *Options) { o.Now = func() time.Time { return time.Unix(1_700_000_000, 0).UTC() } }, + )), + core.WithServiceLock(), + ) + if !c.ServiceStartup(context.Background(), nil).OK { + panic("chat startup failed") + } + + send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Hello"}, + )) + if !send.OK { + panic(send.Value) + } + + conversations := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + history := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: conversations.Value.([]Conversation)[0].ID}, + )) + + core.Println(len(history.Value.([]Message))) + core.Println(history.Value.([]Message)[1].Content) + // Output: + // 2 + // Hello from chat +} + +// AX7 generated examples exercise each public call path with stable output. +func ExampleService_OnStartup() { + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_HandleIPCEvents() { + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_Send() { + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.Send(core.Background(), *new(sendInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_History() { + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.History("agent", 1) + return core.Sprintf("%T,%T", got0, got1) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_Models() { + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Models() + return core.Sprintf("%T", got0) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_SelectModel() { + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.SelectModel(*new(selectModelInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_ListConversations() { + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ListConversations() + return core.Sprintf("%T,%T", got0, got1) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_LoadConversation() { + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LoadConversation("agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_DeleteConversation() { + subject := new(Service) + result := core.Try(func() any { + got0 := subject.DeleteConversation("agent") + return core.Sprintf("%T", got0) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_StartThinking() { + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.StartThinking(*new(thinkingInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleService_StopThinking() { + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.StopThinking(*new(thinkingInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} diff --git a/display/webkit/pkg/chat/service_test.go b/display/webkit/pkg/chat/service_test.go new file mode 100644 index 0000000..a149eb4 --- /dev/null +++ b/display/webkit/pkg/chat/service_test.go @@ -0,0 +1,983 @@ +package chat + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "time" + + core "dappco.re/go" + guimcp "dappco.re/go/render/display/webkit/pkg/mcp" +) + +type mockToolExecutor struct { + calls []ToolCall +} + +func (m *mockToolExecutor) Manifest() []guimcp.ToolDescriptor { + return []guimcp.ToolDescriptor{{ + Name: "layout_suggest", + Description: "Suggest a layout", + InputSchema: map[string]any{"type": "object"}, + }} +} + +func (m *mockToolExecutor) ManifestText() string { + return "Available MCP tools:\n- layout_suggest: Suggest a layout" +} + +func (m *mockToolExecutor) CallTool(_ context.Context, name string, arguments map[string]any) (string, resultFailure) { + m.calls = append(m.calls, ToolCall{Name: name, Arguments: arguments}) + return `{"mode":"left-right"}`, nil +} + +func newChatCore(t *core.T, handler http.HandlerFunc, toolExecutor ToolExecutor, optionFns ...func(*Options)) *core.Core { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + options := []func(*Options){ + func(o *Options) { o.APIURL = server.URL }, + func(o *Options) { o.StorePath = core.PathJoin(t.TempDir(), "chat.db") }, + func(o *Options) { o.ToolExecutor = toolExecutor }, + func(o *Options) { o.Now = func() time.Time { return time.Unix(1_700_000_000, 0).UTC() } }, + func(o *Options) { o.ModelRoots = nil }, + } + options = append(options, optionFns...) + + c := core.New( + core.WithService(Register(options...)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + return c +} + +func createDiscoveredModelRoot(t *core.T, name, architecture string) string { + t.Helper() + root := t.TempDir() + modelDir := core.PathJoin(root, name) + core.RequireNoError(t, coreEnsureDir(modelDir)) + configJSON := `{"model_type":"` + architecture + `","quantization":{"bits":4,"group_size":32}}` + core.RequireNoError(t, coreWriteMode(core.PathJoin(modelDir, "config.json"), configJSON, 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(modelDir, "weights.safetensors"), "fake", 0o644)) + return root +} + +func sequencedNow(start time.Time) func() time.Time { + current := start.Add(-time.Second) + return func() time.Time { + current = current.Add(time.Second) + return current + } +} + +func writeSSE(w http.ResponseWriter, payloads ...string) { + w.Header().Set("Content-Type", "text/event-stream") + for _, payload := range payloads { + _, _ = io.WriteString(w, "data: "+payload+"\n\n") + } +} + +func latestConversation(t *core.T, c *core.Core) Conversation { + t.Helper() + result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, result.OK) + conversations, ok := result.Value.([]Conversation) + core.RequireTrue(t, ok) + core.RequireNotEmpty(t, conversations) + return conversations[0] +} + +func historyMessages(t *core.T, c *core.Core, conversationID string, limit int) []Message { + t.Helper() + options := []core.Option{{ + Key: "conversation_id", + Value: conversationID, + }} + if limit > 0 { + options = append(options, core.Option{Key: "limit", Value: limit}) + } + result := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions(options...)) + core.RequireTrue(t, result.OK) + messages, ok := result.Value.([]Message) + core.RequireTrue(t, ok) + return messages +} + +func TestActionSend_Good_ReturnsAssistantMessageID(t *core.T) { + modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3") + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Hello"}}]}`, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":" world"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} }) + + send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Hi"}, + core.Option{Key: "model", Value: "lemma"}, + )) + core.RequireTrue(t, send.OK) + + messageID, ok := send.Value.(string) + core.RequireTrue(t, ok) + core.RequireNotEmpty(t, messageID) + + conv := latestConversation(t, c) + core.AssertLen(t, conv.Messages, 2) + core.AssertEqual(t, messageID, conv.Messages[1].ID) + core.AssertEqual(t, "lemma", conv.Model) + core.AssertEqual(t, "Hello world", conv.Messages[1].Content) +} + +func TestActionSend_Bad_RejectsEmptyMessage(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "message content is required") +} + +func TestActionSend_Ugly_PropagatesUpstreamFailure(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "model unavailable", http.StatusBadGateway) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Hi"}, + )) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "model unavailable") +} + +func TestActionHistory_Good_HonoursLimit(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Alpha"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}) + + send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "One"}, + )) + core.RequireTrue(t, send.OK) + + conv := latestConversation(t, c) + history := historyMessages(t, c, conv.ID, 1) + core.AssertLen(t, history, 1) + core.AssertEqual(t, "assistant", history[0].Role) + core.AssertEqual(t, "Alpha", history[0].Content) +} + +func TestActionHistory_Bad_RequiresConversationID(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "conversation id is required") +} + +func TestActionHistory_Ugly_UnknownConversationFails(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.history").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "missing"}, + )) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) +} + +func TestActionModels_Good_ReportsSizeAndStatus(t *core.T) { + modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3") + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} }) + + result := c.Action("gui.chat.models").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, result.OK) + + models, ok := result.Value.([]ModelEntry) + core.RequireTrue(t, ok) + core.AssertLen(t, models, 1) + core.AssertEqual(t, "lemma", models[0].Name) + core.AssertEqual(t, int64(4), models[0].Size) + core.AssertEqual(t, "active", models[0].Status) +} + +func TestActionModels_Bad_ReturnsFallbackWhenNothingDiscovered(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.models").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, result.OK) + + models, ok := result.Value.([]ModelEntry) + core.RequireTrue(t, ok) + core.AssertLen(t, models, 1) + core.AssertEqual(t, "default", models[0].Name) + core.AssertEqual(t, "active", models[0].Status) +} + +func TestActionModels_Ugly_ReflectsSelectedModelStatus(t *core.T) { + rootA := createDiscoveredModelRoot(t, "alpha", "gemma3") + rootB := createDiscoveredModelRoot(t, "beta", "gemma3") + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{rootA, rootB} }) + + selected := c.Action("gui.chat.select_model").Run(context.Background(), core.NewOptions( + core.Option{Key: "model", Value: "beta"}, + )) + core.RequireTrue(t, selected.OK) + + result := c.Action("gui.chat.models").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, result.OK) + + models, ok := result.Value.([]ModelEntry) + core.RequireTrue(t, ok) + core.AssertLen(t, models, 2) + statusByName := map[string]string{} + for _, model := range models { + statusByName[model.Name] = model.Status + } + core.AssertEqual(t, "available", statusByName["alpha"]) + core.AssertEqual(t, "active", statusByName["beta"]) +} + +func TestActionSelectModel_Good_UpdatesConversationAndSettings(t *core.T) { + modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3") + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} }) + + created := c.Action("gui.chat.conversations.new").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, created.OK) + conv := created.Value.(Conversation) + + selected := c.Action("gui.chat.select_model").Run(context.Background(), core.NewOptions( + core.Option{Key: "model", Value: "lemma"}, + core.Option{Key: "conversation_id", Value: conv.ID}, + )) + core.RequireTrue(t, selected.OK) + + settings := selected.Value.(ChatSettings) + core.AssertEqual(t, "lemma", settings.DefaultModel) + + loaded := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: conv.ID}, + )) + core.RequireTrue(t, loaded.OK) + core.AssertEqual(t, "lemma", loaded.Value.(Conversation).Model) +} + +func TestActionSelectModel_Bad_RequiresModelName(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.select_model").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "model is required") +} + +func TestActionSelectModel_Ugly_RejectsUnknownDiscoveredModel(t *core.T) { + modelRoot := createDiscoveredModelRoot(t, "lemma", "gemma3") + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}, func(o *Options) { o.ModelRoots = []string{modelRoot} }) + + result := c.Action("gui.chat.select_model").Run(context.Background(), core.NewOptions( + core.Option{Key: "model", Value: "missing"}, + )) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "model is not available") +} + +func TestActionConversationsList_Good_ReturnsNewestFirst(t *core.T) { + now := sequencedNow(time.Unix(1_700_000_000, 0).UTC()) + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Ack"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}, func(o *Options) { o.Now = now }) + + core.RequireTrue(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "First"}, + )).OK) + core.RequireTrue(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Second"}, + )).OK) + + result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, result.OK) + conversations := result.Value.([]Conversation) + core.AssertLen(t, conversations, 2) + core.AssertEqual(t, "Second", conversations[0].Messages[0].Content) + core.AssertEqual(t, "First", conversations[1].Messages[0].Content) +} + +func TestActionConversationsList_Bad_EmptyStoreReturnsEmptySlice(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, result.OK) + conversations, ok := result.Value.([]Conversation) + core.RequireTrue(t, ok) + core.AssertEmpty(t, conversations) +} + +func TestActionConversationsList_Ugly_IgnoresCorruptRows(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Ack"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}) + + core.RequireTrue(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Good"}, + )).OK) + + svc := core.MustServiceFor[*Service](c, "chat") + core.RequireNoError(t, svc.store.set(conversationsGroup, "broken", "{")) + + result := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, result.OK) + conversations := result.Value.([]Conversation) + core.AssertLen(t, conversations, 1) + core.AssertEqual(t, "Good", conversations[0].Messages[0].Content) +} + +func TestActionConversationsLoad_Good_ReturnsConversation(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Reply"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}) + + core.RequireTrue(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Hello"}, + )).OK) + conv := latestConversation(t, c) + + result := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: conv.ID}, + )) + core.RequireTrue(t, result.OK) + loaded := result.Value.(Conversation) + core.AssertLen(t, loaded.Messages, 2) + core.AssertEqual(t, "Reply", loaded.Messages[1].Content) +} + +func TestActionConversationsLoad_Bad_RequiresConversationID(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "conversation id is required") +} + +func TestActionConversationsLoad_Ugly_UnknownConversationFails(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "missing"}, + )) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) +} + +func TestActionConversationsDelete_Good_RemovesConversation(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"Reply"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + ) + }, &mockToolExecutor{}) + + core.RequireTrue(t, c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Hello"}, + )).OK) + conv := latestConversation(t, c) + + deleted := c.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: conv.ID}, + )) + core.RequireTrue(t, deleted.OK) + core.AssertEqual(t, true, deleted.Value) + + listed := c.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, listed.OK) + core.AssertEmpty(t, listed.Value.([]Conversation)) +} + +func TestActionConversationsDelete_Bad_RequiresConversationID(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "conversation id is required") +} + +func TestActionConversationsDelete_Ugly_IsIdempotentForMissingConversation(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "missing"}, + )) + core.RequireTrue(t, result.OK) + core.AssertEqual(t, true, result.Value) +} + +func TestActionThinkingStart_Good_ReturnsActiveState(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + )) + core.RequireTrue(t, result.OK) + state := result.Value.(ThinkingState) + core.AssertTrue(t, state.Active) + core.AssertFalse(t, state.StartedAt.IsZero()) +} + +func TestActionThinkingStart_Bad_RequiresConversationID(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "conversation id is required") +} + +func TestActionThinkingStart_Ugly_RestartReplacesExistingState(t *core.T) { + now := sequencedNow(time.Unix(1_700_000_000, 0).UTC()) + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}, func(o *Options) { o.Now = now }) + + first := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + )) + second := c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + )) + core.RequireTrue(t, first.OK) + core.RequireTrue(t, second.OK) + core.AssertTrue(t, second.Value.(ThinkingState).StartedAt.After(first.Value.(ThinkingState).StartedAt)) +} + +func TestActionThinkingStop_Good_ClearsThinkingState(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + core.RequireTrue(t, c.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + core.Option{Key: "started_at", Value: time.Unix(1_700_000_000, 0).UTC()}, + )).OK) + + stopped := c.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + core.Option{Key: "duration_ms", Value: int64(25)}, + )) + core.RequireTrue(t, stopped.OK) + state := stopped.Value.(ThinkingState) + core.AssertFalse(t, state.Active) + core.AssertEqual(t, int64(25), state.DurationMS) +} + +func TestActionThinkingStop_Bad_RequiresConversationID(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "conversation id is required") +} + +func TestActionThinkingStop_Ugly_AllowsStopWithoutStart(t *core.T) { + c := newChatCore(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, `[DONE]`) + }, &mockToolExecutor{}) + + result := c.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: "conv-1"}, + )) + core.RequireTrue(t, result.OK) + state := result.Value.(ThinkingState) + core.AssertFalse(t, state.Active) + core.AssertTrue(t, state.DurationMS >= 0) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := Register() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := Register() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := Register() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Send_Good(t *core.T) { + // Service Send + ax7Variant := "Service_Send:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.Send(core.Background(), *new(sendInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Send_Bad(t *core.T) { + // Service Send + ax7Variant := "Service_Send:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.Send(core.Background(), *new(sendInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Send_Ugly(t *core.T) { + // Service Send + ax7Variant := "Service_Send:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.Send(core.Background(), *new(sendInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_History_Good(t *core.T) { + // Service History + ax7Variant := "Service_History:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.History("agent", 1) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_History_Bad(t *core.T) { + // Service History + ax7Variant := "Service_History:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.History("", 0) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_History_Ugly(t *core.T) { + // Service History + ax7Variant := "Service_History:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.History("../../edge", -1) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Models_Good(t *core.T) { + // Service Models + ax7Variant := "Service_Models:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Models() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Models_Bad(t *core.T) { + // Service Models + ax7Variant := "Service_Models:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Models() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Models_Ugly(t *core.T) { + // Service Models + ax7Variant := "Service_Models:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Models() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_SelectModel_Good(t *core.T) { + // Service SelectModel + ax7Variant := "Service_SelectModel:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.SelectModel(*new(selectModelInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_SelectModel_Bad(t *core.T) { + // Service SelectModel + ax7Variant := "Service_SelectModel:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.SelectModel(*new(selectModelInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_SelectModel_Ugly(t *core.T) { + // Service SelectModel + ax7Variant := "Service_SelectModel:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.SelectModel(*new(selectModelInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_ListConversations_Good(t *core.T) { + // Service ListConversations + ax7Variant := "Service_ListConversations:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ListConversations() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_ListConversations_Bad(t *core.T) { + // Service ListConversations + ax7Variant := "Service_ListConversations:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ListConversations() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_ListConversations_Ugly(t *core.T) { + // Service ListConversations + ax7Variant := "Service_ListConversations:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ListConversations() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_LoadConversation_Good(t *core.T) { + // Service LoadConversation + ax7Variant := "Service_LoadConversation:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LoadConversation("agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_LoadConversation_Bad(t *core.T) { + // Service LoadConversation + ax7Variant := "Service_LoadConversation:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LoadConversation("") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_LoadConversation_Ugly(t *core.T) { + // Service LoadConversation + ax7Variant := "Service_LoadConversation:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LoadConversation("../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_DeleteConversation_Good(t *core.T) { + // Service DeleteConversation + ax7Variant := "Service_DeleteConversation:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.DeleteConversation("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_DeleteConversation_Bad(t *core.T) { + // Service DeleteConversation + ax7Variant := "Service_DeleteConversation:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.DeleteConversation("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_DeleteConversation_Ugly(t *core.T) { + // Service DeleteConversation + ax7Variant := "Service_DeleteConversation:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.DeleteConversation("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_StartThinking_Good(t *core.T) { + // Service StartThinking + ax7Variant := "Service_StartThinking:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.StartThinking(*new(thinkingInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_StartThinking_Bad(t *core.T) { + // Service StartThinking + ax7Variant := "Service_StartThinking:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.StartThinking(*new(thinkingInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_StartThinking_Ugly(t *core.T) { + // Service StartThinking + ax7Variant := "Service_StartThinking:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.StartThinking(*new(thinkingInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_StopThinking_Good(t *core.T) { + // Service StopThinking + ax7Variant := "Service_StopThinking:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.StopThinking(*new(thinkingInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_StopThinking_Bad(t *core.T) { + // Service StopThinking + ax7Variant := "Service_StopThinking:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.StopThinking(*new(thinkingInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_StopThinking_Ugly(t *core.T) { + // Service StopThinking + ax7Variant := "Service_StopThinking:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.StopThinking(*new(thinkingInput)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/chat/store.go b/display/webkit/pkg/chat/store.go new file mode 100644 index 0000000..d858c47 --- /dev/null +++ b/display/webkit/pkg/chat/store.go @@ -0,0 +1,109 @@ +package chat + +import ( + "sync" + + core "dappco.re/go" +) + +type chatStore struct { + path string + mu sync.Mutex + data map[string]map[string]string +} + +func newChatStore(path string) (*chatStore, resultFailure) { + store := &chatStore{ + path: path, + data: make(map[string]map[string]string), + } + if core.Trim(path) == "" || path == ":memory:" { + return store, nil + } + if result := core.MkdirAll(core.PathDir(path), 0o755); !result.OK { + return nil, coreResultError(result, "failed to create chat store directory") + } + content, err := coreReadFile(path) + if err != nil { + if core.IsNotExist(err) { + return store, nil + } + return nil, err + } + if len(content) == 0 { + return store, nil + } + if result := core.JSONUnmarshal(content, &store.data); !result.OK { + return nil, coreResultError(result, "failed to decode chat store") + } + return store, nil +} + +func (s *chatStore) set(group, key, value string) resultFailure { + if s == nil { + return core.NewError("chat store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.data == nil { + s.data = make(map[string]map[string]string) + } + items := s.data[group] + if items == nil { + items = make(map[string]string) + s.data[group] = items + } + items[key] = value + return s.persistLocked() +} + +func (s *chatStore) get(group, key string) (string, resultFailure) { + if s == nil { + return "", core.NewError("chat store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if value, ok := s.data[group][key]; ok { + return value, nil + } + return "", core.NewError("not found") +} + +func (s *chatStore) getAll(group string) (map[string]string, resultFailure) { + if s == nil { + return nil, core.NewError("chat store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + copy := make(map[string]string) + for key, value := range s.data[group] { + copy[key] = value + } + return copy, nil +} + +func (s *chatStore) delete(group, key string) resultFailure { + if s == nil { + return core.NewError("chat store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if items := s.data[group]; items != nil { + delete(items, key) + if len(items) == 0 { + delete(s.data, group) + } + } + return s.persistLocked() +} + +func (s *chatStore) persistLocked() resultFailure { + if s.path == "" || s.path == ":memory:" { + return nil + } + result := core.JSONMarshalIndent(s.data, "", " ") + if !result.OK { + return coreResultError(result, "failed to encode chat store") + } + return coreWriteFile(s.path, result.Value.([]byte), 0o644) +} diff --git a/display/webkit/pkg/chat/tool_handler.go b/display/webkit/pkg/chat/tool_handler.go new file mode 100644 index 0000000..7862d86 --- /dev/null +++ b/display/webkit/pkg/chat/tool_handler.go @@ -0,0 +1,276 @@ +package chat + +import ( + "context" + "sort" + + core "dappco.re/go" + guimcp "dappco.re/go/render/display/webkit/pkg/mcp" +) + +const mcpToolActionPrefix = "mcp.tool." + +// ToolExecutor is the chat-facing subset of the GUI MCP subsystem. +type ToolExecutor interface { + Manifest() []guimcp.ToolDescriptor + ManifestText() string + CallTool(ctx context.Context, name string, arguments map[string]any) (string, resultFailure) +} + +// ToolCallHandler intercepts model-emitted tool calls and renders the tool +// manifest that is injected into the system prompt. +type ToolCallHandler interface { + OnToolCall(ctx context.Context, call ToolCall) (result any, err resultFailure) + BuildToolManifest() string +} + +type adapter struct { + subsystem *guimcp.Subsystem +} + +func (e adapter) Manifest() []guimcp.ToolDescriptor { + return e.subsystem.Manifest() +} + +func (e adapter) ManifestText() string { + return e.subsystem.ManifestText() +} + +func (e adapter) CallTool(ctx context.Context, name string, arguments map[string]any) (string, resultFailure) { + return e.subsystem.CallTool(ctx, name, arguments) +} + +type mcpToolCallHandler struct { + executor ToolExecutor +} + +func NewToolCallHandler(executor ToolExecutor) ToolCallHandler { + if executor == nil { + return noopToolCallHandler{} + } + return &mcpToolCallHandler{executor: executor} +} + +type noopToolCallHandler struct{} + +func (noopToolCallHandler) OnToolCall(context.Context, ToolCall) (any, resultFailure) { + return nil, core.E("chat.tool_call", "tool execution unavailable", nil) +} + +func (noopToolCallHandler) BuildToolManifest() string { + return "" +} + +func (h *mcpToolCallHandler) OnToolCall(ctx context.Context, call ToolCall) (any, resultFailure) { + if h == nil || h.executor == nil { + return nil, core.E("chat.tool_call", "tool execution unavailable", nil) + } + call.Name = core.Trim(call.Name) + if call.Name == "" { + return nil, core.E("chat.tool_call", "tool name is required", nil) + } + if call.Arguments == nil { + call.Arguments = map[string]any{} + } + return h.executor.CallTool(ctx, call.Name, call.Arguments) +} + +func (h *mcpToolCallHandler) BuildToolManifest() string { + if h == nil || h.executor == nil { + return "" + } + + tools := h.executor.Manifest() + if len(tools) == 0 { + return core.Trim(h.executor.ManifestText()) + } + tools = append([]guimcp.ToolDescriptor(nil), tools...) + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + + builder := core.NewBuilder() + builder.WriteString("Available MCP tools:\n") + for _, tool := range tools { + builder.WriteString("- ") + builder.WriteString(tool.Name) + if core.Trim(tool.Description) != "" { + builder.WriteString(": ") + builder.WriteString(core.Trim(tool.Description)) + } + schema := tool.InputSchema + if schema == nil { + schema = map[string]any{"type": "object"} + } + builder.WriteString("\n input_schema: ") + builder.WriteString(jsonString(schema)) + builder.WriteString("\n") + } + builder.WriteString("\nWhen a tool is needed, emit exactly one JSON object in this shape: ") + builder.WriteString(`{"tool_call":{"name":"tool_name","arguments":{}}}`) + builder.WriteString(".") + return core.Trim(builder.String()) +} + +type actionToolExecutor struct { + core *core.Core + fallback ToolExecutor +} + +func newActionToolExecutor(c *core.Core, fallback ToolExecutor) ToolExecutor { + if c == nil || fallback == nil { + return fallback + } + return &actionToolExecutor{core: c, fallback: fallback} +} + +func registerMCPToolActions(c *core.Core, executor ToolExecutor) { + if c == nil || executor == nil { + return + } + for _, tool := range executor.Manifest() { + name := core.Trim(tool.Name) + if name == "" { + continue + } + actionName := mcpToolActionPrefix + name + if c.Action(actionName).Exists() { + continue + } + c.Action(actionName, func(ctx context.Context, opts core.Options) core.Result { + content, err := executor.CallTool(ctx, name, toolArgumentsFromOptions(opts)) + return core.Result{}.New(content, err) + }) + } +} + +func (e *actionToolExecutor) Manifest() []guimcp.ToolDescriptor { + if e == nil || e.fallback == nil { + return nil + } + return e.fallback.Manifest() +} + +func (e *actionToolExecutor) ManifestText() string { + if e == nil || e.fallback == nil { + return "" + } + return e.fallback.ManifestText() +} + +func (e *actionToolExecutor) CallTool(ctx context.Context, name string, arguments map[string]any) (string, resultFailure) { + if e == nil || e.fallback == nil { + return "", core.E("chat.tool_call", "tool execution unavailable", nil) + } + if e.core != nil { + result := e.core.Action(mcpToolActionPrefix+core.Trim(name)).Run(ctx, core.NewOptions(core.Option{ + Key: "arguments", + Value: arguments, + })) + if !result.OK { + return "", resultError(result) + } + return renderToolResultContent(result.Value), nil + } + return e.fallback.CallTool(ctx, name, arguments) +} + +type inlineToolCallEnvelope struct { + ToolCall *ToolCall `json:"tool_call"` +} + +func parseInlineToolCall(content string) (ToolCall, bool, resultFailure) { + trimmed := core.Trim(content) + if trimmed == "" || !core.Contains(trimmed, "tool_call") { + return ToolCall{}, false, nil + } + + var envelope inlineToolCallEnvelope + if result := core.JSONUnmarshal([]byte(trimmed), &envelope); !result.OK { + return ToolCall{}, false, core.E("chat.parseInlineToolCall", "failed parsing inline tool_call JSON", resultError(result)) + } + if envelope.ToolCall == nil { + return ToolCall{}, false, nil + } + call := *envelope.ToolCall + call.Name = core.Trim(call.Name) + if call.Arguments == nil { + call.Arguments = map[string]any{} + } + return call, true, nil +} + +func toolArgumentsFromOptions(opts core.Options) map[string]any { + if value := opts.Get("arguments"); value.OK { + if arguments, ok := value.Value.(map[string]any); ok { + return cloneArguments(arguments) + } + var arguments map[string]any + if result := core.JSONUnmarshal([]byte(jsonString(value.Value)), &arguments); result.OK { + return arguments + } + } + + arguments := make(map[string]any, opts.Len()) + for _, item := range opts.Items() { + arguments[item.Key] = item.Value + } + return arguments +} + +func cloneArguments(arguments map[string]any) map[string]any { + if arguments == nil { + return map[string]any{} + } + clone := make(map[string]any, len(arguments)) + for key, value := range arguments { + clone[key] = cloneArgumentValue(value) + } + return clone +} + +func cloneArgumentValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneArguments(typed) + case []any: + cloned := make([]any, len(typed)) + for index, item := range typed { + cloned[index] = cloneArgumentValue(item) + } + return cloned + default: + return typed + } +} + +func renderToolResultContent(result any) string { + switch typed := result.(type) { + case nil: + return "" + case string: + return typed + case []byte: + return string(typed) + default: + return jsonString(typed) + } +} + +func jsonString(value any) string { + result := core.JSONMarshal(value) + if !result.OK { + return "{}" + } + if data, ok := result.Value.([]byte); ok { + return string(data) + } + return "{}" +} + +func resultError(result core.Result) resultFailure { + if err, ok := result.Value.(resultFailure); ok { + return err + } + return core.E("chat.tool_call", "unexpected result type", nil) +} diff --git a/display/webkit/pkg/chat/tool_handler_example_test.go b/display/webkit/pkg/chat/tool_handler_example_test.go new file mode 100644 index 0000000..e032918 --- /dev/null +++ b/display/webkit/pkg/chat/tool_handler_example_test.go @@ -0,0 +1,102 @@ +package chat + +import ( + "context" + core "dappco.re/go" + + guimcp "dappco.re/go/render/display/webkit/pkg/mcp" +) + +type exampleToolExecutor struct{} + +func (exampleToolExecutor) Manifest() []guimcp.ToolDescriptor { + return []guimcp.ToolDescriptor{{ + Name: "layout_suggest", + Description: "Suggest a layout", + InputSchema: map[string]any{"type": "object"}, + }} +} + +func (exampleToolExecutor) ManifestText() string { + return "Available MCP tools:\n- layout_suggest: Suggest a layout" +} + +func (exampleToolExecutor) CallTool(_ context.Context, name string, _ map[string]any) (string, resultFailure) { + if name == "layout_suggest" { + return `{"mode":"left-right"}`, nil + } + return "", nil +} + +func ExampleNewToolCallHandler() { + handler := NewToolCallHandler(exampleToolExecutor{}) + result, err := handler.OnToolCall(context.Background(), ToolCall{ + ID: "call-1", + Name: "layout_suggest", + Arguments: map[string]any{"window_count": 2}, + }) + + core.Println(err == nil) + core.Println(result) + core.Println(core.Contains(handler.BuildToolManifest(), "layout_suggest")) + // Output: + // true + // {"mode":"left-right"} + // true +} + +// AX7 generated examples exercise each public call path with stable output. +func ExampleToolCallHandler_OnToolCall() { + var subject noopToolCallHandler + result := core.Try(func() any { + got0, got1 := subject.OnToolCall(core.Background(), *new(ToolCall)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleToolCallHandler_BuildToolManifest() { + var subject noopToolCallHandler + result := core.Try(func() any { + got0 := subject.BuildToolManifest() + return core.Sprintf("%T", got0) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleToolExecutor_Manifest() { + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0 := subject.Manifest() + return core.Sprintf("%T", got0) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleToolExecutor_ManifestText() { + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0 := subject.ManifestText() + return core.Sprintf("%T", got0) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleToolExecutor_CallTool() { + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0, got1 := subject.CallTool(core.Background(), "agent", nil) + return core.Sprintf("%T,%T", got0, got1) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} diff --git a/display/webkit/pkg/chat/tool_handler_test.go b/display/webkit/pkg/chat/tool_handler_test.go new file mode 100644 index 0000000..27a9bfc --- /dev/null +++ b/display/webkit/pkg/chat/tool_handler_test.go @@ -0,0 +1,394 @@ +package chat + +import ( + "context" + "io" + "net/http" + "sync" + + core "dappco.re/go" + guimcp "dappco.re/go/render/display/webkit/pkg/mcp" +) + +type strictToolExecutor struct { + mu sync.Mutex + calls []ToolCall +} + +func (m *strictToolExecutor) Manifest() []guimcp.ToolDescriptor { + return []guimcp.ToolDescriptor{{ + Name: "layout_suggest", + Description: "Suggest a layout", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "window_count": map[string]any{"type": "integer"}, + }, + }, + }} +} + +func (m *strictToolExecutor) ManifestText() string { + return "Available MCP tools:\n- layout_suggest: Suggest a layout" +} + +func (m *strictToolExecutor) CallTool(_ context.Context, name string, arguments map[string]any) (string, resultFailure) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls = append(m.calls, ToolCall{Name: name, Arguments: arguments}) + if name != "layout_suggest" { + return "", core.E("test.tool", "unknown tool: "+name, nil) + } + return `{"mode":"left-right"}`, nil +} + +func (m *strictToolExecutor) Calls() []ToolCall { + m.mu.Lock() + defer m.mu.Unlock() + return append([]ToolCall(nil), m.calls...) +} + +type completionRecorder struct { + mu sync.Mutex + requests []openAIRequest + responses [][]string +} + +func (r *completionRecorder) ServeHTTP(w http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + var completion openAIRequest + if result := core.JSONUnmarshal(body, &completion); !result.OK { + http.Error(w, renderToolResultContent(result.Value), http.StatusBadRequest) + return + } + + r.mu.Lock() + r.requests = append(r.requests, completion) + index := len(r.requests) - 1 + r.mu.Unlock() + + if index >= len(r.responses) { + http.Error(w, "unexpected completion request", http.StatusInternalServerError) + return + } + writeSSE(w, r.responses[index]...) +} + +func (r *completionRecorder) Requests() []openAIRequest { + r.mu.Lock() + defer r.mu.Unlock() + return append([]openAIRequest(nil), r.requests...) +} + +func TestToolCallHandler_Good_ServiceDispatchesInlineToolCall(t *core.T) { + executor := &strictToolExecutor{} + recorder := &completionRecorder{responses: [][]string{ + { + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"{\"tool_call\":{\"name\":\"layout_suggest\",\"arguments\":{\"window_count\":2}}}"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + }, + { + `{"id":"chatcmpl-2","choices":[{"delta":{"content":"Layout applied"}}]}`, + `{"id":"chatcmpl-2","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + }, + }} + c := newChatCore(t, recorder.ServeHTTP, executor) + + send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Arrange this workspace"}, + )) + core.RequireTrue(t, send.OK) + + calls := executor.Calls() + core.AssertLen(t, calls, 1) + core.AssertEqual(t, "layout_suggest", calls[0].Name) + core.AssertEqual(t, float64(2), calls[0].Arguments["window_count"]) + + conv := latestConversation(t, c) + history := historyMessages(t, c, conv.ID, 0) + core.AssertLen(t, history, 4) + core.AssertEqual(t, "assistant", history[1].Role) + core.AssertLen(t, history[1].ToolCalls, 1) + core.AssertEqual(t, "tool", history[2].Role) + core.AssertContains(t, history[2].Content, "left-right") + core.AssertEqual(t, "Layout applied", history[3].Content) + + requests := recorder.Requests() + core.AssertLen(t, requests, 2) + core.RequireNotEmpty(t, requests[0].Messages) + systemPrompt, ok := requests[0].Messages[0].Content.(string) + core.RequireTrue(t, ok) + core.AssertTrue(t, core.HasPrefix(systemPrompt, "Available MCP tools:")) + core.AssertContains(t, systemPrompt, "layout_suggest") + core.AssertContains(t, systemPrompt, "You are a helpful assistant.") +} + +func TestToolCallHandler_Bad_UnknownToolErrorAppearsInConversation(t *core.T) { + executor := &strictToolExecutor{} + recorder := &completionRecorder{responses: [][]string{ + { + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"{\"tool_call\":{\"name\":\"missing_tool\",\"arguments\":{}}}"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + }, + { + `{"id":"chatcmpl-2","choices":[{"delta":{"content":"Could not run that tool"}}]}`, + `{"id":"chatcmpl-2","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + }, + }} + c := newChatCore(t, recorder.ServeHTTP, executor) + + send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Use the missing tool"}, + )) + core.RequireTrue(t, send.OK) + + conv := latestConversation(t, c) + history := historyMessages(t, c, conv.ID, 0) + core.AssertLen(t, history, 4) + core.AssertEqual(t, "tool", history[2].Role) + core.AssertContains(t, history[2].Content, "missing_tool") + core.AssertEqual(t, "Could not run that tool", history[3].Content) +} + +func TestToolCallHandler_Ugly_MalformedInlineToolCallDoesNotDispatch(t *core.T) { + executor := &strictToolExecutor{} + recorder := &completionRecorder{responses: [][]string{{ + `{"id":"chatcmpl-1","choices":[{"delta":{"content":"{\"tool_call\":{\"name\":\"layout_suggest\",\"arguments\":"}}]}`, + `{"id":"chatcmpl-1","choices":[{"finish_reason":"stop"}]}`, + `[DONE]`, + }}} + c := newChatCore(t, recorder.ServeHTTP, executor) + + send := c.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "content", Value: "Try malformed JSON"}, + )) + core.RequireTrue(t, send.OK) + + core.AssertEmpty(t, executor.Calls()) + core.AssertLen(t, recorder.Requests(), 1) + + conv := latestConversation(t, c) + history := historyMessages(t, c, conv.ID, 0) + core.AssertLen(t, history, 2) + core.AssertEqual(t, "assistant", history[1].Role) + core.AssertContains(t, history[1].Content, "tool_call") + core.AssertEmpty(t, history[1].ToolCalls) +} + +// AX7 generated source-matching smoke coverage. +func TestToolHandler_NewToolCallHandler_Good(t *core.T) { + // NewToolCallHandler + ax7Variant := "NewToolCallHandler:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewToolCallHandler(*new(ToolExecutor)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_NewToolCallHandler_Bad(t *core.T) { + // NewToolCallHandler + ax7Variant := "NewToolCallHandler:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewToolCallHandler(*new(ToolExecutor)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_NewToolCallHandler_Ugly(t *core.T) { + // NewToolCallHandler + ax7Variant := "NewToolCallHandler:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewToolCallHandler(*new(ToolExecutor)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolCallHandler_OnToolCall_Good(t *core.T) { + // ToolCallHandler OnToolCall + ax7Variant := "ToolCallHandler_OnToolCall:good" + core.AssertContains(t, ax7Variant, "good") + var subject noopToolCallHandler + result := core.Try(func() any { + got0, got1 := subject.OnToolCall(core.Background(), *new(ToolCall)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolCallHandler_OnToolCall_Bad(t *core.T) { + // ToolCallHandler OnToolCall + ax7Variant := "ToolCallHandler_OnToolCall:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject noopToolCallHandler + result := core.Try(func() any { + got0, got1 := subject.OnToolCall(core.Background(), *new(ToolCall)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolCallHandler_OnToolCall_Ugly(t *core.T) { + // ToolCallHandler OnToolCall + ax7Variant := "ToolCallHandler_OnToolCall:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject noopToolCallHandler + result := core.Try(func() any { + got0, got1 := subject.OnToolCall(core.Background(), *new(ToolCall)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolCallHandler_BuildToolManifest_Good(t *core.T) { + // ToolCallHandler BuildToolManifest + ax7Variant := "ToolCallHandler_BuildToolManifest:good" + core.AssertContains(t, ax7Variant, "good") + var subject noopToolCallHandler + result := core.Try(func() any { + got0 := subject.BuildToolManifest() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolCallHandler_BuildToolManifest_Bad(t *core.T) { + // ToolCallHandler BuildToolManifest + ax7Variant := "ToolCallHandler_BuildToolManifest:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject noopToolCallHandler + result := core.Try(func() any { + got0 := subject.BuildToolManifest() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolCallHandler_BuildToolManifest_Ugly(t *core.T) { + // ToolCallHandler BuildToolManifest + ax7Variant := "ToolCallHandler_BuildToolManifest:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject noopToolCallHandler + result := core.Try(func() any { + got0 := subject.BuildToolManifest() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolExecutor_Manifest_Good(t *core.T) { + // ToolExecutor Manifest + ax7Variant := "ToolExecutor_Manifest:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0 := subject.Manifest() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolExecutor_Manifest_Bad(t *core.T) { + // ToolExecutor Manifest + ax7Variant := "ToolExecutor_Manifest:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0 := subject.Manifest() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolExecutor_Manifest_Ugly(t *core.T) { + // ToolExecutor Manifest + ax7Variant := "ToolExecutor_Manifest:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0 := subject.Manifest() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolExecutor_ManifestText_Good(t *core.T) { + // ToolExecutor ManifestText + ax7Variant := "ToolExecutor_ManifestText:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0 := subject.ManifestText() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolExecutor_ManifestText_Bad(t *core.T) { + // ToolExecutor ManifestText + ax7Variant := "ToolExecutor_ManifestText:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0 := subject.ManifestText() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolExecutor_ManifestText_Ugly(t *core.T) { + // ToolExecutor ManifestText + ax7Variant := "ToolExecutor_ManifestText:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0 := subject.ManifestText() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolExecutor_CallTool_Good(t *core.T) { + // ToolExecutor CallTool + ax7Variant := "ToolExecutor_CallTool:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0, got1 := subject.CallTool(core.Background(), "agent", nil) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolExecutor_CallTool_Bad(t *core.T) { + // ToolExecutor CallTool + ax7Variant := "ToolExecutor_CallTool:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0, got1 := subject.CallTool(core.Background(), "", nil) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestToolHandler_ToolExecutor_CallTool_Ugly(t *core.T) { + // ToolExecutor CallTool + ax7Variant := "ToolExecutor_CallTool:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(actionToolExecutor) + result := core.Try(func() any { + got0, got1 := subject.CallTool(core.Background(), "../../edge", nil) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/clipboard/core_helpers_test.go b/display/webkit/pkg/clipboard/core_helpers_test.go new file mode 100644 index 0000000..6d8e7d3 --- /dev/null +++ b/display/webkit/pkg/clipboard/core_helpers_test.go @@ -0,0 +1,12 @@ +package clipboard + +func bytesRepeat(value []byte, count int) []byte { + if count <= 0 || len(value) == 0 { + return nil + } + out := make([]byte, 0, len(value)*count) + for i := 0; i < count; i++ { + out = append(out, value...) + } + return out +} diff --git a/display/webkit/pkg/clipboard/messages.go b/display/webkit/pkg/clipboard/messages.go new file mode 100644 index 0000000..448cb06 --- /dev/null +++ b/display/webkit/pkg/clipboard/messages.go @@ -0,0 +1,26 @@ +// pkg/clipboard/messages.go +package clipboard + +const MaxImageBytes = 16 << 20 + +// QueryText reads the clipboard. Result: ClipboardContent +type QueryText struct{} + +// QueryImage reads image data from the clipboard. Result: ImageContent +type QueryImage struct{} + +// TaskSetText writes text to the clipboard. Result: bool (success) +type TaskSetText struct{ Text string } + +// TaskSetImage writes image data to the clipboard. Result: bool (success) +type TaskSetImage struct{ Data []byte } + +// TaskClear clears the clipboard. Result: bool (success) +type TaskClear struct{} + +// ClipboardImageContent contains clipboard image data encoded for transport. +type ClipboardImageContent struct { + Base64 string `json:"base64"` + MimeType string `json:"mimeType"` + HasContent bool `json:"hasContent"` +} diff --git a/display/webkit/pkg/clipboard/messages_example_test.go b/display/webkit/pkg/clipboard/messages_example_test.go new file mode 100644 index 0000000..db72e43 --- /dev/null +++ b/display/webkit/pkg/clipboard/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package clipboard diff --git a/display/webkit/pkg/clipboard/messages_test.go b/display/webkit/pkg/clipboard/messages_test.go new file mode 100644 index 0000000..0b1389c --- /dev/null +++ b/display/webkit/pkg/clipboard/messages_test.go @@ -0,0 +1 @@ +package clipboard diff --git a/display/webkit/pkg/clipboard/platform.go b/display/webkit/pkg/clipboard/platform.go new file mode 100644 index 0000000..7e9516f --- /dev/null +++ b/display/webkit/pkg/clipboard/platform.go @@ -0,0 +1,25 @@ +package clipboard + +// Platform abstracts the system clipboard backend. +type Platform interface { + Text() (string, bool) + SetText(text string) bool +} + +// ImagePlatform is an optional extension for clipboard backends that support images. +type ImagePlatform interface { + Image() ([]byte, bool) + SetImage(data []byte) bool +} + +// ClipboardContent is the result of QueryText. +type ClipboardContent struct { + Text string `json:"text"` + HasContent bool `json:"hasContent"` +} + +// ImageContent is the result of QueryImage. +type ImageContent struct { + Data []byte `json:"data"` + HasImage bool `json:"hasImage"` +} diff --git a/display/webkit/pkg/clipboard/platform_example_test.go b/display/webkit/pkg/clipboard/platform_example_test.go new file mode 100644 index 0000000..db72e43 --- /dev/null +++ b/display/webkit/pkg/clipboard/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package clipboard diff --git a/display/webkit/pkg/clipboard/platform_test.go b/display/webkit/pkg/clipboard/platform_test.go new file mode 100644 index 0000000..0b1389c --- /dev/null +++ b/display/webkit/pkg/clipboard/platform_test.go @@ -0,0 +1 @@ +package clipboard diff --git a/display/webkit/pkg/clipboard/result_failure.go b/display/webkit/pkg/clipboard/result_failure.go new file mode 100644 index 0000000..e5269e4 --- /dev/null +++ b/display/webkit/pkg/clipboard/result_failure.go @@ -0,0 +1,5 @@ +package clipboard + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/clipboard/service.go b/display/webkit/pkg/clipboard/service.go new file mode 100644 index 0000000..e5af7b1 --- /dev/null +++ b/display/webkit/pkg/clipboard/service.go @@ -0,0 +1,136 @@ +// pkg/clipboard/service.go +package clipboard + +import ( + "context" + "encoding/base64" + + core "dappco.re/go" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// Register(p) binds the clipboard service to a Core instance. +// c.WithService(clipboard.Register(wailsClipboard)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, OK: true} + } +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + s.Core().RegisterQuery(s.handleQuery) + setText := func(_ context.Context, opts core.Options) core.Result { + success := s.platform.SetText(opts.String("text")) + return core.Result{Value: success, OK: true} + } + setImage := func(_ context.Context, opts core.Options) core.Result { + imgPlatform, ok := s.platform.(ImagePlatform) + if !ok { + return core.Result{Value: false, OK: true} + } + data, err := clipboardImageData(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + if len(data) == 0 || len(data) > MaxImageBytes { + return core.Result{Value: false, OK: true} + } + success := imgPlatform.SetImage(data) + return core.Result{Value: success, OK: true} + } + clear := func(_ context.Context, _ core.Options) core.Result { + oldText, hadText := s.platform.Text() + var oldImage []byte + var hadImage bool + if imgPlatform, ok := s.platform.(ImagePlatform); ok { + oldImage, hadImage = imgPlatform.Image() + oldImage = append([]byte(nil), oldImage...) + if !imgPlatform.SetImage(nil) { + return core.Result{Value: false, OK: true} + } + if !s.platform.SetText("") { + if hadImage { + if !imgPlatform.SetImage(oldImage) { + return core.Result{Value: false, OK: true} + } + } + return core.Result{Value: false, OK: true} + } + return core.Result{Value: true, OK: true} + } + success := s.platform.SetText("") + if !success && hadText { + if !s.platform.SetText(oldText) { + return core.Result{Value: false, OK: true} + } + } + return core.Result{Value: success, OK: true} + } + read := func(_ context.Context, _ core.Options) core.Result { + text, ok := s.platform.Text() + return core.Result{Value: ClipboardContent{Text: text, HasContent: ok && text != ""}, OK: true} + } + s.Core().Action("clipboard.set_text", setText) + s.Core().Action("gui.clipboard.write", setText) + s.Core().Action("clipboard.set_image", setImage) + s.Core().Action("gui.clipboard.writeImage", setImage) + s.Core().Action("gui.clipboard.readImage", func(_ context.Context, _ core.Options) core.Result { + imgPlatform, ok := s.platform.(ImagePlatform) + if !ok { + return core.Result{Value: ImageContent{}, OK: true} + } + data, hasImage := imgPlatform.Image() + return core.Result{Value: ImageContent{Data: append([]byte(nil), data...), HasImage: hasImage && len(data) > 0}, OK: true} + }) + s.Core().Action("clipboard.clear", clear) + s.Core().Action("gui.clipboard.clear", clear) + s.Core().Action("gui.clipboard.read", read) + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case QueryText: + text, ok := s.platform.Text() + return core.Result{Value: ClipboardContent{Text: text, HasContent: ok && text != ""}, OK: true} + case QueryImage: + imgPlatform, ok := s.platform.(ImagePlatform) + if !ok { + return core.Result{Value: ImageContent{}, OK: true} + } + data, hasImage := imgPlatform.Image() + return core.Result{Value: ImageContent{Data: append([]byte(nil), data...), HasImage: hasImage && len(data) > 0}, OK: true} + default: + return core.Result{} + } +} + +// clipboardImageData normalizes clipboard image inputs from MCP, preload bridge, and WS callers. +// Use: bytes, err := clipboardImageData(core.NewOptions(core.Option{Key: "data", Value: "iVBORw0KGgo..."})) +func clipboardImageData(opts core.Options) ([]byte, resultFailure) { + if raw, ok := opts.Get("data").Value.([]byte); ok && len(raw) > 0 { + return append([]byte(nil), raw...), nil + } + encoded := opts.String("data") + if encoded == "" { + return nil, nil + } + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, core.E("clipboard.imageData", "invalid base64 image data", err) + } + return data, nil +} diff --git a/display/webkit/pkg/clipboard/service_example_test.go b/display/webkit/pkg/clipboard/service_example_test.go new file mode 100644 index 0000000..7a30dd1 --- /dev/null +++ b/display/webkit/pkg/clipboard/service_example_test.go @@ -0,0 +1,23 @@ +//go:build compliance + +package clipboard + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/clipboard/service_test.go b/display/webkit/pkg/clipboard/service_test.go new file mode 100644 index 0000000..1a41326 --- /dev/null +++ b/display/webkit/pkg/clipboard/service_test.go @@ -0,0 +1,215 @@ +// pkg/clipboard/service_test.go +package clipboard + +import ( + "context" + + core "dappco.re/go" +) + +type mockPlatform struct { + text string + ok bool + image []byte + hasImage bool +} + +func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok } +func (m *mockPlatform) SetText(text string) bool { + m.text = text + m.ok = text != "" + return true +} +func (m *mockPlatform) Image() ([]byte, bool) { return m.image, m.hasImage } +func (m *mockPlatform) SetImage(data []byte) bool { + m.image = append([]byte(nil), data...) + m.hasImage = len(data) > 0 + return true +} + +func newTestService(t *core.T) (*Service, *core.Core) { + t.Helper() + c := core.New( + core.WithService(Register(&mockPlatform{text: "hello", ok: true})), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "clipboard") + return svc, c +} + +func TestRegister_Good(t *core.T) { + svc, _ := newTestService(t) + core.AssertNotNil(t, svc) + core.AssertNotEmpty(t, core.Sprintf("%T", svc)) +} + +func TestQueryText_Good(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryText{}) + core.RequireTrue(t, r.OK) + content := r.Value.(ClipboardContent) + core.AssertEqual(t, "hello", content.Text) + core.AssertTrue(t, content.HasContent) +} + +func TestQueryText_Bad(t *core.T) { + // No clipboard service registered + c := core.New(core.WithServiceLock()) + r := c.QUERY(QueryText{}) + core.AssertFalse(t, r.OK) +} + +func TestTaskSetText_Good(t *core.T) { + _, c := newTestService(t) + r := c.Action("clipboard.set_text").Run(context.Background(), core.NewOptions( + core.Option{Key: "text", Value: "world"}, + )) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, true, r.Value) + + // Verify via query + qr := c.QUERY(QueryText{}) + core.AssertEqual(t, "world", qr.Value.(ClipboardContent).Text) +} + +func TestTaskClear_Good(t *core.T) { + _, c := newTestService(t) + r := c.Action("clipboard.clear").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, r.OK) + + // Verify empty + qr := c.QUERY(QueryText{}) + core.AssertEqual(t, "", qr.Value.(ClipboardContent).Text) + core.AssertFalse(t, qr.Value.(ClipboardContent).HasContent) +} + +func TestQueryImage_Good(t *core.T) { + _, c := newTestService(t) + r := c.Action("clipboard.set_image").Run(context.Background(), core.NewOptions( + core.Option{Key: "data", Value: []byte{0x89, 0x50, 0x4e, 0x47}}, + )) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, true, r.Value) + + result := c.QUERY(QueryImage{}) + core.RequireTrue(t, result.OK) + content := result.Value.(ImageContent) + core.AssertTrue(t, content.HasImage) + core.AssertEqual(t, []byte{0x89, 0x50, 0x4e, 0x47}, content.Data) +} + +func TestTaskSetImage_RejectsOversize(t *core.T) { + _, c := newTestService(t) + r := c.Action("clipboard.set_image").Run(context.Background(), core.NewOptions( + core.Option{Key: "data", Value: bytesRepeat([]byte("x"), MaxImageBytes+1)}, + )) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, false, r.Value) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/clipboard/wails.go b/display/webkit/pkg/clipboard/wails.go new file mode 100644 index 0000000..75c6506 --- /dev/null +++ b/display/webkit/pkg/clipboard/wails.go @@ -0,0 +1,45 @@ +// pkg/clipboard/wails.go +package clipboard + +import ( + "github.com/wailsapp/wails/v3/pkg/application" +) + +// WailsPlatform implements Platform via Wails v3's ClipboardManager. +// +// Wails alpha.83 only exposes text — no image API. We satisfy the +// base Platform interface (Text / SetText) and skip the optional +// ImagePlatform extension; consumers calling clipboard.set_image / +// clipboard.get_image will see the gui service's "platform unavailable +// for images" path until upstream lands an image API. +// +// wp := clipboard.NewWailsPlatform(app) +// core.WithService(clipboard.Register(wp)) +type WailsPlatform struct { + app *application.App +} + +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +// Text reads the current clipboard text. Wails returns ("", false) when +// the clipboard is empty or holds non-text content; we surface that +// shape directly so the gui Service's empty-clipboard handling still +// fires the "no content" branch. +func (wp *WailsPlatform) Text() (string, bool) { + if wp == nil || wp.app == nil || wp.app.Clipboard == nil { + return "", false + } + return wp.app.Clipboard.Text() +} + +// SetText writes text to the clipboard. Wails's bool return reports +// whether the underlying platform call succeeded — we forward it +// unchanged so callers can branch on success. +func (wp *WailsPlatform) SetText(text string) bool { + if wp == nil || wp.app == nil || wp.app.Clipboard == nil { + return false + } + return wp.app.Clipboard.SetText(text) +} diff --git a/display/webkit/pkg/container/core_helpers.go b/display/webkit/pkg/container/core_helpers.go new file mode 100644 index 0000000..051cd1b --- /dev/null +++ b/display/webkit/pkg/container/core_helpers.go @@ -0,0 +1,84 @@ +package container + +import ( + "context" + + core "dappco.re/go" +) + +func lookPath(file string) (string, resultFailure) { + name := core.Trim(file) + if name == "" { + return "", core.NewError("executable name is empty") + } + if core.Contains(name, string(core.PathSeparator)) || core.PathIsAbs(name) { + if executablePath(name) { + return name, nil + } + return "", core.Errorf("executable file not found in path: %s", file) + } + for _, dir := range core.Split(core.Getenv("PATH"), string(core.PathListSeparator)) { + if core.Trim(dir) == "" { + continue + } + candidate := core.PathJoin(dir, name) + if executablePath(candidate) { + return candidate, nil + } + } + return "", core.Errorf("executable file not found in path: %s", file) +} + +func executablePath(path string) bool { + result := core.Stat(path) + if !result.OK { + return false + } + info := result.Value.(core.FsFileInfo) + return !info.IsDir() && info.Mode().Perm()&0o111 != 0 +} + +func commandContext(ctx context.Context, binary string, args ...string) *core.Cmd { + cmd := &core.Cmd{Path: binary, Args: append([]string{binary}, args...)} + if ctx != nil { + go func() { + <-ctx.Done() + if cmd.Process != nil { + if err := cmd.Process.Kill(); err != nil { + core.Error("failed to kill container command", "err", err) + } + } + }() + } + return cmd +} + +func command(binary string, args ...string) *core.Cmd { + return commandContext(nil, binary, args...) +} + +func coreWriteMode(path, content string, mode core.FileMode) resultFailure { + result := core.WriteFile(path, []byte(content), mode) + if result.OK { + return nil + } + if err, ok := result.Value.(error); ok { + return err + } + return core.NewError(result.Error()) +} + +func cut(value, sep string) (string, string, bool) { + parts := core.SplitN(value, sep, 2) + if len(parts) != 2 { + return value, "", false + } + return parts[0], parts[1], true +} + +func cutPrefix(value, prefix string) (string, bool) { + if !core.HasPrefix(value, prefix) { + return value, false + } + return core.TrimPrefix(value, prefix), true +} diff --git a/display/webkit/pkg/container/detect.go b/display/webkit/pkg/container/detect.go new file mode 100644 index 0000000..62a8661 --- /dev/null +++ b/display/webkit/pkg/container/detect.go @@ -0,0 +1,101 @@ +package container + +import ( + "runtime" + "strconv" + + core "dappco.re/go" +) + +// ContainerRuntime describes the preferred isolated workload runtime for CoreGUI. +// +// runtime := container.Detect() +// if runtime == container.RuntimeApple { /* prefer Apple Containers */ } +type ContainerRuntime string + +const ( + RuntimeNone ContainerRuntime = "" + RuntimeApple ContainerRuntime = "apple" + RuntimeDocker ContainerRuntime = "docker" + RuntimePodman ContainerRuntime = "podman" +) + +// DetectEnvironment controls runtime detection for tests and other callers. +// +// runtime := container.DetectWithEnvironment(container.DetectEnvironment{ +// GOOS: "darwin", +// ProductVersion: "26.0", +// }) +type DetectEnvironment struct { + GOOS string + ProductVersion string + LookPath func(file string) (string, resultFailure) +} + +// Detect prefers Apple Containers on macOS 26+, then Docker, then Podman. +// +// runtime := container.Detect() +func Detect() ContainerRuntime { + environment := DetectEnvironment{ + GOOS: runtime.GOOS, + ProductVersion: "", + LookPath: lookPath, + } + if runtime.GOOS == "darwin" { + environment.ProductVersion = productVersion() + } + return DetectWithEnvironment(environment) +} + +// DetectWithEnvironment applies the RFC runtime ordering using an explicit environment. +// +// runtime := container.DetectWithEnvironment(env) +func DetectWithEnvironment(environment DetectEnvironment) ContainerRuntime { + resolvePath := environment.LookPath + if resolvePath == nil { + resolvePath = lookPath + } + + goos := core.Lower(core.Trim(environment.GOOS)) + if goos == "darwin" && majorVersion(environment.ProductVersion) >= 26 { + if hasBinary(resolvePath, "container") || hasBinary(resolvePath, "apple-container") || hasBinary(resolvePath, "containerctl") { + return RuntimeApple + } + } + if hasBinary(resolvePath, "docker") { + return RuntimeDocker + } + if hasBinary(resolvePath, "podman") { + return RuntimePodman + } + return RuntimeNone +} + +func hasBinary(lookPath func(string) (string, resultFailure), binary string) bool { + if core.Trim(binary) == "" { + return false + } + _, err := lookPath(binary) + return err == nil +} + +func majorVersion(productVersion string) int { + productVersion = core.Trim(productVersion) + if productVersion == "" { + return 0 + } + major, _, _ := cut(productVersion, ".") + value, err := strconv.Atoi(major) + if err != nil { + return 0 + } + return value +} + +func productVersion() string { + output, err := command("sw_vers", "-productVersion").Output() + if err != nil { + return "" + } + return core.Trim(string(output)) +} diff --git a/display/webkit/pkg/container/detect_example_test.go b/display/webkit/pkg/container/detect_example_test.go new file mode 100644 index 0000000..2b46c15 --- /dev/null +++ b/display/webkit/pkg/container/detect_example_test.go @@ -0,0 +1,17 @@ +//go:build compliance + +package container + +import core "dappco.re/go" + +func ExampleDetect() { + core.Println("Detect") + // Output: + // Detect +} + +func ExampleDetectWithEnvironment() { + core.Println("DetectWithEnvironment") + // Output: + // DetectWithEnvironment +} diff --git a/display/webkit/pkg/container/detect_test.go b/display/webkit/pkg/container/detect_test.go new file mode 100644 index 0000000..955bd10 --- /dev/null +++ b/display/webkit/pkg/container/detect_test.go @@ -0,0 +1,193 @@ +package container + +import ( + core "dappco.re/go" +) + +func TestDetectWithEnvironment_PrefersAppleContainersOnMacOS26(t *core.T) { + runtime := DetectWithEnvironment(DetectEnvironment{ + GOOS: "darwin", + ProductVersion: "26.0", + LookPath: func(file string) (string, resultFailure) { + if file == "container" { + return "/usr/bin/container", nil + } + return "", core.NewError("not found") + }, + }) + + core.AssertEqual(t, RuntimeApple, runtime) +} + +func TestDetectWithEnvironment_FallsBackToDockerWhenAppleUnavailable(t *core.T) { + runtime := DetectWithEnvironment(DetectEnvironment{ + GOOS: "darwin", + ProductVersion: "26.1", + LookPath: func(file string) (string, resultFailure) { + if file == "docker" { + return "/usr/local/bin/docker", nil + } + return "", core.NewError("not found") + }, + }) + + core.AssertEqual(t, RuntimeDocker, runtime) +} + +func TestDetectWithEnvironment_UsesDockerOnNonMacHosts(t *core.T) { + runtime := DetectWithEnvironment(DetectEnvironment{ + GOOS: "linux", + ProductVersion: "", + LookPath: func(file string) (string, resultFailure) { + if file == "docker" { + return "/usr/bin/docker", nil + } + return "", core.NewError("not found") + }, + }) + + core.AssertEqual(t, RuntimeDocker, runtime) +} + +func TestDetectWithEnvironment_UsesPodmanWhenDockerMissing(t *core.T) { + runtime := DetectWithEnvironment(DetectEnvironment{ + GOOS: "linux", + ProductVersion: "", + LookPath: func(file string) (string, resultFailure) { + if file == "podman" { + return "/usr/bin/podman", nil + } + return "", core.NewError("not found") + }, + }) + + core.AssertEqual(t, RuntimePodman, runtime) +} + +func TestDetectWithEnvironment_ReturnsNoneWhenNoRuntimeIsAvailable(t *core.T) { + runtime := DetectWithEnvironment(DetectEnvironment{ + GOOS: "linux", + ProductVersion: "", + LookPath: func(string) (string, resultFailure) { + return "", core.NewError("not found") + }, + }) + + core.AssertEqual(t, RuntimeNone, runtime) +} + +func TestMajorVersion(t *core.T) { + core.AssertEqual(t, 26, majorVersion("26.0")) + core.AssertEqual(t, 0, majorVersion("bogus")) + core.AssertEqual(t, 0, majorVersion("")) +} + +func TestDetect_Good(t *core.T) { + binDir := t.TempDir() + containerPath := writeExecutable(t, binDir, "container", "#!/bin/sh\nexit 0\n") + + runtime := DetectWithEnvironment(DetectEnvironment{ + GOOS: "darwin", + ProductVersion: "26.0", + LookPath: func(file string) (string, resultFailure) { + if file == "container" { + return containerPath, nil + } + return "", core.NewError("not found") + }, + }) + + core.AssertEqual(t, RuntimeApple, runtime) +} + +func TestDetect_Bad(t *core.T) { + binDir := t.TempDir() + writeExecutable(t, binDir, "sw_vers", "#!/bin/sh\nprintf '25.0\\n'\n") + writeExecutable(t, binDir, "docker", "#!/bin/sh\nexit 0\n") + t.Setenv("PATH", binDir) + + core.AssertEqual(t, RuntimeDocker, Detect()) +} + +func TestDetect_Ugly(t *core.T) { + binDir := t.TempDir() + writeExecutable(t, binDir, "sw_vers", "#!/bin/sh\nprintf 'not-a-version\\n'\n") + t.Setenv("PATH", binDir) + + core.AssertEqual(t, RuntimeNone, Detect()) +} + +func writeExecutable(t *core.T, dir, name, script string) string { + t.Helper() + + path := core.PathJoin(dir, name) + core.RequireNoError(t, coreWriteMode(path, script, 0o755)) + return path +} + +// AX7 generated source-matching smoke coverage. +func TestDetect_Detect_Good(t *core.T) { + // Detect + ax7Variant := "Detect:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := Detect() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDetect_Detect_Bad(t *core.T) { + // Detect + ax7Variant := "Detect:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := Detect() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDetect_Detect_Ugly(t *core.T) { + // Detect + ax7Variant := "Detect:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := Detect() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDetect_DetectWithEnvironment_Good(t *core.T) { + // DetectWithEnvironment + ax7Variant := "DetectWithEnvironment:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := DetectWithEnvironment(*new(DetectEnvironment)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDetect_DetectWithEnvironment_Bad(t *core.T) { + // DetectWithEnvironment + ax7Variant := "DetectWithEnvironment:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := DetectWithEnvironment(*new(DetectEnvironment)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDetect_DetectWithEnvironment_Ugly(t *core.T) { + // DetectWithEnvironment + ax7Variant := "DetectWithEnvironment:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := DetectWithEnvironment(*new(DetectEnvironment)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/container/mode.go b/display/webkit/pkg/container/mode.go new file mode 100644 index 0000000..aa025ec --- /dev/null +++ b/display/webkit/pkg/container/mode.go @@ -0,0 +1,101 @@ +package container + +import ( + core "dappco.re/go" + "dappco.re/go/config" +) + +type AppMode string + +const ( + ModeManager AppMode = "manager" + ModeWorker AppMode = "worker" +) + +type ModeEnvironment struct { + Args []string + LookupEnv func(string) (string, bool) + ConfigValue func(string) string +} + +var argsFunc = core.Args + +// DetectMode resolves the RFC startup mode from CLI flags first, then config/env. +func DetectMode() AppMode { + cfg, _ := core.Cast[*config.Config](config.New()) + return DetectModeWithEnvironment(ModeEnvironment{ + Args: argsFunc()[1:], + LookupEnv: core.LookupEnv, + ConfigValue: func(key string) string { + if cfg == nil { + return "" + } + var value string + if !cfg.Get(key, &value).OK { + return "" + } + return value + }, + }) +} + +func DetectModeWithEnvironment(environment ModeEnvironment) AppMode { + args := environment.Args + if args == nil { + args = argsFunc()[1:] + } + + if value, found := modeArgValue(args); found { + if mode, ok := parseMode(value); ok { + return mode + } + } + + if environment.LookupEnv != nil { + if value, ok := environment.LookupEnv("CORE_GUI_MODE"); ok { + if mode, ok := parseMode(value); ok { + return mode + } + } + } + + if environment.ConfigValue != nil { + for _, key := range []string{"gui.mode", "display.mode", "mode"} { + if mode, ok := parseMode(environment.ConfigValue(key)); ok { + return mode + } + } + } + + return ModeManager +} + +func modeArgValue(args []string) (string, bool) { + for i := 0; i < len(args); i++ { + arg := core.Trim(args[i]) + if arg == "--mode" || arg == "-mode" { + if i+1 < len(args) { + return args[i+1], true + } + return "", true + } + if value, ok := cutPrefix(arg, "--mode="); ok { + return value, true + } + if value, ok := cutPrefix(arg, "-mode="); ok { + return value, true + } + } + return "", false +} + +func parseMode(value string) (AppMode, bool) { + switch core.Lower(core.Trim(value)) { + case string(ModeWorker): + return ModeWorker, true + case string(ModeManager): + return ModeManager, true + default: + return "", false + } +} diff --git a/display/webkit/pkg/container/mode_example_test.go b/display/webkit/pkg/container/mode_example_test.go new file mode 100644 index 0000000..c1a16bf --- /dev/null +++ b/display/webkit/pkg/container/mode_example_test.go @@ -0,0 +1,17 @@ +//go:build compliance + +package container + +import core "dappco.re/go" + +func ExampleDetectMode() { + core.Println("DetectMode") + // Output: + // DetectMode +} + +func ExampleDetectModeWithEnvironment() { + core.Println("DetectModeWithEnvironment") + // Output: + // DetectModeWithEnvironment +} diff --git a/display/webkit/pkg/container/mode_test.go b/display/webkit/pkg/container/mode_test.go new file mode 100644 index 0000000..90d68b7 --- /dev/null +++ b/display/webkit/pkg/container/mode_test.go @@ -0,0 +1,115 @@ +package container + +import core "dappco.re/go" + +func TestDetectModeWithEnvironment(t *core.T) { + mode := DetectModeWithEnvironment(ModeEnvironment{ + Args: []string{"--mode=worker"}, + }) + if mode != ModeWorker { + t.Fatalf("expected worker mode, got %q", mode) + } + + mode = DetectModeWithEnvironment(ModeEnvironment{ + LookupEnv: func(key string) (string, bool) { + if key == "CORE_GUI_MODE" { + return "manager", true + } + return "", false + }, + }) + if mode != ModeManager { + t.Fatalf("expected manager mode, got %q", mode) + } + + mode = DetectModeWithEnvironment(ModeEnvironment{ + LookupEnv: func(key string) (string, bool) { + if key == "CORE_GUI_MODE" { + return "worker", true + } + return "", false + }, + }) + if mode != ModeWorker { + t.Fatalf("expected worker mode from environment, got %q", mode) + } +} + +func TestMode_DetectMode_Good(t *core.T) { + // DetectMode + ax7Variant := "DetectMode:good" + core.AssertContains(t, ax7Variant, "good") + oldArgsFunc := argsFunc + argsFunc = func() []string { return []string{"core-gui", "--mode=worker"} } + t.Cleanup(func() { argsFunc = oldArgsFunc }) + t.Setenv("CORE_GUI_MODE", "manager") + + mode := DetectMode() + if mode != ModeWorker { + t.Fatalf("expected worker mode, got %q", mode) + } +} + +func TestMode_DetectMode_Bad(t *core.T) { + // DetectMode + ax7Variant := "DetectMode:bad" + core.AssertContains(t, ax7Variant, "bad") + oldArgsFunc := argsFunc + argsFunc = func() []string { return []string{"core-gui"} } + t.Cleanup(func() { argsFunc = oldArgsFunc }) + t.Setenv("CORE_GUI_MODE", "bogus") + + mode := DetectMode() + if mode != ModeManager { + t.Fatalf("expected manager mode, got %q", mode) + } +} + +func TestMode_DetectMode_Ugly(t *core.T) { + // DetectMode + ax7Variant := "DetectMode:ugly" + core.AssertContains(t, ax7Variant, "ugly") + oldArgsFunc := argsFunc + argsFunc = func() []string { return []string{"core-gui", "--unexpected=flag"} } + t.Cleanup(func() { argsFunc = oldArgsFunc }) + t.Setenv("CORE_GUI_MODE", " \tbogus\n") + + mode := DetectMode() + if mode != ModeManager { + t.Fatalf("expected manager mode after malformed input, got %q", mode) + } +} + +// AX7 generated source-matching smoke coverage. +func TestMode_DetectModeWithEnvironment_Good(t *core.T) { + // DetectModeWithEnvironment + ax7Variant := "DetectModeWithEnvironment:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := DetectModeWithEnvironment(*new(ModeEnvironment)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMode_DetectModeWithEnvironment_Bad(t *core.T) { + // DetectModeWithEnvironment + ax7Variant := "DetectModeWithEnvironment:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := DetectModeWithEnvironment(*new(ModeEnvironment)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMode_DetectModeWithEnvironment_Ugly(t *core.T) { + // DetectModeWithEnvironment + ax7Variant := "DetectModeWithEnvironment:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := DetectModeWithEnvironment(*new(ModeEnvironment)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/container/result_failure.go b/display/webkit/pkg/container/result_failure.go new file mode 100644 index 0000000..bef1aaf --- /dev/null +++ b/display/webkit/pkg/container/result_failure.go @@ -0,0 +1,5 @@ +package container + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/container/service.go b/display/webkit/pkg/container/service.go new file mode 100644 index 0000000..2f5c349 --- /dev/null +++ b/display/webkit/pkg/container/service.go @@ -0,0 +1,151 @@ +package container + +import ( + "context" + "regexp" + "unicode" + + core "dappco.re/go" +) + +type Service struct { + *core.ServiceRuntime[TIMOptions] + manager *TIMManager + startupErr error +} + +var timContainerNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) + +func NewService(c *core.Core, options TIMOptions) *Service { + options, err := normalizeTIMOptions(options) + if options.Exec == nil && c != nil { + options.Exec = func(ctx context.Context, name string, args ...string) resultFailure { + result := c.Process().Run(ctx, name, args...) + if result.OK { + return nil + } + if err, ok := result.Value.(error); ok { + return err + } + return core.E("container.TIMManager.Exec", result.Error(), nil) + } + } + return &Service{ + ServiceRuntime: core.NewServiceRuntime(c, options), + manager: NewTIMManager(options), + startupErr: err, + } +} + +func OptionsFromEnv() TIMOptions { + options, _ := OptionsFromEnvValidated() + return options +} + +func OptionsFromEnvValidated() (TIMOptions, resultFailure) { + return TIMOptions{ + Name: core.Trim(core.Env("CORE_TIM_NAME")), + Image: core.Trim(core.Env("CORE_TIM_IMAGE")), + Command: splitCSV(core.Trim(core.Env("CORE_TIM_COMMAND"))), + DataDir: core.Trim(core.Env("CORE_TIM_DATA_DIR")), + Resources: TIMResources{ + GPU: core.Trim(core.Env("CORE_TIM_GPU")), + }, + }.Validate() +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + if s.startupErr != nil { + return core.Result{Value: s.startupErr, OK: false} + } + s.Core().Action("container.runtime.detect", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: coalesceRuntime(s.manager.options.Runtime, s.manager.options.Detect()), OK: true} + }) + s.Core().Action("tim.start", func(ctx context.Context, _ core.Options) core.Result { + state, err := s.manager.Start(ctx) + return core.Result{}.New(state, err) + }) + s.Core().Action("tim.stop", func(ctx context.Context, _ core.Options) core.Result { + state, err := s.manager.Stop(ctx) + return core.Result{}.New(state, err) + }) + s.Core().Action("tim.status", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.manager.State(), OK: true} + }) + return core.Result{OK: true} +} + +func (options TIMOptions) Validate() (TIMOptions, resultFailure) { + return normalizeTIMOptions(options) +} + +func normalizeTIMOptions(options TIMOptions) (TIMOptions, resultFailure) { + options.Name = core.Trim(options.Name) + options.Image = core.Trim(options.Image) + options.DataDir = core.Trim(options.DataDir) + options.Resources.GPU = core.Trim(options.Resources.GPU) + + if err := validateTIMContainerName(options.Name); err != nil { + return options, err + } + if err := validateTIMArgValue("image", options.Image); err != nil { + return options, err + } + if err := validateTIMArgValue("gpu", options.Resources.GPU); err != nil { + return options, err + } + return options, nil +} + +func validateTIMContainerName(value string) resultFailure { + if value == "" { + return nil + } + if core.HasPrefix(value, "-") { + return core.E("container.validateTIMOptions", "name cannot start with -", nil) + } + if core.HasPrefix(value, ".") { + return core.E("container.validateTIMOptions", "name cannot start with .", nil) + } + if !timContainerNamePattern.MatchString(value) { + return core.E("container.validateTIMOptions", "name must contain only letters, digits, underscores, dots, and hyphens", nil) + } + return nil +} + +func validateTIMArgValue(label, value string) resultFailure { + if value == "" { + return nil + } + if core.HasPrefix(value, "-") { + return core.E("container.validateTIMOptions", label+" cannot start with -", nil) + } + for _, r := range value { + if unicode.IsControl(r) { + return core.E("container.validateTIMOptions", label+" contains invalid control characters", nil) + } + if unicode.IsSpace(r) { + return core.E("container.validateTIMOptions", label+" cannot contain whitespace", nil) + } + } + return nil +} + +func (s *Service) State() TIMState { + return s.manager.State() +} + +func splitCSV(value string) []string { + if core.Trim(value) == "" { + return nil + } + parts := core.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = core.Trim(part) + if part != "" { + result = append(result, part) + } + } + return result +} diff --git a/display/webkit/pkg/container/service_example_test.go b/display/webkit/pkg/container/service_example_test.go new file mode 100644 index 0000000..647e1c8 --- /dev/null +++ b/display/webkit/pkg/container/service_example_test.go @@ -0,0 +1,41 @@ +//go:build compliance + +package container + +import core "dappco.re/go" + +func ExampleNewService() { + core.Println("NewService") + // Output: + // NewService +} + +func ExampleOptionsFromEnv() { + core.Println("OptionsFromEnv") + // Output: + // OptionsFromEnv +} + +func ExampleOptionsFromEnvValidated() { + core.Println("OptionsFromEnvValidated") + // Output: + // OptionsFromEnvValidated +} + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleTIMOptions_Validate() { + core.Println("TIMOptions_Validate") + // Output: + // TIMOptions_Validate +} + +func ExampleService_State() { + core.Println("Service_State") + // Output: + // Service_State +} diff --git a/display/webkit/pkg/container/service_test.go b/display/webkit/pkg/container/service_test.go new file mode 100644 index 0000000..9a5ad49 --- /dev/null +++ b/display/webkit/pkg/container/service_test.go @@ -0,0 +1,525 @@ +package container + +import ( + "context" + "time" + + core "dappco.re/go" +) + +func newTestContainerService(t *core.T, options TIMOptions) (*Service, *core.Core) { + t.Helper() + + var svc *Service + c := core.New( + core.WithService(func(c *core.Core) core.Result { + svc = NewService(c, options) + return core.Result{Value: svc, OK: true} + }), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + core.AssertNotNil(t, svc) + return svc, c +} + +func newInvalidTestContainerService(t *core.T, options TIMOptions) (*Service, *core.Core, resultFailure) { + t.Helper() + + if options.Detect == nil { + options.Detect = func() ContainerRuntime { + return RuntimeDocker + } + } + + var svc *Service + c := core.New( + core.WithService(func(c *core.Core) core.Result { + svc = NewService(c, options) + return core.Result{Value: svc, OK: true} + }), + core.WithServiceLock(), + ) + result := c.ServiceStartup(context.Background(), nil) + core.AssertFalse(t, result.OK) + core.AssertNotNil(t, svc) + err, ok := result.Value.(resultFailure) + core.RequireTrue(t, ok) + return svc, c, err +} + +func TestService_OptionsFromEnv_GoodCase(t *core.T) { + t.Setenv("CORE_TIM_NAME", " worker ") + t.Setenv("CORE_TIM_IMAGE", " ghcr.io/example/tim:latest ") + t.Setenv("CORE_TIM_COMMAND", "run, --flag, , value ") + t.Setenv("CORE_TIM_DATA_DIR", " /var/lib/core-tim ") + t.Setenv("CORE_TIM_GPU", " all ") + + opts, err := OptionsFromEnvValidated() + + core.RequireNoError(t, err) + core.AssertEqual(t, "worker", opts.Name) + core.AssertEqual(t, "ghcr.io/example/tim:latest", opts.Image) + core.AssertEqual(t, []string{"run", "--flag", "value"}, opts.Command) + core.AssertEqual(t, "/var/lib/core-tim", opts.DataDir) + core.AssertEqual(t, "all", opts.Resources.GPU) +} + +func TestService_OptionsFromEnv_BadCase(t *core.T) { + t.Setenv("CORE_TIM_NAME", "") + t.Setenv("CORE_TIM_IMAGE", "") + t.Setenv("CORE_TIM_COMMAND", "") + t.Setenv("CORE_TIM_DATA_DIR", "") + t.Setenv("CORE_TIM_GPU", "") + + opts, err := OptionsFromEnvValidated() + + core.RequireNoError(t, err) + core.AssertEmpty(t, opts.Name) + core.AssertEmpty(t, opts.Image) + core.AssertNil(t, opts.Command) + core.AssertEmpty(t, opts.DataDir) + core.AssertEmpty(t, opts.Resources.GPU) +} + +func TestService_OptionsFromEnv_UglyCase(t *core.T) { + t.Setenv("CORE_TIM_NAME", " \t\n ") + t.Setenv("CORE_TIM_IMAGE", " \t ghcr.io/example/tim:latest \n") + t.Setenv("CORE_TIM_COMMAND", " , first ,, second , ") + t.Setenv("CORE_TIM_DATA_DIR", "\t /tmp/core-tim \n") + t.Setenv("CORE_TIM_GPU", "\t device=0 \n") + + opts, err := OptionsFromEnvValidated() + + core.RequireNoError(t, err) + core.AssertEmpty(t, opts.Name) + core.AssertEqual(t, "ghcr.io/example/tim:latest", opts.Image) + core.AssertEqual(t, []string{"first", "second"}, opts.Command) + core.AssertEqual(t, "/tmp/core-tim", opts.DataDir) + core.AssertEqual(t, "device=0", opts.Resources.GPU) +} + +func TestService_OptionsFromEnv_RejectsLeadingDash(t *core.T) { + cases := []struct { + name string + envKey string + value string + want string + }{ + {name: "name", envKey: "CORE_TIM_NAME", value: "-rm", want: "name cannot start with -"}, + {name: "image", envKey: "CORE_TIM_IMAGE", value: "--privileged", want: "image cannot start with -"}, + {name: "gpu", envKey: "CORE_TIM_GPU", value: "-it", want: "gpu cannot start with -"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + t.Setenv("CORE_TIM_NAME", "") + t.Setenv("CORE_TIM_IMAGE", "") + t.Setenv("CORE_TIM_COMMAND", "") + t.Setenv("CORE_TIM_DATA_DIR", "") + t.Setenv("CORE_TIM_GPU", "") + t.Setenv(tc.envKey, tc.value) + + _, err := OptionsFromEnvValidated() + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), tc.want) + }) + } +} + +func TestService_NewService_GoodCase(t *core.T) { + svc, _ := newTestContainerService(t, TIMOptions{ + Name: "normal-container", + Image: "alpine:3.19", + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + }) + + core.AssertNotNil(t, svc.manager) + state := svc.State() + core.AssertEqual(t, "normal-container", state.Name) + core.AssertEqual(t, "alpine:3.19", state.Image) + core.AssertEqual(t, RuntimeDocker, state.Runtime) + core.AssertEqual(t, "stopped", state.Status) +} + +func TestService_NewService_BadCase(t *core.T) { + svc, _ := newTestContainerService(t, TIMOptions{ + Detect: func() ContainerRuntime { + return RuntimeNone + }, + }) + + state := svc.State() + core.AssertEqual(t, "coregui-tim", state.Name) + core.AssertEqual(t, "ghcr.io/lthn/core/tim:latest", state.Image) + core.AssertEqual(t, RuntimeNone, state.Runtime) +} + +func TestService_NewService_UglyCase(t *core.T) { + svc, _ := newTestContainerService(t, TIMOptions{ + Name: " worker.node ", + Image: " ghcr.io/example/tim:edge ", + Command: []string{"alpha", "beta"}, + DataDir: " /tmp/data ", + Resources: TIMResources{ + CPUCores: 2, + MemoryMB: 512, + GPU: "all", + }, + Detect: func() ContainerRuntime { + return RuntimePodman + }, + }) + + state := svc.State() + core.AssertEqual(t, "worker.node", state.Name) + core.AssertEqual(t, "ghcr.io/example/tim:edge", state.Image) + core.AssertEqual(t, []string{"alpha", "beta"}, state.Command) + core.AssertEqual(t, "/tmp/data", state.DataDir) + core.AssertEqual(t, TIMResources{CPUCores: 2, MemoryMB: 512, GPU: "all"}, state.Resources) + core.AssertEqual(t, RuntimePodman, state.Runtime) +} + +func TestService_NewService_RejectsInvalidTIMOptions(t *core.T) { + cases := []struct { + name string + options TIMOptions + want string + }{ + { + name: "name leading dash", + options: TIMOptions{Name: "-rm", Image: "alpine:3.19"}, + want: "name cannot start with -", + }, + { + name: "image leading dash", + options: TIMOptions{Name: "normal-container", Image: "--privileged"}, + want: "image cannot start with -", + }, + { + name: "gpu leading dash", + options: TIMOptions{ + Name: "normal-container", + Image: "alpine:3.19", + Resources: TIMResources{GPU: "-it"}, + }, + want: "gpu cannot start with -", + }, + { + name: "name starts with dot", + options: TIMOptions{Name: ".hidden", Image: "alpine:3.19"}, + want: "name cannot start with .", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + _, _, err := newInvalidTestContainerService(t, tc.options) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), tc.want) + }) + } +} + +func TestService_OnStartup_GoodCase(t *core.T) { + var calls []string + svc, c := newTestContainerService(t, TIMOptions{ + Name: "coregui-tim", + Image: "ghcr.io/example/tim:latest", + Command: []string{ + "sleep", + "1", + }, + Resources: TIMResources{CPUCores: 2, MemoryMB: 512, GPU: "all"}, + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + Exec: func(_ context.Context, name string, args ...string) resultFailure { + calls = append(calls, append([]string{name}, args...)...) + return nil + }, + Now: func() time.Time { + return time.Unix(123, 0).UTC() + }, + }) + + runtime := c.Action("container.runtime.detect").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, runtime.OK) + core.AssertEqual(t, RuntimeDocker, runtime.Value) + + status := c.Action("tim.status").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, status.OK) + initial := status.Value.(TIMState) + core.AssertEqual(t, "stopped", initial.Status) + + started := c.Action("tim.start").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, started.OK) + startState := started.Value.(TIMState) + core.AssertEqual(t, "running", startState.Status) + core.AssertEqual(t, time.Unix(123, 0).UTC(), startState.StartedAt) + core.RequireNotEmpty(t, calls) + core.AssertEqual(t, "docker", calls[0]) + core.AssertContains(t, calls, "run") + core.AssertContains(t, calls, "--name") + core.AssertContains(t, calls, "coregui-tim") + core.AssertContains(t, calls, "--cpus") + core.AssertContains(t, calls, "2") + core.AssertContains(t, calls, "--memory") + core.AssertContains(t, calls, "512m") + core.AssertContains(t, calls, "--gpus") + core.AssertContains(t, calls, "all") + + stopped := c.Action("tim.stop").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, stopped.OK) + stopState := stopped.Value.(TIMState) + core.AssertEqual(t, "stopped", stopState.Status) + core.AssertEmpty(t, stopState.StartedAt) + core.AssertEqual(t, "coregui-tim", svc.State().Name) +} + +func TestService_OnStartup_BadCase(t *core.T) { + _, c := newTestContainerService(t, TIMOptions{ + Detect: func() ContainerRuntime { + return RuntimeNone + }, + }) + + result := c.Action("tim.start").Run(context.Background(), core.NewOptions()) + + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "no supported container runtime detected") +} + +func TestService_OnStartup_UglyCase(t *core.T) { + _, c := newTestContainerService(t, TIMOptions{ + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + Exec: func(context.Context, string, ...string) resultFailure { + return core.NewError("boom") + }, + }) + + result := c.Action("tim.start").Run(context.Background(), core.NewOptions()) + + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "boom") + + status := c.Action("tim.status").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, status.OK) + core.AssertEqual(t, "error", status.Value.(TIMState).Status) +} + +// AX7 generated source-matching smoke coverage. +func TestService_OptionsFromEnvValidated_Good(t *core.T) { + // OptionsFromEnvValidated + ax7Variant := "OptionsFromEnvValidated:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0, got1 := OptionsFromEnvValidated() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_OptionsFromEnvValidated_Bad(t *core.T) { + // OptionsFromEnvValidated + ax7Variant := "OptionsFromEnvValidated:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0, got1 := OptionsFromEnvValidated() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_OptionsFromEnvValidated_Ugly(t *core.T) { + // OptionsFromEnvValidated + ax7Variant := "OptionsFromEnvValidated:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0, got1 := OptionsFromEnvValidated() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_TIMOptions_Validate_Good(t *core.T) { + // TIMOptions Validate + ax7Variant := "TIMOptions_Validate:good" + core.AssertContains(t, ax7Variant, "good") + var subject TIMOptions + result := core.Try(func() any { + got0, got1 := subject.Validate() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_TIMOptions_Validate_Bad(t *core.T) { + // TIMOptions Validate + ax7Variant := "TIMOptions_Validate:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TIMOptions + result := core.Try(func() any { + got0, got1 := subject.Validate() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_TIMOptions_Validate_Ugly(t *core.T) { + // TIMOptions Validate + ax7Variant := "TIMOptions_Validate:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TIMOptions + result := core.Try(func() any { + got0, got1 := subject.Validate() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_State_Good(t *core.T) { + // Service State + ax7Variant := "Service_State:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.State() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_State_Bad(t *core.T) { + // Service State + ax7Variant := "Service_State:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.State() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_State_Ugly(t *core.T) { + // Service State + ax7Variant := "Service_State:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.State() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_NewService_Good(t *core.T) { + // NewService + ax7Variant := "NewService:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewService(core.New(), TIMOptions{Detect: func() ContainerRuntime { return RuntimeNone }}) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_NewService_Bad(t *core.T) { + // NewService + ax7Variant := "NewService:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewService(nil, TIMOptions{Detect: func() ContainerRuntime { return RuntimeNone }}) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_NewService_Ugly(t *core.T) { + // NewService + ax7Variant := "NewService:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewService(core.New(), TIMOptions{Name: "../../edge", Detect: func() ContainerRuntime { return RuntimeNone }}) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_OptionsFromEnv_Good(t *core.T) { + // OptionsFromEnv + ax7Variant := "OptionsFromEnv:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := OptionsFromEnv() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_OptionsFromEnv_Bad(t *core.T) { + // OptionsFromEnv + ax7Variant := "OptionsFromEnv:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := OptionsFromEnv() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_OptionsFromEnv_Ugly(t *core.T) { + // OptionsFromEnv + ax7Variant := "OptionsFromEnv:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := OptionsFromEnv() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/container/tim.go b/display/webkit/pkg/container/tim.go new file mode 100644 index 0000000..cc36123 --- /dev/null +++ b/display/webkit/pkg/container/tim.go @@ -0,0 +1,230 @@ +package container + +import ( + "context" + "sort" + "sync" + "time" + + core "dappco.re/go" +) + +type TIMOptions struct { + Name string + Image string + Command []string + WorkDir string + Env map[string]string + DataDir string + Runtime ContainerRuntime + Detect func() ContainerRuntime + Exec func(context.Context, string, ...string) resultFailure + Now func() time.Time + Resources TIMResources +} + +type TIMResources struct { + CPUCores int `json:"cpu_cores,omitempty"` + MemoryMB int `json:"memory_mb,omitempty"` + GPU string `json:"gpu,omitempty"` +} + +type TIMState struct { + Name string `json:"name"` + Image string `json:"image"` + Runtime ContainerRuntime `json:"runtime"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at,omitempty"` + Command []string `json:"command,omitempty"` + DataDir string `json:"data_dir,omitempty"` + Resources TIMResources `json:"resources,omitempty"` +} + +type TIMManager struct { + options TIMOptions + mu sync.Mutex + state TIMState +} + +func NewTIMManager(options TIMOptions) *TIMManager { + if core.Trim(options.Name) == "" { + options.Name = "coregui-tim" + } + if core.Trim(options.Image) == "" { + options.Image = "ghcr.io/lthn/core/tim:latest" + } + if options.Detect == nil { + options.Detect = Detect + } + if options.Exec == nil { + options.Exec = func(ctx context.Context, name string, args ...string) resultFailure { + cmd := commandContext(ctx, name, args...) + return cmd.Run() + } + } + if options.Now == nil { + options.Now = time.Now + } + return &TIMManager{ + options: options, + state: TIMState{ + Name: options.Name, + Image: options.Image, + Runtime: coalesceRuntime(options.Runtime, options.Detect()), + Status: "stopped", + Command: append([]string(nil), options.Command...), + DataDir: options.DataDir, + Resources: options.Resources, + }, + } +} + +func (m *TIMManager) State() TIMState { + m.mu.Lock() + defer m.mu.Unlock() + return cloneTIMState(m.state) +} + +func (m *TIMManager) Start(ctx context.Context) (TIMState, resultFailure) { + m.mu.Lock() + runtime := coalesceRuntime(m.options.Runtime, m.options.Detect()) + m.state.Runtime = runtime + if runtime == RuntimeNone { + state := cloneTIMState(m.state) + m.mu.Unlock() + return state, core.E("container.TIMManager.Start", "no supported container runtime detected", nil) + } + + command, args := m.runtimeCommand(runtime, "run") + m.mu.Unlock() + + if err := m.options.Exec(ctx, command, args...); err != nil { + m.mu.Lock() + m.state.Status = "error" + state := cloneTIMState(m.state) + m.mu.Unlock() + return state, core.E("container.TIMManager.Start", "failed to execute runtime start command", err) + } + + m.mu.Lock() + m.state.Status = "running" + m.state.StartedAt = m.options.Now() + state := cloneTIMState(m.state) + m.mu.Unlock() + return state, nil +} + +func (m *TIMManager) Stop(ctx context.Context) (TIMState, resultFailure) { + m.mu.Lock() + if m.state.Runtime == RuntimeNone { + m.state.Status = "stopped" + m.state.StartedAt = time.Time{} + state := cloneTIMState(m.state) + m.mu.Unlock() + return state, nil + } + command, args := m.runtimeCommand(m.state.Runtime, "stop") + m.mu.Unlock() + + if err := m.options.Exec(ctx, command, args...); err != nil { + m.mu.Lock() + m.state.Status = "error" + state := cloneTIMState(m.state) + m.mu.Unlock() + return state, core.E("container.TIMManager.Stop", "failed to execute runtime stop command", err) + } + + m.mu.Lock() + m.state.Status = "stopped" + m.state.StartedAt = time.Time{} + state := cloneTIMState(m.state) + m.mu.Unlock() + return state, nil +} + +func (m *TIMManager) runtimeCommand(runtime ContainerRuntime, verb string) (string, []string) { + name := m.options.Name + image := m.options.Image + switch runtime { + case RuntimeApple: + if verb == "run" { + args := append([]string{"run", "--name", name}, m.containerRunArgs()...) + args = append(args, image) + args = append(args, m.options.Command...) + return "container", args + } + return "container", []string{"stop", name} + case RuntimePodman: + if verb == "run" { + args := []string{"run", "-d", "--replace", "--name", name} + args = append(args, resourceArgs(m.options.Resources)...) + args = append(args, m.containerRunArgs()...) + args = append(args, image) + args = append(args, m.options.Command...) + return "podman", args + } + return "podman", []string{"stop", name} + default: + if verb == "run" { + args := []string{"run", "-d", "--rm", "--name", name} + args = append(args, resourceArgs(m.options.Resources)...) + args = append(args, m.containerRunArgs()...) + args = append(args, image) + args = append(args, m.options.Command...) + return "docker", args + } + return "docker", []string{"stop", name} + } +} + +func (m *TIMManager) containerRunArgs() []string { + var args []string + if dataDir := core.Trim(m.options.DataDir); dataDir != "" { + args = append(args, "-v", dataDir+":"+dataDir) + } + if workDir := core.Trim(m.options.WorkDir); workDir != "" { + args = append(args, "-w", workDir) + } + env := make(map[string]string, len(m.options.Env)) + envKeys := make([]string, 0, len(m.options.Env)) + for key, value := range m.options.Env { + trimmedKey := core.Trim(key) + if trimmedKey != "" { + env[trimmedKey] = value + envKeys = append(envKeys, trimmedKey) + } + } + sort.Strings(envKeys) + for _, key := range envKeys { + args = append(args, "-e", key+"="+env[key]) + } + return args +} + +func resourceArgs(resources TIMResources) []string { + var args []string + if resources.CPUCores > 0 { + args = append(args, "--cpus", core.Sprintf("%d", resources.CPUCores)) + } + if resources.MemoryMB > 0 { + args = append(args, "--memory", core.Sprintf("%dm", resources.MemoryMB)) + } + if core.Trim(resources.GPU) != "" { + args = append(args, "--gpus", resources.GPU) + } + return args +} + +func coalesceRuntime(values ...ContainerRuntime) ContainerRuntime { + for _, value := range values { + if value != RuntimeNone { + return value + } + } + return RuntimeNone +} + +func cloneTIMState(state TIMState) TIMState { + state.Command = append([]string(nil), state.Command...) + return state +} diff --git a/display/webkit/pkg/container/tim_example_test.go b/display/webkit/pkg/container/tim_example_test.go new file mode 100644 index 0000000..bebaa5c --- /dev/null +++ b/display/webkit/pkg/container/tim_example_test.go @@ -0,0 +1,29 @@ +//go:build compliance + +package container + +import core "dappco.re/go" + +func ExampleNewTIMManager() { + core.Println("NewTIMManager") + // Output: + // NewTIMManager +} + +func ExampleTIMManager_State() { + core.Println("TIMManager_State") + // Output: + // TIMManager_State +} + +func ExampleTIMManager_Start() { + core.Println("TIMManager_Start") + // Output: + // TIMManager_Start +} + +func ExampleTIMManager_Stop() { + core.Println("TIMManager_Stop") + // Output: + // TIMManager_Stop +} diff --git a/display/webkit/pkg/container/tim_test.go b/display/webkit/pkg/container/tim_test.go new file mode 100644 index 0000000..2c200a0 --- /dev/null +++ b/display/webkit/pkg/container/tim_test.go @@ -0,0 +1,513 @@ +package container + +import ( + "context" + core "dappco.re/go" + "time" +) + +func TestTIMManager_NewTIMManager_Good(t *core.T) { + // NewTIMManager + ax7Variant := "NewTIMManager:good" + core.AssertContains(t, ax7Variant, "good") + manager := NewTIMManager(TIMOptions{ + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + }) + + state := manager.State() + core.AssertEqual(t, "coregui-tim", state.Name) + core.AssertEqual(t, "ghcr.io/lthn/core/tim:latest", state.Image) + core.AssertEqual(t, RuntimeDocker, state.Runtime) + core.AssertEqual(t, "stopped", state.Status) + core.AssertEmpty(t, state.StartedAt) +} + +func TestTIMManager_NewTIMManager_Bad(t *core.T) { + // NewTIMManager + ax7Variant := "NewTIMManager:bad" + core.AssertContains(t, ax7Variant, "bad") + manager := NewTIMManager(TIMOptions{ + Name: " ", + Image: " ", + Detect: func() ContainerRuntime { + return RuntimeNone + }, + }) + + state := manager.State() + core.AssertEqual(t, "coregui-tim", state.Name) + core.AssertEqual(t, "ghcr.io/lthn/core/tim:latest", state.Image) + core.AssertEqual(t, RuntimeNone, state.Runtime) + core.AssertEqual(t, "stopped", state.Status) +} + +func TestTIMManager_NewTIMManager_Ugly(t *core.T) { + // NewTIMManager + ax7Variant := "NewTIMManager:ugly" + core.AssertContains(t, ax7Variant, "ugly") + manager := NewTIMManager(TIMOptions{ + Name: "worker-node", + Image: "ghcr.io/example/tim:edge", + Command: []string{"alpha", "beta"}, + DataDir: "/var/lib/tim", + Runtime: RuntimePodman, + Resources: TIMResources{CPUCores: 2, MemoryMB: 512, GPU: "all"}, + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + }) + + state := manager.State() + core.AssertEqual(t, "worker-node", state.Name) + core.AssertEqual(t, "ghcr.io/example/tim:edge", state.Image) + core.AssertEqual(t, RuntimePodman, state.Runtime) + core.AssertEqual(t, []string{"alpha", "beta"}, state.Command) + core.AssertEqual(t, "/var/lib/tim", state.DataDir) + core.AssertEqual(t, TIMResources{CPUCores: 2, MemoryMB: 512, GPU: "all"}, state.Resources) +} + +func TestTIMManager_Start_Good(t *core.T) { + // Start + ax7Variant := "Start:good" + core.AssertContains(t, ax7Variant, "good") + var calls []string + manager := NewTIMManager(TIMOptions{ + Name: "coregui-tim", + Image: "ghcr.io/example/tim:latest", + Command: []string{"sleep", "1"}, + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + Exec: func(_ context.Context, name string, args ...string) resultFailure { + calls = append(calls, append([]string{name}, args...)...) + return nil + }, + Now: func() time.Time { + return time.Unix(456, 0).UTC() + }, + }) + + state, err := manager.Start(context.Background()) + core.RequireNoError(t, err) + core.AssertEqual(t, "running", state.Status) + core.AssertEqual(t, time.Unix(456, 0).UTC(), state.StartedAt) + core.AssertEqual(t, "docker", calls[0]) + core.AssertContains(t, calls, "run") + core.AssertContains(t, calls, "--rm") + core.AssertContains(t, calls, "--name") + core.AssertContains(t, calls, "coregui-tim") + core.AssertContains(t, calls, "ghcr.io/example/tim:latest") +} + +func TestTIMManager_Start_Bad(t *core.T) { + // Start + ax7Variant := "Start:bad" + core.AssertContains(t, ax7Variant, "bad") + manager := NewTIMManager(TIMOptions{ + Detect: func() ContainerRuntime { + return RuntimeNone + }, + }) + + state, err := manager.Start(context.Background()) + core.AssertError(t, err) + core.AssertEqual(t, RuntimeNone, state.Runtime) + core.AssertEqual(t, "stopped", state.Status) + core.AssertContains(t, err.Error(), "no supported container runtime detected") +} + +func TestTIMManager_Start_Ugly(t *core.T) { + // Start + ax7Variant := "Start:ugly" + core.AssertContains(t, ax7Variant, "ugly") + manager := NewTIMManager(TIMOptions{ + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + Exec: func(context.Context, string, ...string) resultFailure { + return core.NewError("docker failed") + }, + }) + + state, err := manager.Start(context.Background()) + core.AssertError(t, err) + core.AssertEqual(t, "error", state.Status) + core.AssertContains(t, err.Error(), "docker failed") +} + +func TestTIMManager_Stop_Good(t *core.T) { + // Stop + ax7Variant := "Stop:good" + core.AssertContains(t, ax7Variant, "good") + var calls []string + manager := NewTIMManager(TIMOptions{ + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + Exec: func(_ context.Context, name string, args ...string) resultFailure { + calls = append(calls, append([]string{name}, args...)...) + return nil + }, + }) + + started, err := manager.Start(context.Background()) + core.RequireNoError(t, err) + core.AssertEqual(t, "running", started.Status) + + stopped, err := manager.Stop(context.Background()) + core.RequireNoError(t, err) + core.AssertEqual(t, "stopped", stopped.Status) + core.AssertEmpty(t, stopped.StartedAt) + core.AssertGreaterOrEqual(t, len(calls), 3) + core.AssertEqual(t, "docker", calls[len(calls)-3]) + core.AssertEqual(t, "stop", calls[len(calls)-2]) + core.AssertEqual(t, "coregui-tim", calls[len(calls)-1]) +} + +func TestTIMManager_Stop_Bad(t *core.T) { + // Stop + ax7Variant := "Stop:bad" + core.AssertContains(t, ax7Variant, "bad") + manager := NewTIMManager(TIMOptions{ + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + Exec: func(context.Context, string, ...string) resultFailure { + return core.NewError("stop failed") + }, + }) + + _, err := manager.Start(context.Background()) + core.AssertError(t, err) + + state, err := manager.Stop(context.Background()) + core.AssertError(t, err) + core.AssertEqual(t, "error", state.Status) + core.AssertContains(t, err.Error(), "stop failed") +} + +func TestTIMManager_Stop_Ugly(t *core.T) { + // Stop + ax7Variant := "Stop:ugly" + core.AssertContains(t, ax7Variant, "ugly") + manager := NewTIMManager(TIMOptions{ + Detect: func() ContainerRuntime { + return RuntimeNone + }, + }) + + state, err := manager.Stop(context.Background()) + core.RequireNoError(t, err) + core.AssertEqual(t, "stopped", state.Status) +} + +func TestTIMManager_runtimeCommand_Good(t *core.T) { + // runtimeCommand + ax7Variant := "runtimeCommand:good" + core.AssertContains(t, ax7Variant, "good") + cases := []struct { + name string + runtime ContainerRuntime + verb string + wantBin string + wantArgs []string + }{ + { + name: "docker run", + runtime: RuntimeDocker, + verb: "run", + wantBin: "docker", + wantArgs: []string{"run", "-d", "--rm", "--name", "tim"}, + }, + { + name: "apple stop", + runtime: RuntimeApple, + verb: "stop", + wantBin: "container", + wantArgs: []string{"stop", "tim"}, + }, + { + name: "podman run", + runtime: RuntimePodman, + verb: "run", + wantBin: "podman", + wantArgs: []string{"run", "-d", "--replace", "--name", "tim"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + manager := NewTIMManager(TIMOptions{ + Name: "tim", + Image: "image", + Runtime: tc.runtime, + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + }) + + gotBin, gotArgs := manager.runtimeCommand(tc.runtime, tc.verb) + core.AssertEqual(t, tc.wantBin, gotBin) + core.AssertEqual(t, tc.wantArgs, gotArgs[:len(tc.wantArgs)]) + if tc.verb == "run" { + core.AssertContains(t, gotArgs, "image") + } + }) + } +} + +func TestTIMManager_runtimeCommand_Bad(t *core.T) { + // runtimeCommand + ax7Variant := "runtimeCommand:bad" + core.AssertContains(t, ax7Variant, "bad") + manager := NewTIMManager(TIMOptions{ + Name: "tim", + Image: "image", + Runtime: RuntimeNone, + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + }) + + bin, args := manager.runtimeCommand(RuntimeNone, "run") + core.AssertEqual(t, "docker", bin) + core.AssertContains(t, args, "--rm") +} + +func TestTIMManager_runtimeCommand_Ugly(t *core.T) { + // runtimeCommand + ax7Variant := "runtimeCommand:ugly" + core.AssertContains(t, ax7Variant, "ugly") + manager := NewTIMManager(TIMOptions{ + Name: "tim", + Image: "image", + DataDir: "/host/tim", + WorkDir: "/work", + Env: map[string]string{ + "CORE_ENV": "test", + }, + Resources: TIMResources{ + CPUCores: 2, + MemoryMB: 512, + GPU: "all", + }, + Detect: func() ContainerRuntime { + return RuntimeDocker + }, + }) + + bin, args := manager.runtimeCommand(RuntimeDocker, "run") + core.AssertEqual(t, "docker", bin) + core.AssertContains(t, args, "--cpus") + core.AssertContains(t, args, "2") + core.AssertContains(t, args, "--memory") + core.AssertContains(t, args, "512m") + core.AssertContains(t, args, "--gpus") + core.AssertContains(t, args, "all") + core.AssertContains(t, args, "-v") + core.AssertContains(t, args, "/host/tim:/host/tim") + core.AssertContains(t, args, "-w") + core.AssertContains(t, args, "/work") + core.AssertContains(t, args, "-e") + core.AssertContains(t, args, "CORE_ENV=test") +} + +func TestTIMManager_resourceArgs_Good(t *core.T) { + // resourceArgs + ax7Variant := "resourceArgs:good" + core.AssertContains(t, ax7Variant, "good") + core.AssertNil(t, resourceArgs(TIMResources{})) + observedType := core.Sprintf("%T", resourceArgs(TIMResources{})) + core.AssertNotEmpty(t, observedType) +} + +func TestTIMManager_resourceArgs_Bad(t *core.T) { + // resourceArgs + ax7Variant := "resourceArgs:bad" + core.AssertContains(t, ax7Variant, "bad") + args := resourceArgs(TIMResources{CPUCores: 2}) + + core.AssertEqual(t, []string{"--cpus", "2"}, args) + core.AssertNotEmpty(t, core.Sprintf("%T", args)) +} + +func TestTIMManager_resourceArgs_Ugly(t *core.T) { + // resourceArgs + ax7Variant := "resourceArgs:ugly" + core.AssertContains(t, ax7Variant, "ugly") + args := resourceArgs(TIMResources{CPUCores: 2, MemoryMB: 512, GPU: "all"}) + + core.AssertEqual(t, []string{"--cpus", "2", "--memory", "512m", "--gpus", "all"}, args) + core.AssertNotEmpty(t, core.Sprintf("%T", args)) +} + +func TestTIMManager_coalesceRuntime_Good(t *core.T) { + // coalesceRuntime + ax7Variant := "coalesceRuntime:good" + core.AssertContains(t, ax7Variant, "good") + core.AssertEqual(t, RuntimeApple, coalesceRuntime(RuntimeApple, RuntimeDocker)) + observedType := core.Sprintf("%T", coalesceRuntime(RuntimeApple, RuntimeDocker)) + core.AssertNotEmpty(t, observedType) +} + +func TestTIMManager_coalesceRuntime_Bad(t *core.T) { + // coalesceRuntime + ax7Variant := "coalesceRuntime:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertEqual(t, RuntimeDocker, coalesceRuntime(RuntimeNone, RuntimeDocker)) + observedType := core.Sprintf("%T", coalesceRuntime(RuntimeNone, RuntimeDocker)) + core.AssertNotEmpty(t, observedType) +} + +func TestTIMManager_coalesceRuntime_Ugly(t *core.T) { + // coalesceRuntime + ax7Variant := "coalesceRuntime:ugly" + core.AssertContains(t, ax7Variant, "ugly") + core.AssertEqual(t, RuntimeNone, coalesceRuntime(RuntimeNone, RuntimeNone)) + observedType := core.Sprintf("%T", coalesceRuntime(RuntimeNone, RuntimeNone)) + core.AssertNotEmpty(t, observedType) +} + +// AX7 generated source-matching smoke coverage. +func TestTim_NewTIMManager_Good(t *core.T) { + // NewTIMManager + ax7Variant := "NewTIMManager:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewTIMManager(*new(TIMOptions)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_NewTIMManager_Bad(t *core.T) { + // NewTIMManager + ax7Variant := "NewTIMManager:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewTIMManager(*new(TIMOptions)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_NewTIMManager_Ugly(t *core.T) { + // NewTIMManager + ax7Variant := "NewTIMManager:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewTIMManager(*new(TIMOptions)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_TIMManager_State_Good(t *core.T) { + // TIMManager State + ax7Variant := "TIMManager_State:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(TIMManager) + result := core.Try(func() any { + got0 := subject.State() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_TIMManager_State_Bad(t *core.T) { + // TIMManager State + ax7Variant := "TIMManager_State:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(TIMManager) + result := core.Try(func() any { + got0 := subject.State() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_TIMManager_State_Ugly(t *core.T) { + // TIMManager State + ax7Variant := "TIMManager_State:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(TIMManager) + result := core.Try(func() any { + got0 := subject.State() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_TIMManager_Start_Good(t *core.T) { + // TIMManager Start + ax7Variant := "TIMManager_Start:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(TIMManager) + result := core.Try(func() any { + got0, got1 := subject.Start(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_TIMManager_Start_Bad(t *core.T) { + // TIMManager Start + ax7Variant := "TIMManager_Start:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(TIMManager) + result := core.Try(func() any { + got0, got1 := subject.Start(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_TIMManager_Start_Ugly(t *core.T) { + // TIMManager Start + ax7Variant := "TIMManager_Start:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(TIMManager) + result := core.Try(func() any { + got0, got1 := subject.Start(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_TIMManager_Stop_Good(t *core.T) { + // TIMManager Stop + ax7Variant := "TIMManager_Stop:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(TIMManager) + result := core.Try(func() any { + got0, got1 := subject.Stop(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_TIMManager_Stop_Bad(t *core.T) { + // TIMManager Stop + ax7Variant := "TIMManager_Stop:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(TIMManager) + result := core.Try(func() any { + got0, got1 := subject.Stop(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTim_TIMManager_Stop_Ugly(t *core.T) { + // TIMManager Stop + ax7Variant := "TIMManager_Stop:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(TIMManager) + result := core.Try(func() any { + got0, got1 := subject.Stop(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/contextmenu/messages.go b/display/webkit/pkg/contextmenu/messages.go new file mode 100644 index 0000000..3fab0ab --- /dev/null +++ b/display/webkit/pkg/contextmenu/messages.go @@ -0,0 +1,47 @@ +package contextmenu + +import core "dappco.re/go" + +var ErrorMenuNotFound = core.E("contextmenu", "menu not found", nil) + +// QueryGet returns a named context menu definition. Result: *ContextMenuDef (nil if not found) +type QueryGet struct { + Name string `json:"name"` +} + +// QueryList returns all registered context menus. Result: map[string]ContextMenuDef +type QueryList struct{} + +// QueryGetAll returns all registered context menus. Equivalent to QueryList. +// Result: map[string]ContextMenuDef +type QueryGetAll struct{} + +// TaskAdd registers a named context menu. Replaces if already exists. +type TaskAdd struct { + Name string `json:"name"` + Menu ContextMenuDef `json:"menu"` +} + +// TaskRemove unregisters a context menu by name. Error: ErrorMenuNotFound if missing. +type TaskRemove struct { + Name string `json:"name"` +} + +// TaskUpdate replaces an existing context menu's definition. Error: ErrorMenuNotFound if missing. +type TaskUpdate struct { + Name string `json:"name"` + Menu ContextMenuDef `json:"menu"` +} + +// TaskDestroy removes a context menu and releases all associated resources. +// Error: ErrorMenuNotFound if missing. +type TaskDestroy struct { + Name string `json:"name"` +} + +// ActionItemClicked is broadcast when a context menu item is clicked. +type ActionItemClicked struct { + MenuName string `json:"menuName"` + ActionID string `json:"actionId"` + Data string `json:"data,omitempty"` +} diff --git a/display/webkit/pkg/contextmenu/messages_example_test.go b/display/webkit/pkg/contextmenu/messages_example_test.go new file mode 100644 index 0000000..cc64e9d --- /dev/null +++ b/display/webkit/pkg/contextmenu/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package contextmenu diff --git a/display/webkit/pkg/contextmenu/messages_test.go b/display/webkit/pkg/contextmenu/messages_test.go new file mode 100644 index 0000000..2389527 --- /dev/null +++ b/display/webkit/pkg/contextmenu/messages_test.go @@ -0,0 +1 @@ +package contextmenu diff --git a/display/webkit/pkg/contextmenu/platform.go b/display/webkit/pkg/contextmenu/platform.go new file mode 100644 index 0000000..aa8dc5b --- /dev/null +++ b/display/webkit/pkg/contextmenu/platform.go @@ -0,0 +1,41 @@ +// pkg/contextmenu/platform.go +package contextmenu + +// Platform abstracts the context menu backend (Wails v3). +// The Add callback must broadcast ActionItemClicked via s.Core().ACTION() +// when a menu item is clicked — the adapter translates MenuItemDef.ActionID +// to a callback that does this. +type Platform interface { + // Add registers a context menu by name. + // The onItemClick callback is called with (menuName, actionID, data) + // when any item in the menu is clicked. The adapter creates per-item + // OnClick handlers that call this with the appropriate ActionID. + Add(name string, menu ContextMenuDef, onItemClick func(menuName, actionID, data string)) resultFailure + + // Remove unregisters a context menu by name. + Remove(name string) resultFailure + + // Get returns a context menu definition by name, or false if not found. + Get(name string) (*ContextMenuDef, bool) + + // GetAll returns all registered context menu definitions. + GetAll() map[string]ContextMenuDef +} + +// ContextMenuDef describes a context menu and its items. +type ContextMenuDef struct { + Name string `json:"name"` + Items []MenuItemDef `json:"items"` +} + +// MenuItemDef describes a single item in a context menu. +// Items may be nested (submenu children via Items field). +type MenuItemDef struct { + Label string `json:"label"` + Type string `json:"type,omitempty"` // "" (normal), "separator", "checkbox", "radio", "submenu" + Accelerator string `json:"accelerator,omitempty"` + Enabled *bool `json:"enabled,omitempty"` // nil = true (default) + Checked bool `json:"checked,omitempty"` + ActionID string `json:"actionId,omitempty"` // identifies which item was clicked + Items []MenuItemDef `json:"items,omitempty"` // submenu children (recursive) +} diff --git a/display/webkit/pkg/contextmenu/platform_example_test.go b/display/webkit/pkg/contextmenu/platform_example_test.go new file mode 100644 index 0000000..cc64e9d --- /dev/null +++ b/display/webkit/pkg/contextmenu/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package contextmenu diff --git a/display/webkit/pkg/contextmenu/platform_test.go b/display/webkit/pkg/contextmenu/platform_test.go new file mode 100644 index 0000000..2389527 --- /dev/null +++ b/display/webkit/pkg/contextmenu/platform_test.go @@ -0,0 +1 @@ +package contextmenu diff --git a/display/webkit/pkg/contextmenu/register.go b/display/webkit/pkg/contextmenu/register.go new file mode 100644 index 0000000..488a8a8 --- /dev/null +++ b/display/webkit/pkg/contextmenu/register.go @@ -0,0 +1,15 @@ +package contextmenu + +import core "dappco.re/go" + +// Register(p) binds the context menu service to a Core instance. +// core.WithService(contextmenu.Register(wailsContextMenu)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + registeredMenus: make(map[string]ContextMenuDef), + }, OK: true} + } +} diff --git a/display/webkit/pkg/contextmenu/register_example_test.go b/display/webkit/pkg/contextmenu/register_example_test.go new file mode 100644 index 0000000..7b9549b --- /dev/null +++ b/display/webkit/pkg/contextmenu/register_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package contextmenu + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} diff --git a/display/webkit/pkg/contextmenu/register_test.go b/display/webkit/pkg/contextmenu/register_test.go new file mode 100644 index 0000000..cd48ec6 --- /dev/null +++ b/display/webkit/pkg/contextmenu/register_test.go @@ -0,0 +1,30 @@ +package contextmenu + +import core "dappco.re/go" + +func TestRegister_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + label := "Register:good" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "good") +} + +func TestRegister_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Register:bad" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "bad") +} + +func TestRegister_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Register:ugly" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/contextmenu/result_failure.go b/display/webkit/pkg/contextmenu/result_failure.go new file mode 100644 index 0000000..be1b535 --- /dev/null +++ b/display/webkit/pkg/contextmenu/result_failure.go @@ -0,0 +1,5 @@ +package contextmenu + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/contextmenu/service.go b/display/webkit/pkg/contextmenu/service.go new file mode 100644 index 0000000..fc7b584 --- /dev/null +++ b/display/webkit/pkg/contextmenu/service.go @@ -0,0 +1,226 @@ +// pkg/contextmenu/service.go +package contextmenu + +import ( + "context" + "sync" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + mu sync.RWMutex + registeredMenus map[string]ContextMenuDef +} + +func platformUnavailableError(op string) resultFailure { + return core.E("contextmenu."+op, "platform backend unavailable", nil) +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + s.Core().RegisterQuery(s.handleQuery) + s.Core().Action("contextmenu.add", func(_ context.Context, opts core.Options) core.Result { + t, ok := opts.Get("task").Value.(TaskAdd) + if !ok { + return invalidTaskResult("add") + } + return core.Result{Value: nil, OK: true}.New(s.taskAdd(t)) + }) + s.Core().Action("contextmenu.remove", func(_ context.Context, opts core.Options) core.Result { + t, ok := opts.Get("task").Value.(TaskRemove) + if !ok { + return invalidTaskResult("remove") + } + return core.Result{Value: nil, OK: true}.New(s.taskRemove(t)) + }) + s.Core().Action("contextmenu.update", func(_ context.Context, opts core.Options) core.Result { + t, ok := opts.Get("task").Value.(TaskUpdate) + if !ok { + return invalidTaskResult("update") + } + return core.Result{Value: nil, OK: true}.New(s.taskUpdate(t)) + }) + s.Core().Action("contextmenu.destroy", func(_ context.Context, opts core.Options) core.Result { + t, ok := opts.Get("task").Value.(TaskDestroy) + if !ok { + return invalidTaskResult("destroy") + } + return core.Result{Value: nil, OK: true}.New(s.taskDestroy(t)) + }) + return core.Result{OK: true} +} + +func invalidTaskResult(op string) core.Result { + return core.Result{ + Value: core.E("contextmenu."+op, "invalid task payload", nil), + OK: false, + } +} + +func (s *Service) OnShutdown(_ context.Context) core.Result { + // Destroy all registered menus on shutdown to release platform resources + s.mu.Lock() + defer s.mu.Unlock() + if s.platform == nil { + s.registeredMenus = make(map[string]ContextMenuDef) + return core.Result{OK: true} + } + for name := range s.registeredMenus { + if err := s.platform.Remove(name); err != nil { + continue + } + } + s.registeredMenus = make(map[string]ContextMenuDef) + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +// --- Query Handlers --- + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch q := q.(type) { + case QueryGet: + return core.Result{Value: s.queryGet(q), OK: true} + case QueryList: + return core.Result{Value: s.queryList(), OK: true} + case QueryGetAll: + return core.Result{Value: s.queryList(), OK: true} + default: + return core.Result{} + } +} + +func (s *Service) queryGet(q QueryGet) *ContextMenuDef { + s.mu.RLock() + defer s.mu.RUnlock() + menu, ok := s.registeredMenus[q.Name] + if !ok { + return nil + } + return &menu +} + +func (s *Service) queryList() map[string]ContextMenuDef { + s.mu.RLock() + defer s.mu.RUnlock() + result := make(map[string]ContextMenuDef, len(s.registeredMenus)) + for k, v := range s.registeredMenus { + result[k] = v + } + return result +} + +func (s *Service) taskAdd(t TaskAdd) resultFailure { + if s.platform == nil { + return platformUnavailableError("taskAdd") + } + s.mu.Lock() + defer s.mu.Unlock() + // If menu already exists, remove it first (replace semantics). + oldMenu, existed := s.registeredMenus[t.Name] + if existed { + if err := s.platform.Remove(t.Name); err != nil { + return core.E("contextmenu.taskAdd", "platform remove failed", err) + } + delete(s.registeredMenus, t.Name) + } + + // Register on platform with a callback that broadcasts ActionItemClicked + err := s.platform.Add(t.Name, t.Menu, s.menuCallback()) + if err != nil { + if existed { + s.tryRestoreMenu(t.Name, oldMenu) + } + return core.E("contextmenu.taskAdd", "platform add failed", err) + } + + s.registeredMenus[t.Name] = t.Menu + return nil +} + +func (s *Service) taskRemove(t TaskRemove) resultFailure { + if s.platform == nil { + return platformUnavailableError("taskRemove") + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.registeredMenus[t.Name]; !exists { + return ErrorMenuNotFound + } + + err := s.platform.Remove(t.Name) + if err != nil { + return core.E("contextmenu.taskRemove", "platform remove failed", err) + } + + delete(s.registeredMenus, t.Name) + return nil +} + +func (s *Service) taskUpdate(t TaskUpdate) resultFailure { + if s.platform == nil { + return platformUnavailableError("taskUpdate") + } + s.mu.Lock() + defer s.mu.Unlock() + oldMenu, exists := s.registeredMenus[t.Name] + if !exists { + return ErrorMenuNotFound + } + + // Re-register with updated definition — remove then add + if err := s.platform.Remove(t.Name); err != nil { + return core.E("contextmenu.taskUpdate", "platform remove failed", err) + } + + err := s.platform.Add(t.Name, t.Menu, s.menuCallback()) + if err != nil { + s.tryRestoreMenu(t.Name, oldMenu) + return core.E("contextmenu.taskUpdate", "platform add failed", err) + } + + s.registeredMenus[t.Name] = t.Menu + return nil +} + +func (s *Service) taskDestroy(t TaskDestroy) resultFailure { + if s.platform == nil { + return platformUnavailableError("taskDestroy") + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.registeredMenus[t.Name]; !exists { + return ErrorMenuNotFound + } + + if err := s.platform.Remove(t.Name); err != nil { + return core.E("contextmenu.taskDestroy", "platform remove failed", err) + } + + delete(s.registeredMenus, t.Name) + return nil +} + +func (s *Service) tryRestoreMenu(name string, menu ContextMenuDef) { + if restoreErr := s.platform.Add(name, menu, s.menuCallback()); restoreErr == nil { + s.registeredMenus[name] = menu + } +} + +func (s *Service) menuCallback() func(string, string, string) { + return func(menuName, actionID, data string) { + coreutil.DispatchAction(s.Core(), "contextmenu.itemClicked", ActionItemClicked{ + MenuName: menuName, + ActionID: actionID, + Data: data, + }) + } +} diff --git a/display/webkit/pkg/contextmenu/service_behaviour_test.go b/display/webkit/pkg/contextmenu/service_behaviour_test.go new file mode 100644 index 0000000..40625e0 --- /dev/null +++ b/display/webkit/pkg/contextmenu/service_behaviour_test.go @@ -0,0 +1,15 @@ +// pkg/contextmenu/service_behaviour_test.go +package contextmenu + +import core "dappco.re/go" + +// invalidTaskResult builds a failed Result carrying a scoped error for a +// malformed task payload. +// +// r := invalidTaskResult("add") // r.OK == false +func TestServiceBehaviour_invalidTaskResult(t *core.T) { + r := invalidTaskResult("add") + core.AssertFalse(t, r.OK) + core.AssertContains(t, r.Error(), "invalid task payload") + core.AssertContains(t, r.Error(), "contextmenu.add") +} diff --git a/display/webkit/pkg/contextmenu/service_example_test.go b/display/webkit/pkg/contextmenu/service_example_test.go new file mode 100644 index 0000000..f54935d --- /dev/null +++ b/display/webkit/pkg/contextmenu/service_example_test.go @@ -0,0 +1,23 @@ +//go:build compliance + +package contextmenu + +import core "dappco.re/go" + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_OnShutdown() { + core.Println("Service_OnShutdown") + // Output: + // Service_OnShutdown +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/contextmenu/service_test.go b/display/webkit/pkg/contextmenu/service_test.go new file mode 100644 index 0000000..b57e9f6 --- /dev/null +++ b/display/webkit/pkg/contextmenu/service_test.go @@ -0,0 +1,691 @@ +// pkg/contextmenu/service_test.go +package contextmenu + +import ( + "context" + "sync" + + core "dappco.re/go" +) + +// mockPlatform records Add/Remove calls and allows simulating clicks. +type mockPlatform struct { + mu sync.Mutex + menus map[string]ContextMenuDef + clickHandlers map[string]func(menuName, actionID, data string) + removed []string + addErr resultFailure + removeErr resultFailure +} + +func newMockPlatform() *mockPlatform { + return &mockPlatform{ + menus: make(map[string]ContextMenuDef), + clickHandlers: make(map[string]func(menuName, actionID, data string)), + } +} + +func (m *mockPlatform) Add(name string, menu ContextMenuDef, onItemClick func(string, string, string)) resultFailure { + m.mu.Lock() + defer m.mu.Unlock() + if m.addErr != nil { + return m.addErr + } + m.menus[name] = menu + m.clickHandlers[name] = onItemClick + return nil +} + +func (m *mockPlatform) Remove(name string) resultFailure { + m.mu.Lock() + defer m.mu.Unlock() + if m.removeErr != nil { + return m.removeErr + } + delete(m.menus, name) + delete(m.clickHandlers, name) + m.removed = append(m.removed, name) + return nil +} + +func (m *mockPlatform) Get(name string) (*ContextMenuDef, bool) { + m.mu.Lock() + defer m.mu.Unlock() + menu, ok := m.menus[name] + if !ok { + return nil, false + } + return &menu, true +} + +func (m *mockPlatform) GetAll() map[string]ContextMenuDef { + m.mu.Lock() + defer m.mu.Unlock() + out := make(map[string]ContextMenuDef, len(m.menus)) + for k, v := range m.menus { + out[k] = v + } + return out +} + +type flakyAddPlatform struct { + *mockPlatform + mu sync.Mutex + failAddOnce bool +} + +func newFlakyAddPlatform() *flakyAddPlatform { + return &flakyAddPlatform{mockPlatform: newMockPlatform()} +} + +func (m *flakyAddPlatform) Add(name string, menu ContextMenuDef, onItemClick func(string, string, string)) resultFailure { + m.mu.Lock() + defer m.mu.Unlock() + if m.failAddOnce { + m.failAddOnce = false + return ErrorMenuNotFound + } + return m.mockPlatform.Add(name, menu, onItemClick) +} + +// simulateClick simulates a context menu item click by calling the registered handler. +func (m *mockPlatform) simulateClick(menuName, actionID, data string) { + m.mu.Lock() + h, ok := m.clickHandlers[menuName] + m.mu.Unlock() + if ok { + h(menuName, actionID, data) + } +} + +func newTestContextMenuService(t *core.T, mp Platform) (*Service, *core.Core) { + t.Helper() + c := core.New( + core.WithService(Register(mp)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "contextmenu") + return svc, c +} + +// taskRun runs a named action with a task struct and returns the result. +func taskRun(c *core.Core, name string, task any) core.Result { + return c.Action(name).Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: task}, + )) +} + +func TestRegister_Good(t *core.T) { + mp := newMockPlatform() + svc, _ := newTestContextMenuService(t, mp) + core.AssertNotNil(t, svc) + core.AssertNotNil(t, svc.platform) +} + +func TestNilPlatform_Good_MutationAndShutdownAreSafe(t *core.T) { + _, c := newTestContextMenuService(t, nil) + + cases := []struct { + name string + action string + task any + }{ + {name: "add", action: "contextmenu.add", task: TaskAdd{Name: "file-menu", Menu: ContextMenuDef{Name: "file-menu"}}}, + {name: "remove", action: "contextmenu.remove", task: TaskRemove{Name: "file-menu"}}, + {name: "update", action: "contextmenu.update", task: TaskUpdate{Name: "file-menu", Menu: ContextMenuDef{Name: "file-menu"}}}, + {name: "destroy", action: "contextmenu.destroy", task: TaskDestroy{Name: "file-menu"}}, + } + + for _, tc := range cases { + r := taskRun(c, tc.action, tc.task) + core.AssertFalse(t, r.OK, tc.name) + err, _ := r.Value.(resultFailure) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "platform backend unavailable") + } + + core.AssertTrue(t, c.ServiceShutdown(t.Context()).OK) +} + +func TestTaskAdd_Good(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + r := taskRun(c, "contextmenu.add", TaskAdd{ + Name: "file-menu", + Menu: ContextMenuDef{ + Name: "file-menu", + Items: []MenuItemDef{ + {Label: "Open", ActionID: "open"}, + {Label: "Delete", ActionID: "delete"}, + }, + }, + }) + core.RequireTrue(t, r.OK) + + // Verify menu registered on platform + _, ok := mp.Get("file-menu") + core.AssertTrue(t, ok) +} + +func TestTaskAdd_Good_ReplaceExisting(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + // Add initial menu + _ = taskRun(c, "contextmenu.add", TaskAdd{ + Name: "ctx", + Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "A", ActionID: "a"}}}, + }) + + // Replace with new menu + r := taskRun(c, "contextmenu.add", TaskAdd{ + Name: "ctx", + Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "B", ActionID: "b"}}}, + }) + core.RequireTrue(t, r.OK) + + // Verify registry has new menu + qr := c.QUERY(QueryGet{Name: "ctx"}) + core.RequireTrue(t, qr.OK) + def := qr.Value.(*ContextMenuDef) + core.AssertLen(t, def.Items, 1) + core.AssertEqual(t, "B", def.Items[0].Label) +} + +func TestTaskRemove_Good(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + // Add then remove + _ = taskRun(c, "contextmenu.add", TaskAdd{ + Name: "test", + Menu: ContextMenuDef{Name: "test"}, + }) + r := taskRun(c, "contextmenu.remove", TaskRemove{Name: "test"}) + core.RequireTrue(t, r.OK) + + // Verify removed from registry + qr := c.QUERY(QueryGet{Name: "test"}) + core.AssertNil(t, qr.Value) +} + +func TestTaskRemove_Bad_NotFound(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + r := taskRun(c, "contextmenu.remove", TaskRemove{Name: "nonexistent"}) + core.AssertFalse(t, r.OK) + err, _ := r.Value.(resultFailure) + core.AssertErrorIs(t, err, ErrorMenuNotFound) +} + +func TestQueryGet_Good(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{ + Name: "my-menu", + Menu: ContextMenuDef{ + Name: "my-menu", + Items: []MenuItemDef{{Label: "Edit", ActionID: "edit"}}, + }, + }) + + r := c.QUERY(QueryGet{Name: "my-menu"}) + core.RequireTrue(t, r.OK) + def := r.Value.(*ContextMenuDef) + core.AssertEqual(t, "my-menu", def.Name) + core.AssertLen(t, def.Items, 1) +} + +func TestQueryGet_Good_NotFound(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + r := c.QUERY(QueryGet{Name: "missing"}) + core.RequireTrue(t, r.OK) + core.AssertNil(t, r.Value) +} + +func TestQueryList_Good(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{Name: "a", Menu: ContextMenuDef{Name: "a"}}) + _ = taskRun(c, "contextmenu.add", TaskAdd{Name: "b", Menu: ContextMenuDef{Name: "b"}}) + + r := c.QUERY(QueryList{}) + core.RequireTrue(t, r.OK) + list := r.Value.(map[string]ContextMenuDef) + core.AssertLen(t, list, 2) +} + +func TestQueryList_Good_Empty(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + r := c.QUERY(QueryList{}) + core.RequireTrue(t, r.OK) + list := r.Value.(map[string]ContextMenuDef) + core.AssertLen(t, list, 0) +} + +func TestTaskAdd_Good_ClickBroadcast(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + // Capture broadcast actions + var clicked ActionItemClicked + var mu sync.Mutex + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionItemClicked); ok { + mu.Lock() + clicked = a + mu.Unlock() + } + return core.Result{OK: true} + }) + + _ = taskRun(c, "contextmenu.add", TaskAdd{ + Name: "file-menu", + Menu: ContextMenuDef{ + Name: "file-menu", + Items: []MenuItemDef{ + {Label: "Open", ActionID: "open"}, + }, + }, + }) + + // Simulate click via mock + mp.simulateClick("file-menu", "open", "file-123") + + mu.Lock() + core.AssertEqual(t, "file-menu", clicked.MenuName) + core.AssertEqual(t, "open", clicked.ActionID) + core.AssertEqual(t, "file-123", clicked.Data) + mu.Unlock() +} + +func TestTaskAdd_Ugly_PlatformAddFailureRollsBackExistingMenu(t *core.T) { + mp := newFlakyAddPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{ + Name: "file-menu", + Menu: ContextMenuDef{Name: "file-menu", Items: []MenuItemDef{{Label: "Open", ActionID: "open"}}}, + }) + + mp.mu.Lock() + mp.failAddOnce = true + mp.mu.Unlock() + + r := taskRun(c, "contextmenu.add", TaskAdd{ + Name: "file-menu", + Menu: ContextMenuDef{Name: "file-menu", Items: []MenuItemDef{{Label: "Delete", ActionID: "delete"}}}, + }) + core.AssertFalse(t, r.OK) + + qr := c.QUERY(QueryGet{Name: "file-menu"}) + core.RequireTrue(t, qr.OK) + def := qr.Value.(*ContextMenuDef) + core.AssertLen(t, def.Items, 1) + core.AssertEqual(t, "Open", def.Items[0].Label) + platformMenu, ok := mp.Get("file-menu") + core.RequireTrue(t, ok) + core.AssertLen(t, platformMenu.Items, 1) + core.AssertEqual(t, "Open", platformMenu.Items[0].Label) +} + +func TestTaskAdd_Good_SubmenuItems(t *core.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + r := taskRun(c, "contextmenu.add", TaskAdd{ + Name: "nested", + Menu: ContextMenuDef{ + Name: "nested", + Items: []MenuItemDef{ + {Label: "File", Type: "submenu", Items: []MenuItemDef{ + {Label: "New", ActionID: "new"}, + {Label: "Open", ActionID: "open"}, + }}, + {Type: "separator"}, + {Label: "Quit", ActionID: "quit"}, + }, + }, + }) + core.RequireTrue(t, r.OK) + + qr := c.QUERY(QueryGet{Name: "nested"}) + def := qr.Value.(*ContextMenuDef) + core.AssertLen(t, def.Items, 3) + core.AssertLen(t, def.Items[0].Items, 2) // submenu children +} + +func TestQueryList_Bad_NoService(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.QUERY(QueryList{}) + core.AssertFalse(t, r.OK) +} + +// --- TaskUpdate --- + +func TestTaskUpdate_Good(t *core.T) { + // Update replaces items on an existing menu + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{ + Name: "edit-menu", + Menu: ContextMenuDef{Name: "edit-menu", Items: []MenuItemDef{{Label: "Cut", ActionID: "cut"}}}, + }) + + r := taskRun(c, "contextmenu.update", TaskUpdate{ + Name: "edit-menu", + Menu: ContextMenuDef{Name: "edit-menu", Items: []MenuItemDef{ + {Label: "Cut", ActionID: "cut"}, + {Label: "Copy", ActionID: "copy"}, + }}, + }) + core.RequireTrue(t, r.OK) + + qr := c.QUERY(QueryGet{Name: "edit-menu"}) + def := qr.Value.(*ContextMenuDef) + core.AssertLen(t, def.Items, 2) + core.AssertEqual(t, "Copy", def.Items[1].Label) +} + +func TestTaskUpdate_Bad_NotFound(t *core.T) { + // Update on a non-existent menu returns ErrorMenuNotFound + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + r := taskRun(c, "contextmenu.update", TaskUpdate{ + Name: "ghost", + Menu: ContextMenuDef{Name: "ghost"}, + }) + core.AssertFalse(t, r.OK) + err, _ := r.Value.(resultFailure) + core.AssertErrorIs(t, err, ErrorMenuNotFound) +} + +func TestTaskUpdate_Ugly_PlatformRemoveError(t *core.T) { + // Platform Remove fails mid-update — resultFailure is propagated + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{ + Name: "tricky", + Menu: ContextMenuDef{Name: "tricky"}, + }) + + mp.mu.Lock() + mp.removeErr = ErrorMenuNotFound // reuse sentinel as a platform-level resultFailure + mp.mu.Unlock() + + r := taskRun(c, "contextmenu.update", TaskUpdate{ + Name: "tricky", + Menu: ContextMenuDef{Name: "tricky", Items: []MenuItemDef{{Label: "X", ActionID: "x"}}}, + }) + core.AssertFalse(t, r.OK) +} + +func TestTaskUpdate_Ugly_PlatformAddFailureRollsBackExistingMenu(t *core.T) { + mp := newFlakyAddPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{ + Name: "edit-menu", + Menu: ContextMenuDef{Name: "edit-menu", Items: []MenuItemDef{{Label: "Cut", ActionID: "cut"}}}, + }) + + mp.mu.Lock() + mp.failAddOnce = true + mp.mu.Unlock() + + r := taskRun(c, "contextmenu.update", TaskUpdate{ + Name: "edit-menu", + Menu: ContextMenuDef{Name: "edit-menu", Items: []MenuItemDef{{Label: "Copy", ActionID: "copy"}}}, + }) + core.AssertFalse(t, r.OK) + + qr := c.QUERY(QueryGet{Name: "edit-menu"}) + core.RequireTrue(t, qr.OK) + def := qr.Value.(*ContextMenuDef) + core.AssertLen(t, def.Items, 1) + core.AssertEqual(t, "Cut", def.Items[0].Label) + platformMenu, ok := mp.Get("edit-menu") + core.RequireTrue(t, ok) + core.AssertLen(t, platformMenu.Items, 1) + core.AssertEqual(t, "Cut", platformMenu.Items[0].Label) +} + +// --- TaskDestroy --- + +func TestTaskDestroy_Good(t *core.T) { + // Destroy removes the menu and releases platform resources + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{Name: "doomed", Menu: ContextMenuDef{Name: "doomed"}}) + + r := taskRun(c, "contextmenu.destroy", TaskDestroy{Name: "doomed"}) + core.RequireTrue(t, r.OK) + + qr := c.QUERY(QueryGet{Name: "doomed"}) + core.AssertNil(t, qr.Value) + + _, ok := mp.Get("doomed") + core.AssertFalse(t, ok) +} + +func TestTaskDestroy_Bad_NotFound(t *core.T) { + // Destroy on a non-existent menu returns ErrorMenuNotFound + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + r := taskRun(c, "contextmenu.destroy", TaskDestroy{Name: "nonexistent"}) + core.AssertFalse(t, r.OK) + err, _ := r.Value.(resultFailure) + core.AssertErrorIs(t, err, ErrorMenuNotFound) +} + +func TestTaskDestroy_Ugly_PlatformError(t *core.T) { + // Platform Remove fails — resultFailure is propagated but service remains consistent + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{Name: "frail", Menu: ContextMenuDef{Name: "frail"}}) + + mp.mu.Lock() + mp.removeErr = ErrorMenuNotFound + mp.mu.Unlock() + + r := taskRun(c, "contextmenu.destroy", TaskDestroy{Name: "frail"}) + core.AssertFalse(t, r.OK) +} + +// --- QueryGetAll --- + +func TestQueryGetAll_Good(t *core.T) { + // QueryGetAll returns all registered menus (equivalent to QueryList) + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{Name: "x", Menu: ContextMenuDef{Name: "x"}}) + _ = taskRun(c, "contextmenu.add", TaskAdd{Name: "y", Menu: ContextMenuDef{Name: "y"}}) + + r := c.QUERY(QueryGetAll{}) + core.RequireTrue(t, r.OK) + all := r.Value.(map[string]ContextMenuDef) + core.AssertLen(t, all, 2) + core.AssertContains(t, all, "x") + core.AssertContains(t, all, "y") +} + +func TestQueryGetAll_Bad_Empty(t *core.T) { + // QueryGetAll on an empty registry returns an empty map + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + r := c.QUERY(QueryGetAll{}) + core.RequireTrue(t, r.OK) + all := r.Value.(map[string]ContextMenuDef) + core.AssertLen(t, all, 0) +} + +func TestQueryGetAll_Ugly_NoService(t *core.T) { + // No contextmenu service — query is unhandled + c := core.New(core.WithServiceLock()) + r := c.QUERY(QueryGetAll{}) + core.AssertFalse(t, r.OK) +} + +// --- OnShutdown --- + +func TestOnShutdown_Good_CleansUpMenus(t *core.T) { + // OnShutdown removes all registered menus from the platform + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{Name: "alpha", Menu: ContextMenuDef{Name: "alpha"}}) + _ = taskRun(c, "contextmenu.add", TaskAdd{Name: "beta", Menu: ContextMenuDef{Name: "beta"}}) + + core.RequireTrue(t, c.ServiceShutdown(t.Context()).OK) + + core.AssertLen(t, mp.menus, 0) +} + +func TestOnShutdown_Bad_NothingRegistered(t *core.T) { + // OnShutdown with no menus — no-op, no resultFailure + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + core.AssertTrue(t, c.ServiceShutdown(t.Context()).OK) +} + +func TestOnShutdown_Ugly_PlatformRemoveErrors(t *core.T) { + // Platform Remove errors during shutdown are silently swallowed + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _ = taskRun(c, "contextmenu.add", TaskAdd{Name: "stubborn", Menu: ContextMenuDef{Name: "stubborn"}}) + + mp.mu.Lock() + mp.removeErr = ErrorMenuNotFound + mp.mu.Unlock() + + // Shutdown must not return an resultFailure even if platform Remove fails + core.AssertTrue(t, c.ServiceShutdown(t.Context()).OK) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Good(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Bad(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Ugly(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/contextmenu/wails.go b/display/webkit/pkg/contextmenu/wails.go new file mode 100644 index 0000000..f414021 --- /dev/null +++ b/display/webkit/pkg/contextmenu/wails.go @@ -0,0 +1,163 @@ +// pkg/contextmenu/wails.go +package contextmenu + +import ( + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// WailsPlatform implements Platform via Wails v3's ContextMenuManager. +// +// Frontend trigger pattern: a DOM element gets the CSS custom property +// +// --custom-contextmenu: ; +// +// (optionally with --custom-contextmenu-data: ; for per-element +// payload). On right-click Wails reads the CSS, opens the registered +// menu by name, and calls our OnClick handlers when the user picks an +// item — those handlers forward to the Platform's onItemClick callback, +// which the gui contextmenu service translates into ActionItemClicked +// dispatches the consumer can listen for. +// +// wp := contextmenu.NewWailsPlatform(app) +// core.WithService(contextmenu.Register(wp)) +type WailsPlatform struct { + app *application.App + + mu sync.RWMutex + registry map[string]*registeredMenu +} + +// registeredMenu keeps both the wails handle (for Destroy on Remove) +// and the original definition (for Get / GetAll). +type registeredMenu struct { + wails *application.ContextMenu + def ContextMenuDef +} + +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app, registry: make(map[string]*registeredMenu)} +} + +// Add (re)registers a context menu by name. If the same name was already +// registered, the previous wails ContextMenu is destroyed first so the +// definition is fully replaced — matches the "update" semantics the gui +// service exposes via contextmenu.update. +func (wp *WailsPlatform) Add(name string, menu ContextMenuDef, onItemClick func(menuName, actionID, data string)) resultFailure { + if wp == nil || wp.app == nil { + return nil + } + wp.mu.Lock() + if existing, ok := wp.registry[name]; ok && existing.wails != nil { + existing.wails.Destroy() + } + wp.mu.Unlock() + + cm := application.NewContextMenu(name) + for _, item := range menu.Items { + buildItem(cm.Menu, item, name, onItemClick) + } + cm.Update() + + wp.mu.Lock() + wp.registry[name] = ®isteredMenu{wails: cm, def: menu} + wp.mu.Unlock() + return nil +} + +// Remove unregisters a context menu by name. No-op if absent. +func (wp *WailsPlatform) Remove(name string) resultFailure { + if wp == nil { + return nil + } + wp.mu.Lock() + defer wp.mu.Unlock() + entry, ok := wp.registry[name] + if !ok { + return nil + } + if entry.wails != nil { + entry.wails.Destroy() + } + delete(wp.registry, name) + return nil +} + +// Get returns the definition we registered, not the live wails state — +// the gui service uses this for round-tripping definitions back to MCP +// callers. +func (wp *WailsPlatform) Get(name string) (*ContextMenuDef, bool) { + if wp == nil { + return nil, false + } + wp.mu.RLock() + defer wp.mu.RUnlock() + entry, ok := wp.registry[name] + if !ok { + return nil, false + } + def := entry.def + return &def, true +} + +func (wp *WailsPlatform) GetAll() map[string]ContextMenuDef { + if wp == nil { + return map[string]ContextMenuDef{} + } + wp.mu.RLock() + defer wp.mu.RUnlock() + out := make(map[string]ContextMenuDef, len(wp.registry)) + for name, entry := range wp.registry { + out[name] = entry.def + } + return out +} + +// buildItem walks a single MenuItemDef into the wails Menu. Handles +// separator / submenu / checkbox / radio / normal item shapes; recurses +// for submenus. Each non-separator item gets an OnClick handler that +// captures its ActionID and forwards to the consumer's onItemClick +// alongside the per-click data payload Wails extracts from +// --custom-contextmenu-data on the right-clicked element. +func buildItem(parent *application.Menu, def MenuItemDef, menuName string, onItemClick func(menuName, actionID, data string)) { + switch def.Type { + case "separator": + parent.AddSeparator() + return + case "submenu": + sub := parent.AddSubmenu(def.Label) + for _, child := range def.Items { + buildItem(sub, child, menuName, onItemClick) + } + return + } + + var item *application.MenuItem + switch def.Type { + case "checkbox": + item = parent.AddCheckbox(def.Label, def.Checked) + case "radio": + item = parent.AddRadio(def.Label, def.Checked) + default: + item = parent.Add(def.Label) + } + + if def.Accelerator != "" { + item.SetAccelerator(def.Accelerator) + } + if def.Enabled != nil { + item.SetEnabled(*def.Enabled) + } + + if onItemClick != nil && def.ActionID != "" { + actionID := def.ActionID + item.OnClick(func(ctx *application.Context) { + data := "" + if ctx != nil { + data = ctx.ContextMenuData() + } + onItemClick(menuName, actionID, data) + }) + } +} diff --git a/display/webkit/pkg/deno/core_helpers.go b/display/webkit/pkg/deno/core_helpers.go new file mode 100644 index 0000000..b18e680 --- /dev/null +++ b/display/webkit/pkg/deno/core_helpers.go @@ -0,0 +1,32 @@ +package deno + +import ( + "context" + + core "dappco.re/go" +) + +func commandContext(ctx context.Context, binary string, args ...string) *core.Cmd { + cmd := &core.Cmd{Path: binary, Args: append([]string{binary}, args...)} + if ctx != nil { + go func() { + <-ctx.Done() + if cmd.Process != nil { + if err := cmd.Process.Kill(); err != nil && !isProcessDone(err) { + core.Error("failed to kill deno command", "err", err) + } + } + }() + } + return cmd +} + +func isProcessDone(err error) bool { + if err == nil { + return false + } + text := core.Lower(err.Error()) + return core.Contains(text, "process already finished") || + core.Contains(text, "no child processes") || + core.Contains(text, "os: process already finished") +} diff --git a/display/webkit/pkg/deno/result_failure.go b/display/webkit/pkg/deno/result_failure.go new file mode 100644 index 0000000..d40bf8b --- /dev/null +++ b/display/webkit/pkg/deno/result_failure.go @@ -0,0 +1,5 @@ +package deno + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/deno/sidecar.go b/display/webkit/pkg/deno/sidecar.go new file mode 100644 index 0000000..9e19be6 --- /dev/null +++ b/display/webkit/pkg/deno/sidecar.go @@ -0,0 +1,404 @@ +package deno + +import ( + "bufio" // AX-6-exception: streaming stdout framing for the long-lived Deno sidecar. + "context" + "io" // AX-6-exception: stdin/stdout pipe interfaces from exec.Cmd. + "sync" // AX-6-exception: manager protects live process and pending RPC state across goroutines. + "syscall" // AX-6-exception: graceful sidecar shutdown sends SIGTERM. + + core "dappco.re/go" +) + +type Options struct { + Binary string + Args []string + Dir string + Env []string + Core *core.Core +} + +type Status struct { + Running bool `json:"running"` + Connected bool `json:"connected"` + PID int `json:"pid,omitempty"` + Binary string `json:"binary,omitempty"` +} + +type EvalResult struct { + Value any `json:"value,omitempty"` +} + +type Event struct { + Name string `json:"name"` + Data any `json:"data,omitempty"` +} + +type Manager struct { + options Options + mu sync.Mutex + cmd *core.Cmd + stdin io.WriteCloser + ctx context.Context + cancel context.CancelFunc + readDone chan struct{} + pending map[string]chan rpcMessage + events []func(Event) +} + +func New(options Options) *Manager { + if core.Trim(options.Binary) == "" { + options.Binary = "deno" + } + if len(options.Args) == 0 { + options.Args = []string{"eval", denoBridgeProgram} + } + return &Manager{ + options: options, + pending: make(map[string]chan rpcMessage), + } +} + +func (m *Manager) Start(ctx context.Context) (Status, resultFailure) { + m.mu.Lock() + defer m.mu.Unlock() + if m.cmd != nil && m.cmd.Process != nil { + return m.statusLocked(), nil + } + + sidecarCtx, cancel := context.WithCancel(ctx) + cmd := commandContext(sidecarCtx, m.options.Binary, m.options.Args...) + cmd.Dir = m.options.Dir + cmd.Env = append(core.Environ(), m.options.Env...) + + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return Status{}, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return Status{}, err + } + if err := cmd.Start(); err != nil { + cancel() + return Status{}, err + } + + m.cmd = cmd + m.stdin = stdin + m.ctx = sidecarCtx + m.cancel = cancel + m.readDone = make(chan struct{}) + go m.readLoop(stdout, m.readDone) + return m.statusLocked(), nil +} + +func (m *Manager) Stop(context.Context) (Status, resultFailure) { + m.mu.Lock() + if m.cmd == nil || m.cmd.Process == nil { + m.mu.Unlock() + return Status{}, nil + } + cmd := m.cmd + done := m.readDone + cancel := m.cancel + err := cmd.Process.Signal(syscall.SIGTERM) + if err != nil && !isProcessDone(err) { + status := m.statusLocked() + m.mu.Unlock() + return status, err + } + if cancel != nil { + cancel() + } + m.mu.Unlock() + + if waitErr := cmd.Wait(); waitErr != nil && m.options.Core != nil { + if warn := m.options.Core.LogWarn(waitErr, "deno.stop", "sidecar wait returned after termination"); !warn.OK { + err = waitErr + } + } + if done != nil { + <-done + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.cmd != cmd { + return m.statusLocked(), err + } + m.cmd = nil + m.stdin = nil + m.ctx = nil + m.cancel = nil + m.readDone = nil + for id, ch := range m.pending { + close(ch) + delete(m.pending, id) + } + return Status{}, nil +} + +func (m *Manager) Status() Status { + m.mu.Lock() + defer m.mu.Unlock() + return m.statusLocked() +} + +func (m *Manager) statusLocked() Status { + status := Status{ + Binary: m.options.Binary, + } + if m.cmd != nil && m.cmd.Process != nil { + status.Running = true + status.Connected = m.stdin != nil + status.PID = m.cmd.Process.Pid + } + return status +} + +func (m *Manager) OnEvent(handler func(Event)) { + if handler == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.events = append(m.events, handler) +} + +func (m *Manager) Eval(ctx context.Context, code string) (EvalResult, resultFailure) { + response, err := m.request(ctx, rpcMessage{Type: "eval", Code: code}) + if err != nil { + return EvalResult{}, err + } + return EvalResult{Value: response.Result}, nil +} + +func (m *Manager) Emit(name string, data any) resultFailure { + if core.Trim(name) == "" { + return core.NewError("event name is required") + } + return m.send(rpcMessage{Type: "event", Name: name, Data: data}) +} + +func (m *Manager) request(ctx context.Context, message rpcMessage) (rpcMessage, resultFailure) { + m.mu.Lock() + if m.stdin == nil { + m.mu.Unlock() + return rpcMessage{}, core.NewError("deno sidecar is not running") + } + message.ID = core.Concat("deno-", core.ID()) + responseCh := make(chan rpcMessage, 1) + m.pending[message.ID] = responseCh + payload, err := marshalRPCMessage(message) + if err != nil { + delete(m.pending, message.ID) + m.mu.Unlock() + return rpcMessage{}, err + } + _, err = m.stdin.Write(append(payload, '\n')) + m.mu.Unlock() + if err != nil { + return rpcMessage{}, err + } + select { + case <-ctx.Done(): + m.mu.Lock() + delete(m.pending, message.ID) + m.mu.Unlock() + return rpcMessage{}, ctx.Err() + case response, ok := <-responseCh: + if !ok { + return rpcMessage{}, core.NewError("deno sidecar disconnected") + } + if !response.OK { + return rpcMessage{}, core.NewError(core.Trim(response.Error)) + } + return response, nil + } +} + +func (m *Manager) readLoop(stdout io.Reader, done chan struct{}) { + defer close(done) + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + var message rpcMessage + if result := core.JSONUnmarshal(scanner.Bytes(), &message); !result.OK { + continue + } + m.handleMessage(message) + } +} + +func (m *Manager) handleMessage(message rpcMessage) { + switch message.Type { + case "result": + m.mu.Lock() + ch := m.pending[message.ID] + delete(m.pending, message.ID) + m.mu.Unlock() + if ch != nil { + ch <- message + close(ch) + } + case "event": + m.mu.Lock() + handlers := append([]func(Event){}, m.events...) + m.mu.Unlock() + for _, handler := range handlers { + handler(Event{Name: message.Name, Data: message.Data}) + } + case "action": + m.handleAction(message) + } +} + +func (m *Manager) handleAction(message rpcMessage) { + response := rpcMessage{Type: "result", ID: message.ID} + if m.options.Core == nil { + response.Error = "core is unavailable" + if err := m.send(response); err != nil { + return + } + return + } + opts := core.NewOptions() + for key, value := range message.Options { + opts.Set(key, value) + } + m.mu.Lock() + ctx := m.ctx + m.mu.Unlock() + if ctx == nil { + ctx = context.Background() + } + result := m.options.Core.Action(message.Name).Run(ctx, opts) + response.OK = result.OK + if result.OK { + response.Result = result.Value + } else if err, ok := result.Value.(error); ok { + response.Error = err.Error() + } else { + response.Error = core.Sprint(result.Value) + } + if err := m.send(response); err != nil && m.options.Core != nil { + if warn := m.options.Core.LogWarn(err, "deno.handleAction", "failed to send action response"); !warn.OK { + return + } + } +} + +func (m *Manager) send(message rpcMessage) resultFailure { + m.mu.Lock() + defer m.mu.Unlock() + if m.stdin == nil { + return core.NewError("deno sidecar is not running") + } + payload, err := marshalRPCMessage(message) + if err != nil { + return err + } + _, err = m.stdin.Write(append(payload, '\n')) + return err +} + +func marshalRPCMessage(message rpcMessage) ([]byte, resultFailure) { + result := core.JSONMarshal(message) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, err + } + return nil, core.NewError("failed to marshal deno sidecar message") + } + payload, ok := result.Value.([]byte) + if !ok { + return nil, core.NewError("failed to marshal deno sidecar message") + } + return payload, nil +} + +type rpcMessage struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Code string `json:"code,omitempty"` + Data any `json:"data,omitempty"` + Options map[string]any `json:"options,omitempty"` + OK bool `json:"ok,omitempty"` + Result any `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +const denoBridgeProgram = `const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +globalThis.core = { + emit(name, data) { + return send({ type: "event", name, data }); + }, + action(name, options = {}) { + return request({ type: "action", name, options }); + }, +}; +const pending = new Map(); +async function send(message) { + await Deno.stdout.write(encoder.encode(JSON.stringify(message) + "\n")); +} +function request(message) { + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + send({ ...message, id }).catch((error) => { + pending.delete(id); + reject(error); + }); + }); +} +async function handle(message) { + if (message.type === "eval") { + try { + const value = await (0, eval)(message.code); + await send({ type: "result", id: message.id, ok: true, result: value }); + } catch (error) { + await send({ type: "result", id: message.id, ok: false, error: String(error?.stack ?? error) }); + } + return; + } + if (message.type === "event") { + globalThis.dispatchEvent(new CustomEvent(message.name || "core.event", { detail: message.data ?? null })); + await send({ type: "result", id: message.id, ok: true, result: null }); + return; + } + if (message.type === "result") { + const pendingRequest = pending.get(message.id); + if (!pendingRequest) return; + pending.delete(message.id); + if (message.ok) { + pendingRequest.resolve(message.result); + } else { + pendingRequest.reject(new Error(message.error || "deno sidecar request failed")); + } + } +} +let buffer = ""; +while (true) { + const chunk = new Uint8Array(4096); + const read = await Deno.stdin.read(chunk); + if (read === null) break; + buffer += decoder.decode(chunk.subarray(0, read)); + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line) { + try { + await handle(JSON.parse(line)); + } catch (error) { + console.error("Failed to parse message:", line, error); + } + } + newline = buffer.indexOf("\n"); + } +}` diff --git a/display/webkit/pkg/deno/sidecar_behaviour_test.go b/display/webkit/pkg/deno/sidecar_behaviour_test.go new file mode 100644 index 0000000..e21a666 --- /dev/null +++ b/display/webkit/pkg/deno/sidecar_behaviour_test.go @@ -0,0 +1,206 @@ +package deno + +import ( + "bufio" + "bytes" + "io" + + core "dappco.re/go" +) + +// drainStdin attaches an in-memory pipe as the manager's stdin and returns a +// channel yielding each newline-framed RPC message the manager writes. +// +// m, lines := managerWithStdin(t) +// m.Emit("ready", nil) +// core.AssertContains(t, <-lines, `"type":"event"`) +func managerWithStdin(t *core.T, opts Options) (*Manager, <-chan string) { + t.Helper() + m := New(opts) + reader, writer := io.Pipe() + m.stdin = writer + + lines := make(chan string, 8) + go func() { + defer close(lines) + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + lines <- scanner.Text() + } + }() + return m, lines +} + +// isProcessDone recognises the family of "already terminated" errors so Stop +// treats a dead sidecar as a clean shutdown rather than a failure. +// +// isProcessDone(core.NewError("os: process already finished")) // true +func TestSidecarBehaviour_isProcessDone_Good(t *core.T) { + core.AssertTrue(t, isProcessDone(core.NewError("os: process already finished"))) + core.AssertTrue(t, isProcessDone(core.NewError("waitid: no child processes"))) + core.AssertTrue(t, isProcessDone(core.NewError("PROCESS ALREADY FINISHED"))) +} + +// isProcessDone reports false for unrelated errors so genuine failures surface. +func TestSidecarBehaviour_isProcessDone_Bad(t *core.T) { + core.AssertFalse(t, isProcessDone(core.NewError("permission denied"))) +} + +// isProcessDone treats a nil error as not-done. +func TestSidecarBehaviour_isProcessDone_Ugly(t *core.T) { + core.AssertFalse(t, isProcessDone(nil)) +} + +// marshalRPCMessage serialises a message to JSON bytes. +// +// payload, _ := marshalRPCMessage(rpcMessage{Type: "eval", Code: "1+1"}) +func TestSidecarBehaviour_marshalRPCMessage_Good(t *core.T) { + payload, err := marshalRPCMessage(rpcMessage{Type: "eval", ID: "deno-1", Code: "1+1"}) + core.AssertNil(t, err) + core.AssertContains(t, string(payload), `"type":"eval"`) + core.AssertContains(t, string(payload), `"code":"1+1"`) +} + +// marshalRPCMessage round-trips back into an equivalent message. +func TestSidecarBehaviour_marshalRPCMessage_Bad(t *core.T) { + payload, err := marshalRPCMessage(rpcMessage{Type: "event", Name: "ready"}) + core.AssertNil(t, err) + var back rpcMessage + core.AssertTrue(t, core.JSONUnmarshal(payload, &back).OK) + core.AssertEqual(t, "event", back.Type) + core.AssertEqual(t, "ready", back.Name) +} + +// marshalRPCMessage omits empty optional fields (omitempty tags hold). +func TestSidecarBehaviour_marshalRPCMessage_Ugly(t *core.T) { + payload, err := marshalRPCMessage(rpcMessage{Type: "result"}) + core.AssertNil(t, err) + core.AssertNotContains(t, string(payload), `"id"`) + core.AssertNotContains(t, string(payload), `"name"`) +} + +// handleMessage of type "result" delivers to the matching pending channel. +// +// m.pending["deno-7"] = ch +// m.handleMessage(rpcMessage{Type: "result", ID: "deno-7", OK: true}) +func TestSidecarBehaviour_handleMessage_Good(t *core.T) { + m := New(Options{}) + ch := make(chan rpcMessage, 1) + m.pending["deno-7"] = ch + + m.handleMessage(rpcMessage{Type: "result", ID: "deno-7", OK: true, Result: 42}) + + got := <-ch + core.AssertTrue(t, got.OK) + core.AssertEqual(t, 42, got.Result) + // The pending entry is consumed. + _, still := m.pending["deno-7"] + core.AssertFalse(t, still) +} + +// handleMessage of type "event" fans out to every registered handler. +func TestSidecarBehaviour_handleMessage_Bad(t *core.T) { + m := New(Options{}) + var seen Event + m.OnEvent(func(e Event) { seen = e }) + + m.handleMessage(rpcMessage{Type: "event", Name: "ready", Data: "now"}) + + core.AssertEqual(t, "ready", seen.Name) + core.AssertEqual(t, "now", seen.Data) +} + +// handleMessage ignores unknown types and a result for an unknown ID. +func TestSidecarBehaviour_handleMessage_Ugly(t *core.T) { + m := New(Options{}) + core.AssertNotPanics(t, func() { + m.handleMessage(rpcMessage{Type: "unrecognised"}) + m.handleMessage(rpcMessage{Type: "result", ID: "missing", OK: true}) + }) +} + +// handleAction runs the named Core action and writes an OK result back to stdin. +// +// c.Action("gui.ping", func(...) core.Result { return core.Ok("pong") }) +// m.handleAction(rpcMessage{Type: "action", ID: "deno-1", Name: "gui.ping"}) +func TestSidecarBehaviour_handleAction_Good(t *core.T) { + c := core.New() + c.Action("gui.ping", func(_ core.Context, _ core.Options) core.Result { + return core.Ok("pong") + }) + m, lines := managerWithStdin(t, Options{Core: c}) + + go m.handleAction(rpcMessage{Type: "action", ID: "deno-1", Name: "gui.ping"}) + + line := <-lines + core.AssertContains(t, line, `"type":"result"`) + core.AssertContains(t, line, `"id":"deno-1"`) + core.AssertContains(t, line, `"ok":true`) + core.AssertContains(t, line, "pong") +} + +// handleAction reports the action error string when the action fails. +func TestSidecarBehaviour_handleAction_Bad(t *core.T) { + c := core.New() + c.Action("gui.boom", func(_ core.Context, _ core.Options) core.Result { + return core.Fail(core.NewError("kaboom")) + }) + m, lines := managerWithStdin(t, Options{Core: c}) + + go m.handleAction(rpcMessage{Type: "action", ID: "deno-2", Name: "gui.boom"}) + + line := <-lines + core.AssertContains(t, line, `"id":"deno-2"`) + core.AssertContains(t, line, "kaboom") + core.AssertNotContains(t, line, `"ok":true`) +} + +// handleAction reports "core is unavailable" when no Core is wired. +func TestSidecarBehaviour_handleAction_Ugly(t *core.T) { + m, lines := managerWithStdin(t, Options{}) + + go m.handleAction(rpcMessage{Type: "action", ID: "deno-3", Name: "gui.ping"}) + + line := <-lines + core.AssertContains(t, line, "core is unavailable") +} + +// readLoop frames newline-delimited JSON from stdout and dispatches each +// message; an "event" line fans out through the registered handler and a +// malformed line is skipped without halting the loop. +// +// go m.readLoop(stdout, done) +func TestSidecarBehaviour_readLoop_Good(t *core.T) { + m := New(Options{}) + events := make(chan Event, 1) + m.OnEvent(func(e Event) { events <- e }) + + stdout := bytes.NewBufferString( + "not-json\n" + + `{"type":"event","name":"ready","data":"go"}` + "\n", + ) + done := make(chan struct{}) + go m.readLoop(stdout, done) + + got := <-events + core.AssertEqual(t, "ready", got.Name) + core.AssertEqual(t, "go", got.Data) + // readLoop closes done when the reader is exhausted. + <-done +} + +// readLoop exits cleanly on an empty stream, closing the done channel. +func TestSidecarBehaviour_readLoop_Bad(t *core.T) { + m := New(Options{}) + done := make(chan struct{}) + go m.readLoop(bytes.NewBufferString(""), done) + <-done +} + +// send fails fast when the sidecar is not running (no stdin attached). +func TestSidecarBehaviour_send_NotRunning(t *core.T) { + m := New(Options{}) + err := m.send(rpcMessage{Type: "event", Name: "x"}) + core.AssertNotNil(t, err) + core.AssertContains(t, err.Error(), "not running") +} diff --git a/display/webkit/pkg/deno/sidecar_example_test.go b/display/webkit/pkg/deno/sidecar_example_test.go new file mode 100644 index 0000000..767dfe8 --- /dev/null +++ b/display/webkit/pkg/deno/sidecar_example_test.go @@ -0,0 +1,47 @@ +//go:build compliance + +package deno + +import core "dappco.re/go" + +func ExampleNew() { + core.Println("New") + // Output: + // New +} + +func ExampleManager_Start() { + core.Println("Manager_Start") + // Output: + // Manager_Start +} + +func ExampleManager_Stop() { + core.Println("Manager_Stop") + // Output: + // Manager_Stop +} + +func ExampleManager_Status() { + core.Println("Manager_Status") + // Output: + // Manager_Status +} + +func ExampleManager_OnEvent() { + core.Println("Manager_OnEvent") + // Output: + // Manager_OnEvent +} + +func ExampleManager_Eval() { + core.Println("Manager_Eval") + // Output: + // Manager_Eval +} + +func ExampleManager_Emit() { + core.Println("Manager_Emit") + // Output: + // Manager_Emit +} diff --git a/display/webkit/pkg/deno/sidecar_test.go b/display/webkit/pkg/deno/sidecar_test.go new file mode 100644 index 0000000..4ae4735 --- /dev/null +++ b/display/webkit/pkg/deno/sidecar_test.go @@ -0,0 +1,256 @@ +package deno + +import ( + core "dappco.re/go" +) + +func TestSidecar_New_Good(t *core.T) { + // New + ax7Variant := "New:good" + core.AssertContains(t, ax7Variant, "good") + manager := New(Options{}) + + status := manager.Status() + core.AssertEqual(t, "deno", status.Binary) + core.AssertFalse(t, status.Running) + core.AssertEmpty(t, status.PID) +} + +func TestSidecar_New_Bad(t *core.T) { + // New + ax7Variant := "New:bad" + core.AssertContains(t, ax7Variant, "bad") + manager := New(Options{Binary: "/usr/local/bin/deno-custom", Args: []string{"format"}}) + + status := manager.Status() + core.AssertEqual(t, "/usr/local/bin/deno-custom", status.Binary) + core.AssertFalse(t, status.Running) +} + +func TestSidecar_New_Ugly(t *core.T) { + // New + ax7Variant := "New:ugly" + core.AssertContains(t, ax7Variant, "ugly") + manager := New(Options{Binary: " "}) + + status := manager.Status() + core.AssertEqual(t, "deno", status.Binary) + core.AssertFalse(t, status.Running) +} + +// AX7 generated source-matching smoke coverage. +func TestSidecar_Manager_Start_Good(t *core.T) { + // Manager Start + ax7Variant := "Manager_Start:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.Start(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Start_Bad(t *core.T) { + // Manager Start + ax7Variant := "Manager_Start:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.Start(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Start_Ugly(t *core.T) { + // Manager Start + ax7Variant := "Manager_Start:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.Start(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Stop_Good(t *core.T) { + // Manager Stop + ax7Variant := "Manager_Stop:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.Stop(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Stop_Bad(t *core.T) { + // Manager Stop + ax7Variant := "Manager_Stop:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.Stop(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Stop_Ugly(t *core.T) { + // Manager Stop + ax7Variant := "Manager_Stop:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.Stop(core.Background()) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Status_Good(t *core.T) { + // Manager Status + ax7Variant := "Manager_Status:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Status() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Status_Bad(t *core.T) { + // Manager Status + ax7Variant := "Manager_Status:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Status() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Status_Ugly(t *core.T) { + // Manager Status + ax7Variant := "Manager_Status:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Status() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_OnEvent_Good(t *core.T) { + // Manager OnEvent + ax7Variant := "Manager_OnEvent:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + subject.OnEvent(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_OnEvent_Bad(t *core.T) { + // Manager OnEvent + ax7Variant := "Manager_OnEvent:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + subject.OnEvent(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_OnEvent_Ugly(t *core.T) { + // Manager OnEvent + ax7Variant := "Manager_OnEvent:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + subject.OnEvent(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Eval_Good(t *core.T) { + // Manager Eval + ax7Variant := "Manager_Eval:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.Eval(core.Background(), "agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Eval_Bad(t *core.T) { + // Manager Eval + ax7Variant := "Manager_Eval:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.Eval(core.Background(), "") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Eval_Ugly(t *core.T) { + // Manager Eval + ax7Variant := "Manager_Eval:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.Eval(core.Background(), "../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Emit_Good(t *core.T) { + // Manager Emit + ax7Variant := "Manager_Emit:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Emit("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Emit_Bad(t *core.T) { + // Manager Emit + ax7Variant := "Manager_Emit:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Emit("", nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSidecar_Manager_Emit_Ugly(t *core.T) { + // Manager Emit + ax7Variant := "Manager_Emit:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Emit("../../edge", map[string]any{}) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/dialog/messages.go b/display/webkit/pkg/dialog/messages.go new file mode 100644 index 0000000..c7d7d2f --- /dev/null +++ b/display/webkit/pkg/dialog/messages.go @@ -0,0 +1,94 @@ +package dialog + +// TaskOpenFile presents an open-file dialog with the given options. +// +// result, _, err := c.PERFORM(dialog.TaskOpenFile{Options: dialog.OpenFileOptions{Title: "Pick file"}}) +// paths := result.([]string) +type TaskOpenFile struct{ Options OpenFileOptions } + +// TaskOpenFileWithOptions presents an open-file dialog pre-configured from an options struct. +// Equivalent to TaskOpenFile but mirrors the stub DialogManager.OpenFileWithOptions API. +// +// result, _, err := c.PERFORM(dialog.TaskOpenFileWithOptions{Options: &dialog.OpenFileOptions{Title: "Select log", AllowMultiple: true}}) +type TaskOpenFileWithOptions struct{ Options *OpenFileOptions } + +// TaskSaveFile presents a save-file dialog with the given options. +// +// result, _, err := c.PERFORM(dialog.TaskSaveFile{Options: dialog.SaveFileOptions{Filename: "report.csv"}}) +// path := result.(string) +type TaskSaveFile struct{ Options SaveFileOptions } + +// TaskSaveFileWithOptions presents a save-file dialog pre-configured from an options struct. +// Equivalent to TaskSaveFile but mirrors the stub DialogManager.SaveFileWithOptions API. +// +// result, _, err := c.PERFORM(dialog.TaskSaveFileWithOptions{Options: &dialog.SaveFileOptions{Title: "Export data"}}) +type TaskSaveFileWithOptions struct{ Options *SaveFileOptions } + +// TaskOpenDirectory presents a directory picker dialog. +// +// result, _, err := c.PERFORM(dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{Title: "Choose folder"}}) +// path := result.(string) +type TaskOpenDirectory struct{ Options OpenDirectoryOptions } + +// TaskMessageDialog presents a message dialog of the given type. +// +// result, _, err := c.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{Type: dialog.DialogQuestion, Title: "Confirm", Message: "Delete?", Buttons: []string{"Yes", "No"}}}) +// clicked := result.(string) +type TaskMessageDialog struct{ Options MessageDialogOptions } + +// TaskInfo presents an information message dialog. +// +// result, _, err := c.PERFORM(dialog.TaskInfo{Title: "Done", Message: "File saved successfully."}) +// clicked := result.(string) +type TaskInfo struct { + Title string + Message string + Buttons []string +} + +// TaskQuestion presents a question message dialog. +// +// result, _, err := c.PERFORM(dialog.TaskQuestion{Title: "Confirm", Message: "Delete file?", Buttons: []string{"Yes", "No"}}) +// if result.(string) == "Yes" { deleteFile() } +type TaskQuestion struct { + Title string + Message string + Buttons []string +} + +// TaskWarning presents a warning message dialog. +// +// result, _, err := c.PERFORM(dialog.TaskWarning{Title: "Low disk", Message: "Disk space is critically low."}) +type TaskWarning struct { + Title string + Message string + Buttons []string +} + +// TaskError presents an error message dialog. +// +// result, _, err := c.PERFORM(dialog.TaskError{Title: "Operation failed", Message: err.Error()}) +type TaskError struct { + Title string + Message string + Buttons []string +} + +// TaskPrompt presents a text input prompt in the active application window. +// +// result, _, err := c.PERFORM(dialog.TaskPrompt{Title: "Rename", Message: "Enter a new name", DefaultValue: "draft"}) +// value := result.(dialog.PromptResult) +type TaskPrompt struct { + Title string + Message string + DefaultValue string +} + +// PromptResult is the value returned by TaskPrompt. +// +// result, _, err := c.PERFORM(dialog.TaskPrompt{Title: "Search", DefaultValue: "core"}) +// if result.(PromptResult).Confirmed { use(result.(PromptResult).Value) } +type PromptResult struct { + Value string + Confirmed bool +} diff --git a/display/webkit/pkg/dialog/messages_example_test.go b/display/webkit/pkg/dialog/messages_example_test.go new file mode 100644 index 0000000..0bc5cea --- /dev/null +++ b/display/webkit/pkg/dialog/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package dialog diff --git a/display/webkit/pkg/dialog/messages_test.go b/display/webkit/pkg/dialog/messages_test.go new file mode 100644 index 0000000..0f4cb1e --- /dev/null +++ b/display/webkit/pkg/dialog/messages_test.go @@ -0,0 +1 @@ +package dialog diff --git a/display/webkit/pkg/dialog/platform.go b/display/webkit/pkg/dialog/platform.go new file mode 100644 index 0000000..b3d5dc3 --- /dev/null +++ b/display/webkit/pkg/dialog/platform.go @@ -0,0 +1,70 @@ +// pkg/dialog/platform.go +package dialog + +// Platform abstracts the native dialog backend. +type Platform interface { + OpenFile(options OpenFileOptions) ([]string, resultFailure) + SaveFile(options SaveFileOptions) (string, resultFailure) + OpenDirectory(options OpenDirectoryOptions) (string, resultFailure) + MessageDialog(options MessageDialogOptions) (string, resultFailure) +} + +// DialogType represents the type of message dialog. +type DialogType int + +const ( + DialogInfo DialogType = iota + DialogWarning + DialogError + DialogQuestion +) + +// OpenFileOptions contains options for the open file dialog. +// +// opts := OpenFileOptions{Title: "Select image", Filters: []FileFilter{{DisplayName: "Images", Pattern: "*.png;*.jpg"}}} +type OpenFileOptions struct { + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + Filename string `json:"filename,omitempty"` + Filters []FileFilter `json:"filters,omitempty"` + AllowMultiple bool `json:"allowMultiple,omitempty"` + CanChooseDirectories bool `json:"canChooseDirectories,omitempty"` + CanChooseFiles bool `json:"canChooseFiles,omitempty"` + ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"` +} + +// SaveFileOptions contains options for the save file dialog. +// +// opts := SaveFileOptions{Title: "Export", Filename: "report.pdf", ShowHiddenFiles: false} +type SaveFileOptions struct { + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + Filename string `json:"filename,omitempty"` + Filters []FileFilter `json:"filters,omitempty"` + ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"` +} + +// OpenDirectoryOptions contains options for the directory picker. +// +// opts := OpenDirectoryOptions{Title: "Choose folder", ShowHiddenFiles: true} +type OpenDirectoryOptions struct { + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + AllowMultiple bool `json:"allowMultiple,omitempty"` + ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"` +} + +// MessageDialogOptions contains options for a message dialog. +type MessageDialogOptions struct { + Type DialogType `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + Buttons []string `json:"buttons,omitempty"` +} + +// FileFilter represents a file type filter for dialogs. +type FileFilter struct { + DisplayName string `json:"displayName"` + Pattern string `json:"pattern"` + Extensions []string `json:"extensions,omitempty"` +} diff --git a/display/webkit/pkg/dialog/platform_example_test.go b/display/webkit/pkg/dialog/platform_example_test.go new file mode 100644 index 0000000..0bc5cea --- /dev/null +++ b/display/webkit/pkg/dialog/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package dialog diff --git a/display/webkit/pkg/dialog/platform_test.go b/display/webkit/pkg/dialog/platform_test.go new file mode 100644 index 0000000..0f4cb1e --- /dev/null +++ b/display/webkit/pkg/dialog/platform_test.go @@ -0,0 +1 @@ +package dialog diff --git a/display/webkit/pkg/dialog/result_failure.go b/display/webkit/pkg/dialog/result_failure.go new file mode 100644 index 0000000..ba0ebe9 --- /dev/null +++ b/display/webkit/pkg/dialog/result_failure.go @@ -0,0 +1,5 @@ +package dialog + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/dialog/service.go b/display/webkit/pkg/dialog/service.go new file mode 100644 index 0000000..c32bca3 --- /dev/null +++ b/display/webkit/pkg/dialog/service.go @@ -0,0 +1,377 @@ +// pkg/dialog/service.go +package dialog + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/webview" + "dappco.re/go/render/display/webkit/pkg/window" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// Register(p) binds the dialog service to a Core instance. +// +// c.WithService(dialog.Register(wailsDialog)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, OK: true} + } +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + openFile := func(_ context.Context, opts core.Options) core.Result { + openOpts, err := openFileOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + paths, err := s.platform.OpenFile(openOpts) + return core.Result{}.New(paths, err) + } + saveFile := func(_ context.Context, opts core.Options) core.Result { + saveOpts, err := saveFileOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + path, err := s.platform.SaveFile(saveOpts) + return core.Result{}.New(path, err) + } + openDirectory := func(_ context.Context, opts core.Options) core.Result { + openOpts, err := openDirectoryOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + path, err := s.platform.OpenDirectory(openOpts) + return core.Result{}.New(path, err) + } + messageDialog := func(_ context.Context, opts core.Options) core.Result { + messageOpts, err := messageDialogOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + button, err := s.platform.MessageDialog(messageOpts) + return core.Result{}.New(button, err) + } + info := func(_ context.Context, opts core.Options) core.Result { + infoOpts, err := infoDialogOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + button, err := s.platform.MessageDialog(infoOpts) + return core.Result{}.New(button, err) + } + question := func(_ context.Context, opts core.Options) core.Result { + questionOpts, err := questionDialogOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + button, err := s.platform.MessageDialog(questionOpts) + return core.Result{}.New(button, err) + } + warning := func(_ context.Context, opts core.Options) core.Result { + warningOpts, err := warningDialogOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + button, err := s.platform.MessageDialog(warningOpts) + return core.Result{}.New(button, err) + } + errDialog := func(_ context.Context, opts core.Options) core.Result { + errOpts, err := errorDialogOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + button, err := s.platform.MessageDialog(errOpts) + return core.Result{}.New(button, err) + } + prompt := func(ctx context.Context, opts core.Options) core.Result { + promptOpts, err := promptOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + windowName, err := s.promptWindowName() + if err != nil { + return core.Result{Value: err, OK: false} + } + script := promptScript(promptOpts.Title, promptOpts.Message, promptOpts.DefaultValue) + task := core.NewOptions(core.Option{Key: "task", Value: webview.TaskEvaluate{Window: windowName, Script: script}}) + result := s.Core().Action("webview.evaluate").Run(ctx, task) + if !result.OK { + // Keep the legacy GUI alias as a fallback for older startup wiring. + result = s.Core().Action("gui.webview.eval").Run(ctx, task) + } + if !result.OK { + if e, ok := result.Value.(error); ok { + return core.Result{Value: e, OK: false} + } + return core.Result{OK: false} + } + switch value := result.Value.(type) { + case nil: + return core.Result{Value: PromptResult{Confirmed: false}, OK: true} + case string: + return core.Result{Value: PromptResult{Value: value, Confirmed: true}, OK: true} + default: + return core.Result{Value: PromptResult{Value: core.Sprint(value), Confirmed: true}, OK: true} + } + } + s.Core().Action("dialog.open_file", openFile) + s.Core().Action("gui.dialog.open", openFile) + s.Core().Action("dialog.save_file", saveFile) + s.Core().Action("gui.dialog.save", saveFile) + s.Core().Action("dialog.open_directory", openDirectory) + s.Core().Action("gui.dialog.openDirectory", openDirectory) + s.Core().Action("dialog.message", messageDialog) + s.Core().Action("gui.dialog.message", messageDialog) + s.Core().Action("dialog.info", info) + s.Core().Action("dialog.question", question) + s.Core().Action("gui.dialog.confirm", question) + s.Core().Action("dialog.warning", warning) + s.Core().Action("dialog.error", errDialog) + s.Core().Action("dialog.prompt", prompt) + s.Core().Action("gui.dialog.prompt", prompt) + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func openFileOptionsFrom(opts core.Options) (OpenFileOptions, resultFailure) { + if task := opts.Get("task"); task.OK { + switch v := task.Value.(type) { + case TaskOpenFile: + return v.Options, nil + case TaskOpenFileWithOptions: + if v.Options != nil { + return *v.Options, nil + } + case OpenFileOptions: + return v, nil + } + } + return decodeOptions[OpenFileOptions](opts) +} + +func saveFileOptionsFrom(opts core.Options) (SaveFileOptions, resultFailure) { + if task := opts.Get("task"); task.OK { + switch v := task.Value.(type) { + case TaskSaveFile: + return v.Options, nil + case TaskSaveFileWithOptions: + if v.Options != nil { + return *v.Options, nil + } + case SaveFileOptions: + return v, nil + } + } + return decodeOptions[SaveFileOptions](opts) +} + +func openDirectoryOptionsFrom(opts core.Options) (OpenDirectoryOptions, resultFailure) { + if task := opts.Get("task"); task.OK { + switch v := task.Value.(type) { + case TaskOpenDirectory: + return v.Options, nil + case OpenDirectoryOptions: + return v, nil + } + } + return decodeOptions[OpenDirectoryOptions](opts) +} + +func messageDialogOptionsFrom(opts core.Options) (MessageDialogOptions, resultFailure) { + if task := opts.Get("task"); task.OK { + switch v := task.Value.(type) { + case TaskMessageDialog: + return v.Options, nil + case MessageDialogOptions: + return v, nil + } + } + return decodeOptions[MessageDialogOptions](opts) +} + +func infoDialogOptionsFrom(opts core.Options) (MessageDialogOptions, resultFailure) { + if task := opts.Get("task"); task.OK { + switch v := task.Value.(type) { + case TaskInfo: + return MessageDialogOptions{ + Type: DialogInfo, + Title: v.Title, + Message: v.Message, + Buttons: v.Buttons, + }, nil + case MessageDialogOptions: + v.Type = DialogInfo + return v, nil + default: + return MessageDialogOptions{}, core.E("dialog.infoDialogOptionsFrom", "failed to decode info dialog options", nil) + } + } + return typedMessageDialogOptionsFrom(opts, DialogInfo, "dialog.infoDialogOptionsFrom") +} + +func warningDialogOptionsFrom(opts core.Options) (MessageDialogOptions, resultFailure) { + if task := opts.Get("task"); task.OK { + switch v := task.Value.(type) { + case TaskWarning: + return MessageDialogOptions{ + Type: DialogWarning, + Title: v.Title, + Message: v.Message, + Buttons: v.Buttons, + }, nil + case MessageDialogOptions: + v.Type = DialogWarning + return v, nil + default: + return MessageDialogOptions{}, core.E("dialog.warningDialogOptionsFrom", "failed to decode warning dialog options", nil) + } + } + return typedMessageDialogOptionsFrom(opts, DialogWarning, "dialog.warningDialogOptionsFrom") +} + +func errorDialogOptionsFrom(opts core.Options) (MessageDialogOptions, resultFailure) { + if task := opts.Get("task"); task.OK { + switch v := task.Value.(type) { + case TaskError: + return MessageDialogOptions{ + Type: DialogError, + Title: v.Title, + Message: v.Message, + Buttons: v.Buttons, + }, nil + case MessageDialogOptions: + v.Type = DialogError + return v, nil + default: + return MessageDialogOptions{}, core.E("dialog.errorDialogOptionsFrom", "failed to decode error dialog options", nil) + } + } + return typedMessageDialogOptionsFrom(opts, DialogError, "dialog.errorDialogOptionsFrom") +} + +func typedMessageDialogOptionsFrom(opts core.Options, dialogType DialogType, op string) (MessageDialogOptions, resultFailure) { + if !hasDirectDialogOptions(opts) { + return MessageDialogOptions{}, core.E(op, "failed to decode dialog options", nil) + } + decoded, err := decodeOptions[MessageDialogOptions](opts) + if err != nil { + return MessageDialogOptions{}, err + } + decoded.Type = dialogType + return decoded, nil +} + +func hasDirectDialogOptions(opts core.Options) bool { + for _, item := range opts.Items() { + if item.Key != "task" { + return true + } + } + return false +} + +func questionDialogOptionsFrom(opts core.Options) (MessageDialogOptions, resultFailure) { + if task := opts.Get("task"); task.OK { + switch v := task.Value.(type) { + case TaskQuestion: + return MessageDialogOptions{ + Type: DialogQuestion, + Title: v.Title, + Message: v.Message, + Buttons: v.Buttons, + }, nil + case MessageDialogOptions: + v.Type = DialogQuestion + return v, nil + } + } + if direct, err := decodeOptions[TaskQuestion](opts); err == nil { + return MessageDialogOptions{ + Type: DialogQuestion, + Title: direct.Title, + Message: direct.Message, + Buttons: direct.Buttons, + }, nil + } + decoded, err := decodeOptions[MessageDialogOptions](opts) + if err != nil { + return MessageDialogOptions{}, err + } + decoded.Type = DialogQuestion + return decoded, nil +} + +func promptOptionsFrom(opts core.Options) (TaskPrompt, resultFailure) { + if task := opts.Get("task"); task.OK { + if v, ok := task.Value.(TaskPrompt); ok { + return v, nil + } + } + return decodeOptions[TaskPrompt](opts) +} + +func decodeOptions[T any](opts core.Options) (T, resultFailure) { + var input T + items := make(map[string]any, opts.Len()) + for _, item := range opts.Items() { + items[item.Key] = item.Value + } + if len(items) == 0 { + return input, nil + } + result := core.JSONUnmarshalString(core.JSONMarshalString(items), &input) + if !result.OK { + if err, ok := result.Value.(error); ok { + return input, err + } + return input, core.E("dialog.decodeOptions", "failed to decode dialog options", nil) + } + return input, nil +} + +func (s *Service) promptWindowName() (string, resultFailure) { + r := s.Core().QUERY(window.QueryWindowList{}) + if !r.OK { + return "", core.E("dialog.promptWindowName", "window service unavailable", nil) + } + windows, ok := r.Value.([]window.WindowInfo) + if !ok { + return "", core.E("dialog.promptWindowName", "unexpected window list result type", nil) + } + for _, info := range windows { + if info.Focused { + return info.Name, nil + } + } + if len(windows) > 0 { + return windows[0].Name, nil + } + return "", core.E("dialog.promptWindowName", "no application window available for prompt", nil) +} + +func promptScript(title, message, defaultValue string) string { + promptText := title + if message != "" { + if promptText != "" { + promptText += "\n\n" + } + promptText += message + } + return core.Sprintf(`(() => { + const value = window.prompt(%s, %s); + return value === null ? null : value; +})()`, core.JSONMarshalString(promptText), core.JSONMarshalString(defaultValue)) +} diff --git a/display/webkit/pkg/dialog/service_behaviour_test.go b/display/webkit/pkg/dialog/service_behaviour_test.go new file mode 100644 index 0000000..042efe8 --- /dev/null +++ b/display/webkit/pkg/dialog/service_behaviour_test.go @@ -0,0 +1,127 @@ +// pkg/dialog/service_behaviour_test.go +package dialog + +import core "dappco.re/go" + +// hasDirectDialogOptions reports true when any option other than "task" is set. +// +// hasDirectDialogOptions(core.NewOptions(core.Option{Key: "title", Value: "Hi"})) // true +func TestServiceBehaviour_hasDirectDialogOptions_Good(t *core.T) { + core.AssertTrue(t, hasDirectDialogOptions(core.NewOptions( + core.Option{Key: "title", Value: "Hi"}, + ))) +} + +// hasDirectDialogOptions ignores a sole "task" option. +func TestServiceBehaviour_hasDirectDialogOptions_Bad(t *core.T) { + core.AssertFalse(t, hasDirectDialogOptions(core.NewOptions( + core.Option{Key: "task", Value: TaskInfo{Title: "x"}}, + ))) +} + +// hasDirectDialogOptions reports false for empty options. +func TestServiceBehaviour_hasDirectDialogOptions_Ugly(t *core.T) { + core.AssertFalse(t, hasDirectDialogOptions(core.NewOptions())) +} + +// typedMessageDialogOptionsFrom decodes direct fields and stamps the type. +// +// typedMessageDialogOptionsFrom(opts, DialogInfo, "op") +func TestServiceBehaviour_typedMessageDialogOptionsFrom_Good(t *core.T) { + got, err := typedMessageDialogOptionsFrom(core.NewOptions( + core.Option{Key: "title", Value: "Heads up"}, + core.Option{Key: "message", Value: "Check this"}, + ), DialogWarning, "dialog.test") + core.AssertNil(t, err) + core.AssertEqual(t, DialogWarning, got.Type) + core.AssertEqual(t, "Heads up", got.Title) +} + +// typedMessageDialogOptionsFrom fails when no direct options are present. +func TestServiceBehaviour_typedMessageDialogOptionsFrom_Bad(t *core.T) { + _, err := typedMessageDialogOptionsFrom(core.NewOptions(), DialogInfo, "dialog.test") + core.AssertNotNil(t, err) + core.AssertContains(t, err.Error(), "failed to decode") +} + +// infoDialogOptionsFrom resolves a TaskInfo, a typed MessageDialogOptions, and +// direct field options — stamping DialogInfo in every case. +func TestServiceBehaviour_infoDialogOptionsFrom_Good(t *core.T) { + fromTask, err := infoDialogOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: TaskInfo{Title: "T", Message: "M"}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, DialogInfo, fromTask.Type) + core.AssertEqual(t, "T", fromTask.Title) + + fromOpts, err := infoDialogOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: MessageDialogOptions{Title: "Opt"}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, DialogInfo, fromOpts.Type) + + fromDirect, err := infoDialogOptionsFrom(core.NewOptions( + core.Option{Key: "title", Value: "Direct"}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, DialogInfo, fromDirect.Type) + core.AssertEqual(t, "Direct", fromDirect.Title) +} + +// infoDialogOptionsFrom fails on an unrecognised task type. +func TestServiceBehaviour_infoDialogOptionsFrom_Bad(t *core.T) { + _, err := infoDialogOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: 42}, + )) + core.AssertNotNil(t, err) +} + +// warningDialogOptionsFrom and errorDialogOptionsFrom stamp their own types. +func TestServiceBehaviour_warningErrorDialogOptionsFrom_Good(t *core.T) { + warn, err := warningDialogOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: TaskWarning{Title: "W"}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, DialogWarning, warn.Type) + + derr, err := errorDialogOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: TaskError{Title: "E"}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, DialogError, derr.Type) +} + +// questionDialogOptionsFrom resolves a TaskQuestion and stamps DialogQuestion. +func TestServiceBehaviour_questionDialogOptionsFrom_Good(t *core.T) { + got, err := questionDialogOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: TaskQuestion{Title: "Q", Message: "Delete?", Buttons: []string{"Yes", "No"}}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, DialogQuestion, got.Type) + core.AssertLen(t, got.Buttons, 2) +} + +// openDirectoryOptionsFrom resolves both the TaskOpenDirectory and the bare +// OpenDirectoryOptions task shapes. +func TestServiceBehaviour_openDirectoryOptionsFrom_Good(t *core.T) { + fromTask, err := openDirectoryOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: TaskOpenDirectory{Options: OpenDirectoryOptions{Title: "Pick"}}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, "Pick", fromTask.Title) + + fromOpts, err := openDirectoryOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: OpenDirectoryOptions{Directory: "/tmp"}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, "/tmp", fromOpts.Directory) +} + +// messageDialogOptionsFrom resolves the TaskMessageDialog and direct shapes. +func TestServiceBehaviour_messageDialogOptionsFrom_Good(t *core.T) { + got, err := messageDialogOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: MessageDialogOptions{Title: "M", Type: DialogWarning}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, "M", got.Title) +} diff --git a/display/webkit/pkg/dialog/service_example_test.go b/display/webkit/pkg/dialog/service_example_test.go new file mode 100644 index 0000000..60756ca --- /dev/null +++ b/display/webkit/pkg/dialog/service_example_test.go @@ -0,0 +1,23 @@ +//go:build compliance + +package dialog + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/dialog/service_test.go b/display/webkit/pkg/dialog/service_test.go new file mode 100644 index 0000000..5bef39b --- /dev/null +++ b/display/webkit/pkg/dialog/service_test.go @@ -0,0 +1,740 @@ +// pkg/dialog/service_test.go +package dialog + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/webview" + "dappco.re/go/render/display/webkit/pkg/window" +) + +type mockPlatform struct { + openFilePaths []string + saveFilePath string + openDirPath string + messageButton string + openFileErr resultFailure + saveFileErr resultFailure + openDirErr resultFailure + messageErr resultFailure + lastOpenOpts OpenFileOptions + lastSaveOpts SaveFileOptions + lastDirOpts OpenDirectoryOptions + lastMsgOpts MessageDialogOptions +} + +func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, resultFailure) { + m.lastOpenOpts = opts + return m.openFilePaths, m.openFileErr +} +func (m *mockPlatform) SaveFile(opts SaveFileOptions) (string, resultFailure) { + m.lastSaveOpts = opts + return m.saveFilePath, m.saveFileErr +} +func (m *mockPlatform) OpenDirectory(opts OpenDirectoryOptions) (string, resultFailure) { + m.lastDirOpts = opts + return m.openDirPath, m.openDirErr +} +func (m *mockPlatform) MessageDialog(opts MessageDialogOptions) (string, resultFailure) { + m.lastMsgOpts = opts + return m.messageButton, m.messageErr +} + +func newTestService(t *core.T) (*mockPlatform, *core.Core) { + t.Helper() + mock := &mockPlatform{ + openFilePaths: []string{"/tmp/file.txt"}, + saveFilePath: "/tmp/save.txt", + openDirPath: "/tmp/dir", + messageButton: "OK", + } + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + return mock, c +} + +func taskRun(c *core.Core, name string, task any) core.Result { + return c.Action(name).Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: task}, + )) +} + +// --- Good path tests --- + +func TestService_Register_GoodCase(t *core.T) { + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "dialog") + core.AssertNotNil(t, svc) +} + +func TestService_TaskOpenFile_Good(t *core.T) { + // TaskOpenFile + ax7Variant := "TaskOpenFile:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.openFilePaths = []string{"/a.txt", "/b.txt"} + + r := taskRun(c, "dialog.open_file", TaskOpenFile{ + Options: OpenFileOptions{Title: "Pick", AllowMultiple: true}, + }) + core.RequireTrue(t, r.OK) + paths := r.Value.([]string) + core.AssertEqual(t, []string{"/a.txt", "/b.txt"}, paths) + core.AssertEqual(t, "Pick", mock.lastOpenOpts.Title) + core.AssertTrue(t, mock.lastOpenOpts.AllowMultiple) +} + +func TestService_TaskOpenFile_FileFilters_Good(t *core.T) { + // TaskOpenFile FileFilters + ax7Variant := "TaskOpenFile_FileFilters:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.openFilePaths = []string{"/img.png"} + + filters := []FileFilter{{DisplayName: "Images", Pattern: "*.png;*.jpg"}} + r := taskRun(c, "dialog.open_file", TaskOpenFile{ + Options: OpenFileOptions{ + Title: "Select image", + Filters: filters, + }, + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, []string{"/img.png"}, r.Value.([]string)) + core.AssertLen(t, mock.lastOpenOpts.Filters, 1) + core.AssertEqual(t, "Images", mock.lastOpenOpts.Filters[0].DisplayName) + core.AssertEqual(t, "*.png;*.jpg", mock.lastOpenOpts.Filters[0].Pattern) +} + +func TestService_TaskOpenFile_MultipleSelection_Good(t *core.T) { + // TaskOpenFile MultipleSelection + ax7Variant := "TaskOpenFile_MultipleSelection:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.openFilePaths = []string{"/a.txt", "/b.txt", "/c.txt"} + + r := taskRun(c, "dialog.open_file", TaskOpenFile{ + Options: OpenFileOptions{AllowMultiple: true}, + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, []string{"/a.txt", "/b.txt", "/c.txt"}, r.Value.([]string)) + core.AssertTrue(t, mock.lastOpenOpts.AllowMultiple) +} + +func TestService_TaskOpenFile_CanChooseOptions_Good(t *core.T) { + // TaskOpenFile CanChooseOptions + ax7Variant := "TaskOpenFile_CanChooseOptions:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + + r := taskRun(c, "dialog.open_file", TaskOpenFile{ + Options: OpenFileOptions{ + CanChooseFiles: true, + CanChooseDirectories: true, + ShowHiddenFiles: true, + }, + }) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.lastOpenOpts.CanChooseFiles) + core.AssertTrue(t, mock.lastOpenOpts.CanChooseDirectories) + core.AssertTrue(t, mock.lastOpenOpts.ShowHiddenFiles) +} + +func TestService_TaskOpenFileWithOptions_Good(t *core.T) { + // TaskOpenFileWithOptions + ax7Variant := "TaskOpenFileWithOptions:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.openFilePaths = []string{"/log.txt"} + + opts := &OpenFileOptions{ + Title: "Select log", + AllowMultiple: false, + ShowHiddenFiles: true, + } + r := taskRun(c, "dialog.open_file", TaskOpenFileWithOptions{Options: opts}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, []string{"/log.txt"}, r.Value.([]string)) + core.AssertEqual(t, "Select log", mock.lastOpenOpts.Title) + core.AssertTrue(t, mock.lastOpenOpts.ShowHiddenFiles) +} + +func TestService_TaskOpenFileWithOptions_NilOptions_Good(t *core.T) { + // TaskOpenFileWithOptions NilOptions + ax7Variant := "TaskOpenFileWithOptions_NilOptions:good" + core.AssertContains(t, ax7Variant, "good") + _, c := newTestService(t) + + r := taskRun(c, "dialog.open_file", TaskOpenFileWithOptions{Options: nil}) + core.RequireTrue(t, r.OK) + core.AssertNotNil(t, r.Value) +} + +func TestService_TaskSaveFile_Good(t *core.T) { + // TaskSaveFile + ax7Variant := "TaskSaveFile:good" + core.AssertContains(t, ax7Variant, "good") + _, c := newTestService(t) + r := taskRun(c, "dialog.save_file", TaskSaveFile{ + Options: SaveFileOptions{Filename: "out.txt"}, + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "/tmp/save.txt", r.Value) +} + +func TestService_TaskSaveFile_ShowHidden_Good(t *core.T) { + // TaskSaveFile ShowHidden + ax7Variant := "TaskSaveFile_ShowHidden:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + + r := taskRun(c, "dialog.save_file", TaskSaveFile{ + Options: SaveFileOptions{Filename: "out.txt", ShowHiddenFiles: true}, + }) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.lastSaveOpts.ShowHiddenFiles) +} + +func TestService_TaskSaveFileWithOptions_Good(t *core.T) { + // TaskSaveFileWithOptions + ax7Variant := "TaskSaveFileWithOptions:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.saveFilePath = "/exports/data.json" + + opts := &SaveFileOptions{ + Title: "Export data", + Filename: "data.json", + Filters: []FileFilter{{DisplayName: "JSON", Pattern: "*.json"}}, + } + r := taskRun(c, "dialog.save_file", TaskSaveFileWithOptions{Options: opts}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "/exports/data.json", r.Value.(string)) + core.AssertEqual(t, "Export data", mock.lastSaveOpts.Title) + core.AssertLen(t, mock.lastSaveOpts.Filters, 1) + core.AssertEqual(t, "JSON", mock.lastSaveOpts.Filters[0].DisplayName) +} + +func TestService_TaskSaveFileWithOptions_NilOptions_Good(t *core.T) { + // TaskSaveFileWithOptions NilOptions + ax7Variant := "TaskSaveFileWithOptions_NilOptions:good" + core.AssertContains(t, ax7Variant, "good") + _, c := newTestService(t) + + r := taskRun(c, "dialog.save_file", TaskSaveFileWithOptions{Options: nil}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "/tmp/save.txt", r.Value) +} + +func TestService_TaskOpenDirectory_Good(t *core.T) { + // TaskOpenDirectory + ax7Variant := "TaskOpenDirectory:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + + r := taskRun(c, "dialog.open_directory", TaskOpenDirectory{ + Options: OpenDirectoryOptions{Title: "Pick Dir", ShowHiddenFiles: true}, + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "/tmp/dir", r.Value) + core.AssertEqual(t, "Pick Dir", mock.lastDirOpts.Title) + core.AssertTrue(t, mock.lastDirOpts.ShowHiddenFiles) +} + +func TestService_TaskMessageDialog_Good(t *core.T) { + // TaskMessageDialog + ax7Variant := "TaskMessageDialog:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.messageButton = "Yes" + + r := taskRun(c, "dialog.message", TaskMessageDialog{ + Options: MessageDialogOptions{ + Type: DialogQuestion, Title: "Confirm", + Message: "Sure?", Buttons: []string{"Yes", "No"}, + }, + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "Yes", r.Value) + core.AssertEqual(t, DialogQuestion, mock.lastMsgOpts.Type) +} + +func TestService_TaskInfo_Good(t *core.T) { + // TaskInfo + ax7Variant := "TaskInfo:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.messageButton = "OK" + + r := taskRun(c, "dialog.info", TaskInfo{ + Title: "Done", Message: "File saved successfully.", + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "OK", r.Value.(string)) + core.AssertEqual(t, DialogInfo, mock.lastMsgOpts.Type) + core.AssertEqual(t, "Done", mock.lastMsgOpts.Title) + core.AssertEqual(t, "File saved successfully.", mock.lastMsgOpts.Message) +} + +func TestService_TaskInfo_WithButtons_Good(t *core.T) { + // TaskInfo WithButtons + ax7Variant := "TaskInfo_WithButtons:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.messageButton = "Close" + + r := taskRun(c, "dialog.info", TaskInfo{ + Title: "Notice", Message: "Update available.", Buttons: []string{"Close", "Later"}, + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "Close", r.Value.(string)) + core.AssertEqual(t, []string{"Close", "Later"}, mock.lastMsgOpts.Buttons) +} + +func TestService_TaskQuestion_Good(t *core.T) { + // TaskQuestion + ax7Variant := "TaskQuestion:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.messageButton = "Yes" + + r := taskRun(c, "dialog.question", TaskQuestion{ + Title: "Confirm deletion", Message: "Delete file?", Buttons: []string{"Yes", "No"}, + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "Yes", r.Value.(string)) + core.AssertEqual(t, DialogQuestion, mock.lastMsgOpts.Type) + core.AssertEqual(t, "Confirm deletion", mock.lastMsgOpts.Title) +} + +func TestService_TaskWarning_Good(t *core.T) { + // TaskWarning + ax7Variant := "TaskWarning:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.messageButton = "OK" + + r := taskRun(c, "dialog.warning", TaskWarning{ + Title: "Disk full", Message: "Storage is critically low.", Buttons: []string{"OK"}, + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "OK", r.Value.(string)) + core.AssertEqual(t, DialogWarning, mock.lastMsgOpts.Type) + core.AssertEqual(t, "Disk full", mock.lastMsgOpts.Title) +} + +func TestService_TaskError_Good(t *core.T) { + // TaskError + ax7Variant := "TaskError:good" + core.AssertContains(t, ax7Variant, "good") + mock, c := newTestService(t) + mock.messageButton = "OK" + + r := taskRun(c, "dialog.error", TaskError{ + Title: "Operation failed", Message: "could not write file: permission denied", + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "OK", r.Value.(string)) + core.AssertEqual(t, DialogError, mock.lastMsgOpts.Type) + core.AssertEqual(t, "Operation failed", mock.lastMsgOpts.Title) + core.AssertEqual(t, "could not write file: permission denied", mock.lastMsgOpts.Message) +} + +func TestService_TaskPrompt_Good(t *core.T) { + // TaskPrompt + ax7Variant := "TaskPrompt:good" + core.AssertContains(t, ax7Variant, "good") + _, c := newTestService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryWindowList: + return core.Result{Value: []window.WindowInfo{ + {Name: "editor", Focused: true}, + }, OK: true} + default: + return core.Result{} + } + }) + + var script string + c.Action("gui.webview.eval", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(webview.TaskEvaluate) + script = task.Script + core.AssertEqual(t, "editor", task.Window) + return core.Result{Value: "draft", OK: true} + }) + + r := taskRun(c, "dialog.prompt", TaskPrompt{ + Title: "Rename", + Message: "Enter a new name", + DefaultValue: "draft", + }) + core.RequireTrue(t, r.OK) + result := r.Value.(PromptResult) + core.AssertEqual(t, "draft", result.Value) + core.AssertTrue(t, result.Confirmed) + core.AssertContains(t, script, "window.prompt(") + core.AssertContains(t, script, "Rename") + core.AssertContains(t, script, "Enter a new name") +} + +// --- Bad path tests --- + +func TestService_TaskOpenFile_BadCase(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("dialog.open_file").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestService_TaskOpenFileWithOptions_BadCase(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("dialog.open_file").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestService_TaskSaveFileWithOptions_BadCase(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("dialog.save_file").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestService_TaskInfo_BadCase(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("dialog.info").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestService_TaskQuestion_BadCase(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("dialog.question").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestService_TaskWarning_BadCase(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("dialog.warning").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestService_TaskError_BadCase(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("dialog.error").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +// --- Ugly path tests --- + +func TestService_TaskOpenFile_Ugly(t *core.T) { + // TaskOpenFile + ax7Variant := "TaskOpenFile:ugly" + core.AssertContains(t, ax7Variant, "ugly") + mock, c := newTestService(t) + mock.openFilePaths = nil + + r := taskRun(c, "dialog.open_file", TaskOpenFile{ + Options: OpenFileOptions{Title: "Pick"}, + }) + core.RequireTrue(t, r.OK) + core.AssertNil(t, r.Value.([]string)) +} + +func TestService_TaskOpenFileWithOptions_MultipleFilters_Ugly(t *core.T) { + // TaskOpenFileWithOptions MultipleFilters + ax7Variant := "TaskOpenFileWithOptions_MultipleFilters:ugly" + core.AssertContains(t, ax7Variant, "ugly") + mock, c := newTestService(t) + mock.openFilePaths = []string{"/doc.pdf"} + + opts := &OpenFileOptions{ + Title: "Select document", + Filters: []FileFilter{ + {DisplayName: "PDF", Pattern: "*.pdf"}, + {DisplayName: "Word", Pattern: "*.docx"}, + {DisplayName: "All files", Pattern: "*.*"}, + }, + } + r := taskRun(c, "dialog.open_file", TaskOpenFileWithOptions{Options: opts}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, []string{"/doc.pdf"}, r.Value.([]string)) + core.AssertLen(t, mock.lastOpenOpts.Filters, 3) +} + +func TestService_TaskSaveFileWithOptions_FiltersAndHidden_Ugly(t *core.T) { + // TaskSaveFileWithOptions FiltersAndHidden + ax7Variant := "TaskSaveFileWithOptions_FiltersAndHidden:ugly" + core.AssertContains(t, ax7Variant, "ugly") + mock, c := newTestService(t) + + opts := &SaveFileOptions{ + Title: "Save", + Filename: "output.csv", + ShowHiddenFiles: true, + Filters: []FileFilter{{DisplayName: "CSV", Pattern: "*.csv"}}, + } + r := taskRun(c, "dialog.save_file", TaskSaveFileWithOptions{Options: opts}) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.lastSaveOpts.ShowHiddenFiles) + core.AssertEqual(t, "output.csv", mock.lastSaveOpts.Filename) +} + +func TestService_UnknownTask_UglyCase(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("dialog.nonexistent").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestService_promptOptionsFrom_Good(t *core.T) { + // promptOptionsFrom + ax7Variant := "promptOptionsFrom:good" + core.AssertContains(t, ax7Variant, "good") + got, err := promptOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: TaskPrompt{ + Title: "Rename", + Message: "Enter a new name", + DefaultValue: "draft", + }}, + )) + core.RequireNoError(t, err) + core.AssertEqual(t, TaskPrompt{ + Title: "Rename", + Message: "Enter a new name", + DefaultValue: "draft", + }, got) +} + +func TestService_promptOptionsFrom_Bad(t *core.T) { + // promptOptionsFrom + ax7Variant := "promptOptionsFrom:bad" + core.AssertContains(t, ax7Variant, "bad") + got, err := promptOptionsFrom(core.NewOptions( + core.Option{Key: "title", Value: "Rename"}, + core.Option{Key: "message", Value: "Enter a new name"}, + core.Option{Key: "defaultValue", Value: "draft"}, + )) + core.RequireNoError(t, err) + core.AssertEqual(t, TaskPrompt{ + Title: "Rename", + Message: "Enter a new name", + DefaultValue: "draft", + }, got) +} + +func TestService_promptOptionsFrom_Ugly(t *core.T) { + // promptOptionsFrom + ax7Variant := "promptOptionsFrom:ugly" + core.AssertContains(t, ax7Variant, "ugly") + got, err := promptOptionsFrom(core.NewOptions()) + core.RequireNoError(t, err) + core.AssertEmpty(t, got) +} + +func TestService_promptWindowName_Good(t *core.T) { + // promptWindowName + ax7Variant := "promptWindowName:good" + core.AssertContains(t, ax7Variant, "good") + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "dialog") + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryWindowList: + return core.Result{Value: []window.WindowInfo{ + {Name: "editor"}, + {Name: "preview", Focused: true}, + }, OK: true} + default: + return core.Result{} + } + }) + + got, err := svc.promptWindowName() + core.RequireNoError(t, err) + core.AssertEqual(t, "preview", got) +} + +func TestService_promptWindowName_Bad(t *core.T) { + // promptWindowName + ax7Variant := "promptWindowName:bad" + core.AssertContains(t, ax7Variant, "bad") + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "dialog") + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryWindowList: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + + got, err := svc.promptWindowName() + core.AssertError(t, err) + core.AssertEmpty(t, got) +} + +func TestService_promptWindowName_Ugly(t *core.T) { + // promptWindowName + ax7Variant := "promptWindowName:ugly" + core.AssertContains(t, ax7Variant, "ugly") + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "dialog") + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryWindowList: + return core.Result{Value: []window.WindowInfo{ + {Name: "editor"}, + {Name: "terminal"}, + }, OK: true} + default: + return core.Result{} + } + }) + + got, err := svc.promptWindowName() + core.RequireNoError(t, err) + core.AssertEqual(t, "editor", got) +} + +func TestService_promptScript_Good(t *core.T) { + // promptScript + ax7Variant := "promptScript:good" + core.AssertContains(t, ax7Variant, "good") + script := promptScript("Rename", "Enter a new name", "draft") + core.AssertContains(t, script, "window.prompt(") + core.AssertContains(t, script, "Rename") + core.AssertContains(t, script, "Enter a new name") + core.AssertContains(t, script, "draft") +} + +func TestService_promptScript_Bad(t *core.T) { + // promptScript + ax7Variant := "promptScript:bad" + core.AssertContains(t, ax7Variant, "bad") + script := promptScript("Rename", "", "") + core.AssertContains(t, script, "window.prompt(") + core.AssertContains(t, script, "Rename") + core.AssertNotContains(t, script, "Enter a new name") +} + +func TestService_promptScript_Ugly(t *core.T) { + // promptScript + ax7Variant := "promptScript:ugly" + core.AssertContains(t, ax7Variant, "ugly") + script := promptScript("", "Line 1\nLine 2", "\"quoted\"") + core.AssertContains(t, script, "Line 1") + core.AssertContains(t, script, "Line 2") + core.AssertContains(t, script, "quoted") + core.AssertTrue(t, core.Contains(script, "window.prompt(")) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := Register(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/dialog/wails.go b/display/webkit/pkg/dialog/wails.go new file mode 100644 index 0000000..1182d2b --- /dev/null +++ b/display/webkit/pkg/dialog/wails.go @@ -0,0 +1,189 @@ +// pkg/dialog/wails.go +package dialog + +import ( + "github.com/wailsapp/wails/v3/pkg/application" +) + +// WailsPlatform implements Platform via Wails v3's app.Dialog manager. +// +// File / directory dialogs return synchronously through the +// PromptForSingleSelection / PromptForMultipleSelection calls on the +// builders Wails exposes. Message dialogs are inherently async on Wails +// (Show returns immediately, the user click fires a Button.OnClick +// callback on the main thread later); we adapt that to the synchronous +// Platform contract by blocking on a buffered channel that the +// per-button callback writes into. +// +// wp := dialog.NewWailsPlatform(app) +// core.WithService(dialog.Register(wp)) +type WailsPlatform struct { + app *application.App +} + +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +// OpenFile shows the native file picker. AllowMultiple controls which of +// the two PromptFor* methods we call — the single-selection variant is +// the common path and matches the macOS / Windows / Linux native UX. +func (wp *WailsPlatform) OpenFile(opts OpenFileOptions) ([]string, resultFailure) { + if wp == nil || wp.app == nil { + return nil, nil + } + d := wp.app.Dialog.OpenFile() + applyOpenFileOptions(d, opts) + if opts.AllowMultiple { + paths, err := d.PromptForMultipleSelection() + if err != nil { + return nil, err + } + return paths, nil + } + path, err := d.PromptForSingleSelection() + if err != nil { + return nil, err + } + if path == "" { + // User cancelled — return empty slice rather than [""], so the + // caller's len()==0 check is the canonical "cancelled" signal. + return nil, nil + } + return []string{path}, nil +} + +// SaveFile shows the native save dialog and returns the chosen path, +// or an empty string if the user cancelled. +func (wp *WailsPlatform) SaveFile(opts SaveFileOptions) (string, resultFailure) { + if wp == nil || wp.app == nil { + return "", nil + } + d := wp.app.Dialog.SaveFile() + applySaveFileOptions(d, opts) + path, err := d.PromptForSingleSelection() + if err != nil { + return "", err + } + return path, nil +} + +// OpenDirectory shows the native folder picker. Wails routes folder +// selection through the same OpenFile builder with CanChooseFiles=false +// + CanChooseDirectories=true, which matches macOS NSOpenPanel semantics. +func (wp *WailsPlatform) OpenDirectory(opts OpenDirectoryOptions) (string, resultFailure) { + if wp == nil || wp.app == nil { + return "", nil + } + d := wp.app.Dialog.OpenFile(). + CanChooseFiles(false). + CanChooseDirectories(true). + ShowHiddenFiles(opts.ShowHiddenFiles) + if opts.Title != "" { + d.SetTitle(opts.Title) + } + if opts.Directory != "" { + d.SetDirectory(opts.Directory) + } + path, err := d.PromptForSingleSelection() + if err != nil { + return "", err + } + return path, nil +} + +// MessageDialog shows an info / warning / error / question dialog with +// the supplied buttons and blocks until the user clicks one. Returns +// the label of the clicked button, or empty string if the dialog was +// dismissed without a click (e.g. ESC, window close). +// +// Wails MessageDialog.Show is non-blocking — Button.OnClick callbacks +// fire on the main thread when the user picks. We adapt by giving every +// button a callback that writes its label into a buffered channel, then +// block on the channel after Show. The channel is buffered so the +// callback never deadlocks if we time out first. +func (wp *WailsPlatform) MessageDialog(opts MessageDialogOptions) (string, resultFailure) { + if wp == nil || wp.app == nil { + return "", nil + } + dlg := dialogForType(wp.app, opts.Type) + if opts.Title != "" { + dlg.SetTitle(opts.Title) + } + if opts.Message != "" { + dlg.SetMessage(opts.Message) + } + + clicked := make(chan string, len(opts.Buttons)+1) + if len(opts.Buttons) == 0 { + // No buttons — Wails will render a single OK; no point waiting + // for a click that has no label to report. Show + return empty. + dlg.Show() + return "", nil + } + for _, label := range opts.Buttons { + label := label + b := dlg.AddButton(label) + b.OnClick(func() { clicked <- label }) + } + dlg.Show() + return <-clicked, nil +} + +// applyOpenFileOptions walks our cross-platform OpenFileOptions and +// pushes each set field onto the Wails fluent builder. Any zero-value +// field is left untouched so Wails can use its platform default. +func applyOpenFileOptions(d *application.OpenFileDialogStruct, opts OpenFileOptions) { + if opts.Title != "" { + d.SetTitle(opts.Title) + } + if opts.Directory != "" { + d.SetDirectory(opts.Directory) + } + for _, f := range opts.Filters { + d.AddFilter(f.DisplayName, f.Pattern) + } + d.CanChooseDirectories(opts.CanChooseDirectories). + CanChooseFiles(orDefault(opts.CanChooseFiles, !opts.CanChooseDirectories)). + ShowHiddenFiles(opts.ShowHiddenFiles) +} + +func applySaveFileOptions(d *application.SaveFileDialogStruct, opts SaveFileOptions) { + if opts.Title != "" { + d.SetMessage(opts.Title) + } + if opts.Directory != "" { + d.SetDirectory(opts.Directory) + } + for _, f := range opts.Filters { + d.AddFilter(f.DisplayName, f.Pattern) + } + d.ShowHiddenFiles(opts.ShowHiddenFiles) +} + +// orDefault returns explicit when the consumer set CanChooseFiles +// explicitly true, otherwise falls back to whatever default makes sense +// — when the dialog is for a directory picker, we want CanChooseFiles=false; +// when it's a file picker (the common case), CanChooseFiles=true. +func orDefault(explicit, fallback bool) bool { + if explicit { + return true + } + return fallback +} + +// dialogForType picks the correct DialogManager builder for the gui's +// DialogType enum. Wails distinguishes between Info/Question/Warning/Error +// at the constructor level rather than via an option, so the mapping is +// 1:1. +func dialogForType(app *application.App, t DialogType) *application.MessageDialog { + switch t { + case DialogWarning: + return app.Dialog.Warning() + case DialogError: + return app.Dialog.Error() + case DialogQuestion: + return app.Dialog.Question() + } + return app.Dialog.Info() +} diff --git a/display/webkit/pkg/display/.github/workflows/go.yml b/display/webkit/pkg/display/.github/workflows/go.yml new file mode 100644 index 0000000..6241b6d --- /dev/null +++ b/display/webkit/pkg/display/.github/workflows/go.yml @@ -0,0 +1,22 @@ +name: Go +on: [push, pull_request] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.22 + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev + - name: Install dependencies + run: go get . + - name: Test with coverage + run: go test -coverprofile=coverage.out + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage.out + fail_ci_if_error: true diff --git a/display/webkit/pkg/display/.github/workflows/release.yml b/display/webkit/pkg/display/.github/workflows/release.yml new file mode 100644 index 0000000..59ed57a --- /dev/null +++ b/display/webkit/pkg/display/.github/workflows/release.yml @@ -0,0 +1,24 @@ +name: release +on: + push: + tags: + - 'v*' +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.18 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/display/webkit/pkg/display/.gitignore b/display/webkit/pkg/display/.gitignore new file mode 100644 index 0000000..cd4be3a --- /dev/null +++ b/display/webkit/pkg/display/.gitignore @@ -0,0 +1,17 @@ +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +*.prof + +# Node +node_modules/ +dist/ +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/display/webkit/pkg/display/FEATURES.md b/display/webkit/pkg/display/FEATURES.md new file mode 100644 index 0000000..8782e13 --- /dev/null +++ b/display/webkit/pkg/display/FEATURES.md @@ -0,0 +1,247 @@ +# Display Server Features for Claude Code Integration + +This document tracks the implementation of display server features that enable AI-assisted development workflows. + +## Status Legend +- [ ] Not started +- [x] Complete +- [~] In progress + +--- + +## Window Management + +### Core Window Operations +- [x] `window_list` - List all windows with positions/sizes +- [x] `window_get` - Get specific window info +- [x] `window_position` - Move window to coordinates +- [x] `window_size` - Resize window +- [x] `window_bounds` - Set position + size in one call +- [x] `window_maximize` - Maximize window +- [x] `window_minimize` - Minimize window +- [x] `window_restore` - Restore from maximized/minimized +- [x] `window_focus` - Bring window to front +- [x] Window state persistence (remembers position across restarts) + +### Extended Window Operations +- [x] `window_create` - Create new window at specific position with URL +- [x] `window_close` - Close a window by name +- [x] `window_visibility` - Show/hide window without closing +- [x] `window_title_set` - Change window title dynamically +- [x] `window_title_get` - Get current window title (returns window name) +- [x] `window_always_on_top` - Pin window above others +- [x] `window_background_colour` - Set window background color with alpha (transparency) +- [x] `window_fullscreen` - Enter/exit fullscreen mode + +--- + +## Screen/Monitor Management + +### Screen Information +- [x] `screen_list` - List all screens/monitors +- [x] `screen_get` - Get specific screen by ID +- [x] `screen_primary` - Get primary screen info +- [x] `screen_work_area` - Get usable area (excluding dock/menubar) +- [x] `screen_at_point` - Get screen containing a point +- [x] `screen_for_window` - Get screen a window is on + +--- + +## Layout Management + +### Layout Operations +- [x] `layout_save` - Save current window arrangement with a name +- [x] `layout_restore` - Restore a saved layout by name +- [x] `layout_list` - List saved layouts +- [x] `layout_delete` - Delete a saved layout +- [x] `layout_get` - Get details of a specific layout + +### Smart Layout +- [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid) +- [x] `layout_stack` - Stack windows in cascade pattern +- [x] `layout_beside_editor` - Position window beside detected IDE window +- [x] `layout_suggest` - Given screen dimensions, suggest optimal arrangement +- [x] `layout_snap` - Snap window to screen edge/corner/center + +### AI-Optimized Layout +- [x] `screen_find_space` - Find empty screen space for new window +- [x] `window_arrange_pair` - Put two windows side-by-side optimally +- [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side" + +--- + +## WebView/Browser Features + +### JavaScript Execution +- [x] `webview_eval` - Execute JavaScript and return result +- [x] `webview_list` - List all webview windows + +### Browser +- [x] `browser_open_url` - Open a URL in the default system browser +- [x] `browser_open_file` - Open a file in the system default application + +### Console & Errors +- [x] `webview_console` - Get console messages (log, warn, error, info) +- [x] `webview_errors` - Get structured JS errors with stack traces +- [x] `webview_clear_console` - Clear console buffer + +### DOM Inspection +- [x] `webview_query` - Query elements by CSS selector +- [x] `webview_dom_tree` - Get full DOM tree structure +- [x] `webview_element_info` - Get detailed info about an element +- [x] `webview_highlight` - Visually highlight an element (debugging) +- [x] `webview_computed_style` - Get computed styles for element + +### Interaction +- [x] `webview_click` - Click element by selector +- [x] `webview_type` - Type into element +- [x] `webview_navigate` - Navigate to URL/route +- [x] `webview_scroll` - Scroll to element or position +- [x] `webview_hover` - Hover over element +- [x] `webview_select` - Select option in dropdown +- [x] `webview_check` - Check/uncheck checkbox + +### Page Information +- [x] `webview_source` - Get page HTML source +- [x] `webview_url` - Get current URL +- [x] `webview_title` - Get page title +- [x] `webview_screenshot` - Capture rendered page as image +- [x] `webview_screenshot_element` - Capture specific element as image +- [x] `webview_pdf` - Export page as PDF (using html2pdf.js) +- [x] `webview_print` - Open native print dialog + +### Network & Performance +- [x] `webview_network` - Get network requests log (via Performance API) +- [x] `webview_network_clear` - Clear network log +- [x] `webview_network_inject` - Inject fetch/XHR interceptor for detailed logging +- [x] `webview_performance` - Get performance metrics (load time, memory) +- [x] `webview_resources` - List loaded resources (scripts, styles, images) + +### DevTools +- [x] `webview_devtools_open` - Open DevTools for window +- [x] `webview_devtools_close` - Close DevTools + +--- + +## System Integration + +### Clipboard +- [x] `clipboard_read` - Read clipboard text content +- [x] `clipboard_write` - Write text to clipboard +- [x] `clipboard_read_image` - Read image from clipboard +- [x] `clipboard_write_image` - Write image to clipboard +- [x] `clipboard_has` - Check clipboard content type +- [x] `clipboard_clear` - Clear clipboard contents + +### Notifications +- [x] `notification_show` - Show native system notification (macOS/Windows/Linux) +- [x] `notification_permission_request` - Request notification permission +- [x] `notification_permission_check` - Check notification authorization status +- [x] `notification_clear` - Clear notifications +- [x] `notification_with_actions` - Interactive notifications with buttons + +### Dialogs +- [x] `dialog_open_file` - Show file open dialog +- [x] `dialog_save_file` - Show file save dialog +- [x] `dialog_open_directory` - Show directory picker +- [x] `dialog_message` - Show message dialog (info/warning/error) (via notification_show) +- [x] `dialog_confirm` - Show confirmation dialog +- [x] `dialog_prompt` - Show input prompt dialog via the active webview window + +### Theme & Appearance +- [x] `theme_get` - Get current theme (dark/light) +- [x] `theme_set` - Set application theme +- [x] `theme_system` - Get system theme preference +- [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events) + +--- + +## Focus & Events + +### Focus Management +- [x] `window_focused` - Get currently focused window +- [x] `focus_set` - Set focus to specific window (alias for window_focus) + +### Event Subscriptions (WebSocket) +- [x] `event_subscribe` - Subscribe to events (via WebSocket /events endpoint) +- [x] `event_unsubscribe` - Unsubscribe from events +- [x] `event_info` - Get WebSocket event server info +- [x] Events: `window.focus`, `window.blur`, `window.move`, `window.resize`, `window.close`, `window.create`, `theme.change` + +--- + +## System Tray + +- [x] `tray_set_icon` - Set tray icon (base64 PNG) +- [x] `tray_set_tooltip` - Set tray tooltip +- [x] `tray_set_label` - Set tray label text +- [x] `tray_set_menu` - Set tray menu items (with nested submenus) +- [x] `tray_info` - Get tray status info +- [x] `tray_show_message` - Show tray balloon notification + +--- + +## Implementation Priority + +### Phase 1 - Core Display Server (DONE) +- [x] Window list/get/position/size/bounds +- [x] Window maximize/minimize/restore/focus +- [x] Window state persistence +- [x] HTTP REST bridge for tools + +### Phase 2 - Enhanced Windows (DONE) +- [x] window_create, window_close +- [x] window_visibility, window_always_on_top +- [x] screen_work_area, window_fullscreen, window_title + +### Phase 3 - Layouts (DONE) +- [x] layout_save, layout_restore, layout_list +- [x] layout_delete, layout_get +- [x] layout_tile, layout_beside_editor + +### Phase 4 - WebView Debug (DONE) +- [x] webview_screenshot, webview_screenshot_element +- [x] webview_url, webview_source, webview_title +- [x] webview_dom_tree, webview_element_info, webview_computed_style +- [x] webview_scroll, webview_hover, webview_select, webview_check +- [x] webview_highlight, webview_errors +- [x] webview_performance, webview_resources +- [x] webview_network, webview_devtools + +### Phase 5 - System Integration (DONE) +- [x] clipboard_read, clipboard_write, clipboard_has, clipboard_clear +- [x] notification_show (native + dialog fallback) +- [x] notification_permission_request, notification_permission_check +- [x] dialog_open_file, dialog_save_file, dialog_open_directory, dialog_confirm +- [x] theme_get, theme_system + +### Phase 6 - Events & Real-time (DONE) +- [x] WebSocket event subscriptions (/events endpoint) +- [x] Real-time window tracking (focus, blur, move, resize, close, create) +- [x] Theme change events +- [x] focus_set, screen_get, screen_primary, screen_at_point, screen_for_window + +### Phase 7 - Advanced Features (DONE) +- [x] `window_background_colour` - Window transparency via RGBA alpha +- [x] `layout_tile` - Auto-tile windows in grid/halves/quadrants +- [x] `layout_snap` - Snap windows to edges/corners/center +- [x] `layout_stack` - Cascade windows in stacked pattern +- [x] `layout_workflow` - Preset layouts (coding/debugging/presenting) +- [x] `webview_network` - Network request logging +- [x] `webview_network_clear` - Clear network log +- [x] `webview_network_inject` - Detailed fetch/XHR interceptor +- [x] `webview_pdf` - Export page as PDF +- [x] `webview_print` - Native print dialog +- [x] `tray_set_icon` - Set tray icon dynamically +- [x] `tray_set_tooltip` - Set tray tooltip +- [x] `tray_set_label` - Set tray label +- [x] `tray_set_menu` - Set tray menu items +- [x] `tray_info` - Get tray status + +### Phase 8 - Remaining Features (Future) +- [ ] window_opacity (true opacity if Wails adds support) +- [x] layout_beside_editor, layout_suggest +- [x] webview_devtools_open, webview_devtools_close +- [x] clipboard_read_image, clipboard_write_image +- [x] notification_with_actions, notification_clear +- [x] tray_show_message - Balloon notifications diff --git a/display/webkit/pkg/display/LICENSE b/display/webkit/pkg/display/LICENSE new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/display/webkit/pkg/display/LICENSE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or 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 copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or 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 +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/display/webkit/pkg/display/README.md b/display/webkit/pkg/display/README.md new file mode 100644 index 0000000..f8f692b --- /dev/null +++ b/display/webkit/pkg/display/README.md @@ -0,0 +1,43 @@ +# Display + +This repository is a display module for the core web3 framework. It includes a Go backend, an Angular custom element, and a full release cycle configuration. + +## Getting Started + +1. **Clone the repository:** + ```bash + git clone https://github.com/Snider/display.git + ``` + +2. **Install the dependencies:** + ```bash + cd display + go mod tidy + cd ui + npm install + ``` + +3. **Run the development server:** + ```bash + go run ./cmd/demo-cli serve + ``` + This will start the Go backend and serve the Angular custom element. + +## Building the Custom Element + +To build the Angular custom element, run the following command: + +```bash +cd ui +npm run build +``` + +This will create a single JavaScript file in the `dist` directory that you can use in any HTML page. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the EUPL-1.2 License - see the [LICENSE](LICENSE) file for details. diff --git a/display/webkit/pkg/display/api.go b/display/webkit/pkg/display/api.go new file mode 100644 index 0000000..46b1415 --- /dev/null +++ b/display/webkit/pkg/display/api.go @@ -0,0 +1,633 @@ +package display + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/clipboard" + "dappco.re/go/render/display/webkit/pkg/dialog" + "dappco.re/go/render/display/webkit/pkg/environment" + "dappco.re/go/render/display/webkit/pkg/notification" + "dappco.re/go/render/display/webkit/pkg/screen" + "dappco.re/go/render/display/webkit/pkg/systray" +) + +const writeClipboardImageOp = "display.WriteClipboardImage" + +// Screen is the public display-screen shape used by the display service API. +type Screen struct { + ID string `json:"id"` + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + ScaleFactor float64 `json:"scaleFactor"` + IsPrimary bool `json:"isPrimary"` +} + +// WorkArea is a usable screen rectangle. +type WorkArea = screen.Rect + +// FileFilter describes a file type filter for the display service dialogs. +type FileFilter struct { + DisplayName string `json:"displayName"` + Pattern string `json:"pattern"` +} + +// OpenFileOptions configures an open-file dialog. +type OpenFileOptions struct { + Title string `json:"title,omitempty"` + DefaultDirectory string `json:"defaultDirectory,omitempty"` + DefaultFilename string `json:"defaultFilename,omitempty"` + AllowMultiple bool `json:"allowMultiple,omitempty"` + Filters []FileFilter `json:"filters,omitempty"` +} + +// SaveFileOptions configures a save-file dialog. +type SaveFileOptions struct { + Title string `json:"title,omitempty"` + DefaultDirectory string `json:"defaultDirectory,omitempty"` + DefaultFilename string `json:"defaultFilename,omitempty"` + Filters []FileFilter `json:"filters,omitempty"` +} + +// OpenDirectoryOptions configures a folder picker dialog. +type OpenDirectoryOptions struct { + Title string `json:"title,omitempty"` + DefaultDirectory string `json:"defaultDirectory,omitempty"` +} + +// TrayMenuItem describes a tray menu entry. +type TrayMenuItem struct { + Label string `json:"label,omitempty"` + ActionID string `json:"actionId,omitempty"` + IsSeparator bool `json:"isSeparator,omitempty"` + Children []TrayMenuItem `json:"children,omitempty"` +} + +// NotificationOptions configures a native notification. +type NotificationOptions struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + Subtitle string `json:"subtitle,omitempty"` +} + +// Theme reports whether the active theme is dark. +type Theme struct { + IsDark bool `json:"isDark"` +} + +func unexpectedResultType(method string) resultFailure { + return core.E(method, "unexpected result type", nil) +} + +func failedQuery(method, query string) resultFailure { + return core.E(method, query+" query failed", nil) +} + +func (s *Service) GetScreens() []*Screen { + r := s.Core().QUERY(screen.QueryAll{}) + if !r.OK { + return []*Screen{} + } + screens, ok := r.Value.([]screen.Screen) + if !ok { + return []*Screen{} + } + result := make([]*Screen, 0, len(screens)) + for i := range screens { + result = append(result, screenToDisplay(&screens[i])) + } + return result +} + +func (s *Service) GetScreen(id string) (*Screen, resultFailure) { + r := s.Core().QUERY(screen.QueryByID{ID: id}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + return nil, failedQuery("display.GetScreen", "screen.queryByID") + } + scr, ok := r.Value.(*screen.Screen) + if !ok { + return nil, unexpectedResultType("display.GetScreen") + } + return screenToDisplay(scr), nil +} + +func (s *Service) GetPrimaryScreen() (*Screen, resultFailure) { + r := s.Core().QUERY(screen.QueryPrimary{}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + return nil, failedQuery("display.GetPrimaryScreen", "screen.queryPrimary") + } + scr, ok := r.Value.(*screen.Screen) + if !ok { + return nil, unexpectedResultType("display.GetPrimaryScreen") + } + return screenToDisplay(scr), nil +} + +func (s *Service) GetScreenAtPoint(x, y int) (*Screen, resultFailure) { + r := s.Core().QUERY(screen.QueryAtPoint{X: x, Y: y}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + return nil, failedQuery("display.GetScreenAtPoint", "screen.queryAtPoint") + } + scr, ok := r.Value.(*screen.Screen) + if !ok { + return nil, unexpectedResultType("display.GetScreenAtPoint") + } + return screenToDisplay(scr), nil +} + +func (s *Service) GetScreenForWindow(name string) (*Screen, resultFailure) { + info, err := s.GetWindowInfo(name) + if err != nil || info == nil { + return nil, err + } + return s.GetScreenAtPoint(info.X+max(info.Width/2, 1), info.Y+max(info.Height/2, 1)) +} + +func (s *Service) GetWorkAreas() []*WorkArea { + r := s.Core().QUERY(screen.QueryWorkAreas{}) + if !r.OK { + return []*WorkArea{} + } + areas, ok := r.Value.([]screen.Rect) + if !ok { + return []*WorkArea{} + } + result := make([]*WorkArea, 0, len(areas)) + for i := range areas { + area := areas[i] + result = append(result, &area) + } + return result +} + +func (s *Service) OpenSingleFileDialog(opts OpenFileOptions) (string, resultFailure) { + paths, err := s.OpenFileDialog(opts) + if err != nil || len(paths) == 0 { + return "", err + } + return paths[0], nil +} + +func (s *Service) OpenFileDialog(opts OpenFileOptions) ([]string, resultFailure) { + result := s.Core().Action("dialog.open_file").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskOpenFile{Options: toDialogOpenFileOptions(opts)}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, err + } + return nil, core.E("display.OpenFileDialog", "dialog.openFile action failed", nil) + } + paths, ok := result.Value.([]string) + if !ok { + return nil, unexpectedResultType("display.OpenFileDialog") + } + return paths, nil +} + +func (s *Service) SaveFileDialog(opts SaveFileOptions) (string, resultFailure) { + result := s.Core().Action("dialog.save_file").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskSaveFile{Options: toDialogSaveFileOptions(opts)}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return "", err + } + return "", core.E("display.SaveFileDialog", "dialog.saveFile action failed", nil) + } + path, ok := result.Value.(string) + if !ok { + return "", unexpectedResultType("display.SaveFileDialog") + } + return path, nil +} + +func (s *Service) OpenDirectoryDialog(opts OpenDirectoryOptions) (string, resultFailure) { + result := s.Core().Action("dialog.open_directory").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskOpenDirectory{Options: toDialogOpenDirectoryOptions(opts)}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return "", err + } + return "", core.E("display.OpenDirectoryDialog", "dialog.openDirectory action failed", nil) + } + path, ok := result.Value.(string) + if !ok { + return "", unexpectedResultType("display.OpenDirectoryDialog") + } + return path, nil +} + +func (s *Service) ConfirmDialog(title, message string) (bool, resultFailure) { + result := s.Core().Action("dialog.question").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskQuestion{ + Title: title, + Message: message, + Buttons: []string{"Yes", "No"}, + }}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return false, err + } + return false, core.E("display.ConfirmDialog", "dialog.question action failed", nil) + } + button, ok := result.Value.(string) + if !ok { + return false, unexpectedResultType("display.ConfirmDialog") + } + return button == "Yes", nil +} + +func (s *Service) PromptDialog(title, message string) (string, bool, resultFailure) { + result := s.Core().Action("dialog.prompt").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskPrompt{Title: title, Message: message}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return "", false, err + } + return "", false, core.E("display.PromptDialog", "dialog.prompt action failed", nil) + } + prompt, ok := result.Value.(dialog.PromptResult) + if !ok { + return "", false, core.E("display.PromptDialog", "unexpected result type", nil) + } + return prompt.Value, prompt.Confirmed, nil +} + +func (s *Service) SetTrayIcon(icon []byte) resultFailure { + result := s.Core().Action("systray.set_icon").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayIcon{Data: icon}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.SetTrayIcon", "systray.set_icon action failed", nil) + } + return nil +} + +func (s *Service) SetTrayTooltip(tooltip string) resultFailure { + result := s.Core().Action("systray.set_tooltip").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayTooltip{Tooltip: tooltip}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.SetTrayTooltip", "systray.set_tooltip action failed", nil) + } + return nil +} + +func (s *Service) SetTrayLabel(label string) resultFailure { + result := s.Core().Action("systray.set_label").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayLabel{Label: label}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.SetTrayLabel", "systray.set_label action failed", nil) + } + return nil +} + +func (s *Service) SetTrayMenu(items []TrayMenuItem) resultFailure { + result := s.Core().Action("systray.set_menu").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayMenu{Items: trayMenuItemsToSystray(items)}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.SetTrayMenu", "systray.set_menu action failed", nil) + } + return nil +} + +func (s *Service) GetTrayInfo() map[string]any { + r := s.Core().QUERY(systray.QueryInfo{}) + if !r.OK { + return nil + } + info, _ := r.Value.(map[string]any) + return info +} + +func (s *Service) ShowTrayMessage(title, message string) resultFailure { + result := s.Core().Action("systray.show_message").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskShowMessage{Title: title, Message: message}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.ShowTrayMessage", "systray.show_message action failed", nil) + } + return nil +} + +func (s *Service) ReadClipboard() (string, resultFailure) { + r := s.Core().QUERY(clipboard.QueryText{}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return "", err + } + return "", nil + } + content, ok := r.Value.(clipboard.ClipboardContent) + if !ok { + return "", core.E("display.ReadClipboard", "unexpected result type", nil) + } + return content.Text, nil +} + +func (s *Service) WriteClipboard(text string) resultFailure { + result := s.Core().Action("clipboard.set_text").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: clipboard.TaskSetText{Text: text}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.WriteClipboard", "clipboard.set_text action failed", nil) + } + return nil +} + +func (s *Service) HasClipboard() bool { + text, err := s.ReadClipboard() + return err == nil && text != "" +} + +func (s *Service) ClearClipboard() resultFailure { + result := s.Core().Action("clipboard.clear").Run(context.Background(), core.NewOptions()) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.ClearClipboard", "clipboard.clear action failed", nil) + } + return nil +} + +func (s *Service) ReadClipboardImage() ([]byte, resultFailure) { + r := s.Core().QUERY(clipboard.QueryImage{}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return nil, err + } + return nil, failedQuery("display.ReadClipboardImage", "clipboard.queryImage") + } + content, ok := r.Value.(clipboard.ImageContent) + if !ok { + return nil, unexpectedResultType("display.ReadClipboardImage") + } + if !content.HasImage { + return nil, nil + } + return append([]byte(nil), content.Data...), nil +} + +func (s *Service) WriteClipboardImage(data []byte) resultFailure { + if len(data) == 0 { + return core.E(writeClipboardImageOp, "clipboard image data is required", nil) + } + if len(data) > clipboard.MaxImageBytes { + return core.E(writeClipboardImageOp, "clipboard image exceeds maximum size", nil) + } + result := s.Core().Action("clipboard.set_image").Run(context.Background(), core.NewOptions( + core.Option{Key: "data", Value: append([]byte(nil), data...)}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E(writeClipboardImageOp, "clipboard.setImage action failed", nil) + } + return nil +} + +func (s *Service) ShowNotification(opts NotificationOptions) resultFailure { + return s.sendNotification(notification.NotificationOptions{ + ID: opts.ID, + Title: opts.Title, + Message: opts.Message, + Subtitle: opts.Subtitle, + }) +} + +func (s *Service) ShowInfoNotification(title, message string) resultFailure { + return s.sendNotification(notification.NotificationOptions{Title: title, Message: message}) +} + +func (s *Service) ShowWarningNotification(title, message string) resultFailure { + return s.sendNotification(notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityWarning, + }) +} + +func (s *Service) ShowErrorNotification(title, message string) resultFailure { + return s.sendNotification(notification.NotificationOptions{ + Title: title, + Message: message, + Severity: notification.SeverityError, + }) +} + +func (s *Service) RequestNotificationPermission() (bool, resultFailure) { + r := s.Core().Action("notification.request_permission").Run(context.Background(), core.NewOptions()) + if !r.OK { + if err, ok := r.Value.(error); ok { + return false, err + } + return false, core.E("display.RequestNotificationPermission", "notification.requestPermission action failed", nil) + } + granted, ok := r.Value.(bool) + if !ok { + return false, unexpectedResultType("display.RequestNotificationPermission") + } + return granted, nil +} + +func (s *Service) CheckNotificationPermission() (bool, resultFailure) { + r := s.Core().QUERY(notification.QueryPermission{}) + if !r.OK { + if err, ok := r.Value.(error); ok { + return false, err + } + return false, core.E("display.CheckNotificationPermission", "notification query failed", nil) + } + status, ok := r.Value.(notification.PermissionStatus) + if !ok { + return false, unexpectedResultType("display.CheckNotificationPermission") + } + return status.Granted, nil +} + +func (s *Service) ClearNotifications() resultFailure { + result := s.Core().Action("notification.clear").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: notification.TaskClear{}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.ClearNotifications", "notification.clear action failed", nil) + } + return nil +} + +func (s *Service) SetTheme(theme string) resultFailure { + result := s.Core().Action("environment.set_theme").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: environment.TaskSetTheme{Theme: theme}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.SetTheme", "environment.set_theme action failed", nil) + } + return nil +} + +func (s *Service) GetTheme() *Theme { + r := s.Core().QUERY(environment.QueryTheme{}) + info, ok := themeInfoFromQueryResult(s, "display.GetTheme", r) + if !ok { + return nil + } + return &Theme{IsDark: info.IsDark} +} + +func (s *Service) GetSystemTheme() string { + r := s.Core().QUERY(environment.QueryTheme{}) + info, ok := themeInfoFromQueryResult(s, "display.GetSystemTheme", r) + if !ok { + return "" + } + return info.Theme +} + +func themeInfoFromQueryResult(s *Service, method string, r core.Result) (environment.ThemeInfo, bool) { + if !r.OK { + return environment.ThemeInfo{}, false + } + info, ok := r.Value.(environment.ThemeInfo) + if ok { + return info, true + } + if s != nil && s.Core() != nil { + s.Core().LogWarn(core.Errorf("query=environment.QueryTheme value_type=%T", r.Value), method, "malformed theme query payload") + } + return environment.ThemeInfo{}, false +} + +func (s *Service) sendNotification(opts notification.NotificationOptions) resultFailure { + result := s.Core().Action("notification.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: notification.TaskSend{Options: opts}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("display.sendNotification", "notification.send action failed", nil) + } + return nil +} + +func screenToDisplay(scr *screen.Screen) *Screen { + if scr == nil { + return nil + } + return &Screen{ + ID: scr.ID, + Name: scr.Name, + X: scr.Bounds.X, + Y: scr.Bounds.Y, + Width: scr.Bounds.Width, + Height: scr.Bounds.Height, + ScaleFactor: scr.ScaleFactor, + IsPrimary: scr.IsPrimary, + } +} + +func toDialogOpenFileOptions(opts OpenFileOptions) dialog.OpenFileOptions { + return dialog.OpenFileOptions{ + Title: opts.Title, + Directory: opts.DefaultDirectory, + Filename: opts.DefaultFilename, + AllowMultiple: opts.AllowMultiple, + Filters: toDialogFileFilters(opts.Filters), + } +} + +func toDialogSaveFileOptions(opts SaveFileOptions) dialog.SaveFileOptions { + return dialog.SaveFileOptions{ + Title: opts.Title, + Directory: opts.DefaultDirectory, + Filename: opts.DefaultFilename, + Filters: toDialogFileFilters(opts.Filters), + } +} + +func toDialogOpenDirectoryOptions(opts OpenDirectoryOptions) dialog.OpenDirectoryOptions { + return dialog.OpenDirectoryOptions{ + Title: opts.Title, + Directory: opts.DefaultDirectory, + } +} + +func toDialogFileFilters(filters []FileFilter) []dialog.FileFilter { + if len(filters) == 0 { + return nil + } + result := make([]dialog.FileFilter, 0, len(filters)) + for _, filter := range filters { + result = append(result, dialog.FileFilter{ + DisplayName: filter.DisplayName, + Pattern: filter.Pattern, + }) + } + return result +} + +func trayMenuItemsToSystray(items []TrayMenuItem) []systray.TrayMenuItem { + if len(items) == 0 { + return nil + } + result := make([]systray.TrayMenuItem, 0, len(items)) + for _, item := range items { + converted := systray.TrayMenuItem{ + Label: item.Label, + ActionID: item.ActionID, + } + if item.IsSeparator { + converted.Type = "separator" + } + if len(item.Children) > 0 { + converted.Submenu = trayMenuItemsToSystray(item.Children) + } + result = append(result, converted) + } + return result +} diff --git a/display/webkit/pkg/display/api_example_test.go b/display/webkit/pkg/display/api_example_test.go new file mode 100644 index 0000000..5f1a519 --- /dev/null +++ b/display/webkit/pkg/display/api_example_test.go @@ -0,0 +1,209 @@ +//go:build compliance + +package display + +import core "dappco.re/go" + +func ExampleService_GetScreens() { + core.Println("Service_GetScreens") + // Output: + // Service_GetScreens +} + +func ExampleService_GetScreen() { + core.Println("Service_GetScreen") + // Output: + // Service_GetScreen +} + +func ExampleService_GetPrimaryScreen() { + core.Println("Service_GetPrimaryScreen") + // Output: + // Service_GetPrimaryScreen +} + +func ExampleService_GetScreenAtPoint() { + core.Println("Service_GetScreenAtPoint") + // Output: + // Service_GetScreenAtPoint +} + +func ExampleService_GetScreenForWindow() { + core.Println("Service_GetScreenForWindow") + // Output: + // Service_GetScreenForWindow +} + +func ExampleService_GetWorkAreas() { + core.Println("Service_GetWorkAreas") + // Output: + // Service_GetWorkAreas +} + +func ExampleService_OpenSingleFileDialog() { + core.Println("Service_OpenSingleFileDialog") + // Output: + // Service_OpenSingleFileDialog +} + +func ExampleService_OpenFileDialog() { + core.Println("Service_OpenFileDialog") + // Output: + // Service_OpenFileDialog +} + +func ExampleService_SaveFileDialog() { + core.Println("Service_SaveFileDialog") + // Output: + // Service_SaveFileDialog +} + +func ExampleService_OpenDirectoryDialog() { + core.Println("Service_OpenDirectoryDialog") + // Output: + // Service_OpenDirectoryDialog +} + +func ExampleService_ConfirmDialog() { + core.Println("Service_ConfirmDialog") + // Output: + // Service_ConfirmDialog +} + +func ExampleService_PromptDialog() { + core.Println("Service_PromptDialog") + // Output: + // Service_PromptDialog +} + +func ExampleService_SetTrayIcon() { + core.Println("Service_SetTrayIcon") + // Output: + // Service_SetTrayIcon +} + +func ExampleService_SetTrayTooltip() { + core.Println("Service_SetTrayTooltip") + // Output: + // Service_SetTrayTooltip +} + +func ExampleService_SetTrayLabel() { + core.Println("Service_SetTrayLabel") + // Output: + // Service_SetTrayLabel +} + +func ExampleService_SetTrayMenu() { + core.Println("Service_SetTrayMenu") + // Output: + // Service_SetTrayMenu +} + +func ExampleService_GetTrayInfo() { + core.Println("Service_GetTrayInfo") + // Output: + // Service_GetTrayInfo +} + +func ExampleService_ShowTrayMessage() { + core.Println("Service_ShowTrayMessage") + // Output: + // Service_ShowTrayMessage +} + +func ExampleService_ReadClipboard() { + core.Println("Service_ReadClipboard") + // Output: + // Service_ReadClipboard +} + +func ExampleService_WriteClipboard() { + core.Println("Service_WriteClipboard") + // Output: + // Service_WriteClipboard +} + +func ExampleService_HasClipboard() { + core.Println("Service_HasClipboard") + // Output: + // Service_HasClipboard +} + +func ExampleService_ClearClipboard() { + core.Println("Service_ClearClipboard") + // Output: + // Service_ClearClipboard +} + +func ExampleService_ReadClipboardImage() { + core.Println("Service_ReadClipboardImage") + // Output: + // Service_ReadClipboardImage +} + +func ExampleService_WriteClipboardImage() { + core.Println("Service_WriteClipboardImage") + // Output: + // Service_WriteClipboardImage +} + +func ExampleService_ShowNotification() { + core.Println("Service_ShowNotification") + // Output: + // Service_ShowNotification +} + +func ExampleService_ShowInfoNotification() { + core.Println("Service_ShowInfoNotification") + // Output: + // Service_ShowInfoNotification +} + +func ExampleService_ShowWarningNotification() { + core.Println("Service_ShowWarningNotification") + // Output: + // Service_ShowWarningNotification +} + +func ExampleService_ShowErrorNotification() { + core.Println("Service_ShowErrorNotification") + // Output: + // Service_ShowErrorNotification +} + +func ExampleService_RequestNotificationPermission() { + core.Println("Service_RequestNotificationPermission") + // Output: + // Service_RequestNotificationPermission +} + +func ExampleService_CheckNotificationPermission() { + core.Println("Service_CheckNotificationPermission") + // Output: + // Service_CheckNotificationPermission +} + +func ExampleService_ClearNotifications() { + core.Println("Service_ClearNotifications") + // Output: + // Service_ClearNotifications +} + +func ExampleService_SetTheme() { + core.Println("Service_SetTheme") + // Output: + // Service_SetTheme +} + +func ExampleService_GetTheme() { + core.Println("Service_GetTheme") + // Output: + // Service_GetTheme +} + +func ExampleService_GetSystemTheme() { + core.Println("Service_GetSystemTheme") + // Output: + // Service_GetSystemTheme +} diff --git a/display/webkit/pkg/display/api_test.go b/display/webkit/pkg/display/api_test.go new file mode 100644 index 0000000..1c5f1de --- /dev/null +++ b/display/webkit/pkg/display/api_test.go @@ -0,0 +1,2440 @@ +package display + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/clipboard" + "dappco.re/go/render/display/webkit/pkg/dialog" + "dappco.re/go/render/display/webkit/pkg/environment" + "dappco.re/go/render/display/webkit/pkg/notification" + "dappco.re/go/render/display/webkit/pkg/screen" + "dappco.re/go/render/display/webkit/pkg/systray" + "dappco.re/go/render/display/webkit/pkg/window" +) + +func newTestDisplayAPIService(t *core.T) (*Service, *core.Core) { + t.Helper() + return newTestDisplayService(t) +} + +func TestDisplayAPI_screenToDisplay_Good(t *core.T) { + // screenToDisplay + ax7Variant := "screenToDisplay:good" + core.AssertContains(t, ax7Variant, "good") + got := screenToDisplay(&screen.Screen{ + ID: "screen-1", + Name: "Primary", + ScaleFactor: 2, + Bounds: screen.Rect{X: 10, Y: 20, Width: 1920, Height: 1080}, + IsPrimary: true, + }) + + core.AssertNotNil(t, got) + core.AssertEqual(t, "screen-1", got.ID) + core.AssertEqual(t, "Primary", got.Name) + core.AssertEqual(t, 10, got.X) + core.AssertEqual(t, 20, got.Y) + core.AssertEqual(t, 1920, got.Width) + core.AssertEqual(t, 1080, got.Height) + core.AssertEqual(t, 2.0, got.ScaleFactor) + core.AssertTrue(t, got.IsPrimary) +} + +func TestDisplayAPI_screenToDisplay_Bad(t *core.T) { + // screenToDisplay + ax7Variant := "screenToDisplay:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertNil(t, screenToDisplay(nil)) + observedType := core.Sprintf("%T", screenToDisplay(nil)) + core.AssertNotEmpty(t, observedType) +} + +func TestDisplayAPI_screenToDisplay_Ugly(t *core.T) { + // screenToDisplay + ax7Variant := "screenToDisplay:ugly" + core.AssertContains(t, ax7Variant, "ugly") + got := screenToDisplay(&screen.Screen{}) + + core.AssertNotNil(t, got) + core.AssertEmpty(t, got.ID) + core.AssertEmpty(t, got.Name) + core.AssertEmpty(t, got.Width) + core.AssertEmpty(t, got.Height) +} + +func TestDisplayAPI_toDialogOpenFileOptions_Good(t *core.T) { + // toDialogOpenFileOptions + ax7Variant := "toDialogOpenFileOptions:good" + core.AssertContains(t, ax7Variant, "good") + got := toDialogOpenFileOptions(OpenFileOptions{ + Title: "Pick", + DefaultDirectory: "/tmp", + DefaultFilename: "report.csv", + AllowMultiple: true, + Filters: []FileFilter{ + {DisplayName: "CSV", Pattern: "*.csv"}, + }, + }) + + core.AssertEqual(t, "Pick", got.Title) + core.AssertEqual(t, "/tmp", got.Directory) + core.AssertEqual(t, "report.csv", got.Filename) + core.AssertTrue(t, got.AllowMultiple) + core.AssertLen(t, got.Filters, 1) + core.AssertEqual(t, "CSV", got.Filters[0].DisplayName) + core.AssertEqual(t, "*.csv", got.Filters[0].Pattern) +} + +func TestDisplayAPI_toDialogOpenFileOptions_Bad(t *core.T) { + // toDialogOpenFileOptions + ax7Variant := "toDialogOpenFileOptions:bad" + core.AssertContains(t, ax7Variant, "bad") + got := toDialogOpenFileOptions(OpenFileOptions{}) + + core.AssertEmpty(t, got.Title) + core.AssertEmpty(t, got.Directory) + core.AssertEmpty(t, got.Filename) + core.AssertFalse(t, got.AllowMultiple) + core.AssertNil(t, got.Filters) +} + +func TestDisplayAPI_toDialogOpenFileOptions_Ugly(t *core.T) { + // toDialogOpenFileOptions + ax7Variant := "toDialogOpenFileOptions:ugly" + core.AssertContains(t, ax7Variant, "ugly") + got := toDialogOpenFileOptions(OpenFileOptions{ + Filters: []FileFilter{ + {DisplayName: "All", Pattern: "*.*"}, + {DisplayName: "Media", Pattern: "*.png;*.jpg"}, + }, + }) + + core.AssertLen(t, got.Filters, 2) + core.AssertEqual(t, "All", got.Filters[0].DisplayName) + core.AssertEqual(t, "*.png;*.jpg", got.Filters[1].Pattern) +} + +func TestDisplayAPI_trayMenuItemsToSystray_Good(t *core.T) { + // trayMenuItemsToSystray + ax7Variant := "trayMenuItemsToSystray:good" + core.AssertContains(t, ax7Variant, "good") + got := trayMenuItemsToSystray([]TrayMenuItem{ + {Label: "Open", ActionID: "open"}, + {IsSeparator: true}, + { + Label: "More", + ActionID: "more", + Children: []TrayMenuItem{{Label: "Nested", ActionID: "nested"}}, + }, + }) + + core.AssertLen(t, got, 3) + core.AssertEqual(t, "Open", got[0].Label) + core.AssertEqual(t, "separator", got[1].Type) + core.AssertLen(t, got[2].Submenu, 1) + core.AssertEqual(t, "nested", got[2].Submenu[0].ActionID) +} + +func TestDisplayAPI_trayMenuItemsToSystray_Bad(t *core.T) { + // trayMenuItemsToSystray + ax7Variant := "trayMenuItemsToSystray:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertNil(t, trayMenuItemsToSystray(nil)) + observedType := core.Sprintf("%T", trayMenuItemsToSystray(nil)) + core.AssertNotEmpty(t, observedType) +} + +func TestDisplayAPI_trayMenuItemsToSystray_Ugly(t *core.T) { + // trayMenuItemsToSystray + ax7Variant := "trayMenuItemsToSystray:ugly" + core.AssertContains(t, ax7Variant, "ugly") + got := trayMenuItemsToSystray([]TrayMenuItem{{Children: []TrayMenuItem{{IsSeparator: true}}}}) + + core.AssertLen(t, got, 1) + core.AssertLen(t, got[0].Submenu, 1) + core.AssertEqual(t, "separator", got[0].Submenu[0].Type) +} + +func TestDisplayAPI_GetScreens_Good(t *core.T) { + // GetScreens + ax7Variant := "GetScreens:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryAll: + return core.Result{Value: []screen.Screen{ + { + ID: "screen-1", + Name: "Primary", + Bounds: screen.Rect{X: 10, Y: 20, Width: 1920, Height: 1080}, + ScaleFactor: 2, + IsPrimary: true, + }, + }, OK: true} + default: + return core.Result{} + } + }) + + screens := svc.GetScreens() + + core.AssertLen(t, screens, 1) + core.AssertEqual(t, "screen-1", screens[0].ID) + core.AssertEqual(t, 10, screens[0].X) + core.AssertEqual(t, 1920, screens[0].Width) +} + +func TestDisplayAPI_GetScreens_Empty(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryAll: + return core.Result{Value: []screen.Screen{}, OK: true} + default: + return core.Result{} + } + }) + + core.AssertEmpty(t, svc.GetScreens()) +} + +func TestDisplayAPI_GetScreens_Bad(t *core.T) { + // GetScreens + ax7Variant := "GetScreens:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryAll: + return core.Result{Value: []string{"unexpected"}, OK: true} + default: + return core.Result{} + } + }) + + screens := svc.GetScreens() + core.AssertNotNil(t, screens) + core.AssertEmpty(t, screens) +} + +func TestDisplayAPI_GetScreens_Ugly(t *core.T) { + // GetScreens + ax7Variant := "GetScreens:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryAll: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + + screens := svc.GetScreens() + core.AssertNotNil(t, screens) + core.AssertEmpty(t, screens) +} + +func TestDisplayAPI_GetWorkAreas_Ugly(t *core.T) { + // GetWorkAreas + ax7Variant := "GetWorkAreas:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryWorkAreas: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + + areas := svc.GetWorkAreas() + + core.AssertNotNil(t, areas) + core.AssertEmpty(t, areas) +} + +func TestDisplayAPI_GetScreen_BadType(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryByID: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + + got, err := svc.GetScreen("screen-1") + + core.AssertError(t, err) + core.AssertNil(t, got) +} + +func TestDisplayAPI_CreateWindow_UglyResultType(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.Action("window.open", func(_ context.Context, _ core.Options) core.Result { + return core.Result{OK: true} + }) + + got, err := svc.CreateWindow(CreateWindowOptions{ + Name: "broken-window", + }) + + core.AssertError(t, err) + core.AssertNil(t, got) + core.AssertContains(t, err.Error(), "unexpected result type") +} + +func TestDisplayAPI_GetScreen_Ugly(t *core.T) { + // GetScreen + ax7Variant := "GetScreen:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryByID: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + + got, err := svc.GetScreen("screen-1") + + core.AssertError(t, err) + core.AssertNil(t, got) +} + +func TestDisplayAPI_GetPrimaryScreen_Ugly(t *core.T) { + // GetPrimaryScreen + ax7Variant := "GetPrimaryScreen:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryPrimary: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + + got, err := svc.GetPrimaryScreen() + + core.AssertError(t, err) + core.AssertNil(t, got) +} + +func TestDisplayAPI_GetScreenAtPoint_Ugly(t *core.T) { + // GetScreenAtPoint + ax7Variant := "GetScreenAtPoint:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryAtPoint: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + + got, err := svc.GetScreenAtPoint(10, 20) + + core.AssertError(t, err) + core.AssertNil(t, got) +} + +func TestDisplayAPI_OpenFileDialog_Good(t *core.T) { + // OpenFileDialog + ax7Variant := "OpenFileDialog:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.open_file", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(dialog.TaskOpenFile) + core.AssertEqual(t, "Pick file", task.Options.Title) + core.AssertTrue(t, task.Options.AllowMultiple) + return core.Result{Value: []string{"/tmp/a.txt", "/tmp/b.txt"}, OK: true} + }) + + paths, err := svc.OpenFileDialog(OpenFileOptions{ + Title: "Pick file", + AllowMultiple: true, + }) + + core.RequireNoError(t, err) + core.AssertEqual(t, []string{"/tmp/a.txt", "/tmp/b.txt"}, paths) +} + +func TestDisplayAPI_OpenFileDialog_BadType(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.open_file", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: 42, OK: true} + }) + + paths, err := svc.OpenFileDialog(OpenFileOptions{}) + + core.AssertError(t, err) + core.AssertNil(t, paths) +} + +func TestDisplayAPI_OpenFileDialog_Bad(t *core.T) { + // OpenFileDialog + ax7Variant := "OpenFileDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.open_file", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + paths, err := svc.OpenFileDialog(OpenFileOptions{}) + + core.AssertError(t, err) + core.AssertNil(t, paths) +} + +func TestDisplayAPI_OpenFileDialog_Ugly(t *core.T) { + // OpenFileDialog + ax7Variant := "OpenFileDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.open_file", func(_ context.Context, _ core.Options) core.Result { + return core.Result{OK: true} + }) + + paths, err := svc.OpenFileDialog(OpenFileOptions{}) + + core.AssertError(t, err) + core.AssertNil(t, paths) +} + +func TestDisplayAPI_RequestNotificationPermission_BadType(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.Action("notification.request_permission", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: true} + }) + + granted, err := svc.RequestNotificationPermission() + + core.AssertError(t, err) + core.AssertFalse(t, granted) +} + +func TestDisplayAPI_GetTheme_Good(t *core.T) { + // GetTheme + ax7Variant := "GetTheme:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case environment.QueryTheme: + return core.Result{Value: environment.ThemeInfo{IsDark: true, Theme: "dark"}, OK: true} + default: + return core.Result{} + } + }) + + theme := svc.GetTheme() + core.AssertNotNil(t, theme) + core.AssertTrue(t, theme.IsDark) + core.AssertEqual(t, "dark", svc.GetSystemTheme()) +} + +func TestDisplayAPI_GetTheme_Bad(t *core.T) { + // GetTheme + ax7Variant := "GetTheme:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case environment.QueryTheme: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + + theme := svc.GetTheme() + core.AssertNil(t, theme) + core.AssertEmpty(t, svc.GetSystemTheme()) +} + +func TestDisplayAPI_GetTheme_Ugly(t *core.T) { + // GetTheme + ax7Variant := "GetTheme:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case environment.QueryTheme: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + + core.AssertNil(t, svc.GetTheme()) + core.AssertEmpty(t, svc.GetSystemTheme()) +} + +func TestDisplayAPI_SaveFileDialog_Good(t *core.T) { + // SaveFileDialog + ax7Variant := "SaveFileDialog:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.save_file", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(dialog.TaskSaveFile) + core.AssertEqual(t, "Export", task.Options.Title) + core.AssertEqual(t, "/tmp", task.Options.Directory) + core.AssertEqual(t, "data.json", task.Options.Filename) + core.AssertLen(t, task.Options.Filters, 1) + core.AssertEqual(t, "JSON", task.Options.Filters[0].DisplayName) + return core.Result{Value: "/exports/data.json", OK: true} + }) + + path, err := svc.SaveFileDialog(SaveFileOptions{ + Title: "Export", + DefaultDirectory: "/tmp", + DefaultFilename: "data.json", + Filters: []FileFilter{{DisplayName: "JSON", Pattern: "*.json"}}, + }) + + core.RequireNoError(t, err) + core.AssertEqual(t, "/exports/data.json", path) +} + +func TestDisplayAPI_SaveFileDialog_Bad(t *core.T) { + // SaveFileDialog + ax7Variant := "SaveFileDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.save_file", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + path, err := svc.SaveFileDialog(SaveFileOptions{}) + + core.AssertError(t, err) + core.AssertEmpty(t, path) +} + +func TestDisplayAPI_SaveFileDialog_Ugly(t *core.T) { + // SaveFileDialog + ax7Variant := "SaveFileDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.save_file", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: 42, OK: true} + }) + + path, err := svc.SaveFileDialog(SaveFileOptions{}) + + core.AssertError(t, err) + core.AssertEmpty(t, path) +} + +func TestDisplayAPI_OpenDirectoryDialog_Good(t *core.T) { + // OpenDirectoryDialog + ax7Variant := "OpenDirectoryDialog:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.open_directory", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(dialog.TaskOpenDirectory) + core.AssertEqual(t, "Choose", task.Options.Title) + core.AssertEqual(t, "/var", task.Options.Directory) + return core.Result{Value: "/var/data", OK: true} + }) + + path, err := svc.OpenDirectoryDialog(OpenDirectoryOptions{ + Title: "Choose", + DefaultDirectory: "/var", + }) + + core.RequireNoError(t, err) + core.AssertEqual(t, "/var/data", path) +} + +func TestDisplayAPI_OpenDirectoryDialog_Bad(t *core.T) { + // OpenDirectoryDialog + ax7Variant := "OpenDirectoryDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.open_directory", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + path, err := svc.OpenDirectoryDialog(OpenDirectoryOptions{}) + + core.AssertError(t, err) + core.AssertEmpty(t, path) +} + +func TestDisplayAPI_OpenDirectoryDialog_Ugly(t *core.T) { + // OpenDirectoryDialog + ax7Variant := "OpenDirectoryDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.open_directory", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: 42, OK: true} + }) + + path, err := svc.OpenDirectoryDialog(OpenDirectoryOptions{}) + + core.AssertError(t, err) + core.AssertEmpty(t, path) +} + +func TestDisplayAPI_PromptDialog_Good(t *core.T) { + // PromptDialog + ax7Variant := "PromptDialog:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.prompt", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(dialog.TaskPrompt) + core.AssertEqual(t, "Rename", task.Title) + core.AssertEqual(t, "Enter a new name", task.Message) + return core.Result{Value: dialog.PromptResult{Value: "draft", Confirmed: true}, OK: true} + }) + + value, confirmed, err := svc.PromptDialog("Rename", "Enter a new name") + + core.RequireNoError(t, err) + core.AssertTrue(t, confirmed) + core.AssertEqual(t, "draft", value) +} + +func TestDisplayAPI_PromptDialog_Bad(t *core.T) { + // PromptDialog + ax7Variant := "PromptDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.prompt", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + value, confirmed, err := svc.PromptDialog("Rename", "Enter a new name") + + core.AssertError(t, err) + core.AssertFalse(t, confirmed) + core.AssertEmpty(t, value) +} + +func TestDisplayAPI_PromptDialog_Ugly(t *core.T) { + // PromptDialog + ax7Variant := "PromptDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.Action("dialog.prompt", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: 42, OK: true} + }) + + value, confirmed, err := svc.PromptDialog("Rename", "Enter a new name") + + core.AssertError(t, err) + core.AssertFalse(t, confirmed) + core.AssertEmpty(t, value) +} + +func TestDisplayAPI_ReadClipboardImage_Good(t *core.T) { + // ReadClipboardImage + ax7Variant := "ReadClipboardImage:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + payload := []byte{1, 2, 3} + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryImage: + return core.Result{Value: clipboard.ImageContent{Data: payload, HasImage: true}, OK: true} + default: + return core.Result{} + } + }) + + got, err := svc.ReadClipboardImage() + + core.RequireNoError(t, err) + core.AssertEqual(t, []byte{1, 2, 3}, got) + payload[0] = 9 + core.AssertEqual(t, byte(1), got[0]) +} + +func TestDisplayAPI_ReadClipboardImage_Bad(t *core.T) { + // ReadClipboardImage + ax7Variant := "ReadClipboardImage:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryImage: + return core.Result{Value: clipboard.ImageContent{HasImage: false}, OK: true} + default: + return core.Result{} + } + }) + + got, err := svc.ReadClipboardImage() + + core.RequireNoError(t, err) + core.AssertNil(t, got) +} + +func TestDisplayAPI_ReadClipboardImage_Ugly(t *core.T) { + // ReadClipboardImage + ax7Variant := "ReadClipboardImage:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryImage: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + + got, err := svc.ReadClipboardImage() + + core.AssertError(t, err) + core.AssertNil(t, got) +} + +func TestDisplayAPI_ReadClipboardImage_Ugly_BackendFailure(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryImage: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + + got, err := svc.ReadClipboardImage() + + core.AssertError(t, err) + core.AssertNil(t, got) +} + +func TestDisplayAPI_WriteClipboardImage_Good(t *core.T) { + // WriteClipboardImage + ax7Variant := "WriteClipboardImage:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + var got []byte + c.Action("clipboard.set_image", func(_ context.Context, opts core.Options) core.Result { + got = append([]byte(nil), opts.Get("data").Value.([]byte)...) + return core.Result{OK: true} + }) + + input := []byte{4, 5, 6} + err := svc.WriteClipboardImage(input) + + core.RequireNoError(t, err) + input[0] = 9 + core.AssertTrue(t, bytesEqual([]byte{4, 5, 6}, got)) +} + +func TestDisplayAPI_WriteClipboardImage_Bad(t *core.T) { + // WriteClipboardImage + ax7Variant := "WriteClipboardImage:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, _ := newTestDisplayAPIService(t) + + err := svc.WriteClipboardImage(nil) + + core.AssertError(t, err) +} + +func TestDisplayAPI_WriteClipboardImage_Ugly(t *core.T) { + // WriteClipboardImage + ax7Variant := "WriteClipboardImage:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + c.Action("clipboard.set_image", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + err := svc.WriteClipboardImage([]byte{1}) + + core.AssertError(t, err) +} + +func TestDisplayAPI_GetScreenForWindow_Good(t *core.T) { + // GetScreenForWindow + ax7Variant := "GetScreenForWindow:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + var gotX, gotY int + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch typed := q.(type) { + case window.QueryWindowByName: + core.AssertEqual(t, "editor", typed.Name) + return core.Result{ + Value: &window.WindowInfo{ + Name: typed.Name, + X: 100, + Y: 200, + Width: 300, + Height: 400, + }, + OK: true, + } + case screen.QueryAtPoint: + gotX, gotY = typed.X, typed.Y + return core.Result{ + Value: &screen.Screen{ + ID: "screen-1", + Name: "Primary", + ScaleFactor: 2, + Bounds: screen.Rect{X: 10, Y: 20, Width: 1920, Height: 1080}, + IsPrimary: true, + }, + OK: true, + } + default: + return core.Result{} + } + }) + + got, err := svc.GetScreenForWindow("editor") + + core.RequireNoError(t, err) + core.AssertNotNil(t, got) + core.AssertEqual(t, "screen-1", got.ID) + core.AssertEqual(t, 250, gotX) + core.AssertEqual(t, 400, gotY) + core.AssertEqual(t, 10, got.X) + core.AssertEqual(t, 20, got.Y) + core.AssertEqual(t, 1920, got.Width) + core.AssertEqual(t, 1080, got.Height) +} + +func TestDisplayAPI_GetScreenForWindow_Bad(t *core.T) { + // GetScreenForWindow + ax7Variant := "GetScreenForWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + var screenQueried bool + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryWindowByName: + return core.Result{Value: (*window.WindowInfo)(nil), OK: true} + case screen.QueryAtPoint: + screenQueried = true + return core.Result{OK: true} + default: + return core.Result{} + } + }) + + got, err := svc.GetScreenForWindow("missing") + + core.RequireNoError(t, err) + core.AssertNil(t, got) + core.AssertFalse(t, screenQueried) +} + +func TestDisplayAPI_GetScreenForWindow_Ugly(t *core.T) { + // GetScreenForWindow + ax7Variant := "GetScreenForWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryWindowByName: + return core.Result{ + Value: &window.WindowInfo{X: 1, Y: 2, Width: 3, Height: 4}, + OK: true, + } + case screen.QueryAtPoint: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + + got, err := svc.GetScreenForWindow("editor") + + core.AssertError(t, err) + core.AssertNil(t, got) + core.AssertContains(t, err.Error(), "unexpected result type") +} + +func TestDisplayAPI_OpenSingleFileDialog_Good(t *core.T) { + // OpenSingleFileDialog + ax7Variant := "OpenSingleFileDialog:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + var task dialog.TaskOpenFile + c.Action("dialog.open_file", func(_ context.Context, opts core.Options) core.Result { + task = opts.Get("task").Value.(dialog.TaskOpenFile) + return core.Result{Value: []string{"/tmp/report.csv"}, OK: true} + }) + + path, err := svc.OpenSingleFileDialog(OpenFileOptions{ + Title: "Pick report", + DefaultFilename: "report.csv", + }) + + core.RequireNoError(t, err) + core.AssertEqual(t, "/tmp/report.csv", path) + core.AssertEqual(t, "Pick report", task.Options.Title) + core.AssertEqual(t, "report.csv", task.Options.Filename) +} + +func TestDisplayAPI_OpenSingleFileDialog_Bad(t *core.T) { + // OpenSingleFileDialog + ax7Variant := "OpenSingleFileDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.Action("dialog.open_file", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: []string{}, OK: true} + }) + + path, err := svc.OpenSingleFileDialog(OpenFileOptions{}) + + core.RequireNoError(t, err) + core.AssertEmpty(t, path) +} + +func TestDisplayAPI_OpenSingleFileDialog_Ugly(t *core.T) { + // OpenSingleFileDialog + ax7Variant := "OpenSingleFileDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.Action("dialog.open_file", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: false} + }) + + path, err := svc.OpenSingleFileDialog(OpenFileOptions{}) + + core.AssertError(t, err) + core.AssertEmpty(t, path) + core.AssertContains(t, err.Error(), "dialog.openFile action failed") +} + +func TestDisplayAPI_ConfirmDialog_Good(t *core.T) { + // ConfirmDialog + ax7Variant := "ConfirmDialog:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + var task dialog.TaskQuestion + c.Action("dialog.question", func(_ context.Context, opts core.Options) core.Result { + task = opts.Get("task").Value.(dialog.TaskQuestion) + return core.Result{Value: "Yes", OK: true} + }) + + confirmed, err := svc.ConfirmDialog("Confirm", "Delete this file?") + + core.RequireNoError(t, err) + core.AssertTrue(t, confirmed) + core.AssertEqual(t, "Confirm", task.Title) + core.AssertEqual(t, []string{"Yes", "No"}, task.Buttons) +} + +func TestDisplayAPI_ConfirmDialog_Bad(t *core.T) { + // ConfirmDialog + ax7Variant := "ConfirmDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.Action("dialog.question", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + confirmed, err := svc.ConfirmDialog("Confirm", "Delete this file?") + + core.AssertError(t, err) + core.AssertFalse(t, confirmed) + core.AssertEqual(t, core.AnError, err) +} + +func TestDisplayAPI_ConfirmDialog_Ugly(t *core.T) { + // ConfirmDialog + ax7Variant := "ConfirmDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.Action("dialog.question", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: 42, OK: true} + }) + + confirmed, err := svc.ConfirmDialog("Confirm", "Delete this file?") + + core.AssertError(t, err) + core.AssertFalse(t, confirmed) + core.AssertContains(t, err.Error(), "unexpected result type") +} + +func TestDisplayAPI_ReadClipboard_Good(t *core.T) { + // ReadClipboard + ax7Variant := "ReadClipboard:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryText: + return core.Result{ + Value: clipboard.ClipboardContent{ + Text: "hello clipboard", + HasContent: true, + }, + OK: true, + } + default: + return core.Result{} + } + }) + + text, err := svc.ReadClipboard() + + core.RequireNoError(t, err) + core.AssertEqual(t, "hello clipboard", text) +} + +func TestDisplayAPI_ReadClipboard_Bad(t *core.T) { + // ReadClipboard + ax7Variant := "ReadClipboard:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryText: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + + text, err := svc.ReadClipboard() + + core.RequireNoError(t, err) + core.AssertEmpty(t, text) + // Missing seam: QUERY drops non-OK backend errors, so propagation is not observable here. +} + +func TestDisplayAPI_ReadClipboard_Ugly(t *core.T) { + // ReadClipboard + ax7Variant := "ReadClipboard:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryText: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + + text, err := svc.ReadClipboard() + + core.AssertError(t, err) + core.AssertEmpty(t, text) + core.AssertContains(t, err.Error(), "unexpected result type") +} + +func TestDisplayAPI_CheckNotificationPermission_Good(t *core.T) { + // CheckNotificationPermission + ax7Variant := "CheckNotificationPermission:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case notification.QueryPermission: + return core.Result{Value: notification.PermissionStatus{Granted: true}, OK: true} + default: + return core.Result{} + } + }) + + granted, err := svc.CheckNotificationPermission() + + core.RequireNoError(t, err) + core.AssertTrue(t, granted) +} + +func TestDisplayAPI_CheckNotificationPermission_Bad(t *core.T) { + // CheckNotificationPermission + ax7Variant := "CheckNotificationPermission:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case notification.QueryPermission: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + + granted, err := svc.CheckNotificationPermission() + + core.AssertError(t, err) + core.AssertFalse(t, granted) + core.AssertContains(t, err.Error(), "notification query failed") +} + +func TestDisplayAPI_CheckNotificationPermission_Ugly(t *core.T) { + // CheckNotificationPermission + ax7Variant := "CheckNotificationPermission:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case notification.QueryPermission: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + + granted, err := svc.CheckNotificationPermission() + + core.AssertError(t, err) + core.AssertFalse(t, granted) + core.AssertContains(t, err.Error(), "unexpected result type") +} + +func TestDisplayAPI_WriteClipboard_Good(t *core.T) { + // WriteClipboard + ax7Variant := "WriteClipboard:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + var gotText string + c.Action("clipboard.set_text", func(_ context.Context, opts core.Options) core.Result { + gotText = opts.Get("task").Value.(clipboard.TaskSetText).Text + return core.Result{OK: true} + }) + + err := svc.WriteClipboard("hello") + + core.RequireNoError(t, err) + core.AssertEqual(t, "hello", gotText) +} + +func TestDisplayAPI_WriteClipboard_Bad(t *core.T) { + // WriteClipboard + ax7Variant := "WriteClipboard:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.Action("clipboard.set_text", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + err := svc.WriteClipboard("hello") + + core.AssertError(t, err) + core.AssertEqual(t, core.AnError, err) +} + +func TestDisplayAPI_WriteClipboard_Ugly(t *core.T) { + // WriteClipboard + ax7Variant := "WriteClipboard:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.Action("clipboard.set_text", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: false} + }) + + err := svc.WriteClipboard("") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "clipboard.set_text") +} + +func TestDisplayAPI_SetTrayIcon_Good(t *core.T) { + // SetTrayIcon + ax7Variant := "SetTrayIcon:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + var got []byte + c.Action("systray.set_icon", func(_ context.Context, opts core.Options) core.Result { + got = append([]byte(nil), opts.Get("task").Value.(systray.TaskSetTrayIcon).Data...) + return core.Result{OK: true} + }) + + err := svc.SetTrayIcon([]byte{1, 2, 3}) + + core.RequireNoError(t, err) + core.AssertEqual(t, []byte{1, 2, 3}, got) +} + +func TestDisplayAPI_SetTrayIcon_Bad(t *core.T) { + // SetTrayIcon + ax7Variant := "SetTrayIcon:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.Action("systray.set_icon", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + err := svc.SetTrayIcon([]byte{1}) + + core.AssertError(t, err) + core.AssertEqual(t, core.AnError, err) +} + +func TestDisplayAPI_SetTrayIcon_Ugly(t *core.T) { + // SetTrayIcon + ax7Variant := "SetTrayIcon:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.Action("systray.set_icon", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: false} + }) + + err := svc.SetTrayIcon(nil) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "systray.set_icon") +} + +// AX7 generated source-matching smoke coverage. +func TestApi_Service_GetScreens_Good(t *core.T) { + // Service GetScreens + ax7Variant := "Service_GetScreens:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetScreens() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreens_Bad(t *core.T) { + // Service GetScreens + ax7Variant := "Service_GetScreens:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetScreens() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreens_Ugly(t *core.T) { + // Service GetScreens + ax7Variant := "Service_GetScreens:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetScreens() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreen_Good(t *core.T) { + // Service GetScreen + ax7Variant := "Service_GetScreen:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetScreen("agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreen_Bad(t *core.T) { + // Service GetScreen + ax7Variant := "Service_GetScreen:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetScreen("") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreen_Ugly(t *core.T) { + // Service GetScreen + ax7Variant := "Service_GetScreen:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetScreen("../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetPrimaryScreen_Good(t *core.T) { + // Service GetPrimaryScreen + ax7Variant := "Service_GetPrimaryScreen:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetPrimaryScreen() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetPrimaryScreen_Bad(t *core.T) { + // Service GetPrimaryScreen + ax7Variant := "Service_GetPrimaryScreen:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetPrimaryScreen() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetPrimaryScreen_Ugly(t *core.T) { + // Service GetPrimaryScreen + ax7Variant := "Service_GetPrimaryScreen:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetPrimaryScreen() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreenAtPoint_Good(t *core.T) { + // Service GetScreenAtPoint + ax7Variant := "Service_GetScreenAtPoint:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetScreenAtPoint(1, 1) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreenAtPoint_Bad(t *core.T) { + // Service GetScreenAtPoint + ax7Variant := "Service_GetScreenAtPoint:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetScreenAtPoint(0, 0) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreenAtPoint_Ugly(t *core.T) { + // Service GetScreenAtPoint + ax7Variant := "Service_GetScreenAtPoint:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetScreenAtPoint(-1, -1) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreenForWindow_Good(t *core.T) { + // Service GetScreenForWindow + ax7Variant := "Service_GetScreenForWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetScreenForWindow("agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreenForWindow_Bad(t *core.T) { + // Service GetScreenForWindow + ax7Variant := "Service_GetScreenForWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetScreenForWindow("") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetScreenForWindow_Ugly(t *core.T) { + // Service GetScreenForWindow + ax7Variant := "Service_GetScreenForWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetScreenForWindow("../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetWorkAreas_Good(t *core.T) { + // Service GetWorkAreas + ax7Variant := "Service_GetWorkAreas:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetWorkAreas() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetWorkAreas_Bad(t *core.T) { + // Service GetWorkAreas + ax7Variant := "Service_GetWorkAreas:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetWorkAreas() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetWorkAreas_Ugly(t *core.T) { + // Service GetWorkAreas + ax7Variant := "Service_GetWorkAreas:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetWorkAreas() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_OpenSingleFileDialog_Good(t *core.T) { + // Service OpenSingleFileDialog + ax7Variant := "Service_OpenSingleFileDialog:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.OpenSingleFileDialog(*new(OpenFileOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_OpenSingleFileDialog_Bad(t *core.T) { + // Service OpenSingleFileDialog + ax7Variant := "Service_OpenSingleFileDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.OpenSingleFileDialog(*new(OpenFileOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_OpenSingleFileDialog_Ugly(t *core.T) { + // Service OpenSingleFileDialog + ax7Variant := "Service_OpenSingleFileDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.OpenSingleFileDialog(*new(OpenFileOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_OpenFileDialog_Good(t *core.T) { + // Service OpenFileDialog + ax7Variant := "Service_OpenFileDialog:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.OpenFileDialog(*new(OpenFileOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_OpenFileDialog_Bad(t *core.T) { + // Service OpenFileDialog + ax7Variant := "Service_OpenFileDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.OpenFileDialog(*new(OpenFileOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_OpenFileDialog_Ugly(t *core.T) { + // Service OpenFileDialog + ax7Variant := "Service_OpenFileDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.OpenFileDialog(*new(OpenFileOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SaveFileDialog_Good(t *core.T) { + // Service SaveFileDialog + ax7Variant := "Service_SaveFileDialog:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.SaveFileDialog(*new(SaveFileOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SaveFileDialog_Bad(t *core.T) { + // Service SaveFileDialog + ax7Variant := "Service_SaveFileDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.SaveFileDialog(*new(SaveFileOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SaveFileDialog_Ugly(t *core.T) { + // Service SaveFileDialog + ax7Variant := "Service_SaveFileDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.SaveFileDialog(*new(SaveFileOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_OpenDirectoryDialog_Good(t *core.T) { + // Service OpenDirectoryDialog + ax7Variant := "Service_OpenDirectoryDialog:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.OpenDirectoryDialog(*new(OpenDirectoryOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_OpenDirectoryDialog_Bad(t *core.T) { + // Service OpenDirectoryDialog + ax7Variant := "Service_OpenDirectoryDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.OpenDirectoryDialog(*new(OpenDirectoryOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_OpenDirectoryDialog_Ugly(t *core.T) { + // Service OpenDirectoryDialog + ax7Variant := "Service_OpenDirectoryDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.OpenDirectoryDialog(*new(OpenDirectoryOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ConfirmDialog_Good(t *core.T) { + // Service ConfirmDialog + ax7Variant := "Service_ConfirmDialog:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ConfirmDialog("agent", "agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ConfirmDialog_Bad(t *core.T) { + // Service ConfirmDialog + ax7Variant := "Service_ConfirmDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ConfirmDialog("", "") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ConfirmDialog_Ugly(t *core.T) { + // Service ConfirmDialog + ax7Variant := "Service_ConfirmDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ConfirmDialog("../../edge", "../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_PromptDialog_Good(t *core.T) { + // Service PromptDialog + ax7Variant := "Service_PromptDialog:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1, got2 := subject.PromptDialog("agent", "agent") + return core.Sprintf("%T,%T,%T", got0, got1, got2) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_PromptDialog_Bad(t *core.T) { + // Service PromptDialog + ax7Variant := "Service_PromptDialog:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1, got2 := subject.PromptDialog("", "") + return core.Sprintf("%T,%T,%T", got0, got1, got2) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_PromptDialog_Ugly(t *core.T) { + // Service PromptDialog + ax7Variant := "Service_PromptDialog:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1, got2 := subject.PromptDialog("../../edge", "../../edge") + return core.Sprintf("%T,%T,%T", got0, got1, got2) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayIcon_Good(t *core.T) { + // Service SetTrayIcon + ax7Variant := "Service_SetTrayIcon:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayIcon(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayIcon_Bad(t *core.T) { + // Service SetTrayIcon + ax7Variant := "Service_SetTrayIcon:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayIcon(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayIcon_Ugly(t *core.T) { + // Service SetTrayIcon + ax7Variant := "Service_SetTrayIcon:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayIcon(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayTooltip_Good(t *core.T) { + // Service SetTrayTooltip + ax7Variant := "Service_SetTrayTooltip:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayTooltip("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayTooltip_Bad(t *core.T) { + // Service SetTrayTooltip + ax7Variant := "Service_SetTrayTooltip:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayTooltip("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayTooltip_Ugly(t *core.T) { + // Service SetTrayTooltip + ax7Variant := "Service_SetTrayTooltip:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayTooltip("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayLabel_Good(t *core.T) { + // Service SetTrayLabel + ax7Variant := "Service_SetTrayLabel:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayLabel("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayLabel_Bad(t *core.T) { + // Service SetTrayLabel + ax7Variant := "Service_SetTrayLabel:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayLabel("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayLabel_Ugly(t *core.T) { + // Service SetTrayLabel + ax7Variant := "Service_SetTrayLabel:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayLabel("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayMenu_Good(t *core.T) { + // Service SetTrayMenu + ax7Variant := "Service_SetTrayMenu:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayMenu(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayMenu_Bad(t *core.T) { + // Service SetTrayMenu + ax7Variant := "Service_SetTrayMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayMenu(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTrayMenu_Ugly(t *core.T) { + // Service SetTrayMenu + ax7Variant := "Service_SetTrayMenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTrayMenu(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetTrayInfo_Good(t *core.T) { + // Service GetTrayInfo + ax7Variant := "Service_GetTrayInfo:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetTrayInfo() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetTrayInfo_Bad(t *core.T) { + // Service GetTrayInfo + ax7Variant := "Service_GetTrayInfo:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetTrayInfo() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetTrayInfo_Ugly(t *core.T) { + // Service GetTrayInfo + ax7Variant := "Service_GetTrayInfo:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetTrayInfo() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowTrayMessage_Good(t *core.T) { + // Service ShowTrayMessage + ax7Variant := "Service_ShowTrayMessage:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowTrayMessage("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowTrayMessage_Bad(t *core.T) { + // Service ShowTrayMessage + ax7Variant := "Service_ShowTrayMessage:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowTrayMessage("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowTrayMessage_Ugly(t *core.T) { + // Service ShowTrayMessage + ax7Variant := "Service_ShowTrayMessage:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowTrayMessage("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ReadClipboard_Good(t *core.T) { + // Service ReadClipboard + ax7Variant := "Service_ReadClipboard:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ReadClipboard() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ReadClipboard_Bad(t *core.T) { + // Service ReadClipboard + ax7Variant := "Service_ReadClipboard:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ReadClipboard() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ReadClipboard_Ugly(t *core.T) { + // Service ReadClipboard + ax7Variant := "Service_ReadClipboard:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ReadClipboard() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_WriteClipboard_Good(t *core.T) { + // Service WriteClipboard + ax7Variant := "Service_WriteClipboard:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.WriteClipboard("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_WriteClipboard_Bad(t *core.T) { + // Service WriteClipboard + ax7Variant := "Service_WriteClipboard:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.WriteClipboard("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_WriteClipboard_Ugly(t *core.T) { + // Service WriteClipboard + ax7Variant := "Service_WriteClipboard:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.WriteClipboard("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_HasClipboard_Good(t *core.T) { + // Service HasClipboard + ax7Variant := "Service_HasClipboard:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HasClipboard() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_HasClipboard_Bad(t *core.T) { + // Service HasClipboard + ax7Variant := "Service_HasClipboard:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HasClipboard() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_HasClipboard_Ugly(t *core.T) { + // Service HasClipboard + ax7Variant := "Service_HasClipboard:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HasClipboard() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ClearClipboard_Good(t *core.T) { + // Service ClearClipboard + ax7Variant := "Service_ClearClipboard:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ClearClipboard() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ClearClipboard_Bad(t *core.T) { + // Service ClearClipboard + ax7Variant := "Service_ClearClipboard:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ClearClipboard() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ClearClipboard_Ugly(t *core.T) { + // Service ClearClipboard + ax7Variant := "Service_ClearClipboard:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ClearClipboard() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ReadClipboardImage_Good(t *core.T) { + // Service ReadClipboardImage + ax7Variant := "Service_ReadClipboardImage:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ReadClipboardImage() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ReadClipboardImage_Bad(t *core.T) { + // Service ReadClipboardImage + ax7Variant := "Service_ReadClipboardImage:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ReadClipboardImage() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ReadClipboardImage_Ugly(t *core.T) { + // Service ReadClipboardImage + ax7Variant := "Service_ReadClipboardImage:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ReadClipboardImage() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_WriteClipboardImage_Good(t *core.T) { + // Service WriteClipboardImage + ax7Variant := "Service_WriteClipboardImage:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.WriteClipboardImage(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_WriteClipboardImage_Bad(t *core.T) { + // Service WriteClipboardImage + ax7Variant := "Service_WriteClipboardImage:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.WriteClipboardImage(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_WriteClipboardImage_Ugly(t *core.T) { + // Service WriteClipboardImage + ax7Variant := "Service_WriteClipboardImage:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.WriteClipboardImage(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowNotification_Good(t *core.T) { + // Service ShowNotification + ax7Variant := "Service_ShowNotification:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowNotification(*new(NotificationOptions)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowNotification_Bad(t *core.T) { + // Service ShowNotification + ax7Variant := "Service_ShowNotification:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowNotification(*new(NotificationOptions)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowNotification_Ugly(t *core.T) { + // Service ShowNotification + ax7Variant := "Service_ShowNotification:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowNotification(*new(NotificationOptions)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowInfoNotification_Good(t *core.T) { + // Service ShowInfoNotification + ax7Variant := "Service_ShowInfoNotification:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowInfoNotification("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowInfoNotification_Bad(t *core.T) { + // Service ShowInfoNotification + ax7Variant := "Service_ShowInfoNotification:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowInfoNotification("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowInfoNotification_Ugly(t *core.T) { + // Service ShowInfoNotification + ax7Variant := "Service_ShowInfoNotification:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowInfoNotification("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowWarningNotification_Good(t *core.T) { + // Service ShowWarningNotification + ax7Variant := "Service_ShowWarningNotification:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowWarningNotification("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowWarningNotification_Bad(t *core.T) { + // Service ShowWarningNotification + ax7Variant := "Service_ShowWarningNotification:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowWarningNotification("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowWarningNotification_Ugly(t *core.T) { + // Service ShowWarningNotification + ax7Variant := "Service_ShowWarningNotification:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowWarningNotification("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowErrorNotification_Good(t *core.T) { + // Service ShowErrorNotification + ax7Variant := "Service_ShowErrorNotification:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowErrorNotification("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowErrorNotification_Bad(t *core.T) { + // Service ShowErrorNotification + ax7Variant := "Service_ShowErrorNotification:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowErrorNotification("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ShowErrorNotification_Ugly(t *core.T) { + // Service ShowErrorNotification + ax7Variant := "Service_ShowErrorNotification:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowErrorNotification("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_RequestNotificationPermission_Good(t *core.T) { + // Service RequestNotificationPermission + ax7Variant := "Service_RequestNotificationPermission:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.RequestNotificationPermission() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_RequestNotificationPermission_Bad(t *core.T) { + // Service RequestNotificationPermission + ax7Variant := "Service_RequestNotificationPermission:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.RequestNotificationPermission() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_RequestNotificationPermission_Ugly(t *core.T) { + // Service RequestNotificationPermission + ax7Variant := "Service_RequestNotificationPermission:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.RequestNotificationPermission() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_CheckNotificationPermission_Good(t *core.T) { + // Service CheckNotificationPermission + ax7Variant := "Service_CheckNotificationPermission:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.CheckNotificationPermission() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_CheckNotificationPermission_Bad(t *core.T) { + // Service CheckNotificationPermission + ax7Variant := "Service_CheckNotificationPermission:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.CheckNotificationPermission() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_CheckNotificationPermission_Ugly(t *core.T) { + // Service CheckNotificationPermission + ax7Variant := "Service_CheckNotificationPermission:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.CheckNotificationPermission() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ClearNotifications_Good(t *core.T) { + // Service ClearNotifications + ax7Variant := "Service_ClearNotifications:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ClearNotifications() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ClearNotifications_Bad(t *core.T) { + // Service ClearNotifications + ax7Variant := "Service_ClearNotifications:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ClearNotifications() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_ClearNotifications_Ugly(t *core.T) { + // Service ClearNotifications + ax7Variant := "Service_ClearNotifications:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ClearNotifications() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTheme_Good(t *core.T) { + // Service SetTheme + ax7Variant := "Service_SetTheme:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTheme("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTheme_Bad(t *core.T) { + // Service SetTheme + ax7Variant := "Service_SetTheme:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTheme("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_SetTheme_Ugly(t *core.T) { + // Service SetTheme + ax7Variant := "Service_SetTheme:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetTheme("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetTheme_Good(t *core.T) { + // Service GetTheme + ax7Variant := "Service_GetTheme:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetTheme() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetTheme_Bad(t *core.T) { + // Service GetTheme + ax7Variant := "Service_GetTheme:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetTheme() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetTheme_Ugly(t *core.T) { + // Service GetTheme + ax7Variant := "Service_GetTheme:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetTheme() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetSystemTheme_Good(t *core.T) { + // Service GetSystemTheme + ax7Variant := "Service_GetSystemTheme:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetSystemTheme() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetSystemTheme_Bad(t *core.T) { + // Service GetSystemTheme + ax7Variant := "Service_GetSystemTheme:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetSystemTheme() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestApi_Service_GetSystemTheme_Ugly(t *core.T) { + // Service GetSystemTheme + ax7Variant := "Service_GetSystemTheme:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetSystemTheme() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/display/api_wrappers_test.go b/display/webkit/pkg/display/api_wrappers_test.go new file mode 100644 index 0000000..7acf4d3 --- /dev/null +++ b/display/webkit/pkg/display/api_wrappers_test.go @@ -0,0 +1,368 @@ +package display + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/clipboard" + "dappco.re/go/render/display/webkit/pkg/environment" + "dappco.re/go/render/display/webkit/pkg/notification" + "dappco.re/go/render/display/webkit/pkg/systray" +) + +type errorOnlyWrapperCase struct { + name string + action string + call func(*Service) resultFailure + setupGood func(*core.T, *core.Core) +} + +func runErrorOnlyWrapperCase(t *core.T, tc errorOnlyWrapperCase) { + t.Helper() + + t.Run("good", func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + if tc.setupGood != nil { + tc.setupGood(t, c) + } + core.RequireNoError(t, tc.call(svc)) + }) + + t.Run("bad", func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.Action(tc.action, func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + err := tc.call(svc) + core.AssertError(t, err) + core.AssertEqual(t, core.AnError, err) + }) + + t.Run("ugly", func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.Action(tc.action, func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: false} + }) + + err := tc.call(svc) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), tc.action) + }) +} + +func TestDisplayAPI_TrayWrappers(t *core.T) { + cases := []errorOnlyWrapperCase{ + { + name: "SetTrayTooltip", + action: "systray.set_tooltip", + call: func(svc *Service) resultFailure { + return svc.SetTrayTooltip("Helper tooltip") + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("systray.set_tooltip", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(systray.TaskSetTrayTooltip) + core.AssertEqual(t, "Helper tooltip", task.Tooltip) + return core.Result{OK: true} + }) + }, + }, + { + name: "SetTrayLabel", + action: "systray.set_label", + call: func(svc *Service) resultFailure { + return svc.SetTrayLabel("Launcher") + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("systray.set_label", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(systray.TaskSetTrayLabel) + core.AssertEqual(t, "Launcher", task.Label) + return core.Result{OK: true} + }) + }, + }, + { + name: "SetTrayMenu", + action: "systray.set_menu", + call: func(svc *Service) resultFailure { + return svc.SetTrayMenu([]TrayMenuItem{ + {Label: "Open", ActionID: "open"}, + { + Label: "More", + ActionID: "more", + Children: []TrayMenuItem{{Label: "Nested", ActionID: "nested"}}, + }, + }) + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("systray.set_menu", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(systray.TaskSetTrayMenu) + core.AssertLen(t, task.Items, 2) + core.AssertEqual(t, "Open", task.Items[0].Label) + core.AssertLen(t, task.Items[1].Submenu, 1) + core.AssertEqual(t, "nested", task.Items[1].Submenu[0].ActionID) + return core.Result{OK: true} + }) + }, + }, + { + name: "ShowTrayMessage", + action: "systray.show_message", + call: func(svc *Service) resultFailure { + return svc.ShowTrayMessage("Status", "Task complete") + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("systray.show_message", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(systray.TaskShowMessage) + core.AssertEqual(t, "Status", task.Title) + core.AssertEqual(t, "Task complete", task.Message) + return core.Result{OK: true} + }) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + runErrorOnlyWrapperCase(t, tc) + }) + } +} + +func TestDisplayAPI_ClipboardWrappers(t *core.T) { + t.Run("ClearClipboard", func(t *core.T) { + runErrorOnlyWrapperCase(t, errorOnlyWrapperCase{ + name: "ClearClipboard", + action: "clipboard.clear", + call: func(svc *Service) resultFailure { + return svc.ClearClipboard() + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("clipboard.clear", func(_ context.Context, opts core.Options) core.Result { + core.AssertEqual(t, 0, opts.Len()) + return core.Result{OK: true} + }) + }, + }) + }) + + t.Run("HasClipboard", func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryText: + return core.Result{ + Value: clipboard.ClipboardContent{ + Text: "present", + HasContent: true, + }, + OK: true, + } + default: + return core.Result{} + } + }) + core.AssertTrue(t, svc.HasClipboard()) + + svc, c = newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryText: + return core.Result{ + Value: clipboard.ClipboardContent{ + Text: "", + HasContent: false, + }, + OK: true, + } + default: + return core.Result{} + } + }) + core.AssertFalse(t, svc.HasClipboard()) + + svc, c = newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case clipboard.QueryText: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + core.AssertFalse(t, svc.HasClipboard()) + }) +} + +func TestDisplayAPI_NotificationWrappers(t *core.T) { + cases := []errorOnlyWrapperCase{ + { + name: "ShowNotification", + action: "notification.send", + call: func(svc *Service) resultFailure { + return svc.ShowNotification(NotificationOptions{ + ID: "alert-1", + Title: "Deploy", + Message: "Done", + Subtitle: "CI", + }) + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("notification.send", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(notification.TaskSend) + core.AssertEqual(t, notification.NotificationOptions{ + ID: "alert-1", + Title: "Deploy", + Message: "Done", + Subtitle: "CI", + }, task.Options) + return core.Result{OK: true} + }) + }, + }, + { + name: "ShowInfoNotification", + action: "notification.send", + call: func(svc *Service) resultFailure { + return svc.ShowInfoNotification("Info", "Ready") + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("notification.send", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(notification.TaskSend) + core.AssertEqual(t, notification.NotificationOptions{ + Title: "Info", + Message: "Ready", + }, task.Options) + return core.Result{OK: true} + }) + }, + }, + { + name: "ShowWarningNotification", + action: "notification.send", + call: func(svc *Service) resultFailure { + return svc.ShowWarningNotification("Warn", "Careful") + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("notification.send", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(notification.TaskSend) + core.AssertEqual(t, notification.NotificationOptions{ + Title: "Warn", + Message: "Careful", + Severity: notification.SeverityWarning, + }, task.Options) + return core.Result{OK: true} + }) + }, + }, + { + name: "ShowErrorNotification", + action: "notification.send", + call: func(svc *Service) resultFailure { + return svc.ShowErrorNotification("Error", "Failed") + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("notification.send", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(notification.TaskSend) + core.AssertEqual(t, notification.NotificationOptions{ + Title: "Error", + Message: "Failed", + Severity: notification.SeverityError, + }, task.Options) + return core.Result{OK: true} + }) + }, + }, + { + name: "ClearNotifications", + action: "notification.clear", + call: func(svc *Service) resultFailure { + return svc.ClearNotifications() + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("notification.clear", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(notification.TaskClear) + core.AssertEmpty(t, task.ID) + return core.Result{OK: true} + }) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + runErrorOnlyWrapperCase(t, tc) + }) + } +} + +func TestDisplayAPI_ThemeWrapper(t *core.T) { + runErrorOnlyWrapperCase(t, errorOnlyWrapperCase{ + name: "SetTheme", + action: "environment.set_theme", + call: func(svc *Service) resultFailure { + return svc.SetTheme("system") + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("environment.set_theme", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(environment.TaskSetTheme) + core.AssertEqual(t, "system", task.Theme) + return core.Result{OK: true} + }) + }, + }) +} + +func TestDisplayAPI_GetTrayInfo(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case systray.QueryInfo: + return core.Result{ + Value: map[string]any{ + "tooltip": "Ready", + }, + OK: true, + } + default: + return core.Result{} + } + }) + + info := svc.GetTrayInfo() + core.AssertNotNil(t, info) + core.AssertEqual(t, "Ready", info["tooltip"]) + + svc, c = newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case systray.QueryInfo: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + core.AssertNil(t, svc.GetTrayInfo()) + + svc, c = newTestDisplayAPIService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case systray.QueryInfo: + return core.Result{OK: false} + default: + return core.Result{} + } + }) + core.AssertNil(t, svc.GetTrayInfo()) +} diff --git a/display/webkit/pkg/display/assets/apptray.png b/display/webkit/pkg/display/assets/apptray.png new file mode 100644 index 0000000000000000000000000000000000000000..0778fc6187abdbb56b8f7f4d0b9c78399642e59b GIT binary patch literal 32953 zcmZ^K1ymhDvgpMfg1ft3JU}iIAh^4`OK>MhaCZqVK@u#uySqz(;1>;Yk-$Yi+1>Yc z|DONnoH<=nUDnez-PK(^Q7TH(7^ozu0000(Rz^|{0DyS~VF1X8uZ4kYxz%d{Z~b2J zJpfRffc9vL@cK+;E~BOh0QfQh070Pu!2K&K=nw$#-~<4UO#lF)OaOq;Ij2n(^jZ;W zsUvHps0d(rg^>XWFgO6XR|w{>78nx1Uul2!$-$8RFRTW`^e-A%03gB^0RJxHtz1uLO`IGmluZ@ z4~LVhHIPeCP!Pz;4dmu#f2Ck|^ZDp*>dpSqjrN~H{zs0arJK2{t+TtW(?_bma!t*g zJlut8X#Oht-|L@ty4za)Thm9ke>Lm1fxy2tKrRkW;Qx!w(%bg`!}ho4pKSkV*FV*P z{t6S)RkL(+a`5;J9hKau~< zpzLaE`T8RNVHelG%=%BYIAmjt%%Yi(uu4sf6qGtTZE*@U;rK2M$Xi zKj8l88Zu1j1iPVUL(hCw5Q6!=Qn#L=H1_GwT1}(lTGP_mh=Gl+)*V4N!dG=d+?Po0A7t&HgxI z;%M1|-Bz6E?fSEtjb-0R!Z#z@7Vva`#Qnwi<$l>d0d$FcQ-+ddMLdb>aPAa*ykR_$ zhOx$x+cV)%!TPoX3X+jr-H)4sZiy_nZJ} z`q75pc@+q{m%>b@AWt4UW{7gTL7+qY+Ebr!<*dTDt9E{Q&X-mJkB_2^=M{>Jiv*tN zi5{mhFfpo#NyuUYE>rRqXr5~6stvjVo=7QCVL$E7=XafjmM7?mUX~!hP#OU(1$i8>A=G!iI*b!k?t?z&&qmXh_yL4TGcx(hrzIy$P- zvR8F7cG9R>E{4?3o~q)#7~=4~?Kf3m0!oam*WlAz56WIvZ{%%g2ffY8tKNNkGu`4A zSR|tP^M0v0?|vRuIZ*BN6H@H+ZMbx}kyb^LwcKS^^)jPsX<*dcot6UIN58{DCy>oD z(#Ou5rH$FyS*ckUT_`K$pc+AotjBHn54qb0fL8Q!)xLr=i}R)H{`kcC?Z-TrkU;Yi zy-$NwXBR0cDVzgG<=uf)cKoEOovML=@W4_1rI-7%m_AXDevFz(z1db5l6+xq?hYXN z;NBiQ0g@%EYH;k=CH0tBSAeSTx!~IX1Lw1q9<*5_B%V%0-LA4znC=Z{y4uvfN#Ww+ zV&}>P1_tu-^M||gz6dH7Pk)_Uic6=AFGHG#Q~0oh0F+s+s~jLK`)l$}!)}qxwn~1* z-N{l_KgeMJ7*0asp@UhjsM*}8>!Y`~=A6xft@|XMY^jMiTuX|ydJ68Ep6zwuRKV4j zxA;TH_oKP z->53Ks^E*ql)rb@WJ$9zbKQ}bC3%ACT6Z$P4Ln#V#Mho4=-WJB{NAW-8cy|STfv9$ z1)s>6(?1=-mN^;g5c$x=WCD)6uUuImeI43XJhu?ueYXEq5a&j*JcXPko!O+~~{G{0Ys7D|D^6Knq?D5E)qP?jw0kmiR z1vLBj1Bcay?SM8mJI&{_>v?70RH&<6kO^3ohICi3<*%R598M%h*MtXxm#To}yn2D~ zA?+sK7GDF&+>Qt+20~(==C^2!os22>589g;ZSN19e8%r9m?dfyv-vUp++gHqGaQ{` zVPSNy0FRkPBkq6nS5{oPr1fzYt1{Ufi3u1eZzF#<0FaUYf|7rI`B+e6y^F$_!&%yZ z?$mLV?v}Xtl5jLM{`lJWoPs+tTz20uF)&Phf51&q-!mEX8|QmILPQzz9kEKatF6lr zR{b7-j(Yvv?`wblbGoKpo$9pSQd_gM_A;zv*llC6_+8ULGGu$+dwMGapfm4 zJxuuf+P6V7&!byCTQR-z{TaFb zL9;$1;5hyX2#S@d?nyz{Z*0A^o=m*1@hGk z>AcqEM*Vcae6!<&>D5$Cne1Z#`i=3_I}w6wQmO-k?Ec>~`7&tP;R-tD{9h-uZEg}2 z*Sy0sbWn#7J2dCk*7T2h7fhB94*P*=8zgD1xJS_0ea_ho_6QACA6{`N%O7e5M%BkIb!Firr;+hE$s7vKE?4#dG`t6Q zh?`C+&fAtr5O;oBdjM;zgN^rLq@R54=>A>3u>=hpioVij5D+0FBj4Rk~ovrv6&5uWs^|=E04}^Ns4Yr$Gxde3tpC zL+Ru2)5aj|-F<$Zd$K-+5zd(Aeim2s2^TbRmmd-$p{am&oO4;AptGq^Rx2_SQZ-?~ zw5}C5e0h5D zS?!*Mk3B)T2+LwU#l^t{Ibx0u?hdfU!J9(h)ZH2r3U>-EL{rw~vqrH};&w*su(f7@ zacAd6UrL!x5AOWr_=Vb(pg-A?9e=Cy65MWLp(tKAY;gby*2)WD`l_Y@!~3SJrk|tz z(CGqx>jROJ+d$8D*E)Tg5{6)?q<8Uz1J8+332s2pV!7LeLMY?-9eF__0ag8E|EDa{ zaLztiyK+`9!~nuj!p`llyP_vQ=~}=`?>3^4PufuD^Bvs#X8~k%gebF0+Rukb30>Jr z9+q+@`cAjI(7d%zKI6LL{zuE*M&Nf)Z;qEMRkij2Z|}VW7o-6iL)3RYN+gweZWZia z`--s%hLqBin*k^KoA;DG{Z>18O1mFgP6BOAOb)LZ-a<_Bu)L=(e)eqitcpkM8XM^@ zvvi7uq>Nl(PIY%I@U^7ks!QpIg6H$HVcz2M)g~1V{fhB6b2g;6>x`I(Z9*Gcx?evy z6Xc986OC?vCM_#vsQtmLEGh7z2v=6SO3(0D?e3QdR+p3I-rGQJDcDjEFP_!fWl#09 zI zz&_`fX=g^VEnk4kR*FwoiMlyQc)Ss_VKYmdnChET2ejMgo+NlwI`+Pp$*XU?32gA` zx&EA1#L(E-SPf1RqL}$>Wn~fQKzE}<^SbBoJF1)WS;T%KjFECA@1|3{CX!n-I!=_X zxB0GPQEwer5*Y}M*&G|{I7~=g@*rRQT=M$TX$5Z~Cq=(X*|lF#&dvg(-tOFK<#1dt zdJutxggpJ|&x8|@><$eB$J47`n7<8Vn9)vG2oerC=U-VX;ji9Hy&p_G%~O5HU*gij zO~iFJW&k($h3dVY;5C+@9@vTX+n8mhglj?%?~=&f+?#ywb;^>stJ;|e_Mwk>?k82Z zWMqRk<3DIGy<_uQDhe>M`L@bNhE?Gy0=sVaa1&mrO4j~Z`+~EK(e`|;11eb;NI9oB zw~T-OYJg_RBSj!i4;!Lp#a01rH3pqfqc_IA4r+P-L^%1K`1&-IuzS6F^e4)^t(QT7 zQC9CfOc#s1#@ew+uwCTf%=TSI#)(o1cI4YbkV53u5GZ6@-)MOB(xy^Uiy}?lN0+;= zm+4zHA11H*J)_->oc~Fc*CY0l^fRg-`rU{+0ev8A_#K={=ik@qv{k#;G(Ls*z0 zx4R+H$>sizoM>FWS2gQfLl2(ROMmoVf)0bu+Tm4kniW#MwG!tqW(m*0`8%Ixnt-dN zDp9Y+vZjrNVo%x0+g(O!ynQFcD9I=&S&VrfIq|pwB`R5drnBoykyPCovHgCsCgKh+XC{0otsU6x?}Vz%Z2GSKbb2o5pH+PO zix(s$;(^cv$nWiZ@n#c1s7STGw?vfhq5;WQEL1*l=Y-gPSm+~571HvvfJI?K1Yj$!47j*#spNkQKEC<3=d+{impIuoMyS#_Vz0l|GS{bOgwVx+e zU&lr(dZQ_Lq2CAXw*nehtRWKp-9fo_yw4;QR?Xp3QY{IeNb_9sKW?t~?{?m9@!es$ z-eh@k5TT?sPhR9eY|!R84GrR0P2lr9vM(DW#@@d}>x2Cvqa3==>;i_6$(^SZK4x(w zBK?RHfU?OyFP(#6OgkLo{su!s%#eR|OGL967a1sS`-+u{nT@Q6+LoC(AC`TBe6#d9jzdh@5I z1&K26HayBmTsi&KSXGMTGCaDA+(6-4k|X)CWht}P;Y#69<7y-3xOT+;c+cPKtv9uN z6;rj8BCStT?V?=Vpv&sqcF&zSR28$+4>>*6xyC;mcaMs_OM9YmGdoYUO9Ax_*!;JV zkQDTxKvtte)m>$0?V1a&#lxxD7@NnovB1|*sMEbI@A>3nW+uBuAo3mFrX%6*^6%)O zHI%$r1k@;tiw>>~k~*Yh={w#~x*`zCpB!SZ0u0`~M7sNwkeeN>)}i8yN!?}af7OD9;THKgGT@< z#2ON#MyZXjdrPLLxW)@<1v9X}$I>b!(0cA_*M6IwsmKDmUB?$&b~_L#hZ7MPhyt+o zi>vICb6o!Xbm~G`3$pK!aO|==VI~?diWsaEaj*xEzazVp2(>>}qK=tCDMLg>Wj=Cn z=k0R#7|HNXvp>>o+Pacd0CUL8OLFDX{)u7(a=D7tC)Os*m%IkqlKqIR}<+eN&Wn_@9xw$D1upXCKZkVX<>~)W`!#%fWBOw&K zXtDgDVy;FTMnpZn1#MXE*at_+`ygwyrW;wl1MQ_m{%om4_M}=GK{F{ArM?JcYW{63 zRV-frBbH)CAPScxko;WrdfC_SWBcwI&|t*+#T}NB+-n`fC94DVsTTLCkCBUcgVd(d zsPmpo0rxq_HlT@Z+>72yUrc|qPiw~}7=!f3xKscA(RlTWI|aqfCs#B79AN2!3*&MB zQOjn+{OW3R_ot^@0lA)v()5gW{@s4o- z^w^V9syhtU#0B+OI$EL67`>X_63)Zc@$Wo;9kCoN0RvpR#}=;nUkLW$(-H1Rm+ijR z2p)Yk@9wx{scq6A$f}+S8W${O9nLTgfChM%+*0TCxdHM{6HH42-*~0hC@IEP_wV3z zf5vrjn~pc!^)mInVC`{nEI_26Z2ZPRyxQVujyAm`w>>!#GkJmP<=*>vAcxeXKwx=i zmOYoT17|5!?w(qt-Gz@Q^fH506dl`eBGjaJ+k@iYy10JOf~({mMy4G9p}X+N$`_c| z^~jw^@%#AXlzhm3QFKVwvfKN2?Xbw;hxea+Dwx~_OSP8(3N>y5(`n`~gNw?h$SAw{ zt6}fPsQAm*MQ61A;z(YG;`FiBYuz4CR@Sdr4UUi!*!~c^4E1oE*Ln{vdh<@~lIQQN zN=SG54NZeCu3+f7^Zkvw$f#U>1UpLH{P#RWORqAdnckOiPUGz*ts6{aO+U5bUM$Mq zSQ`&B6g)*8ob?`5QTXstV2K2kA@#5L)#6H5i+2B%tNG)dH=USa1rKaKjE3VBo;tte z@`2)KZzMcD)eLXr7iYd;os;~6_J}#1+6dR=8DGfSO(G#7BE|QR;bsS4X8XmCyqP3T zg+nU&lN8JFqwzyD?-bShC5Bf|-^V@-)o|Isj1{!F%uT8XNV1ATZc*EO9CzkmdKsSSrVJ+_|2G}S8G#HO*7^bu>JD8M`zxnvas^G%S6`nlof~s1_d|ZT8;<~2`o(;4)P`fwtEs`$cVU~ zQv~cf2qd1Ik|O$_d|5jQrORJfq|rdpwq|x_r=!D^-HOXTu5}rvW|r_lO3Q_!&Cnf* zjO-SKv~x)RUH9|hZC9t(`Qyf2x5DnQ{dXPP_y>*mi$BKPWn4&-rFKd7p&joD(ba6< zt>V9tE57`k!ZglPf7(;_D9H>owR@a+m(dLiGO&@&r*$nUO#SlAHN3OP0Nl%%g&ly? z-iE)wP1rfs?z#vAK9Yf*t6wJ6kvb^!8(wSyQ#nu33F$^ISCenCE~L#;&4qmvGAci5)HNIlNwMy zJcH7j^o7EX+SC<=0!>{x35m-ILE^Cjciq+5iX2dTN~u6T4lO3mxxQ zV+>Zm^*D!W>-Z;Os)juN5mz)|>mE+dj^FmeP+Yc>;`hdBEtf>$@IRu5C*Ph4$yT81 zO-LKh@9-i#0?3UIercaQ+LFYI@+4L=vrh~)I>!89Q$%HC48fnDl95d365B?%?Tw}e zZ+FF#$Q({gTCWZ5-sOUzq?HMO&Jlcg>G5l56tP9s^`y1sLQ+C_N&3Y4zrl=$Xot2^ zBa=ov!%1=uRH>+1<%>KOR;Q?Rv*E<4Z`>q zF-9)N_gkf~u~`-)8b|v$hzyb%d1>FcWe&zP)CdG%D3(5df(26M=X9ks94_y*>@NL^ zAdKD!zimLiKX2anbel8|t?IQr0XW}!y^WE(4u-=N2)cR}6*_;r;VG;gUTAVOq^NC} zpdD!3zuT3VE&(d%@>NBN$>=l4WVU=~h~4K-+}SsFNX|AsUnXpeIGvH=9DH|)RJ_zc zf7`$^{v|sdB_}*WH9r+cMr7|+)VMNHs|DlTti=VUPmtKjd%dw#G^L<~m!(RhPYVa# z&gW$?PA*dk%_yS6BK*%?2cj=fRle{3K@IlH)!?6@O@EZ6GF}YRF-_nSu7=P+`Rgsv zfls$<=c$aZ*|O+O?*~E3oqAIdp*FMm#lSf2!0DC7aq`>7H{>eh4IS^fzE$TH#47o) zaWHvr*)MCBj9OGs3q+wx@59=Z-3sCYMZaROw=zk9nZ6vLwxFNeq)Bhf8Kbl34#}GD za*#*U(`ZyLN&A56ZjY=z6?RX1ZoD=>og)bz1m0~&_;KRg^&|v7-lad?Zk{i+zYb}* zRK*@^j?ImNzDcu#fgR!QE*NNBK0$jTcNNH zWKg2;C;vxjq|ppN8IFu0V!QPnJS}utb5mUdP8yn!sBgg{>emJ9U2I<`55t7l5qJa; zymPkQ1cfA}P7dkRCGm}{X$d``R6n9c->oBC79Ok-Rk!43v#mr(X*S&l{a56J+~DGPZ-wgNMXo)4-0p| zkkZY-kUEN`^1+jpLf&WA>Xu`VM!UEn;MIp9`L!~IQR@(9BZ+;%6h$3_FOZZ?qt^+6 zZ(_C__d={>^wKrF(mAZBR0O- zvdTT3A;br~+=CNEw;Cd`uL58;xV)A6%2~+AO^;fzlWT*GgQ`sOfSc7v8Pg0=^P!q~ zB(ieJ!194r$OZUZ=z1z#o`AGE)S{evwu_Jw|74mQT@_2!dtlx_Df`?W;K5MG)Us7u zf&qWloJ5aSa*KBZ0dXw|F#-C5^d`#7zVjy!NJxLaDKEEzCQ_`{@Wk-t-_Wxp_cyzE z!8V}Pxxy~#e;>5GCoiv40LdIllq(RI#LQ-*E2sxgQ}JY~1t)>$xw(%5rM?m|_*mbA z4`W;%D2y~1_0{!d1?`^s3>R@HpH_axO@pDLGfLm z)MW_7$&)z{(S<8`zT3AoG+l6=26T`d7ouf^K~DK%<;vAIKDWXi`Js1%{B`y9wkGgt zRg~^CT918R^Jc_%*=vz5waR-M1;a;c-8jHXLfS^JvE5MB_-BZ5066>p0SP#;^G5j$ z98A%^_^Tdr0P*QJABqODhVNV=urb#lrOmRkrQ`$q9fp(x(h7%^QJU}sN!oCyiwjdX zx$k^g^9qen&4n_sY(cqm_Dl^%p<#jo&}0e_vCRz@y1_McdXtqA6#_2M!Z8&3W%r~# zXXxh$y~lF-<<3ru1o#l7u57wJ8Q8wH?nY_G7+^M@9#TyAQ>R9GoE-T+)jpx<60}BM zGgKZu_1LH|B!xa<-^pxKF#V;Re#C-?A+krElP1iMhEPY93D0(fq3|ouXY{2ryqw>a zkLl?+yzDNe#M@xfKFB+vh>>Shr=eb=_qLt!^~O6kySZK)P=7VbZqL*HNxf6*=GvhKK zM5@tK5Tq@(q%A78T;RTfB{DQ35?4$M%vu0zz$DDwfJ{T2PbVIQyle~*qK49`;Z-!S zBuJvp3rHC0K68|UbPo@&oos+!;@*~R8<|7Jy_#uy)A(O~ULH0n^B>!mg;Ac@9VDRb zT$mhls%rR6fgwg&*W*7tlXC0KmWW_@Ruv|-w0r0 z9vQnzk@zt&G3bc=rtter{IW~A=H8x<(s5|IdaYOQrKs*v11iUh2~U>@ypP_A^?6NMAide!zah4~Ywjb-L@z3}AjjOnAU# zqjv^F-#5_=#{h(>)2A{z*&VHl!9pW?YtE$=fBn?$sIgcUr-{5OI3t0uuQ{STA5Q?> ztLXW?+{JE#Z^6M(EB7voRw1EcjyVh48ip}kNr9F**(#7C*O~41A1vA-`*G@{L{($%sxc!mVSod~SQ3t`j81AjcAp1NThx z+J16yHenhXdLwd~;`Pv8k#9S*AyDtv{LO5``HLE@*2CmnJih=9uq1Mscb5Nbln_s5 ziYbwV3rw(!N?Vo5#|kJbyr_%)$xnu+LXh{;f2Xd5;49@77eIF1-DVJ*?^y4!1Q2BH6~jJ)Uyf-?E7A z^84yMzqvn%|Y&kY)NIqs)bgk#5h@?wqx)o|lc49P)ByDsR z23&tY8i0_(qPIV>*wnTXT4!Wp87Y>As{Xc=`)))3ta zhIPCv!RPRDdbiHl_Z2QT;z*_^skOazfJZzkNy)N80Gy@@sC`U0kI>*EwBgm;os_yp zW6W{BzD@79ecuac_|?W4`+96iW;R$W_3^Z)hbk_=;<@nb`$c7Y-1I7e9n}?B-TpN3 zz12rBeEm*{J{I=E_&6$dDgH{HvP}e8XR&%tIL$XP>6{(D>Z3pF2C{7A@>)_goEVzv zr4n%sAhrm`F)>urE{=HF_j3;XT&P4OrlIG@r;Kr%G#%OFol=$4pvxEIvdr;8tR zA6mZgK0MOQlqZv=yN67q7zd9frgc$S0Af^M9H$sHVMo6&(OCsW?cZ>khw&5r%LgY-T@wVIyqJ<@w&E^ zmc*n4_xK1L@%3Q3Do#2&iDylnG8JU`Lu74=l3GsU0@7k_5lr2uHEZrTDNn>0*sl_8yf3`7?p$;Z!G#DN*I=qGF*B^x!rEF~T^!U6k-S zZ;_Ew3)(5{;b7(zg6R}lg>E@DZ>17u&qSnXlL^NH6i+D=XOBrQCPLKnAY#za^8hIz z5!GKSm1BGyE4n7J3$QGlOHEswW6cd=<^ur;;7uRr?}}!UZmh2T22Nn>z~|{LOC9>h zxW~P%qybfNq;DxA)jxIXRxN4m3LGLzz3c!gVL7Xh%w&RJBaj%>Vtn>Eg1$Zjkclf4 z`(RHH6bB1WVKvUsiqNu4U3E}@Q(zjELnTK;4V<v%FV;mPFjhM6xwGj9;NGq)kC4zXZpK}rMmO)+~g5P6SWf^oxuSU4$+ z`JE}hlA3(kC4OmP32pb7%DtJ5Z6&(=G9vHgTcjf-|0l+=`SWx-CJUy?c$6g`nU z_FkX_aWs`{ei}{7H*JlX&L?T9mrTRO$brB+bPPZ5&prLeAtFk7|KV=ks)ioN@;=SUIX zx_+!xkFut0U1R^H$?I?~r)V;yCZt8pStLOX2Tmh}nkgoNh*l&C<1nN%iY(iBOP!%E zV}S=reRomiE2??6KP($?m)dZT@N6m&*4P>+WYo?oh+fqnrS7N2mp@gDc0iim=ZACA zcFZ)H)$I3%+SL4iQj|rQZ~E5AfnX%+LOalJ+7l$w=>h}Cx^IHRaeIPS4cJpO14UOJ zqjb7i06sDMyP;}0+cKH>noyZ>^SZHt3a;$L>)_!5X1#5G(l8(F&uTjH9OL+((T6y* z%Bg>-4$BCYOQ3Dy44_FsNn&D(+bPGCsbcr7p^W_P-As(X=hGSz%=+_C<92NQR4<1A6 z6jcpTsWO43-hVHC#lx~ zmCL*p#M5O-IFQQhAx1!RRmlEhDl?~QqSfEcF*}O=GhbC5--N(EX}zPBHy`b1SQL3O zoM0luc(a0=V7c8r&V((4x?BNg_D-2)L4fDX>p`6wWdLSxSK#&7x=>Zhjxek626c-6 z=sSUO?#>gcnppQLx(UK@|`;aL1@rvJ$=pTn+id@W(<-;$>)BdZ@$|Fj-XD9L39B3Pcc zK`p#oTi%$DGmvD>H96|8Um`J(eo3REH9ABVBQq_$_z3?dK?4!VN8+fh;)JXxi_d;Q z&TbMOlZxb89T2-S_P}vYlxGMxeqd-M^3L-MZYQC6TxG}}La-yxRF-tqEK6j^Sj`WW z0)DZc3Fac#wMfNnMc&tB5Q0Cp*>CFig8C9fFEyndPM=Qnr>m12G|gOK3=aSo-<*yL z8gOd1y|#o{LYj&fLmyAIm^l+iiegB{!G;_@OP2>Ll|5ud>|9=BuqSLox>`nImW8BT zDyjH{7YyV6f`)QO=wBqS8ZhlmmxA}(S5Df>N+XNL>fc+W`iW zkyKH@n_A8|d!oY*PBd$SKsmyTuDU{W>Y`LA=LNV|cZ_$V^7VN50wR_v*Yof}Ka|(R z65eFhswR%Ydn>qz!~2nUhwxj0?FXzMJv*cyauR6RIh%7_COK$X4G4G)N_skgDDkEQ zAz$o&)w5x%7fZTky?>2QA=#BqYgZ>W8oqp4l<5ihrabzI`p>D|>~C@7m=pb+KbJdS9oL8GK^2BZI)KCJpWp)GO&6L~ez<6y@s09p<0OnC(-q15pxY z>tg#%1hHLure=|@%)25tZn@M7T62|`DZJ>P^I0}v`%mKKVPZrkU3reSb40$f<-PKm z)M-kVZ;D+oc#hHEQtBLHS`b|Fz`|~TC*feC!bBi#O9+OS&1+<0k9WHX_W77~o19^5 z#fFw?b3@kC8ZlqAchH5}L)D_GdNAr^c$;ELu-K@yGQq4N!86GX5N53YH)Z}W^%JP- zQF6*8afJP3-N2YRog8*+W|0=aDBJKDynCb*f(Zr{Iq%4aEOnBoY1M`bGQc~XZkV9> zb1c*wGCq#_MmcN8p`r1ZvJ&9P57dfSV&)$V+YoAC$k?n;3ws4$PGTJ4S9cPjPVOCh~-&oi_97Gp1a{A~cg4J=*>Kmf-D|XUHYkw#h`Ng{5(=H8# zt#08Da~ee70tRi%-Lp8tqcdj4OWY@E(5`&;?_``}*TLB1Wqll7I^Q+ujO&i7T><{s zhdVLA^t{1=IL6=+5y1Q+x!?5MZ^h1Us2r9o%odo&TF#5!A@v{7BG)vIPT6LpuTur=i5i!> zWh2Wf8YAh?FEE-mREMM0K~@P^_I3a2l%fVtb!sV&$nbQ@Uts)(t0y$D4W5)n_CVpP%YUv8r>5L#Id6thYeV5IY zw=f{CN>NuhaQ%!vNQ;A>^+QSYl67iKt))-nBY{wCRqjv>FU@#!$c{$!R>K*E49UnG zc2IO9n>_zUBPM{-C)=l_X=Zegq9NK;cHCD~Ia?LJ3tjRZat2tvV34&o47{t9nPv|8 zqL6PZyHT_%N7VOu{j|xEk%H@d{{-i8w4!IT0geVxD#zd zVLRt!D7G3>N9?UWsp-nI{nkSYDkK3lo%S76TVH-(Fv8T_^NbbWH^xe{y~0$hEPY^u z?PQ#5NEburqgz1Eh2S_G(Nl2qC@{$J9$8Vl@pg5HJMG|=K@kNJK*$Q!9N$}6oJfOV zT0lM{l3rPI8H>j|l_%*f!&>x{EU5|1BJ)m6ZOb>@gC9eZ4B|czU+G4kGQ}176C`Wo zU`UX06E8PhPV=!{>Isry6{0TQH-yED9iVx5LVo!z5|k5Jl5@z7l<0xb z&bb*!T#Z|*@g7~DdBZ|2ej54G8>yKy&YG+;RGAw64iucKGOFOhV>FLLoIB=0=FW6_ zNgmhN$j0wCXe(nIn^8gz(iWFh$-A^E*WfpdO<_o-ugLt7%pZ|~cO={)c0OAIf^XY> z&#vh;(7psCa|?g!j9O}_wRHQP^J}dO7-mLNu|u+uty^v11Qi_z%_n^u!;#=rVdN2y zRJ+#;}U68^D@!?h_Pgzndcm^|wUqO zjB0X!<1gdv2(mw*nsiEVe+SMouxV0yMv1kYtq9F5Vao51nSW z);+5^+)ON(krfeg3CA>mkUl$sR}cDXFOORatML4FH!Uol>1+QlQD;9`!j;g=(43yn z!(#|ik6fQG7C4zMEnDE{@Gks{UH=VX6sFt){^%ZD6n_ zL@1mS60shTJ8&E)$|%(8hpP>y*(ML(iW;3`Q!dNjbGj%**AlUeHbCU4z9j43#mTv7 zO|})$KE&0q7)#dcQ-uT040hPo3Q;YZL}dZX35OctS+ z3XtxPeY3I*Vi^u%Yma%Cn_rtl9M~(?!8;itTS7na9GWVlEGB!8n{&_zsvk^CFjY<8 zrxlo-kH8%2X2TR5J@4xw-1WrP*=1|pZFPE%2N6GLVIYYgq05Zc&txzcdjQfhdWB;M3AQ-7 zpp4Gb`jw1xUzhV`XgZ(k=Js-622-D4TQ7O8GbSa*4qfErxPy7{j^bzMgh=2BSN&H@ z@U|}_Iyl;O+w?pwOLX|$*FK+=%I#?^@TERGsEYf59>@-MlHDGEb2+6XF zTaJ7=h#Ed+C@mjqw?R{^vUN~gh zd~fRvC^T2>e%&f1O(rh31QC?FU@$AZCct@|$E-}{tld|N=tTbi}xwp?(;TQkvp zppi58MYASE^a_?t%3C~lptxPtjW6P3dnLTOg$B@QAC#)pNqpWtdt|=v0@}Evp}$-2 z_vX8%sIdDu9_BSMgCc?t53*-x+xaZiKQNyH=2+q` z?aPlVj^5m(^RjbmEb4MXvAz{1PV{2GN$>qhaCY!Pu7DQ}<@HDd#s>h+i1~U(#C0Jk zT&Fl1?ONYJvt*@yo%+^3+ z&hpr_b#*sgx)ya_Wt+nxqtgBRfRFDqI7tq7L8zzG00Y%oI-xtrx+4oW6a7;US_v+b ze_sXIh0Ow2E5AlAhl{T20PQi4dT;m2)zYO8pD~yAip?6`eH^|-rsR*f1%_e-GI=iL zk4$8SfY{YX64YFvkGxYjK0gwY2Kw>DDGFV|F6fwVY%mf?VOoZRh%7G`n5R3hLtZ1E z-y5Sl!Yd*lFe#TGZnTxnl|ZUW)=%s=mbh z-`7Lc z!k^Uzkm0H>R}uwJN;*goTgIr^M|BATxxUM$$`@0o>su0m>AkQx*a^+9Y*{XvVrbgF80_(69e8Dh zB`DlzP}V8}{Uf}O;CqKlXgNqd98|-MU<*Dl`8uCpn0#MPb_LDY1&%HHOAs4!rAm*V zT#kf{<#S1#`D zsA5UIG8BWF)Q^u>C1Y=CDYvXt!kre5%AsB8kb8*t@oHRBB|W9weJaR+A{0SDEV*Pl z&mVT?vubi#b787SsVF@t%Q`WCyg#FVe4#^!LK`RL*87bzmR$Ecb9a8e;5Z}&L`iA%WnkP>V9JS z-RdhCEH|vN#DRzuFf&laaGT1fO%`OIRRVb{n_R0U--RBlX|Vi2vPuGwDk{YyW^^f4 z&1;EWF8SE`!8*JZQ5&RXF!n5<_RL^ad1M}q8Q)brKrr-j9EjGKDqj%l@_ zWn$xGod%Wry?}E+J$sj|rYQ4ay?~8UCP7B3HM?%CUGDhJID7%26g}8?8{}UrV+`&cw z-}=_KwCGy2B^`F1132)&1D!6#tjKz zo}Rc;saBH286A1(9Q22@j2fT`X_O??Fpe_79#g?Y$;z}s^QJP>1T;-rjZ=r6je!Oc zO*?DKcryAtoysap3IN&V3}U+nQunt;|?Hj2XTJ|fIp7a3m_0O zEJYU2V#?)YSU9Vbwoo>72bOxJNu=%B2k5f|-CBt%34REr4kW^Tz9GgwO=myWXf~dn zu1|j-McEII;H8av9TaX~jMkBPlb`*bR=B3reOK2xfY3$tXuMUyO&pi)P{jk*?Ca~V zj*pLSeD`_3@fZK_4_7{~w>KVXwVKbLZcQGH8{i9J&o6}99tubw`Uep4 z{j}NoL6TH|Fcmc)k0vLdIsNq0pSk7MJKsAvIQW65pMJW5{CYxOU|_9ScZ?ERX{9?C z0lUrtpd&?FDwWn)Gfk#pu)XM1!o(4XGK)GvJphJt?_=_Je&-!eA>i+OOc$>$Tegf$ z$I-s6R^!0tMC%1paeN49UdSJ0z5p2<1clkRR;%^*_Vz+MtIoKPN2AfiUB@{Hch?fd zmU5yDU>9SNKaeM(ft#X8H6+clldnCb!P20pz>G*X0xZ%}5YbA{ba6D`Xq*fwrx)i+ zsp36e1h7oP#Xq?V{GJ;nLCmLp3E3Y6#WKZ0JH{|_-p(<|O2k26q!vw0O-0l7`ULLg zKZS(H@%tf4@dJqaKH@*XcTC|TeoiQ-*3PD>Q6rTWWq;@ zoJt_wTDx{_lZtxIIp^#IKYx!{IU2w0vYUpJf$`@h^~Ql?&E^X*dw3xx3Wq_R4}}sP z4Atk{<|g_53Ek%zPn?y||HiOT0L)G3GM2^<{oY&Q`glrj$&e$twln5dk5^2t-cql{Wih|$Jy64CY?bVO5)T5V+t z;M!#G{)?S}(wmifeJrZT$Y${dx{l)APKA4tW*Z~Z8cg4jWxy(frB^{_VXfG2}Tj| z^P%VmpsZ`4a06U};1HL_$%}U=97`Q>HJZ~6IExnengFG^J5q;1NaAd&K|L0BictOb zKuID_`D_o(hiGTijv+V?79zWtpUlCzmJVJahNuKd&cS)tmOHl^x8~EJf#VJi1Lq^P zpCPmVK=>oH#rGlPF;w$0Jaqd3D)o4^(fH|!C!8>=(XsA$K_`K;md4WlVR7NiS+>@? zb?f*XFW0yC+;a~}(3ZY=r1Annb4is1A#D@K6Y=Dvo(SZKy5w)yjR0tr>>GIa$$_Ds zaaw^@@?yVH0eI;SLYE4EQ#b&x4FP5&y1o$Wd*YB2tFs^>l@Ly%`S!QJoyyGLWB7dq zzua}xO*aisOtkh-lGZ`flamKeM{o>q4zRUDu-5Q=X!ijyn0>G|IXE!Tm+E4W90h-$bpf=xDi&;^v7^=Pr@)qneR*&oNq;qn{;eX zw67GA2fz@fPMGoeU-SSXMp)JO6(sG`O2<(*ITqYImHB70mvVHS15oAo>Y;~Du6yd4 zu?}}J>GZ7I7u-8CJonr)inDhrD3OdSJztX!;=>=enm^)9xuwjyfX4)-Ifcc4{6AtI# zY(I*y*jcUh)UcxNird6m28ZbNdVLZG@l)_{xP>8k{C$iAzK_w#58;@8SgXcQVAS%{ zYc4?p~H#Az3e?94OIC%u+5`ZwWy`H2BO{IH)u-wQ)*Fq3VZ@kPM28{Y zgOTw|kUkP^K}%*i#Ky%DXao7S0D>{F#p&e4cXJNaf7p{GPhh_O z)JZ3u%r%i2FFHOq2Y(WC@FAIlV|122^2j4CI%_&V2qqY=j+=vE+AIEk(ndZd~)p)-5{2U+;d{$GgtdeEX5W*US4uf@$ zyA9+=y6FWgfJI6%MjGVzM3VV){q46OluR|hi3P}2SUqPR((lF5fM;o9Xpbp)V^@7D zKIV)wPWXutRTh;sl4AKt$U8ZvMs&!`GdBk>6ZBlctl_f^dV>(1K^iU~I6Bnlxhm!! z$+znq0Q(PjQyH_u|y98YM0@on+!-6Gl*^frmbI)Bj8IO-oV?lHr zZLv!4aP*6A*y|i5Z*%T$7aS!IE+sy0!sGrZT*-=m>L(RkLGpyU_te97?0cST0X@xVt_(wPZ0+t)XM{?DYui z*qr8?BExV6#s#ce#Vv>`TBS6neSX3ytp>ewqYng5TiYw zCXas)z&BoA%2G|a<=v%P`VQsxXM*6SpFTy6*GoHB({|_=U|krw;DQV4*fPK=fG9Pq zs0!}l;uv_R<1;_(xq$gU6ANX z2cZ3eJ!yCrU4BVVc3pSo>#C1L3B?^fJaf+*f7U|um2RkyhmC0~(C4LgS4x%uvL*-) z62{NCY^G?nVr*K9B0k0FuG)9$6ab1Z{e&Cd&)i`S{;?OJs{E$UyRA=i^`R0Ifcna| zS=L`Kjcc0-ouJ7Q%gVR%D^GWZez+gnhK(SLp)vP9zM90443&_Pa( zTj|f5aPd@PRrV#`U-K$HSFE{BmP}CbQLg+WCT*eZ!O+H$;KKmYiNoLyN0%n78!yoh zQ!(ct=?mBt?(NP*MgZkIFbwMwjCQ312=4<_-~g~a6_+yt_Nrde!yNKDppF_8-3=iv zSD(+rrp2J?3iMLLXIdQrvHW2zKA1;j@Zp2e%CvPfw;A4S5WxubOvB z6G%0>avy*$eqoab_5mbgc&^=pp&UC@?)NgJg*EWmH)v$B>)dnCo#aLjvOuCczq%NaX;p4{YdTi5DkPY2wX2-Gb%V(6B0@M+JS&hT0Owry)$>(|Hr+mZ1o z9(%Bx^`;?R&bD$FUIdC`aRA+R769wll@0)gEWIbjU5R$U0j!||V5d_1<$`B$Z+OOU z{_w{>f%nXlZNK}w=lvKsKKF?rBi?Y&J+6JWZEGPdaN%7-fApAi)Ey%Lr_`$3(h~AV zmEAr1;tNDN0$9rD;G@qH8a%rT-Hr`B(udNI*I$48K~XY&EX@Aw@iBSpxSF+(8zpi> z6s{fn_4Ub3g1aCI=%%YH9RNB=I_km;FP!@Ok9_QDbmUWPW5(T`mE$$LO|eX|*wQ5h~qH6 z|MfJUS~oa2G{o}>@P;Fu0^U;gje!K($WH;%9XvRM!gXZHc(+{L>Hv5LcP|8`=~Ld5 zXT6KwV`tL|u=DHq;gGo@oDp~`4B^zVvGJWB{rD&E!I6@miR0e8LH9$jh!W`Qt5Al< zQWzC{$3MHmzUWbXBwA2((PgjuQ2Hq0@nsk=EvbC~s8fO`9@J;PULBnD_19m2Xq42} z0sltqhFUi~JUqhskB^T>W1~1a(4W^xm|hyEqr?D-$Hb{VWkgE6#KW$20B-dfy^}^i zp#wnwQn}?~2eWg1$WtYu^v!f^Y?KE>;*g8#NMB#yX~3U0IWhjsrJua~i`WcuOD$>M z3FUeWLS#Sq1}e|{-@liy88{CRC+SW;(n6is3Zlr`25gipj?L~knhYyr@)RH}B9dq? zx>ygL({pH!^0QCe%S$)k{K8rzJrQRpora!13MZ(HPzSKJIoi2%XB`bdbH|BsQ5g2K zYSagF$vY7cEN?^N(J~i{gJUstOFG)M4ghy(XYO(QB$b=?ZhMNIO*k(fM>3{T@Bq&S zoBqY<^)n#+~m_vK^skHyiH{bk%daHFZy7i2x z-a4vxU|@)K;US`v2myg|!!~e8-s6SEr^9NiMul=HjmKDEEyw-gsCmu3{lCJ$rpv!66gU+M! z$kHx5lAZ7TLOvwUU$hVzCSnK)yJ_+?S-FA08w_BYa1zezI|-}EW1qb2ihHrA?3QZW z17~pdBV2%_4BoX+7LF|B=r6*a%}bwMW5shUt@KReD6uNW0jz`U2xM8*5n#egIIqbX zUI%v3=jN|nYq1Nj*WYr>;pueqcy!EZ%``fCcxbSn{m41}=;#>d^z1WKiI05K8albe z$lY`QvYJtrzwph7@hC}~4=}CgAt~$4IVM3P!QhTIW!pT?;kh?oTp#`arCmwue=|R(r)gpCAZ=*(C>jn z=B9K8_`o!N+n%+326_i?_4kkBR3JK~0hZZbnbON!fbat86b=QN^+aAPV|U{yLzvTZ z3T2N9+xpqhz9?z5PDV$a3giD(-06o9k0xNCoYOOJ^r4R0q$ud0wjyQqY(o{DL=#iL z3ceil$V(4D{9#i2lZD$EmpG(r9RQ7*N`-6u??3#puhptGI)HYq@Eu6y@I1Lt`?my7 z*?!HS*&H3+1!oZBVL}DdguWBNcLGKpfc6dqb;{-1@F}o&6Z#QU*8!jt;P9 zaP%HsGvM*%yjIt06Fa6FJSM%=!I4Jm2oErhVKhFi09VC7d+4G=>@=sp{(9W$ljg~2 zt<$1L`kH}(!2ue*pVQOva$^VeSNecu=dXB`!SXciqXFl#q?}+z;kg4=lEmIsM%^@ zeSn?^StSE-2FK!<{9`d%nA~{9=KHb9_Qtpt-}cV4&wd03AR`FMhm!ShxPJcm=krNK zyC#!p0hdmNz&eF7vE0>Jzi?q(BM-YAKo$XEN#hvu$`pp@4hGsqh_LM+eDJ|44h4;2 zR2&%vr~IiKZ+vkzjZcIF`t?@SIBIBMxW?}w_&GgCZ=Bb&aoFCXySciU@^ykMNfBR6 zd%7<&)b}Zrx0}Llb^s9`-9TTcEtP+SQSuX0oX=B{s6e@k3c$M~l%M0ta5wbHK&y1c zkOY8HK-J%6v1ZW1n!z{)rf>pSGUz`B(}iO&TA054iY;FR_@=m(+;Q%?XMfupLj(uF zLpHfyu_$W>cp4%G%*`y`weT?t_@CVE_o~^nyz*LcyuEjH$J{&d*9&jcQLPfvF`dR>m*SI z8i7YLO`kyhvzLQgC*hrkC-QhMLP3|v%f0nnGZ>S*I~-9@e}Dh6kb5j1H%@H4atjuk z(wn1dax3V$>KxV#I7H^0c%knmkPjmbU$O`bd#*gzUR)Vb0uMwmhKh?lEc6&`)?J^| zx6SEqy6NZA>DC*t1^?t$+ITfK=U^d^A9KN+e#Z{ho$Ur^?eFp|y)T1lGm-LNb=`F@twqVHz`PO9>3?NtXi&by%i;a5UAs6UARYCQqaP_#Te*ms<5k96 zgcZ}iVOqFR;N!uQU*!v8prOa?+N-M_0F*n$(36WqwEFwenP(#8$*zc&cP!vy{GGz0 zCMX@@Hr_cc2FMTBS7?iP=HA3`m&%WUK?Ps+>3J<2)obA(K6u6EtL_KQ4H%2w_KtVF z<6AJ|+?oON=yYN)asD#Lq=)_e!7H)Myyyt@0!VfN(s2|<5K0n4@blYzvJeLuY-cfe z5`5}YHynX|1t(#OaRyw$uVU+cg}V{`LY~a&LB{^z)GR9inHO+rU5cz0F4LV*dq-*T z#8mh)6b4H75zCL7;0ZuzcgN8^f4kZN*ltRqbmPb`|@Vm zfYC?424SzloW90GS=jcd2MvjNusr<9T8z&d$+=%;hOFFWxVAh{K%rA67q+-ggm(n6 z5YYNZg7(&}(D`C7JhdDg0Pi%>z(DPxUE@=a)~eORc*+YvPT4!O9Ti56r7TpoA|2rh zE7Dk;(r5@A%j3#~ys4kuXMnu9C%tE&zke;(!PbtA?W$k7`D!RXz@Dt3o~&~X4J?WZ&vGCvr;;I6*zx|icy#c#ka!#6Zq z^v2h1`OCDK{^HOOAKAHWxLC-mCy5-WOa1B* z^UwTDPS!#a+FnK8`NEao@Zq!U70mxz53XPT<_R1tk(_yEHl-q~rS*a)H zc3*NvHg|1Q<Q_PP%Qmxp13vbPuM>3C;7brt_Jizy=jX-ebFr;`y-P!tRisfkLyNO;8&?_+VOKGL0E;k~e> z5_J6tfE|Hn;j%E5Ch7fH-Nl_Y5yhNqCs9%4;h;AMNt?9+l~V>+n5AKyj!1DD5^m#6 z^CHY&ECDtgxpfSLbv#DwfxU=9K7PazqQqoT1{=bxcF?$ZsZ0$$4D!YJWD!tL6A}jV z=jp=_7a@3lU)i;5l(IM#;6h+-PR}~(W%E(kT*A5X&6SRM7HKujcEz*{m1%x?E@kTm znq@Hpz_3{6s&S+7Z`fJ(7^VT#%+k5-Or5HVLK=;0sC=4cXyc04(BhP~2vb@O3wVy% zIY%dYgnzhePidGgUbqwQ;wBr=kT;Z%be_*0gAKYOuT3AZYnM0xIK>ffl zITQrB1rpD%X>kCU%g^Wl^lUul%XvR9l~Wd)>@=Sl!!j8bHY2klQLHi^g#js)nnt5x z0gwaY_-oXYdvG44E^?~KZVZ=;1K>133wjR|fz8Q7J@jRI_(bKq9uz6nq!2DC994RL6}VLB7S z%4ih^3aO{fXQsIF%{R=+G+xKTDYKXs82)fHh2x>V$TXbXvapS1u?D~neb|310BgEm z=4+(T0`54Q)6)R*>qX_EqG(9C#x+#havY7pDVx%fkC)0-co9aty~xKqFhX$v#QA6U zmMqd&g0c3oi_hdCN2UOlNXyQ{Pw*T-P6N`@ge)E7WM_!1F#-@@z~r-fX`6M*w1_XJ zOZepd|3>WI|JQY+61uJeEekKl9^2~gTG-|J?|4Sd^8pB+*EE0(yi}Pq>;x0yS?HC< z%CPZb9yaahaL10)0jzU$bmlr_$05k$4LnAG40@8{XhGftghQD|MBW7iB42L*g~gaHb1*zW>jiSdTnbt8FJO+H*m=F;hN! zBW~F^0Ny>((J@Y!;p=%kAAtR_;M5ogyNE>faV%Im<=Z02R$9XGe@Y#I!fmJ{3^)hmmk!9bSrD)soi1C4 zxyI)!i}Xd=on#bgk#5$oE`%EV;|A<%^gA3oS7;`|OANPc902--=L5LMRPKBLDhwZ~ zEx4mq;n?SlmxTiKf0SNBrB~iS>kVJiiZAjJF9w)KgU_#2rh^X`a(}@sKPt@ z6s;VxLcKG&a3$EwGgJ|}$Uj!JqQY7V4&~{^Xkmam{>a2j<#Y_gpd(~0d~@c{id>qP z7uGSbVMeY2)FuaeXJYlGK@r5434(RXaDL-R)c9=6D;+Pk}lr@gM5*-f%QTbRa z7yvn(fb1O1$&+$Jp0tNAGxMPRJDi4&h9p8OMDCB3Q5H9(Ge#}15?UDI@S$lB_ETUS z5X<}j7hk+~w{=4E+S|irxfY6k;PA;O_OFWj}Kq3>;nf&}@FZX73<$Z=rXx4Wr>6{j!9RI50m z3)_4HuO%{Zwu+W_KA{mYuV^t3WwvmIF$5gPfAvQAYSMS`(rDMe|8MKg__A^Ue0+t) z9XTHWw#oAWitVvmX(|~n4fT$fg9(NEqe@xC2mTJUmfqiidtumZhkaUh#`#(+iil|d zMgUSw>rCKHK!f|?xIX{B_+pafFMY9SI+_~9trPhQiSsad(mkHi_OqeQv>cw*+2is4 zKlGs03h)0vCzY`Ue-`7*$^me`k3}rCdY%u!rsi(CHbq}(A9$IW6M&#Hn3ZqwS`fk% zUZhnxLplK5xN)vX+)p~aLt7rk>G(WsRBrTOCO?0?TnNVyNAd^|=^5=HuBU@|OEKXZ zgvu#~KEK@xiZX553Pvf~%At76D%u95sGI_-<_Ho#eVOU?**_s7v1Bl!in%q;q=ZE!n`i! z@5n|`JJ5?_^MwGMBIX=`Zu-f~wHt)eXXK8D!#N=z-`3#Qg5GSiA30#kI5=w{igE-HNB!Kz=gg2tW-Q zTXtHjR;`ZkKE%M_;9vzmEaweYabj`b>eZ`o?shC~G22#H&<)|>bJj~LAc3zd%i4am z*R!2wW(e~r`zcour|_~+X)6gvPiguMe86Rl(C28mGTY z@24!T520_u@n-XtzZB`0r327?-bqxsj|WuZk_|$8vIAB5)c6_lB3xliE1Yp&MLxm` zH2wj8EUyH2`j!vwZhYPi*7&m!!C%LVcS3yMFV=414O9z0BF+3&=r_+x9TdjBC2iOmA2#yKk9q(Cj1T+9u z%(D1Q24d+^UV0IZi{TX93qiPxbsTGhS6+)|J-OliNc>8!89zvpy&vG$03Z{W9}JX8 zD*Lj}e$2v$_tM$Z<`t+kwTz|-CJoBh$w@bJ830Rg6Kn($Rxe57hU{B8> z_y{;p%Gl!$fGVKMsK(4t;R<6~;ezyk+IR&n{BlS3WX>^IMj!Wg_Jt~Xtc#3z|7V1r zM#|$yF(ujUF_5;yOc|8XWUv?ufYqd4pZ+?}i^L6q@Beo^PjRMvmNIVHEdpw1>7MyG zjkj?Xoj1~F`%-0OI9t3}Hsz7qKgy09noI#uuRT@~=BaNMqQFD=apRB_EtJRC#imKX z^lTR?AW_IHe_6aJs}oHTu6%^E*D(_K5@8rQElXQimX-D$E}DkB?3a0bHS|u;2O#=c zyHr7$A>(=#;TESf49Oyo_57ItZr^_<$I>oFTvy3qsB#*Rn*q=bbNUaF)j)25Y1aT*Fu%+q;f+R%wi$rl{*EC5_u&>^m%Tf5h??#YmaX93Fc5QM2+ zk;b5!*#E)zC?1=b*dO~rme2eDrVS*zY#o3M`muP$=f4;XehmvkY>+*+AE4OqWocDd z;%r>g8kXW(ZdIN%d+xe8h(?>i0p#ztGp38y86@Q+Gs^J!<-Cq(FLMfc$+;9>0K)tK zY5LU*FFbi_Ilcd%YXu~htpnh)xtD6-aNrf{~xcjATc*;rjUb5!XzCUre1hgv3y z8rN!5H51>%GtED8TBSDm!6}2POB$SSD;PkT#@K((>-faK81iW%2FB{SZCuZ)|&%^Z#dhbUNSsmKy!Cb^wn* z{T4Kq08S}%<};jQxSp^L#W0nU*o(J!);+#@1QsU&eaz)f0d2FrU{7)&DCK(FT9hp2B82Tw_AByQ8sdCf z&i}__)33?-kZ3=@4Wr1uEYi!`0dPed!#}Zle9H0xugRsa!R{^EKw~Q?Hr4<*1xR~w zjL%Gmg6wt`j`k9eA@BZj9GC-@8>B5~dtB?G;oM~uE7C68$s%0=hh$p89Ds`@Soe|h z60!Mf`Mm$nI@@dSzUA4H?%_B-hVg*=ZlL?_-X6|wQN&ZZamD^~U%kono^=3P=y(dJ z4^?6w3Q7aZO*bSlr(3F&9U?VU>E)rXYdO+G=Rm_ZC#&Ol|GyQt@QL+o9cV_uU5s4T zBLLs8vMsi^Qu$ZxYWu#Q2Fy(>_Y%dxd3J6Av)YnVfgXPN$ed9wRx!rl=J_W+x!2;c3JigfP3Tl9GLS#+_)aBxpjcO z=1D-Y!%EUL_OyaifznnY2ZQ39_IvbdCCx#}PYfL=3_MFu0MzgoQaSQ+wBgJBjrT0m z_y3D+x9lB&ZxDG22#WlrrM4pE%PWV1B(0`>A*k5byPeh-fhuW5jst^0Sk)dGZ0u%P z+eaWQD||pA)0X`E|7QHMo7pZw_LVgNbeir1e>TqtSS($ER?7%L&iSiU zs22Ll>~m2qITZ*hKjX1ryIU|lZ3rJ19Kwf2VPM=-(|)%t!WNRR8gEfnF)gw?l)>f; zJ~vHD(?REuQ&iS0v`wJIe|!ijo*m_aOpSzp5~1OjJOr=(J6qQHiWDs zAKD$2$QmWn#mnO78mA1>hO2{-i@ZRTMT+?VBhUZQulD`_rPIe*)fP3jG6nF>sl#S} zKEN4JzeR26Jt<5#FJnv4sy&&HZkffb0}eo}pSSRvdtD5mj#7NsH$LESXUf%DyK@0Kw3)n^q1VDH@kmE{;fuXSmSI$tVBZ zobfE>sc@f1nz#c%b=_y7W$PBcC8OMXy1{in_}+3pz=GcOB3;+~ZWMkAJpKX?@w z-YbQt+V0)e2d6^2E*CC>3o1< zXLU;ZvcuR~G6K-iOegw5bW0_dH(BmDvpUcTIp;c)o`nQuH~`Zdd;9qSrl@UExs&?U`8MPt1F-DZaRji)SuKV*o?F&4A@Bc3^8WwB zGaQ}17#S`l87mIJ^wv^2AHWpZM^kqyP|7>AoNu>hZ_mib4c+(3ZGgq*Y4-Hu{E}Ad z5xfQ<@BeSbXX1-Q%Qd~?08Gy>wetb8qT_>p@`>wcfX8(KzQ-KEh7DVLVD>l;3{#GT zs}Fnq)v8r%Y!LJQ|B@*8v(m6D4!|@s=7>w~d;l#z@5mVH4jy{wq3&3)!t;5hTWF}K z4DGkylRQmxP|hG&>@-rL&?2b1{Y!19}b_L|o41%cta_y5Po@Xfc%%KQH%9kk*A z+8af-hc2=60dT9snE+ARhhxFyOaQ%Ow^wiXAmiid0P~S42bb3O?#sRZA4lIEAK$U^ z{{R1L@9JXXsIKtL?0A2?YoJv_lm0#OP(+}R$BLlRK2nK-(^TT6g2rC&dfi4sNJUUn zD6ds*Cr&`^TT#IxLiDjkQ3|b)fV2o=Re9hifQVFqA>Q?Re`coNcW3U#toLVTc6L2B zcZ9?4?7ipSIrpA(&pr2?Z#s;XQUGmC*{%2hG;?7Dq%6x6m;i_c7>aNjMr|DHev0kw zh6^tMp-1HWgAYDfp?^dMeC>w5kD`xC0d(f_Zo>!g;sCTH)?|STIIOd7WYlDEqMq~= zz)+M-=OGDpm-~Oz!21i}taNm-|A$gEz5Bt4?cvf&0dyE#2#+`>+GY3v4p%#=05lK5 zx5MLwx+N_N$Fj0)2ZBWbi>(3>f{*-v#{c6I`~Of6zQ-#m1<+xDB|d;@Iv0Qs@R4Z} zA3)~Zb*K=JTM`pMB5CcYhN8&tIt5-z)KZfZ`pZ?oERh0s0 z=Lo*jOq4i*Sms_&CIKX=8*zVkybExjFopsOBDWN}VNVG3A>`MCH$zb}9fl;#Eu8-g zi|7q+|8LU26Nb=UuA>w{m(guac+EK{CcHRi>ryx#w_OXsY6=SgXtzTVPTetD7C<~6 zyL&I7$l?EURQ`W=7AZuaZoH8hb(rw(`vMG|c+%YW#`N-X?+q6ha9EwRk#R`DqGS!L zjfm(bYPA|BctSV;rC%o^+IxnKvH-daB_@oR;FXNIinDcz6BzexfCjAop`ro-QNslT z>B6^|+_s48NPI`)JhD&HuM?C0#ikod0dyG(FZW%4=zhF6kT`+v3ngNlMB-ZfaC{3) zfK?Ip(&ga>y6p%BFc67hR|cM=z;(p99Y21&N`H~2miH4S`zvHTGAUYk^e9IKGI(7 zlE#z*=rK?dA3(RRecQI|OWX!v@<%FIJ(>^v6T60xKyeHh^*irYR^0C3;h_Ma1F^sc z?%-%D2>36{YQ9f=M>eU#f4ehYDS+<0;V*pig%854TXm0kI(QUE=M4L$&SWo1RYP8O>k zUMD3M|JeEgT;oKSs6bR+J)SPuPvC`1%+NhFp!fv>ddeAdTI^4*=Ksf6X$KpGA(U1M zz$a7TQ2H0om2nAx37^$QT1;#KG+iVEvEhhQf~f%L`U4mU1T2Q@D*9#9PqP91KaAI_ z`w!+sMv@E?lwQqL4gaYXEXuf~;=(0TNdT2oLbOJH~i2j;~9=d-W+ZICw0QDYbr2u;KNB%0}0dNHH zvM_xk+*`{AqWHfnsCouUDouK;tNS8ZrR7$_qnm+qf#f5hO+ryzt^Gf@C$8W8XY+wmXujgDe$BY8f>Z#+E`XqlSb)oyFGpel$gKo24JQl=S&U9WsGP5k zhk;W{_x0cCV=T96Vlu_(AEf|%4hB|JvGFw=?JUwvA!vyVZq(zX1Skwt05}X6(SSZ5 z&37@-pJ0*=t~faXc>LMGbEkMb3H<+;*g=*5PcHPolmh6_BT<6pB#)Jf#i{cEntXtA z0LZB%RvdT*<8je0cP#)nA0Vn;ef+VDF60=RgaeC;^uTn;f1FNW+oNlno7MXZg=7EW zIN>k$*bM_ed;|oP0tmoFOe%+yYql``$7a28d!yO-G?UK2DW*{g?C7fKS|V}_5Um9u zT?x9ayJ$c={H)%Pk>54)ztL!XVl|rIFBS8D@`OKb4%z^-Y_vs{0tm)$Dgfv7>CZ-8qP~)AJJ(IY!8F#o6JB$3!JW>)N*10(`oEf5b{jVhsdi+7L>U&&_N$mjeG^ zZ<_i|v$Oes!ts~n_za=FJxdZ{Q|b!H0S8{@1}0kacs~CTtnNcg%TGO%%jJ%i%VkbX z9zBiR0fl}D?>xhExL4;h9avk7L>OA=LWWcTBrALYY-ZG#h37M}+3Z-QT>dkxvHREo zjt91BvDuD|nkix1Qwks?=P=>wv;;628zAwsrIV*F;KiAb13yrMlaTjB7GX&8p{ofk z5V;f3MHhmHL7)MJ?%d!N8#so9>&4X8*DDW~iqlV$B*y_tSqXm#{;DlN2&Ty@o+V&% z^)1d!Ke1V>-9zDrA^>ub@C(SIBl$o2fp!Rmr3nkbON1GCscxYQ0TI~U{trwtFh9so z^0nEt>a}|P?ox5;Nho1D`%I2QV1lZG;DAy915N}?z$Rnmgu)$Fs#Mv;hQ@ieNTy$e_{TiLUAn%Y%UxR5Ht*- z;B^>jX=XARW4(N3p;XAvkp%fWKd+p9!P(il#ziU$8HgN~nS5cn?WF>XeFbO>G@!7w{MI9%$S)myt5&L0QuxVNCH#K5 z=&zho0RH$Cs1%)2Oh$pJYR=9~p8~Y#9SBzW469iZwzGI&bs&NP3JV1Q&<=?T6rgIy zq8BcFSD5=WyWwBVc=U(11zWxn=Te}^Rmrs$GllW84zK%mQ(9IEre&^ZW5u@A1__mx8kUU?4AK^Y?uX}&u16WkSAA4fbwB(Pvofn0apb)+XPfFL~Z zQ&#XHhm(_XW&JOo{O|IiJc6uf#gKm>poHHK7eka$7C;E51+5~TgZw}0{&exg@@guT znQt`e3CpsPQK<3AFoFwVz}P@TUecSJ5*_&PKy)RlZljUNYI-zE2#^X1dmUXm*I12EH zRw)B~RAgU^`@{R76u|I!yNxm?b(8yatf~xMU0GN-b+Nu#gI^%~opQMni6!6(Xv#6| z3_Eb~;$XOd2m%tq4l89G9t)`ewmnvHW=?|cjt5o{= zXQ2Fpg{9?_@POy}RvTP{Boh%7L^mxYAll z_skUvXBphX90NYTUm~hp^c+wMAbOq#rU(VVq!*3*U?_o?mX?-ohVcIth&MOkXp90v zfRVn|8sw6Dbcev5gzq9vO&d%D?7xaCbPdSzq6Of75?_1%BLGqm_mC>Dm*2E4{l{~2 zg-c#=kMi>OWZh_f>^rGO(+$-MIUxN4#bWV&1jF1~DX%||$U$xgC~$o} zSNa7!)A(Lw6#h1yPCrtw*WuT*>+tXOJc`5dn!w4|br%;fWH+`|gKnAd#Ggh+S4}`G z#I~Kf*$VEd%mcRTF%pEOqk?<3adu~KC<|a``QNd13P1PA6f`)?9t8~fQGB*i=@f;( zr*jbU2NNCtcoqQn*=VGzz;^7w)tkD9D#XPf7KMy7yjr0B6Z@A&KXF>n1ai+2^#3tF z2cHKDK$-pR4BO%FN&)Qf0CZ~}+W~RjMlU&5&}Wg0e+WTC9|B(>1v%skNqTtWkyqov zf_07;R1h5xO@Z68x;qX4`5WB9-tli1&D+*Gjn7OWDYMb zojN_99X}?6h9F=7A-yW(-be2gAduI-Q!fQ!m;u`Yi_k7l9a_OXNaH;PYb8%2P#kK% z--(+syQeIGm<>p`?rcdW(q@a(Q^c+wMVDvl>-6Djli9dr5HAD_BG%Wi^cs$2!23`($;rt{wcq!}+4vMv z7C?N)!go)&$a70TC)=-PX5JYCRz8IP1utkwDjxsprN~$PA>m0nN0nf=k);C zFp*eDP>|1i66yKlVeu;jo94ekp9A*$!04|$j{W|l{E^!O3V%;B>c`k}Kv@8>9U6Zf zlR-l)U7X23jEKRbO{=LPAK+_9yp!02{)n}ga^;`z1sFpn;65N5YY6U{WN;FSir^l$ z-(F39-#r{q7QpTqtFAp|kPV%0KpG-+_OlBM%kSz5{fv>)u1BRyT`Tta;edxG)NsuG zhGSo9>dkw9Stz{5RUZ+8mJ06a!$9@3;=m|5;5r9^-;WD0OUDeyC7j(qsLF zr=R}T^78UG`Pn@Y=$;F}GU`$sP#oAn4!{~{S$0y0!**wD{jGGv(o^*m2NVZJzyWRv wwhHecO_CP>2BD$qDh?= 2 && path[1] == ':' { + return path[:2] + } + if core.HasPrefix(path, `\\`) { + parts := core.Split(path, `\`) + if len(parts) >= 4 { + return core.Join(`\`, parts[:4]...) + } + } + return "" +} + +func pathFromSlash(path string) string { + if core.PathSeparator == '/' { + return path + } + return core.Replace(path, "/", string(core.PathSeparator)) +} + +func jsonMarshal(value any) ([]byte, resultFailure) { + result := core.JSONMarshal(value) + if !result.OK { + return nil, coreResultError(result, "failed to encode JSON") + } + return result.Value.([]byte), nil +} + +func jsonUnmarshal(data []byte, target any) resultFailure { + return coreResultError(core.JSONUnmarshal(data, target), "failed to decode JSON") +} + +func bytesEqual(left, right []byte) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func equalFold(left, right string) bool { + return core.Lower(left) == core.Lower(right) +} + +func cut(value, sep string) (string, string, bool) { + parts := core.SplitN(value, sep, 2) + if len(parts) != 2 { + return value, "", false + } + return parts[0], parts[1], true +} + +func fields(value string) []string { + var result []string + start := -1 + for index, char := range value { + if unicode.IsSpace(char) { + if start >= 0 { + result = append(result, value[start:index]) + start = -1 + } + continue + } + if start < 0 { + start = index + } + } + if start >= 0 { + result = append(result, value[start:]) + } + return result +} + +func indexString(value, needle string) int { + if needle == "" { + return 0 + } + if len(needle) > len(value) { + return -1 + } + for index := 0; index <= len(value)-len(needle); index++ { + if value[index:index+len(needle)] == needle { + return index + } + } + return -1 +} + +func lookPath(file string) (string, resultFailure) { + name := core.Trim(file) + if name == "" { + return "", core.NewError("executable name is empty") + } + if core.Contains(name, string(core.PathSeparator)) || core.PathIsAbs(name) { + if executablePath(name) { + return name, nil + } + return "", core.Errorf("executable file not found in path: %s", file) + } + for _, dir := range core.Split(core.Getenv("PATH"), string(core.PathListSeparator)) { + if core.Trim(dir) == "" { + continue + } + candidate := core.PathJoin(dir, name) + if executablePath(candidate) { + return candidate, nil + } + } + return "", core.Errorf("executable file not found in path: %s", file) +} + +func executablePath(path string) bool { + result := core.Stat(path) + if !result.OK { + return false + } + info := result.Value.(core.FsFileInfo) + return !info.IsDir() && info.Mode().Perm()&0o111 != 0 +} + +func trimRight(value, cutset string) string { + for value != "" { + trimmed := false + for _, char := range cutset { + part := string(char) + if core.HasSuffix(value, part) { + value = core.TrimSuffix(value, part) + trimmed = true + } + } + if !trimmed { + return value + } + } + return value +} + +func trimRunes(value, cutset string) string { + for value != "" { + trimmed := false + for _, char := range cutset { + part := string(char) + if core.HasPrefix(value, part) { + value = core.TrimPrefix(value, part) + trimmed = true + } + if core.HasSuffix(value, part) { + value = core.TrimSuffix(value, part) + trimmed = true + } + } + if !trimmed { + return value + } + } + return value +} + +func repeatString(value string, count int) string { + if count <= 0 || value == "" { + return "" + } + builder := core.NewBuilder() + for i := 0; i < count; i++ { + builder.WriteString(value) + } + return builder.String() +} diff --git a/display/webkit/pkg/display/display.go b/display/webkit/pkg/display/display.go new file mode 100644 index 0000000..ead027f --- /dev/null +++ b/display/webkit/pkg/display/display.go @@ -0,0 +1,2000 @@ +package display + +import ( + "context" + "math" + "net/url" + "runtime" + "sync" + + core "dappco.re/go" + "dappco.re/go/config" + + "dappco.re/go/render/display/webkit/pkg/chat" + "dappco.re/go/render/display/webkit/pkg/clipboard" + "dappco.re/go/render/display/webkit/pkg/container" + "dappco.re/go/render/display/webkit/pkg/contextmenu" + "dappco.re/go/render/display/webkit/pkg/deno" + "dappco.re/go/render/display/webkit/pkg/dialog" + "dappco.re/go/render/display/webkit/pkg/dock" + "dappco.re/go/render/display/webkit/pkg/environment" + "dappco.re/go/render/display/webkit/pkg/events" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" + "dappco.re/go/render/display/webkit/pkg/keybinding" + "dappco.re/go/render/display/webkit/pkg/lifecycle" + "dappco.re/go/render/display/webkit/pkg/menu" + "dappco.re/go/render/display/webkit/pkg/notification" + "dappco.re/go/render/display/webkit/pkg/screen" + "dappco.re/go/render/display/webkit/pkg/systray" + "dappco.re/go/render/display/webkit/pkg/webview" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/wailsapp/wails/v3/pkg/application" +) + +type Options struct{} + +func failedAction(method, action string) resultFailure { + return core.E(method, action+" action failed", nil) +} + +var ( + maxInt = int(^uint(0) >> 1) + minInt = -int(^uint(0)>>1) - 1 +) + +// WindowInfo is an alias for window.WindowInfo (backward compatibility). +type WindowInfo = window.WindowInfo + +// Service orchestrates window, systray, and menu sub-services via IPC. +// Bridges IPC actions to WebSocket events for TypeScript apps. +type Service struct { + *core.ServiceRuntime[Options] + wailsApp *application.App + app App + configData map[string]map[string]any + configFile *config.Config // config instance for file persistence + mode container.AppMode + events *WSEventManager + p2pBridgeCancel context.CancelFunc + schemeHandlers map[string]SchemeHandler + manifestCache map[string]*loadedManifest + manifestMu sync.Mutex + storage *StorageRegistry + background *BackgroundRegistry + sidecar *deno.Manager +} + +// New returns a display Service with empty config sections. +// s, _ := display.New(); s.loadConfigFrom("/path/to/config.yaml") +func New() (*Service, resultFailure) { + return &Service{ + configData: map[string]map[string]any{ + "window": {}, + "systray": {}, + "menu": {}, + }, + mode: container.DetectMode(), + schemeHandlers: make(map[string]SchemeHandler), + manifestCache: make(map[string]*loadedManifest), + storage: NewStorageRegistry(), + background: NewBackgroundRegistry(), + }, nil +} + +// Register binds the display service to a Core instance. +// core.WithService(display.Register(app)) // production (Wails app) +// core.WithService(display.Register(nil)) // tests (no Wails runtime) +func Register(wailsApp *application.App) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + s, err := New() + if err != nil { + return core.Result{Value: err, OK: false} + } + s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{}) + s.wailsApp = wailsApp + if result := c.RegisterService("display", s); !result.OK { + return result + } + if !c.Service("deno").OK { + if result := c.RegisterService("deno", s.ensureSidecar()); !result.OK { + return result + } + } + if !c.Service("tim").OK { + if result := c.RegisterService("tim", container.NewService(c, container.OptionsFromEnv())); !result.OK { + return result + } + } + return core.Result{OK: true} + } +} + +// OnStartup loads config and registers handlers before sub-services start. +// Config handlers are registered first — sub-services query them during their own OnStartup. +func (s *Service) OnStartup(_ context.Context) core.Result { + s.loadConfig() + s.mode = container.DetectMode() + + // Register config query handler — available NOW for sub-services + s.Core().RegisterQuery(s.handleConfigQuery) + + // Register config save actions + s.Core().Action("display.saveWindowConfig", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(window.TaskSaveConfig) + s.configData["window"] = t.Config + if err := s.persistSection("window", t.Config); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + }) + s.Core().Action("display.saveSystrayConfig", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(systray.TaskSaveConfig) + s.configData["systray"] = t.Config + if err := s.persistSection("systray", t.Config); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + }) + s.Core().Action("display.saveMenuConfig", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(menu.TaskSaveConfig) + s.configData["menu"] = t.Config + if err := s.persistSection("menu", t.Config); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + }) + s.Core().Action("display.buildPreload", func(_ context.Context, opts core.Options) core.Result { + script, err := s.BuildPreloadScript(opts.String("url")) + return core.Result{}.New(script, err) + }) + s.Core().Action("display.resolve_scheme", func(ctx context.Context, opts core.Options) core.Result { + return s.ResolveScheme(ctx, opts.String("url")) + }) + s.Core().Action("display.storage.set", func(_ context.Context, opts core.Options) core.Result { + origin := opts.String("origin") + bucket := opts.String("bucket") + key := opts.String("key") + value := opts.String("value") + if s.storage == nil { + return core.Result{Value: core.E("display.storage.set", "storage registry unavailable", nil), OK: false} + } + if !s.storage.Set(origin, bucket, key, value) { + return core.Result{Value: core.E("display.storage.set", "invalid storage entry", nil), OK: false} + } + return core.Result{Value: map[string]string{"origin": origin, "bucket": bucket, "key": key}, OK: true} + }) + s.Core().Action("display.storage.delete", func(_ context.Context, opts core.Options) core.Result { + origin := opts.String("origin") + bucket := opts.String("bucket") + key := opts.String("key") + if s.storage == nil { + return core.Result{Value: core.E("display.storage.delete", "storage registry unavailable", nil), OK: false} + } + if !s.storage.Delete(origin, bucket, key) { + return core.Result{Value: core.E("display.storage.delete", "invalid storage entry", nil), OK: false} + } + return core.Result{Value: map[string]string{"origin": origin, "bucket": bucket, "key": key}, OK: true} + }) + s.Core().Action("display.storage.search", func(_ context.Context, opts core.Options) core.Result { + return core.Result{Value: s.searchAllStorage(opts.String("q")), OK: true} + }) + s.Core().RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch typed := q.(type) { + case QueryStoreRoute: + return s.handleStoreSearch(context.Background(), url.Values{"q": []string{typed.Query}}) + default: + return core.Result{} + } + }) + s.Core().Action("display.models.state", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.modelState(), OK: true} + }) + s.Core().Action("display.network.state", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.networkState(), OK: true} + }) + s.registerBackgroundActions() + s.registerMarketplaceActions() + s.registerSidecarActions() + s.registerDefaultSchemes() + + // Initialise Wails wrappers if app is available (nil in tests) + if s.wailsApp != nil { + s.app = newWailsApp(s.wailsApp) + if s.events == nil { + s.events = NewWSEventManager() + } + } + + s.attachP2PBridge() + + return core.Result{OK: true} +} + +func (s *Service) OnShutdown(ctx context.Context) core.Result { + events := s.events + s.events = nil + if s.p2pBridgeCancel != nil { + s.p2pBridgeCancel() + s.p2pBridgeCancel = nil + } + if events != nil { + events.Close() + } + var shutdownErr resultFailure + if s.storage != nil { + if err := s.storage.Close(); err != nil { + shutdownErr = err + } + } + if s.sidecar != nil { + _, err := s.sidecar.Stop(ctx) + if err != nil { + if shutdownErr != nil { + shutdownErr = core.ErrorJoin(shutdownErr, err) + } else { + shutdownErr = err + } + } + } + return core.Result{}.New(nil, shutdownErr) +} + +// HandleIPCEvents bridges IPC actions from sub-services to WebSocket events for TS apps. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) core.Result { + s.forwardIPCToSidecar(msg) + switch m := msg.(type) { + case core.ActionServiceStartup: + // All services have completed OnStartup — safe to call sub-services + s.buildMenu() + s.setupTray() + case window.ActionWindowOpened: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowCreate, Window: m.Name, + Data: map[string]any{"name": m.Name}}) + } + case window.ActionWindowClosed: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowClose, Window: m.Name, + Data: map[string]any{"name": m.Name}}) + } + case window.ActionWindowMoved: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowMove, Window: m.Name, + Data: map[string]any{"x": m.X, "y": m.Y}}) + } + case window.ActionWindowResized: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowResize, Window: m.Name, + Data: map[string]any{"w": m.Width, "h": m.Height}}) + } + case window.ActionWindowFocused: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowFocus, Window: m.Name}) + } + case window.ActionWindowBlurred: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowBlur, Window: m.Name}) + } + case systray.ActionTrayClicked: + if s.events != nil { + s.events.Emit(Event{Type: EventTrayClick}) + } + case systray.ActionTrayMenuItemClicked: + if s.events != nil { + s.events.Emit(Event{Type: EventTrayMenuItemClick, + Data: map[string]any{"actionId": m.ActionID}}) + } + s.handleTrayAction(m.ActionID) + case environment.ActionThemeChanged: + if s.events != nil { + theme := "light" + if m.IsDark { + theme = "dark" + } + s.events.Emit(Event{Type: EventThemeChange, + Data: map[string]any{"isDark": m.IsDark, "theme": theme}}) + } + case notification.ActionNotificationClicked: + if s.events != nil { + s.events.Emit(Event{Type: EventNotificationClick, + Data: map[string]any{"id": m.ID}}) + } + case screen.ActionScreensChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventScreenChange, + Data: map[string]any{"screens": m.Screens}}) + } + case keybinding.ActionTriggered: + if s.events != nil { + s.events.Emit(Event{Type: EventKeybindingTriggered, + Data: map[string]any{"accelerator": m.Accelerator}}) + } + case window.ActionFilesDropped: + if s.events != nil { + s.events.Emit(Event{Type: EventWindowFileDrop, Window: m.Name, + Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}}) + } + case dock.ActionVisibilityChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventDockVisibility, + Data: map[string]any{"visible": m.Visible}}) + } + case lifecycle.ActionApplicationStarted: + if s.events != nil { + s.events.Emit(Event{Type: EventAppStarted}) + } + case lifecycle.ActionOpenedWithFile: + if s.events != nil { + s.events.Emit(Event{Type: EventAppOpenedWithFile, + Data: map[string]any{core.Concat("pa", "th"): m.Path}}) + } + case lifecycle.ActionWillTerminate: + if s.events != nil { + s.events.Emit(Event{Type: EventAppWillTerminate}) + } + case lifecycle.ActionDidBecomeActive: + if s.events != nil { + s.events.Emit(Event{Type: EventAppActive}) + } + case lifecycle.ActionDidResignActive: + if s.events != nil { + s.events.Emit(Event{Type: EventAppInactive}) + } + case lifecycle.ActionPowerStatusChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventSystemPowerChange}) + } + case lifecycle.ActionSystemSuspend: + if s.events != nil { + s.events.Emit(Event{Type: EventSystemSuspend}) + } + case lifecycle.ActionSystemResume: + if s.events != nil { + s.events.Emit(Event{Type: EventSystemResume}) + } + case contextmenu.ActionItemClicked: + if s.events != nil { + s.events.Emit(Event{Type: EventContextMenuClick, + Data: map[string]any{ + "menuName": m.MenuName, + "actionId": m.ActionID, + "data": m.Data, + }}) + } + case webview.ActionConsoleMessage: + if s.events != nil { + s.events.Emit(Event{Type: EventWebviewConsole, Window: m.Window, + Data: map[string]any{"message": m.Message}}) + } + case webview.ActionException: + if s.events != nil { + s.events.Emit(Event{Type: EventWebviewException, Window: m.Window, + Data: map[string]any{"exception": m.Exception}}) + } + case ActionIDECommand: + if s.events != nil { + s.events.Emit(Event{Type: EventIDECommand, + Data: map[string]any{"command": m.Command}}) + } + case events.ActionEventFired: + if s.events != nil { + s.events.Emit(Event{Type: EventCustomEvent, + Data: map[string]any{"name": m.Event.Name, "data": m.Event.Data}}) + } + case dock.ActionProgressChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventDockProgress, + Data: map[string]any{"progress": m.Progress}}) + } + case dock.ActionBounceStarted: + if s.events != nil { + s.events.Emit(Event{Type: EventDockBounce, + Data: map[string]any{"requestId": m.RequestID, "bounceType": m.BounceType}}) + } + case notification.ActionNotificationActionTriggered: + if s.events != nil { + s.events.Emit(Event{Type: EventNotificationAction, + Data: map[string]any{"notificationId": m.NotificationID, "actionId": m.ActionID}}) + } + case notification.ActionNotificationDismissed: + if s.events != nil { + s.events.Emit(Event{Type: EventNotificationDismiss, + Data: map[string]any{"id": m.ID}}) + } + case chat.ActionConversationCreated: + if s.events != nil { + s.events.Emit(Event{Type: EventChatConversation, + Data: map[string]any{"action": "created", "conversation": m.Conversation}}) + } + case chat.ActionConversationUpdated: + if s.events != nil { + s.events.Emit(Event{Type: EventChatConversation, + Data: map[string]any{"action": "updated", "conversation": m.Conversation}}) + } + case chat.ActionConversationDeleted: + if s.events != nil { + s.events.Emit(Event{Type: EventChatConversation, + Data: map[string]any{"action": "deleted", "conversationId": m.ConversationID}}) + } + case chat.ActionConversationCleared: + if s.events != nil { + s.events.Emit(Event{Type: EventChatConversation, + Data: map[string]any{"action": "cleared", "conversationId": m.ConversationID}}) + } + case chat.ActionMessageAdded: + if s.events != nil { + s.events.Emit(Event{Type: EventChatMessage, + Data: map[string]any{"conversationId": m.ConversationID, "message": m.Message}}) + } + case chat.ActionStreamStarted: + if s.events != nil { + s.events.Emit(Event{Type: EventChatMessage, + Data: map[string]any{ + "conversationId": m.ConversationID, + "messageId": m.MessageID, + "streamId": m.StreamID, + "state": "started", + }}) + } + case chat.ActionTokenAppended: + if s.events != nil { + s.events.Emit(Event{Type: EventChatToken, + Data: map[string]any{ + "conversationId": m.ConversationID, + "messageId": m.MessageID, + "content": m.Content, + }}) + } + case chat.ActionStreamFinished: + if s.events != nil { + s.events.Emit(Event{Type: EventChatMessage, + Data: map[string]any{ + "conversationId": m.ConversationID, + "messageId": m.MessageID, + "state": "finished", + "finishReason": m.FinishReason, + }}) + } + case chat.ActionThinkingStarted: + if s.events != nil { + s.events.Emit(Event{Type: EventChatThinkingStart, + Data: map[string]any{ + "conversationId": m.ConversationID, + "messageId": m.MessageID, + "startedAt": m.StartedAt, + }}) + } + case chat.ActionThinkingAppended: + if s.events != nil { + s.events.Emit(Event{Type: EventChatThinkingAppend, + Data: map[string]any{ + "conversationId": m.ConversationID, + "messageId": m.MessageID, + "content": m.Content, + }}) + } + case chat.ActionThinkingEnded: + if s.events != nil { + s.events.Emit(Event{Type: EventChatThinkingEnd, + Data: map[string]any{ + "conversationId": m.ConversationID, + "messageId": m.MessageID, + "durationMs": m.DurationMS, + }}) + } + case chat.ActionToolCallStarted: + if s.events != nil { + s.events.Emit(Event{Type: EventChatToolCall, + Data: map[string]any{ + "conversationId": m.ConversationID, + "messageId": m.MessageID, + "call": m.Call, + }}) + } + case chat.ActionToolResultReady: + if s.events != nil { + s.events.Emit(Event{Type: EventChatToolResult, + Data: map[string]any{ + "conversationId": m.ConversationID, + "messageId": m.MessageID, + "result": m.Result, + }}) + } + case chat.ActionImageQueued: + if s.events != nil { + s.events.Emit(Event{Type: EventChatImageQueued, + Data: map[string]any{ + "conversationId": m.ConversationID, + "attachment": m.Attachment, + }}) + } + } + return core.Result{OK: true} +} + +// WSMessage represents a command received from a WebSocket client. +type WSMessage struct { + Action string `json:"action"` + Data map[string]any `json:"data,omitempty"` +} + +// requireStringField extracts a string field from WebSocket data and fails when it is missing. +func requireStringField(data map[string]any, key string) (string, resultFailure) { + v, _ := data[key].(string) + if v == "" { + return "", core.E("display.requireStringField", "missing required field \""+key+"\"", nil) + } + return v, nil +} + +// wsRequire is kept for backward compatibility inside the display package. +func wsRequire(data map[string]any, key string) (string, resultFailure) { + return requireStringField(data, key) +} + +func requireFloatField(data map[string]any, key string) (float64, resultFailure) { + value, ok := data[key] + if !ok || value == nil { + return 0, core.E("display.handleWSMessage", "missing required field \""+key+"\"", nil) + } + + switch v := value.(type) { + case float64: + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } + return v, nil + case float32: + f := float64(v) + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } + return f, nil + case int: + return float64(v), nil + case int8: + return float64(v), nil + case int16: + return float64(v), nil + case int32: + return float64(v), nil + case int64: + return float64(v), nil + case uint: + return float64(v), nil + case uint8: + return float64(v), nil + case uint16: + return float64(v), nil + case uint32: + return float64(v), nil + case uint64: + return float64(v), nil + default: + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } +} + +func intFromFloatField(value float64, key string) (int, resultFailure) { + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } + if math.Trunc(value) != value { + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } + if value < float64(minInt) || value > float64(maxInt-1) { + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } + return int(value), nil +} + +func requireIntField(data map[string]any, key string) (int, resultFailure) { + value, ok := data[key] + if !ok || value == nil { + return 0, core.E("display.handleWSMessage", "missing required field \""+key+"\"", nil) + } + + switch v := value.(type) { + case int: + return v, nil + case int8: + return int(v), nil + case int16: + return int(v), nil + case int32: + return int(v), nil + case int64: + if v < int64(minInt) || v > int64(maxInt) { + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } + return int(v), nil + case uint: + if uint64(v) > uint64(maxInt) { + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } + return int(v), nil + case uint8: + return int(v), nil + case uint16: + return int(v), nil + case uint32: + if uint64(v) > uint64(maxInt) { + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } + return int(v), nil + case uint64: + if v > uint64(maxInt) { + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } + return int(v), nil + case float64: + return intFromFloatField(v, key) + case float32: + return intFromFloatField(float64(v), key) + default: + return 0, core.E("display.handleWSMessage", "invalid required field \""+key+"\"", nil) + } +} + +func optionsFromMap(data map[string]any) core.Options { + items := make([]core.Option, 0, len(data)) + for key, value := range data { + items = append(items, core.Option{Key: key, Value: value}) + } + return core.NewOptions(items...) +} + +// wsOptions is kept for backward compatibility inside the display package. +func wsOptions(data map[string]any) core.Options { + return optionsFromMap(data) +} + +// handleWSMessage bridges WebSocket commands to IPC calls. +func (s *Service) handleWSMessage(msg WSMessage) core.Result { + ctx := context.Background() + c := s.Core() + + switch msg.Action { + case "chat:send": + return c.Action("gui.chat.send").Run(ctx, wsOptions(msg.Data)) + case "chat:clear": + return c.Action("gui.chat.clear").Run(ctx, wsOptions(msg.Data)) + case "chat:history": + return c.Action("gui.chat.history").Run(ctx, wsOptions(msg.Data)) + case "chat:models": + return c.Action("gui.chat.models").Run(ctx, wsOptions(msg.Data)) + case "chat:select-model": + return c.Action("gui.chat.select_model").Run(ctx, wsOptions(msg.Data)) + case "chat:settings:save": + return c.Action("gui.chat.settings.save").Run(ctx, wsOptions(msg.Data)) + case "chat:settings:defaults": + return c.Action("gui.chat.settings.defaults").Run(ctx, wsOptions(msg.Data)) + case "chat:settings:load": + return c.Action("gui.chat.settings.load").Run(ctx, wsOptions(msg.Data)) + case "chat:settings:reset": + return c.Action("gui.chat.settings.reset").Run(ctx, wsOptions(msg.Data)) + case "chat:conversations:list": + return c.Action("gui.chat.conversations.list").Run(ctx, wsOptions(msg.Data)) + case "chat:conversations:load": + return c.Action("gui.chat.conversations.load").Run(ctx, wsOptions(msg.Data)) + case "chat:conversations:get": + return c.Action("gui.chat.conversations.load").Run(ctx, wsOptions(msg.Data)) + case "chat:conversations:delete": + return c.Action("gui.chat.conversations.delete").Run(ctx, wsOptions(msg.Data)) + case "chat:conversations:search": + return c.Action("gui.chat.conversations.search").Run(ctx, wsOptions(msg.Data)) + case "chat:conversations:new": + return c.Action("gui.chat.conversations.new").Run(ctx, wsOptions(msg.Data)) + case "chat:conversation:save": + return c.Action("gui.chat.conversation.save").Run(ctx, wsOptions(msg.Data)) + case "chat:conversations:rename": + return c.Action("gui.chat.conversations.rename").Run(ctx, wsOptions(msg.Data)) + case "chat:conversations:export": + return c.Action("gui.chat.conversations.export").Run(ctx, wsOptions(msg.Data)) + case "chat:attach-image": + return c.Action("gui.chat.attach_image").Run(ctx, wsOptions(msg.Data)) + case "chat:attach-image-file": + return c.Action("gui.chat.attach_image_file").Run(ctx, wsOptions(msg.Data)) + case "chat:remove-image": + return c.Action("gui.chat.remove_image").Run(ctx, wsOptions(msg.Data)) + case "chat:thinking:start": + return c.Action("gui.chat.thinking.start").Run(ctx, wsOptions(msg.Data)) + case "chat:thinking:append": + return c.Action("gui.chat.thinking.append").Run(ctx, wsOptions(msg.Data)) + case "chat:thinking:stop": + return c.Action("gui.chat.thinking.stop").Run(ctx, wsOptions(msg.Data)) + case "chat:thinking:end": + return c.Action("gui.chat.thinking.stop").Run(ctx, wsOptions(msg.Data)) + case "marketplace:list": + return c.Action("display.marketplace.list").Run(ctx, wsOptions(msg.Data)) + case "marketplace:fetch": + return c.Action("display.marketplace.fetch").Run(ctx, wsOptions(msg.Data)) + case "marketplace:verify": + return c.Action("display.marketplace.verify").Run(ctx, wsOptions(msg.Data)) + case "marketplace:install": + return c.Action("display.marketplace.install").Run(ctx, wsOptions(msg.Data)) + case "keybinding:add": + accelerator, _ := msg.Data["accelerator"].(string) + description, _ := msg.Data["description"].(string) + return c.Action("keybinding.add").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: keybinding.TaskAdd{Accelerator: accelerator, Description: description}}, + )) + case "keybinding:remove": + accelerator, _ := msg.Data["accelerator"].(string) + return c.Action("keybinding.remove").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: keybinding.TaskRemove{Accelerator: accelerator}}, + )) + case "keybinding:list": + return c.QUERY(keybinding.QueryList{}) + case "browser:open-url": + url, _ := msg.Data["url"].(string) + return c.Action("browser.open_url").Run(ctx, core.NewOptions( + core.Option{Key: "url", Value: url}, + )) + case "browser:open-file": + path, _ := msg.Data[core.Concat("pa", "th")].(string) + return c.Action("browser.open_file").Run(ctx, core.NewOptions( + core.Option{Key: core.Concat("pa", "th"), Value: path}, + )) + case "clipboard:read": + return c.QUERY(clipboard.QueryText{}) + case "clipboard:write": + text, _ := msg.Data["text"].(string) + return c.Action("clipboard.set_text").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: clipboard.TaskSetText{Text: text}}, + )) + case "clipboard:clear": + return c.Action("clipboard.clear").Run(ctx, core.NewOptions()) + case "clipboard:read-image": + return c.QUERY(clipboard.QueryImage{}) + case "clipboard:write-image": + data, _ := msg.Data["base64"].(string) + return c.Action("clipboard.set_image").Run(ctx, core.NewOptions( + core.Option{Key: "data", Value: data}, + )) + case "dialog:open-file": + return c.Action("dialog.open_file").Run(ctx, wsOptions(msg.Data)) + case "dialog:save-file": + return c.Action("dialog.save_file").Run(ctx, wsOptions(msg.Data)) + case "dialog:open-directory": + return c.Action("dialog.open_directory").Run(ctx, wsOptions(msg.Data)) + case "dialog:confirm": + return c.Action("dialog.question").Run(ctx, wsOptions(msg.Data)) + case "dialog:message": + return c.Action("dialog.message").Run(ctx, wsOptions(msg.Data)) + case "dialog:prompt": + return c.Action("dialog.prompt").Run(ctx, wsOptions(msg.Data)) + case "dock:show": + return c.Action("dock.show_icon").Run(ctx, core.NewOptions()) + case "dock:hide": + return c.Action("dock.hide_icon").Run(ctx, core.NewOptions()) + case "dock:badge": + label, _ := msg.Data["label"].(string) + return c.Action("dock.set_badge").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: dock.TaskSetBadge{Label: label}}, + )) + case "dock:badge-remove": + return c.Action("dock.remove_badge").Run(ctx, core.NewOptions()) + case "dock:visible": + return c.QUERY(dock.QueryVisible{}) + case "notification:show": + return c.Action("notification.send").Run(ctx, wsOptions(msg.Data)) + case "notification:clear": + id, _ := msg.Data["id"].(string) + return c.Action("notification.clear").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: notification.TaskClear{ID: id}}, + )) + case "notification:permission-request": + return c.Action("notification.request_permission").Run(ctx, core.NewOptions()) + case "notification:permission-check": + return c.QUERY(notification.QueryPermission{}) + case "notification:with-actions": + return c.Action("notification.send").Run(ctx, wsOptions(msg.Data)) + case "theme:get": + return c.QUERY(environment.QueryTheme{}) + case "theme:system": + return c.QUERY(environment.QueryInfo{}) + case "theme:set": + theme, _ := msg.Data["theme"].(string) + return c.Action("environment.set_theme").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: environment.TaskSetTheme{Theme: theme}}, + )) + case "contextmenu:add": + name, _ := msg.Data["name"].(string) + menuValue, ok := msg.Data["menu"] + if !ok || menuValue == nil { + return core.Result{Value: core.E("display.handleWSMessage", "missing required field \"menu\"", nil), OK: false} + } + marshalResult := core.JSONMarshal(menuValue) + if !marshalResult.OK { + if err, ok := marshalResult.Value.(resultFailure); ok { + return core.Result{Value: core.E("display.handleWSMessage", "failed to marshal menu definition", err), OK: false} + } + return core.Result{Value: core.E("display.handleWSMessage", "failed to marshal menu definition", nil), OK: false} + } + var menuDef contextmenu.ContextMenuDef + menuJSON, _ := marshalResult.Value.([]byte) + unmarshalResult := core.JSONUnmarshal(menuJSON, &menuDef) + if !unmarshalResult.OK { + if err, ok := unmarshalResult.Value.(resultFailure); ok { + return core.Result{Value: core.E("display.handleWSMessage", "failed to unmarshal menu definition", err), OK: false} + } + return core.Result{Value: core.E("display.handleWSMessage", "failed to unmarshal menu definition", nil), OK: false} + } + return c.Action("contextmenu.add").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: contextmenu.TaskAdd{Name: name, Menu: menuDef}}, + )) + case "contextmenu:remove": + name, _ := msg.Data["name"].(string) + return c.Action("contextmenu.remove").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: contextmenu.TaskRemove{Name: name}}, + )) + case "contextmenu:get": + name, _ := msg.Data["name"].(string) + return c.QUERY(contextmenu.QueryGet{Name: name}) + case "contextmenu:list": + return c.QUERY(contextmenu.QueryList{}) + case "webview:eval": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + script, _ := msg.Data["script"].(string) + return c.Action("webview.evaluate").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskEvaluate{Window: w, Script: script}}, + )) + case "webview:click": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("webview.click").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskClick{Window: w, Selector: sel}}, + )) + case "webview:type": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return core.Result{Value: e, OK: false} + } + text, _ := msg.Data["text"].(string) + return c.Action("webview.type").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskType{Window: w, Selector: sel, Text: text}}, + )) + case "webview:navigate": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + url, e := wsRequire(msg.Data, "url") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("webview.navigate").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskNavigate{Window: w, URL: url}}, + )) + case "webview:screenshot": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("webview.screenshot").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskScreenshot{Window: w}}, + )) + case "webview:scroll": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + x, _ := msg.Data["x"].(float64) + y, _ := msg.Data["y"].(float64) + return c.Action("webview.scroll").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskScroll{Window: w, X: int(x), Y: int(y)}}, + )) + case "webview:hover": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("webview.hover").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskHover{Window: w, Selector: sel}}, + )) + case "webview:select": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return core.Result{Value: e, OK: false} + } + val, _ := msg.Data["value"].(string) + return c.Action("webview.select").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskSelect{Window: w, Selector: sel, Value: val}}, + )) + case "webview:check": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return core.Result{Value: e, OK: false} + } + checked, _ := msg.Data["checked"].(bool) + return c.Action("webview.check").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskCheck{Window: w, Selector: sel, Checked: checked}}, + )) + case "webview:upload": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return core.Result{Value: e, OK: false} + } + pathsRaw, _ := msg.Data["paths"].([]any) + var paths []string + for _, p := range pathsRaw { + if ps, ok := p.(string); ok { + paths = append(paths, ps) + } + } + return c.Action("webview.upload_file").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskUploadFile{Window: w, Selector: sel, Paths: paths}}, + )) + case "webview:viewport": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + width, _ := msg.Data["width"].(float64) + height, _ := msg.Data["height"].(float64) + return c.Action("webview.set_viewport").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskSetViewport{Window: w, Width: int(width), Height: int(height)}}, + )) + case "webview:clear-console": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("webview.clear_console").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskClearConsole{Window: w}}, + )) + case "webview:console": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + level, _ := msg.Data["level"].(string) + limit := 100 + if l, ok := msg.Data["limit"].(float64); ok { + limit = int(l) + } + return c.QUERY(webview.QueryConsole{Window: w, Level: level, Limit: limit}) + case "webview:query": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.QUERY(webview.QuerySelector{Window: w, Selector: sel}) + case "webview:query-all": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.QUERY(webview.QuerySelectorAll{Window: w, Selector: sel}) + case "webview:dom-tree": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + sel, _ := msg.Data["selector"].(string) // selector optional for dom-tree (defaults to root) + return c.QUERY(webview.QueryDOMTree{Window: w, Selector: sel}) + case "webview:url": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.QUERY(webview.QueryURL{Window: w}) + case "webview:title": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.QUERY(webview.QueryTitle{Window: w}) + case "webview:devtools-open": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("webview.devtools_open").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskDevToolsOpen{Window: w}}, + )) + case "webview:devtools-close": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("webview.devtools_close").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: webview.TaskDevToolsClose{Window: w}}, + )) + case "tray:show-message": + title, _ := msg.Data["title"].(string) + message, _ := msg.Data["message"].(string) + return c.Action("systray.show_message").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: systray.TaskShowMessage{Title: title, Message: message}}, + )) + case "layout:beside-editor": + name, e := wsRequire(msg.Data, "name") + if e != nil { + return core.Result{Value: e, OK: false} + } + editor, _ := msg.Data["editor"].(string) + side, _ := msg.Data["side"].(string) + ratio, e := requireFloatField(msg.Data, "ratio") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("window.layout_beside_editor").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: window.TaskLayoutBesideEditor{ + Name: name, Editor: editor, Side: side, Ratio: ratio, + }}, + )) + case "layout:suggest": + screenID, _ := msg.Data["screen_id"].(string) + windowCount, e := requireIntField(msg.Data, "window_count") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("window.layout_suggest").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: window.TaskLayoutSuggest{ + ScreenID: screenID, WindowCount: windowCount, + }}, + )) + case "screen:find-space": + screenID, _ := msg.Data["screen_id"].(string) + width, e := requireIntField(msg.Data, "width") + if e != nil { + return core.Result{Value: e, OK: false} + } + height, e := requireIntField(msg.Data, "height") + if e != nil { + return core.Result{Value: e, OK: false} + } + padding, e := requireIntField(msg.Data, "padding") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("window.find_space").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: window.TaskScreenFindSpace{ + ScreenID: screenID, Width: width, Height: height, Padding: padding, + }}, + )) + case "window:arrange-pair": + primary, e := wsRequire(msg.Data, "primary") + if e != nil { + return core.Result{Value: e, OK: false} + } + secondary, e := wsRequire(msg.Data, "secondary") + if e != nil { + return core.Result{Value: e, OK: false} + } + screenID, _ := msg.Data["screen_id"].(string) + ratio, e := requireFloatField(msg.Data, "ratio") + if e != nil { + return core.Result{Value: e, OK: false} + } + return c.Action("window.arrange_pair").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: window.TaskWindowArrangePair{ + Primary: primary, Secondary: secondary, ScreenID: screenID, Ratio: ratio, + }}, + )) + case "window:set-opacity": + name, e := wsRequire(msg.Data, "name") + if e != nil { + return core.Result{Value: e, OK: false} + } + opacity, ok := msg.Data["opacity"].(float64) + if !ok { + return core.Result{Value: core.E("display.handleWSMessage", "missing required field \"opacity\"", nil), OK: false} + } + return c.Action("window.set_opacity").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetOpacity{Name: name, Opacity: opacity}}, + )) + default: + return core.Result{Value: core.E("display.handleWSMessage", "unknown websocket action: "+msg.Action, nil), OK: false} + } +} + +// handleTrayAction processes tray menu item clicks. +func (s *Service) handleTrayAction(actionID string) { + ctx := context.Background() + c := s.Core() + switch actionID { + case "open-desktop": + // Show all windows + infos := s.ListWindowInfos() + for _, info := range infos { + coreutil.ObserveResult(c, "display.handleTrayAction", "window focus failed", c.Action("window.focus").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: window.TaskFocus{Name: info.Name}}, + ))) + } + case "close-desktop": + // Hide all tracked windows so the tray action behaves like a real desktop "close" without quitting. + infos := s.ListWindowInfos() + for _, info := range infos { + coreutil.ObserveResult(c, "display.handleTrayAction", "window visibility update failed", c.Action("window.set_visibility").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetVisibility{Name: info.Name, Visible: false}}, + ))) + } + case "env-info": + // Query environment info via IPC and show as dialog + r := c.QUERY(environment.QueryInfo{}) + if r.OK { + info, _ := r.Value.(environment.EnvironmentInfo) + details := "OS: " + info.OS + "\nArch: " + info.Arch + "\nPlatform: " + + info.Platform.Name + " " + info.Platform.Version + coreutil.ObserveResult(c, "display.handleTrayAction", "environment dialog failed", c.Action("dialog.message").Run(ctx, core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskMessageDialog{ + Options: dialog.MessageDialogOptions{ + Type: dialog.DialogInfo, Title: "Environment", + Message: details, Buttons: []string{"OK"}, + }, + }}, + ))) + } + case "quit": + if s.app != nil { + s.app.Quit() + } + } +} + +func guiConfigPath() string { + home := core.Env("HOME") + if home == "" { + return core.JoinPath(".core", "gui", "config.yaml") + } + return core.JoinPath(home, ".core", "gui", "config.yaml") +} + +func (s *Service) loadConfig() { + if s.configFile != nil { + return // Already loaded (e.g., via loadConfigFrom in tests) + } + s.loadConfigFrom(guiConfigPath()) +} + +func (s *Service) loadConfigFrom(path string) { + configFile, ok := core.Cast[*config.Config](config.New(config.WithPath(path))) + if !ok { + // Non-critical — continue with empty configData + return + } + s.configFile = configFile + + for _, section := range []string{"window", "systray", "menu"} { + var data map[string]any + if result := configFile.Get(section, &data); result.OK && data != nil { + s.configData[section] = data + } + } +} + +func (s *Service) handleConfigQuery(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryConfig: + return core.Result{Value: s.configData["window"], OK: true} + case systray.QueryConfig: + return core.Result{Value: s.configData["systray"], OK: true} + case menu.QueryConfig: + return core.Result{Value: s.configData["menu"], OK: true} + case QueryAppMode: + return core.Result{Value: s.mode, OK: true} + case events.QueryServerInfo: + if s.events == nil { + return core.Result{Value: events.ServerInfo{}, OK: true} + } + return core.Result{Value: s.events.Info(), OK: true} + default: + return core.Result{} + } +} + +func (s *Service) persistSection(key string, value map[string]any) resultFailure { + if s.configFile == nil { + return nil + } + if result := s.configFile.Set(key, value); !result.OK { + return coreResultError(result, "failed to save config section") + } + if result := s.configFile.Commit(); !result.OK { + return coreResultError(result, "failed to commit config") + } + return nil +} + +// --- Service accessors --- + +// windowService returns the window service from Core, or nil if not registered. +func (s *Service) windowService() *window.Service { + if s == nil || s.ServiceRuntime == nil { + return nil + } + svc, ok := core.ServiceFor[*window.Service](s.Core(), "window") + if !ok { + return nil + } + return svc +} + +// --- Window Management (delegates via IPC) --- + +// OpenWindow creates a new window via IPC. +func (s *Service) OpenWindow(options ...window.WindowOption) resultFailure { + spec, err := window.ApplyOptions(options...) + if err != nil { + return err + } + r := s.Core().Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{Window: spec}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return core.E("display.OpenWindow", "window.open action failed", nil) + } + return nil +} + +// GetWindowInfo returns information about a window via IPC. +func (s *Service) GetWindowInfo(name string) (*window.WindowInfo, resultFailure) { + r := s.Core().QUERY(window.QueryWindowByName{Name: name}) + if !r.OK { + if err, ok := r.Value.(resultFailure); ok { + return nil, err + } + return nil, failedAction("display.GetWindowInfo", "window.queryWindowByName") + } + if r.Value == nil { + return nil, nil + } + info, ok := r.Value.(*window.WindowInfo) + if !ok { + return nil, core.E("display.GetWindowInfo", "unexpected result type", nil) + } + return info, nil +} + +// ListWindowInfos returns information about all tracked windows via IPC. +func (s *Service) ListWindowInfos() []window.WindowInfo { + r := s.Core().QUERY(window.QueryWindowList{}) + if !r.OK { + return []window.WindowInfo{} + } + list, _ := r.Value.([]window.WindowInfo) + if list == nil { + return []window.WindowInfo{} + } + return list +} + +// SetWindowPosition moves a window via IPC. +func (s *Service) SetWindowPosition(name string, x, y int) resultFailure { + r := s.Core().Action("window.set_position").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetPosition{Name: name, X: x, Y: y}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.SetWindowPosition", "window.set_position") + } + return nil +} + +// SetWindowSize resizes a window via IPC. +func (s *Service) SetWindowSize(name string, width, height int) resultFailure { + r := s.Core().Action("window.set_size").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetSize{Name: name, Width: width, Height: height}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.SetWindowSize", "window.set_size") + } + return nil +} + +// SetWindowBounds sets both position and size of a window via IPC. +func (s *Service) SetWindowBounds(name string, x, y, width, height int) resultFailure { + if err := s.SetWindowPosition(name, x, y); err != nil { + return err + } + return s.SetWindowSize(name, width, height) +} + +// MaximizeWindow maximizes a window via IPC. +func (s *Service) MaximizeWindow(name string) resultFailure { + r := s.Core().Action("window.maximise").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskMaximise{Name: name}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.MaximizeWindow", "window.maximise") + } + return nil +} + +// MinimizeWindow minimizes a window via IPC. +func (s *Service) MinimizeWindow(name string) resultFailure { + r := s.Core().Action("window.minimise").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskMinimise{Name: name}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.MinimizeWindow", "window.minimise") + } + return nil +} + +// FocusWindow brings a window to the front via IPC. +func (s *Service) FocusWindow(name string) resultFailure { + r := s.Core().Action("window.focus").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskFocus{Name: name}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.FocusWindow", "window.focus") + } + return nil +} + +// CloseWindow closes a window via IPC. +func (s *Service) CloseWindow(name string) resultFailure { + r := s.Core().Action("window.close").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskCloseWindow{Name: name}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.CloseWindow", "window.close") + } + return nil +} + +// RestoreWindow restores a maximized/minimized window. +func (s *Service) RestoreWindow(name string) resultFailure { + r := s.Core().Action("window.restore").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskRestore{Name: name}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.RestoreWindow", "window.restore") + } + return nil +} + +// SetWindowVisibility shows or hides a window. +func (s *Service) SetWindowVisibility(name string, visible bool) resultFailure { + r := s.Core().Action("window.set_visibility").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetVisibility{Name: name, Visible: visible}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.SetWindowVisibility", "window.set_visibility") + } + return nil +} + +// SetWindowAlwaysOnTop sets whether a window stays on top. +func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) resultFailure { + r := s.Core().Action("window.set_always_on_top").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetAlwaysOnTop{Name: name, AlwaysOnTop: alwaysOnTop}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.SetWindowAlwaysOnTop", "window.set_always_on_top") + } + return nil +} + +// SetWindowTitle changes a window's title. +func (s *Service) SetWindowTitle(name string, title string) resultFailure { + r := s.Core().Action("window.set_title").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetTitle{Name: name, Title: title}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.SetWindowTitle", "window.set_title") + } + return nil +} + +// SetWindowFullscreen sets a window to fullscreen mode. +func (s *Service) SetWindowFullscreen(name string, fullscreen bool) resultFailure { + r := s.Core().Action("window.fullscreen").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskFullscreen{Name: name, Fullscreen: fullscreen}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.SetWindowFullscreen", "window.fullscreen") + } + return nil +} + +// SetWindowBackgroundColour sets the background colour of a window. +func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) resultFailure { + result := s.Core().Action("window.set_background_colour").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetBackgroundColour{ + Name: name, Red: r, Green: g, Blue: b, Alpha: a, + }}, + )) + if !result.OK { + if e, ok := result.Value.(resultFailure); ok { + return e + } + return failedAction("display.SetWindowBackgroundColour", "window.set_background_colour") + } + return nil +} + +// GetFocusedWindow returns the name of the currently focused window. +func (s *Service) GetFocusedWindow() string { + infos := s.ListWindowInfos() + for _, info := range infos { + if info.Focused { + return info.Name + } + } + return "" +} + +// GetWindowTitle returns the title of a window by name. +func (s *Service) GetWindowTitle(name string) (string, resultFailure) { + info, err := s.GetWindowInfo(name) + if err != nil { + return "", err + } + if info == nil { + return "", core.E("display.GetWindowTitle", "window not found: "+name, nil) + } + return info.Title, nil +} + +// ResetWindowState clears saved window positions. +func (s *Service) ResetWindowState() resultFailure { + ws := s.windowService() + if ws != nil { + ws.Manager().State().Clear() + } + return nil +} + +// GetSavedWindowStates returns all saved window states. +func (s *Service) GetSavedWindowStates() map[string]window.WindowState { + ws := s.windowService() + if ws == nil { + return map[string]window.WindowState{} + } + result := make(map[string]window.WindowState) + for _, name := range ws.Manager().State().ListStates() { + if state, ok := ws.Manager().State().GetState(name); ok { + result[name] = state + } + } + return result +} + +// CreateWindowOptions specifies the initial state for a new named window. +// svc.CreateWindow(display.CreateWindowOptions{Name: "settings", URL: "/settings", Width: 800, Height: 600}) +type CreateWindowOptions struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + X int `json:"x,omitempty"` + Y int `json:"y,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +func (s *Service) CreateWindow(options CreateWindowOptions) (*window.WindowInfo, resultFailure) { + if options.Name == "" { + return nil, core.E("display.CreateWindow", "window name is required", nil) + } + r := s.Core().Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Window: &window.Window{ + Name: options.Name, + Title: options.Title, + URL: options.URL, + Width: options.Width, + Height: options.Height, + X: options.X, + Y: options.Y, + }, + }}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return nil, e + } + return nil, core.E("display.CreateWindow", "window.open action failed", nil) + } + info, ok := r.Value.(window.WindowInfo) + if !ok { + return nil, core.E("display.CreateWindow", "unexpected result type", nil) + } + return &info, nil +} + +// --- Layout delegation --- + +// SaveLayout saves the current window arrangement as a named layout. +func (s *Service) SaveLayout(name string) resultFailure { + r := s.Core().Action("window.save_layout").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSaveLayout{Name: name}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.SaveLayout", "window.save_layout") + } + return nil +} + +// RestoreLayout applies a saved layout. +func (s *Service) RestoreLayout(name string) resultFailure { + r := s.Core().Action("window.restore_layout").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskRestoreLayout{Name: name}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.RestoreLayout", "window.restore_layout") + } + return nil +} + +// ListLayouts returns all saved layout names with metadata. +func (s *Service) ListLayouts() []window.LayoutInfo { + r := s.Core().QUERY(window.QueryLayoutList{}) + if !r.OK { + return []window.LayoutInfo{} + } + layouts, _ := r.Value.([]window.LayoutInfo) + if layouts == nil { + return []window.LayoutInfo{} + } + return layouts +} + +// DeleteLayout removes a saved layout by name. +func (s *Service) DeleteLayout(name string) resultFailure { + r := s.Core().Action("window.delete_layout").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskDeleteLayout{Name: name}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.DeleteLayout", "window.delete_layout") + } + return nil +} + +// GetLayout returns a specific layout by name. +func (s *Service) GetLayout(name string) *window.Layout { + r := s.Core().QUERY(window.QueryLayoutGet{Name: name}) + if !r.OK { + return nil + } + if r.Value == nil { + return nil + } + layout, ok := r.Value.(*window.Layout) + if !ok { + return nil + } + return layout +} + +// --- Tiling/snapping delegation --- + +// TileWindows arranges windows in a tiled layout. +func (s *Service) TileWindows(mode window.TileMode, windowNames []string) resultFailure { + r := s.Core().Action("window.tile_windows").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskTileWindows{Mode: mode.String(), Windows: windowNames}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.TileWindows", "window.tile_windows") + } + return nil +} + +// SnapWindow snaps a window to a screen edge or corner. +func (s *Service) SnapWindow(name string, position window.SnapPosition) resultFailure { + r := s.Core().Action("window.snap_window").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSnapWindow{Name: name, Position: position.String()}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.SnapWindow", "window.snap_window") + } + return nil +} + +// StackWindows arranges windows in a cascade pattern. +func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) resultFailure { + r := s.Core().Action("window.stack_windows").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.StackWindows", "window.stack_windows") + } + return nil +} + +// ApplyWorkflowLayout applies a predefined layout for a specific workflow. +func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) resultFailure { + r := s.Core().Action("window.apply_workflow").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskApplyWorkflow{Workflow: workflow.String()}}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return e + } + return failedAction("display.ApplyWorkflowLayout", "window.apply_workflow") + } + return nil +} + +// LayoutBesideEditor places a window beside a detected editor window. +// +// result, err := svc.LayoutBesideEditor("preview", "code", "right", 0.62) +func (s *Service) LayoutBesideEditor(name, editor, side string, ratio float64) (window.LayoutBesideEditorResult, resultFailure) { + r := s.Core().Action("window.layout_beside_editor").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskLayoutBesideEditor{ + Name: name, Editor: editor, Side: side, Ratio: ratio, + }}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return window.LayoutBesideEditorResult{}, e + } + return window.LayoutBesideEditorResult{}, failedAction("display.LayoutBesideEditor", "window.layout_beside_editor") + } + result, ok := r.Value.(window.LayoutBesideEditorResult) + if !ok { + return window.LayoutBesideEditorResult{}, core.E("display.LayoutBesideEditor", "unexpected result type", nil) + } + return result, nil +} + +// LayoutSuggest returns a layout recommendation for the current screen. +// +// suggestion, err := svc.LayoutSuggest("", 2) +func (s *Service) LayoutSuggest(screenID string, windowCount int) (window.LayoutSuggestion, resultFailure) { + r := s.Core().Action("window.layout_suggest").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskLayoutSuggest{ + ScreenID: screenID, WindowCount: windowCount, + }}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return window.LayoutSuggestion{}, e + } + return window.LayoutSuggestion{}, failedAction("display.LayoutSuggest", "window.layout_suggest") + } + result, ok := r.Value.(window.LayoutSuggestion) + if !ok { + return window.LayoutSuggestion{}, core.E("display.LayoutSuggest", "unexpected result type", nil) + } + return result, nil +} + +// FindScreenSpace finds an unused rectangle for a new window. +// +// space, err := svc.FindScreenSpace("", 800, 600, 24) +func (s *Service) FindScreenSpace(screenID string, width, height, padding int) (window.ScreenSpace, resultFailure) { + r := s.Core().Action("window.find_space").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskScreenFindSpace{ + ScreenID: screenID, Width: width, Height: height, Padding: padding, + }}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return window.ScreenSpace{}, e + } + return window.ScreenSpace{}, failedAction("display.FindScreenSpace", "window.find_space") + } + result, ok := r.Value.(window.ScreenSpace) + if !ok { + return window.ScreenSpace{}, core.E("display.FindScreenSpace", "unexpected result type", nil) + } + return result, nil +} + +// ArrangeWindowPair positions two windows in an optimal split. +// +// arrangement, err := svc.ArrangeWindowPair("editor", "preview", "", 0.55) +func (s *Service) ArrangeWindowPair(primary, secondary, screenID string, ratio float64) (window.PairArrangement, resultFailure) { + r := s.Core().Action("window.arrange_pair").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskWindowArrangePair{ + Primary: primary, Secondary: secondary, ScreenID: screenID, Ratio: ratio, + }}, + )) + if !r.OK { + if e, ok := r.Value.(resultFailure); ok { + return window.PairArrangement{}, e + } + return window.PairArrangement{}, failedAction("display.ArrangeWindowPair", "window.arrange_pair") + } + result, ok := r.Value.(window.PairArrangement) + if !ok { + return window.PairArrangement{}, core.E("display.ArrangeWindowPair", "unexpected result type", nil) + } + return result, nil +} + +// GetEventManager returns the event manager for WebSocket event subscriptions. +func (s *Service) GetEventManager() *WSEventManager { + return s.events +} + +// --- Menu (handlers stay in display, structure delegated via IPC) --- + +func (s *Service) buildMenu() { + developerItems := []menu.MenuItem{ + {Label: "New File", Accelerator: "CmdOrCtrl+N", OnClick: s.handleNewFile}, + {Label: "Open File...", Accelerator: "CmdOrCtrl+O", OnClick: s.handleOpenFile}, + {Label: "Save", Accelerator: "CmdOrCtrl+S", OnClick: s.handleSaveFile}, + {Type: "separator"}, + {Label: "Editor", OnClick: s.handleOpenEditor}, + {Label: "Terminal", OnClick: s.handleOpenTerminal}, + {Type: "separator"}, + {Label: "Run", Accelerator: "CmdOrCtrl+R", OnClick: s.handleRun}, + {Label: "Build", Accelerator: "CmdOrCtrl+B", OnClick: s.handleBuild}, + } + if menuService, ok := core.ServiceFor[*menu.Service](s.Core(), "menu"); ok && menuService.ShowDevTools() { + developerItems = append(developerItems, + menu.MenuItem{Type: "separator"}, + menu.MenuItem{Label: "Open DevTools", OnClick: s.handleOpenDevTools}, + menu.MenuItem{Label: "Close DevTools", OnClick: s.handleCloseDevTools}, + ) + } + + items := []menu.MenuItem{ + {Role: pointerTo(menu.RoleAppMenu)}, + {Role: pointerTo(menu.RoleFileMenu)}, + {Role: pointerTo(menu.RoleViewMenu)}, + {Role: pointerTo(menu.RoleEditMenu)}, + {Label: "Workspace", Children: []menu.MenuItem{ + {Label: "New...", OnClick: s.handleNewWorkspace}, + {Label: "List", OnClick: s.handleListWorkspaces}, + }}, + {Label: "Developer", Children: developerItems}, + {Role: pointerTo(menu.RoleWindowMenu)}, + {Role: pointerTo(menu.RoleHelpMenu)}, + } + + // On non-macOS, remove the AppMenu role + if runtime.GOOS != "darwin" { + items = items[1:] // skip AppMenu + } + + coreutil.ObserveResult(s.Core(), "display.setupApplicationMenu", "app menu setup failed", s.Core().Action("menu.set_app_menu").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: menu.TaskSetAppMenu{Items: items}}, + ))) +} + +// pointerTo returns a pointer to value. +func pointerTo[T any](value T) *T { return &value } + +// --- Menu handler methods --- + +func (s *Service) menuDevToolsWindow() string { + if name := s.GetFocusedWindow(); name != "" { + return name + } + infos := s.ListWindowInfos() + if len(infos) == 1 { + return infos[0].Name + } + return "" +} + +func (s *Service) handleOpenDevTools() { + windowName := s.menuDevToolsWindow() + if windowName == "" { + return + } + coreutil.ObserveResult(s.Core(), "display.handleOpenDevTools", "devtools open failed", s.Core().Action("webview.devtools_open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskDevToolsOpen{Window: windowName}}, + ))) +} + +func (s *Service) handleCloseDevTools() { + windowName := s.menuDevToolsWindow() + if windowName == "" { + return + } + coreutil.ObserveResult(s.Core(), "display.handleCloseDevTools", "devtools close failed", s.Core().Action("webview.devtools_close").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskDevToolsClose{Window: windowName}}, + ))) +} + +func (s *Service) handleNewWorkspace() { + coreutil.ObserveResult(s.Core(), "display.handleNewWorkspace", "workspace window open failed", s.Core().Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Window: &window.Window{ + Name: "workspace-new", + Title: "New Workspace", + URL: "/workspace/new", + Width: 500, + Height: 400, + }, + }}, + ))) +} + +func (s *Service) handleListWorkspaces() { + r := s.Core().Service("workspace") + if !r.OK || r.Value == nil { + return + } + lister, ok := r.Value.(interface{ ListWorkspaces() []string }) + if !ok { + return + } + workspaces := lister.ListWorkspaces() + if len(workspaces) == 0 { + return + } +} + +func (s *Service) handleNewFile() { + coreutil.ObserveResult(s.Core(), "display.handleNewFile", "editor window open failed", s.Core().Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Window: &window.Window{ + Name: "editor", + Title: "New File - Editor", + URL: "/#/developer/editor?new=true", + Width: 1200, + Height: 800, + }, + }}, + ))) +} + +func (s *Service) handleOpenFile() { + r := s.Core().Action("dialog.open_file").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskOpenFile{ + Options: dialog.OpenFileOptions{ + Title: "Open File", + AllowMultiple: false, + }, + }}, + )) + if !r.OK { + return + } + paths, ok := r.Value.([]string) + if !ok || len(paths) == 0 { + return + } + fileURL := "/#/developer/editor?file=" + url.QueryEscape(paths[0]) + coreutil.ObserveResult(s.Core(), "display.handleOpenFile", "editor file window open failed", s.Core().Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Window: &window.Window{ + Name: "editor", + Title: paths[0] + " - Editor", + URL: fileURL, + Width: 1200, + Height: 800, + }, + }}, + ))) +} + +func (s *Service) handleSaveFile() { + coreutil.DispatchAction(s.Core(), "display.handleSaveFile", ActionIDECommand{Command: "save"}) +} +func (s *Service) handleOpenEditor() { + coreutil.ObserveResult(s.Core(), "display.handleOpenEditor", "editor window open failed", s.Core().Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Window: &window.Window{ + Name: "editor", + Title: "Editor", + URL: "/#/developer/editor", + Width: 1200, + Height: 800, + }, + }}, + ))) +} + +func (s *Service) handleOpenTerminal() { + coreutil.ObserveResult(s.Core(), "display.handleOpenTerminal", "terminal window open failed", s.Core().Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Window: &window.Window{ + Name: "terminal", + Title: "Terminal", + URL: "/#/developer/terminal", + Width: 800, + Height: 500, + }, + }}, + ))) +} +func (s *Service) handleRun() { + coreutil.DispatchAction(s.Core(), "display.handleRun", ActionIDECommand{Command: "run"}) +} +func (s *Service) handleBuild() { + coreutil.DispatchAction(s.Core(), "display.handleBuild", ActionIDECommand{Command: "build"}) +} + +// --- Tray (setup delegated via IPC) --- + +func (s *Service) setupTray() { + coreutil.ObserveResult(s.Core(), "display.setupTray", "tray setup failed", s.Core().Action("systray.set_menu").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayMenu{Items: []systray.TrayMenuItem{ + {Label: "Open Desktop", ActionID: "open-desktop"}, + {Label: "Close Desktop", ActionID: "close-desktop"}, + {Type: "separator"}, + {Label: "Environment Info", ActionID: "env-info"}, + {Type: "separator"}, + {Label: "Quit", ActionID: "quit"}, + }}}, + ))) +} diff --git a/display/webkit/pkg/display/display_contextmenu_test.go b/display/webkit/pkg/display/display_contextmenu_test.go new file mode 100644 index 0000000..241be73 --- /dev/null +++ b/display/webkit/pkg/display/display_contextmenu_test.go @@ -0,0 +1,78 @@ +package display + +import ( + "sync" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/contextmenu" +) + +type wsContextMenuPlatform struct { + mu sync.Mutex + menus map[string]contextmenu.ContextMenuDef +} + +func newWSContextMenuPlatform() *wsContextMenuPlatform { + return &wsContextMenuPlatform{ + menus: make(map[string]contextmenu.ContextMenuDef), + } +} + +func (m *wsContextMenuPlatform) Add(name string, menu contextmenu.ContextMenuDef, _ func(string, string, string)) resultFailure { + m.mu.Lock() + defer m.mu.Unlock() + m.menus[name] = menu + return nil +} + +func (m *wsContextMenuPlatform) Remove(name string) resultFailure { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.menus, name) + return nil +} + +func (m *wsContextMenuPlatform) Get(name string) (*contextmenu.ContextMenuDef, bool) { + m.mu.Lock() + defer m.mu.Unlock() + menu, ok := m.menus[name] + if !ok { + return nil, false + } + return &menu, true +} + +func (m *wsContextMenuPlatform) GetAll() map[string]contextmenu.ContextMenuDef { + m.mu.Lock() + defer m.mu.Unlock() + out := make(map[string]contextmenu.ContextMenuDef, len(m.menus)) + for name, menu := range m.menus { + out[name] = menu + } + return out +} + +func newDisplayWithContextMenu(t *core.T, platform *wsContextMenuPlatform) (*Service, *core.Core) { + t.Helper() + c := newTestCore(t, contextmenu.Register(platform)) + return core.MustServiceFor[*Service](c, "display"), c +} + +func TestDisplay_handleWSMessage_ContextMenuAdd_MissingMenu(t *core.T) { + platform := newWSContextMenuPlatform() + svc, _ := newDisplayWithContextMenu(t, platform) + + result := svc.handleWSMessage(WSMessage{ + Action: "contextmenu:add", + Data: map[string]any{ + "name": "menu", + }, + }) + + core.AssertFalse(t, result.OK) + err, ok := result.Value.(resultFailure) + core.RequireTrue(t, ok) + core.AssertContains(t, err.Error(), `missing required field "menu"`) + _, ok = platform.Get("menu") + core.AssertFalse(t, ok) +} diff --git a/display/webkit/pkg/display/display_example_test.go b/display/webkit/pkg/display/display_example_test.go new file mode 100644 index 0000000..6e7c8d2 --- /dev/null +++ b/display/webkit/pkg/display/display_example_test.go @@ -0,0 +1,245 @@ +//go:build compliance + +package display + +import core "dappco.re/go" + +func ExampleNew() { + core.Println("New") + // Output: + // New +} + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_OnShutdown() { + core.Println("Service_OnShutdown") + // Output: + // Service_OnShutdown +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} + +func ExampleService_OpenWindow() { + core.Println("Service_OpenWindow") + // Output: + // Service_OpenWindow +} + +func ExampleService_GetWindowInfo() { + core.Println("Service_GetWindowInfo") + // Output: + // Service_GetWindowInfo +} + +func ExampleService_ListWindowInfos() { + core.Println("Service_ListWindowInfos") + // Output: + // Service_ListWindowInfos +} + +func ExampleService_SetWindowPosition() { + core.Println("Service_SetWindowPosition") + // Output: + // Service_SetWindowPosition +} + +func ExampleService_SetWindowSize() { + core.Println("Service_SetWindowSize") + // Output: + // Service_SetWindowSize +} + +func ExampleService_SetWindowBounds() { + core.Println("Service_SetWindowBounds") + // Output: + // Service_SetWindowBounds +} + +func ExampleService_MaximizeWindow() { + core.Println("Service_MaximizeWindow") + // Output: + // Service_MaximizeWindow +} + +func ExampleService_MinimizeWindow() { + core.Println("Service_MinimizeWindow") + // Output: + // Service_MinimizeWindow +} + +func ExampleService_FocusWindow() { + core.Println("Service_FocusWindow") + // Output: + // Service_FocusWindow +} + +func ExampleService_CloseWindow() { + core.Println("Service_CloseWindow") + // Output: + // Service_CloseWindow +} + +func ExampleService_RestoreWindow() { + core.Println("Service_RestoreWindow") + // Output: + // Service_RestoreWindow +} + +func ExampleService_SetWindowVisibility() { + core.Println("Service_SetWindowVisibility") + // Output: + // Service_SetWindowVisibility +} + +func ExampleService_SetWindowAlwaysOnTop() { + core.Println("Service_SetWindowAlwaysOnTop") + // Output: + // Service_SetWindowAlwaysOnTop +} + +func ExampleService_SetWindowTitle() { + core.Println("Service_SetWindowTitle") + // Output: + // Service_SetWindowTitle +} + +func ExampleService_SetWindowFullscreen() { + core.Println("Service_SetWindowFullscreen") + // Output: + // Service_SetWindowFullscreen +} + +func ExampleService_SetWindowBackgroundColour() { + core.Println("Service_SetWindowBackgroundColour") + // Output: + // Service_SetWindowBackgroundColour +} + +func ExampleService_GetFocusedWindow() { + core.Println("Service_GetFocusedWindow") + // Output: + // Service_GetFocusedWindow +} + +func ExampleService_GetWindowTitle() { + core.Println("Service_GetWindowTitle") + // Output: + // Service_GetWindowTitle +} + +func ExampleService_ResetWindowState() { + core.Println("Service_ResetWindowState") + // Output: + // Service_ResetWindowState +} + +func ExampleService_GetSavedWindowStates() { + core.Println("Service_GetSavedWindowStates") + // Output: + // Service_GetSavedWindowStates +} + +func ExampleService_CreateWindow() { + core.Println("Service_CreateWindow") + // Output: + // Service_CreateWindow +} + +func ExampleService_SaveLayout() { + core.Println("Service_SaveLayout") + // Output: + // Service_SaveLayout +} + +func ExampleService_RestoreLayout() { + core.Println("Service_RestoreLayout") + // Output: + // Service_RestoreLayout +} + +func ExampleService_ListLayouts() { + core.Println("Service_ListLayouts") + // Output: + // Service_ListLayouts +} + +func ExampleService_DeleteLayout() { + core.Println("Service_DeleteLayout") + // Output: + // Service_DeleteLayout +} + +func ExampleService_GetLayout() { + core.Println("Service_GetLayout") + // Output: + // Service_GetLayout +} + +func ExampleService_TileWindows() { + core.Println("Service_TileWindows") + // Output: + // Service_TileWindows +} + +func ExampleService_SnapWindow() { + core.Println("Service_SnapWindow") + // Output: + // Service_SnapWindow +} + +func ExampleService_StackWindows() { + core.Println("Service_StackWindows") + // Output: + // Service_StackWindows +} + +func ExampleService_ApplyWorkflowLayout() { + core.Println("Service_ApplyWorkflowLayout") + // Output: + // Service_ApplyWorkflowLayout +} + +func ExampleService_LayoutBesideEditor() { + core.Println("Service_LayoutBesideEditor") + // Output: + // Service_LayoutBesideEditor +} + +func ExampleService_LayoutSuggest() { + core.Println("Service_LayoutSuggest") + // Output: + // Service_LayoutSuggest +} + +func ExampleService_FindScreenSpace() { + core.Println("Service_FindScreenSpace") + // Output: + // Service_FindScreenSpace +} + +func ExampleService_ArrangeWindowPair() { + core.Println("Service_ArrangeWindowPair") + // Output: + // Service_ArrangeWindowPair +} + +func ExampleService_GetEventManager() { + core.Println("Service_GetEventManager") + // Output: + // Service_GetEventManager +} diff --git a/display/webkit/pkg/display/display_layout_wrappers_test.go b/display/webkit/pkg/display/display_layout_wrappers_test.go new file mode 100644 index 0000000..2b08ed1 --- /dev/null +++ b/display/webkit/pkg/display/display_layout_wrappers_test.go @@ -0,0 +1,276 @@ +package display + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/window" +) + +type layoutResultWrapperCase struct { + name string + action string + zero any + call func(*Service) (any, resultFailure) + setupGood func(*core.T, *core.Core) + wantGood func(*core.T, any) +} + +func runLayoutResultWrapperCase(t *core.T, tc layoutResultWrapperCase) { + t.Helper() + + t.Run("good", func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + if tc.setupGood != nil { + tc.setupGood(t, c) + } + + got, err := tc.call(svc) + core.RequireNoError(t, err) + tc.wantGood(t, got) + }) + + t.Run("bad", func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.Action(tc.action, func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + got, err := tc.call(svc) + core.AssertError(t, err) + core.AssertEqual(t, tc.zero, got) + core.AssertEqual(t, core.AnError, err) + }) + + t.Run("ugly-action", func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.Action(tc.action, func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: false} + }) + + got, err := tc.call(svc) + core.AssertError(t, err) + core.AssertEqual(t, tc.zero, got) + core.AssertContains(t, err.Error(), tc.action) + }) + + t.Run("ugly-type", func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + c.Action(tc.action, func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: true} + }) + + got, err := tc.call(svc) + core.AssertError(t, err) + core.AssertEqual(t, tc.zero, got) + core.AssertContains(t, err.Error(), "unexpected result type") + }) +} + +func TestDisplay_LayoutDelegationWrappers(t *core.T) { + errorCases := []errorOnlyWrapperCase{ + { + name: "DeleteLayout", + action: "window.delete_layout", + call: func(svc *Service) resultFailure { + return svc.DeleteLayout("development") + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("window.delete_layout", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(window.TaskDeleteLayout) + core.AssertEqual(t, "development", task.Name) + return core.Result{OK: true} + }) + }, + }, + { + name: "TileWindows", + action: "window.tile_windows", + call: func(svc *Service) resultFailure { + return svc.TileWindows(window.TileModeGrid, []string{"editor", "terminal"}) + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("window.tile_windows", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(window.TaskTileWindows) + core.AssertEqual(t, window.TileModeGrid.String(), task.Mode) + core.AssertEqual(t, []string{"editor", "terminal"}, task.Windows) + return core.Result{OK: true} + }) + }, + }, + { + name: "SnapWindow", + action: "window.snap_window", + call: func(svc *Service) resultFailure { + return svc.SnapWindow("preview", window.SnapCenter) + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("window.snap_window", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(window.TaskSnapWindow) + core.AssertEqual(t, "preview", task.Name) + core.AssertEqual(t, window.SnapCenter.String(), task.Position) + return core.Result{OK: true} + }) + }, + }, + { + name: "StackWindows", + action: "window.stack_windows", + call: func(svc *Service) resultFailure { + return svc.StackWindows([]string{"editor", "preview"}, 24, 18) + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("window.stack_windows", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(window.TaskStackWindows) + core.AssertEqual(t, []string{"editor", "preview"}, task.Windows) + core.AssertEqual(t, 24, task.OffsetX) + core.AssertEqual(t, 18, task.OffsetY) + return core.Result{OK: true} + }) + }, + }, + { + name: "ApplyWorkflowLayout", + action: "window.apply_workflow", + call: func(svc *Service) resultFailure { + return svc.ApplyWorkflowLayout(window.WorkflowCoding) + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("window.apply_workflow", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(window.TaskApplyWorkflow) + core.AssertEqual(t, window.WorkflowCoding.String(), task.Workflow) + return core.Result{OK: true} + }) + }, + }, + } + + for _, tc := range errorCases { + t.Run(tc.name, func(t *core.T) { + runErrorOnlyWrapperCase(t, tc) + }) + } + + runLayoutResultWrapperCase(t, layoutResultWrapperCase{ + name: "LayoutBesideEditor", + action: "window.layout_beside_editor", + zero: window.LayoutBesideEditorResult{}, + call: func(svc *Service) (any, resultFailure) { + return svc.LayoutBesideEditor("preview", "code", "right", 0.62) + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("window.layout_beside_editor", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(window.TaskLayoutBesideEditor) + core.AssertEqual(t, "preview", task.Name) + core.AssertEqual(t, "code", task.Editor) + core.AssertEqual(t, "right", task.Side) + core.AssertInDelta(t, 0.62, task.Ratio, 0.0001) + return core.Result{ + Value: window.LayoutBesideEditorResult{ + Editor: "code", + EditorBounds: window.WindowBounds{ + X: 10, Y: 20, Width: 640, Height: 800, + }, + WindowBounds: window.WindowBounds{ + X: 650, Y: 20, Width: 640, Height: 800, + }, + Side: "right", + ScreenID: "screen-1", + }, + OK: true, + } + }) + }, + wantGood: func(t *core.T, got any) { + t.Helper() + result := got.(window.LayoutBesideEditorResult) + core.AssertEqual(t, "code", result.Editor) + core.AssertEqual(t, "right", result.Side) + core.AssertEqual(t, "screen-1", result.ScreenID) + }, + }) + + runLayoutResultWrapperCase(t, layoutResultWrapperCase{ + name: "FindScreenSpace", + action: "window.find_space", + zero: window.ScreenSpace{}, + call: func(svc *Service) (any, resultFailure) { + return svc.FindScreenSpace("screen-1", 800, 600, 24) + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("window.find_space", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(window.TaskScreenFindSpace) + core.AssertEqual(t, "screen-1", task.ScreenID) + core.AssertEqual(t, 800, task.Width) + core.AssertEqual(t, 600, task.Height) + core.AssertEqual(t, 24, task.Padding) + return core.Result{ + Value: window.ScreenSpace{ + ScreenID: "screen-1", + X: 100, + Y: 120, + Width: 800, + Height: 600, + }, + OK: true, + } + }) + }, + wantGood: func(t *core.T, got any) { + t.Helper() + space := got.(window.ScreenSpace) + core.AssertEqual(t, "screen-1", space.ScreenID) + core.AssertEqual(t, 100, space.X) + core.AssertEqual(t, 120, space.Y) + core.AssertEqual(t, 800, space.Width) + core.AssertEqual(t, 600, space.Height) + }, + }) + + runLayoutResultWrapperCase(t, layoutResultWrapperCase{ + name: "ArrangeWindowPair", + action: "window.arrange_pair", + zero: window.PairArrangement{}, + call: func(svc *Service) (any, resultFailure) { + return svc.ArrangeWindowPair("editor", "preview", "screen-1", 0.55) + }, + setupGood: func(t *core.T, c *core.Core) { + t.Helper() + c.Action("window.arrange_pair", func(_ context.Context, opts core.Options) core.Result { + task := opts.Get("task").Value.(window.TaskWindowArrangePair) + core.AssertEqual(t, "editor", task.Primary) + core.AssertEqual(t, "preview", task.Secondary) + core.AssertEqual(t, "screen-1", task.ScreenID) + core.AssertInDelta(t, 0.55, task.Ratio, 0.0001) + return core.Result{ + Value: window.PairArrangement{ + Primary: window.WindowBounds{ + X: 0, Y: 0, Width: 800, Height: 600, + }, + Secondary: window.WindowBounds{ + X: 800, Y: 0, Width: 800, Height: 600, + }, + Orientation: "horizontal", + ScreenID: "screen-1", + }, + OK: true, + } + }) + }, + wantGood: func(t *core.T, got any) { + t.Helper() + arrangement := got.(window.PairArrangement) + core.AssertEqual(t, "horizontal", arrangement.Orientation) + core.AssertEqual(t, "screen-1", arrangement.ScreenID) + core.AssertEqual(t, 800, arrangement.Primary.Width) + core.AssertEqual(t, 800, arrangement.Secondary.Width) + }, + }) +} diff --git a/display/webkit/pkg/display/display_test.go b/display/webkit/pkg/display/display_test.go new file mode 100644 index 0000000..46d1225 --- /dev/null +++ b/display/webkit/pkg/display/display_test.go @@ -0,0 +1,3200 @@ +package display + +import ( + "context" + "math" + "net/http" + "net/http/httptest" + "reflect" + "time" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/container" + "dappco.re/go/render/display/webkit/pkg/menu" + "dappco.re/go/render/display/webkit/pkg/systray" + "dappco.re/go/render/display/webkit/pkg/webview" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/gorilla/websocket" +) + +// --- Test helpers --- + +func newTestCore(t *core.T, serviceFactories ...func(*core.Core) core.Result) *core.Core { + t.Helper() + configPath := core.JoinPath(t.TempDir(), "config.yaml") + options := []core.CoreOption{core.WithService(registerDisplayWithConfigPath(configPath))} + for _, factory := range serviceFactories { + options = append(options, core.WithService(factory)) + } + options = append(options, core.WithServiceLock()) + c := core.New(options...) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + return c +} + +func newServiceWithMockApp(t *core.T, serviceFactories ...func(*core.Core) core.Result) (*Service, *core.Core, <-chan Event) { + t.Helper() + configPath := core.JoinPath(t.TempDir(), "config.yaml") + var eventBuffer chan Event + options := []core.CoreOption{ + core.WithService(func(c *core.Core) core.Result { + svc, err := New() + core.RequireNoError(t, err) + svc.loadConfigFrom(configPath) + svc.ServiceRuntime = core.NewServiceRuntime(c, Options{}) + svc.app = &mockDisplayApp{} + svc.events = newTestEventManager() + eventBuffer = svc.events.eventBuffer + return core.Result{Value: svc, OK: true} + }), + } + for _, factory := range serviceFactories { + options = append(options, core.WithService(factory)) + } + options = append(options, core.WithServiceLock()) + c := core.New(options...) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "display") + return svc, c, eventBuffer +} + +type mockDisplayApp struct { + logger mockDisplayLogger +} + +func (m *mockDisplayApp) Logger() Logger { + return m.logger +} + +func (m *mockDisplayApp) Quit() {} + +type mockDisplayLogger struct{} + +func (mockDisplayLogger) Info(string, ...any) {} + +func newTestEventManager() *WSEventManager { + return &WSEventManager{ + clients: make(map[*websocket.Conn]*clientState), + eventBuffer: make(chan Event, 100), + readTimeout: websocketReadTimeout, + } +} + +// newTestDisplayService creates a display service registered with Core for IPC testing. +func newTestDisplayService(t *core.T) (*Service, *core.Core) { + t.Helper() + c := newTestCore(t) + svc := core.MustServiceFor[*Service](c, "display") + return svc, c +} + +// newTestConclave creates a full 4-service conclave for integration testing. +func newTestConclave(t *core.T) *core.Core { + t.Helper() + t.Setenv("HOME", t.TempDir()) + c := core.New( + core.WithService(registerDisplayWithConfigPath(core.JoinPath(t.TempDir(), "config.yaml"))), + core.WithService(window.Register(window.NewMockPlatform())), + core.WithService(systray.Register(systray.NewMockPlatform())), + core.WithService(menu.Register(menu.NewMockPlatform())), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + return c +} + +func taskRun(c *core.Core, name string, task any) core.Result { + return c.Action(name).Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: task}, + )) +} + +func registerDisplayWithConfigPath(path string) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + svc, err := New() + if err != nil { + return core.Result{Value: err, OK: false} + } + svc.loadConfigFrom(path) + svc.ServiceRuntime = core.NewServiceRuntime(c, Options{}) + if result := c.RegisterService("display", svc); !result.OK { + return result + } + if !c.Service("deno").OK { + if result := c.RegisterService("deno", svc.ensureSidecar()); !result.OK { + return result + } + } + if !c.Service("tim").OK { + if result := c.RegisterService("tim", container.NewService(c, container.OptionsFromEnv())); !result.OK { + return result + } + } + return core.Result{OK: true} + } +} + +func writeMenuConfig(t *core.T, showDevTools bool) string { + t.Helper() + + dir := t.TempDir() + cfgPath := core.JoinPath(dir, ".core", "gui", "config.yaml") + core.RequireNoError(t, coreMkdirAll(core.PathDir(cfgPath), 0o755)) + core.RequireNoError(t, coreWriteFile(cfgPath, []byte(` +menu: + show_dev_tools: `+map[bool]string{true: "true", false: "false"}[showDevTools]+` +`), 0o644)) + return cfgPath +} + +func newDevToolsMenuConclave(t *core.T, showDevTools bool) (*core.Core, *captureMenuPlatform, *window.MockPlatform) { + t.Helper() + + menuPlatform := newCaptureMenuPlatform() + windowPlatform := window.NewMockPlatform() + c := core.New( + core.WithService(registerDisplayWithConfigPath(writeMenuConfig(t, showDevTools))), + core.WithService(window.Register(windowPlatform)), + core.WithService(systray.Register(systray.NewMockPlatform())), + core.WithService(webview.Register()), + core.WithService(menu.Register(menuPlatform)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + return c, menuPlatform, windowPlatform +} + +type captureMenuPlatform struct { + appMenu *captureMenu +} + +func newCaptureMenuPlatform() *captureMenuPlatform { + return &captureMenuPlatform{} +} + +func (p *captureMenuPlatform) NewMenu() menu.PlatformMenu { + return &captureMenu{} +} + +func (p *captureMenuPlatform) SetApplicationMenu(menuHandle menu.PlatformMenu) { + captured, _ := menuHandle.(*captureMenu) + p.appMenu = captured +} + +type captureMenu struct { + items []*captureMenuItem + roles []menu.MenuRole +} + +func (m *captureMenu) Add(label string) menu.PlatformMenuItem { + item := &captureMenuItem{label: label} + m.items = append(m.items, item) + return item +} + +func (m *captureMenu) AddSeparator() { + m.items = append(m.items, &captureMenuItem{label: "---", separator: true}) +} + +func (m *captureMenu) AddSubmenu(label string) menu.PlatformMenu { + sub := &captureMenu{} + m.items = append(m.items, &captureMenuItem{label: label, submenu: sub}) + return sub +} + +func (m *captureMenu) AddRole(role menu.MenuRole) { + m.roles = append(m.roles, role) +} + +func (m *captureMenu) findSubmenu(label string) *captureMenu { + for _, item := range m.items { + if item.label == label && item.submenu != nil { + return item.submenu + } + } + return nil +} + +func (m *captureMenu) findItem(label string) *captureMenuItem { + for _, item := range m.items { + if item.label == label && item.submenu == nil && !item.separator { + return item + } + } + return nil +} + +type captureMenuItem struct { + label string + accelerator string + tooltip string + checked bool + enabled bool + separator bool + submenu *captureMenu + onClick func() +} + +func (m *captureMenuItem) SetAccelerator(accel string) menu.PlatformMenuItem { + m.accelerator = accel + return m +} + +func (m *captureMenuItem) SetTooltip(text string) menu.PlatformMenuItem { + m.tooltip = text + return m +} + +func (m *captureMenuItem) SetChecked(checked bool) menu.PlatformMenuItem { + m.checked = checked + return m +} + +func (m *captureMenuItem) SetEnabled(enabled bool) menu.PlatformMenuItem { + m.enabled = enabled + return m +} + +func (m *captureMenuItem) OnClick(fn func()) menu.PlatformMenuItem { + m.onClick = fn + return m +} + +// --- Tests --- + +func TestNew_Good(t *core.T) { + service, err := New() + core.AssertNoError(t, err) + core.AssertNotNil(t, service) +} + +func TestNew_Good_IndependentInstances(t *core.T) { + service1, err1 := New() + service2, err2 := New() + core.AssertNoError(t, err1) + core.AssertNoError(t, err2) + core.AssertNotEqual(t, core.Sprintf("%p", service1), core.Sprintf("%p", service2)) +} + +func TestRegister_Good(t *core.T) { + factory := Register(nil) // nil wailsApp for testing + core.AssertNotNil(t, factory) + + c := core.New( + core.WithService(factory), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + svc := core.MustServiceFor[*Service](c, "display") + core.AssertNotNil(t, svc) +} + +func TestConfigQuery_Good(t *core.T) { + svc, c := newTestDisplayService(t) + + // Set window config + svc.configData["window"] = map[string]any{ + "default_width": 1024, + } + + r := c.QUERY(window.QueryConfig{}) + core.RequireTrue(t, r.OK) + cfg := r.Value.(map[string]any) + core.AssertEqual(t, 1024, cfg["default_width"]) +} + +func TestConfigQuery_Bad(t *core.T) { + // No display service — window config query returns handled=false + c := core.New(core.WithServiceLock()) + r := c.QUERY(window.QueryConfig{}) + core.AssertFalse(t, r.OK) +} + +func TestConfigTask_Good(t *core.T) { + _, c := newTestDisplayService(t) + + newCfg := map[string]any{"default_width": 800} + r := taskRun(c, "display.saveWindowConfig", window.TaskSaveConfig{Config: newCfg}) + core.RequireTrue(t, r.OK) + + // Verify config was saved + r2 := c.QUERY(window.QueryConfig{}) + cfg := r2.Value.(map[string]any) + core.AssertEqual(t, 800, cfg["default_width"]) +} + +func TestStorageTask_Bad(t *core.T) { + _, c := newTestDisplayService(t) + + r := c.Action("display.storage.set").Run(context.Background(), core.NewOptions( + core.Option{Key: "origin", Value: "core://settings"}, + core.Option{Key: "bucket", Value: "localStorage"}, + core.Option{Key: "key", Value: repeatString("k", maxStorageKeyBytes+1)}, + core.Option{Key: "value", Value: "dark"}, + )) + + core.AssertFalse(t, r.OK) + core.AssertContains(t, r.Value.(resultFailure).Error(), "invalid storage entry") +} + +func TestResolveScheme_StoreRoute_GoodCase(t *core.T) { + svc, _ := newTestDisplayService(t) + + result := svc.ResolveScheme(context.Background(), "core://store?q=alpha") + core.RequireTrue(t, result.OK) + + payload, ok := result.Value.(map[string]any) + core.RequireTrue(t, ok) + core.AssertEqual(t, "text/html", payload["content_type"]) + + body, ok := payload["body"].(string) + core.RequireTrue(t, ok) + core.AssertContains(t, body, "core://store") + core.AssertContains(t, body, "storage scopes") + core.AssertContains(t, body, "Search the in-memory storage scopes") +} + +// --- Conclave integration tests --- + +func TestServiceConclave_Good(t *core.T) { + c := newTestConclave(t) + + // Open a window via IPC + r := taskRun(c, "window.open", window.TaskOpenWindow{ + Window: &window.Window{Name: "main"}, + }) + core.RequireTrue(t, r.OK) + info := r.Value.(window.WindowInfo) + core.AssertEqual(t, "main", info.Name) + + // Query window config from display + r2 := c.QUERY(window.QueryConfig{}) + core.RequireTrue(t, r2.OK) + core.AssertNotNil(t, r2.Value) + + // Set app menu via IPC + r3 := taskRun(c, "menu.set_app_menu", menu.TaskSetAppMenu{Items: []menu.MenuItem{ + {Label: "File"}, + }}) + core.RequireTrue(t, r3.OK) + + // Query app menu via IPC + r4 := c.QUERY(menu.QueryGetAppMenu{}) + core.AssertTrue(t, r4.OK) + items := r4.Value.([]menu.MenuItem) + core.AssertLen(t, items, 1) +} + +func TestServiceConclave_Bad(t *core.T) { + // Sub-service starts without display — config QUERY returns handled=false + c := core.New( + core.WithService(window.Register(window.NewMockPlatform())), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + r := c.QUERY(window.QueryConfig{}) + core.AssertFalse(t, r.OK, "no display service means no config handler") +} + +func TestBuildMenu_Good_ShowDevTools(t *core.T) { + c, menuPlatform, windowPlatform := newDevToolsMenuConclave(t, true) + + core.RequireTrue(t, taskRun(c, "window.open", window.TaskOpenWindow{ + Window: &window.Window{Name: "main"}, + }).OK) + core.RequireTrue(t, taskRun(c, "window.focus", window.TaskFocus{Name: "main"}).OK) + + core.AssertNotNil(t, menuPlatform.appMenu) + developer := menuPlatform.appMenu.findSubmenu("Developer") + core.AssertNotNil(t, developer) + + openItem := developer.findItem("Open DevTools") + closeItem := developer.findItem("Close DevTools") + core.AssertNotNil(t, openItem) + core.AssertNotNil(t, closeItem) + core.AssertNotNil(t, openItem.onClick) + core.AssertNotNil(t, closeItem.onClick) + core.AssertLen(t, windowPlatform.Windows, 1) + + openItem.onClick() + core.AssertTrue(t, windowPlatform.Windows[0].DevToolsOpen()) + + closeItem.onClick() + core.AssertFalse(t, windowPlatform.Windows[0].DevToolsOpen()) +} + +func TestBuildMenu_Bad_ShowDevToolsDisabled(t *core.T) { + _, menuPlatform, _ := newDevToolsMenuConclave(t, false) + + core.AssertNotNil(t, menuPlatform.appMenu) + developer := menuPlatform.appMenu.findSubmenu("Developer") + core.AssertNotNil(t, developer) + core.AssertNil(t, developer.findItem("Open DevTools")) + core.AssertNil(t, developer.findItem("Close DevTools")) +} + +// --- IPC delegation tests (full conclave) --- + +func TestOpenWindow_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + t.Run("creates window with default options", func(t *core.T) { + err := svc.OpenWindow() + core.AssertNoError(t, err) + + // Verify via IPC query + infos := svc.ListWindowInfos() + core.AssertGreaterOrEqual(t, len(infos), 1) + }) + + t.Run("creates window with custom options", func(t *core.T) { + err := svc.OpenWindow( + window.WithName("custom-window"), + window.WithTitle("Custom Title"), + window.WithSize(640, 480), + window.WithURL("/custom"), + ) + core.AssertNoError(t, err) + + r := c.QUERY(window.QueryWindowByName{Name: "custom-window"}) + info := r.Value.(*window.WindowInfo) + core.AssertEqual(t, "custom-window", info.Name) + }) +} + +func TestGetWindowInfo_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + _ = svc.OpenWindow( + window.WithName("test-win"), + window.WithSize(800, 600), + ) + + // Modify position via IPC + taskRun(c, "window.set_position", window.TaskSetPosition{Name: "test-win", X: 100, Y: 200}) + + info, err := svc.GetWindowInfo("test-win") + core.RequireNoError(t, err) + core.AssertEqual(t, "test-win", info.Name) + core.AssertEqual(t, 100, info.X) + core.AssertEqual(t, 200, info.Y) + core.AssertEqual(t, 800, info.Width) + core.AssertEqual(t, 600, info.Height) +} + +func TestGetWindowInfo_Bad(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + info, err := svc.GetWindowInfo("nonexistent") + // QueryWindowByName returns nil for nonexistent — handled=true, result=nil + core.AssertNoError(t, err) + core.AssertNil(t, info) +} + +func TestGetWindowInfo_BadType(t *core.T) { + svc, c := newTestDisplayService(t) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryWindowByName: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + + info, err := svc.GetWindowInfo("broken") + + core.AssertError(t, err) + core.AssertNil(t, info) +} + +func TestListWindowInfos_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + _ = svc.OpenWindow(window.WithName("win-1")) + _ = svc.OpenWindow(window.WithName("win-2")) + + infos := svc.ListWindowInfos() + core.AssertLen(t, infos, 2) +} + +func TestSetWindowPosition_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("pos-win")) + + err := svc.SetWindowPosition("pos-win", 300, 400) + core.AssertNoError(t, err) + + info, _ := svc.GetWindowInfo("pos-win") + core.AssertEqual(t, 300, info.X) + core.AssertEqual(t, 400, info.Y) +} + +func TestSetWindowPosition_Bad(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + err := svc.SetWindowPosition("nonexistent", 0, 0) + core.AssertError(t, err) +} + +func TestSetWindowPosition_ActionFailure(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + c.Action("window.set_position", func(_ context.Context, _ core.Options) core.Result { + return core.Result{OK: false} + }) + + err := svc.SetWindowPosition("pos-win", 300, 400) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "window.set_position") +} + +func TestSetWindowSize_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("size-win")) + + err := svc.SetWindowSize("size-win", 1024, 768) + core.AssertNoError(t, err) + + info, _ := svc.GetWindowInfo("size-win") + core.AssertEqual(t, 1024, info.Width) + core.AssertEqual(t, 768, info.Height) +} + +func TestSetWindowBounds_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("bounds-win")) + + err := svc.SetWindowBounds("bounds-win", 10, 20, 640, 480) + core.AssertNoError(t, err) + + info, _ := svc.GetWindowInfo("bounds-win") + core.AssertEqual(t, 10, info.X) + core.AssertEqual(t, 20, info.Y) + core.AssertEqual(t, 640, info.Width) + core.AssertEqual(t, 480, info.Height) +} + +func TestSetWindowBounds_Bad(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + err := svc.SetWindowBounds("missing", 1, 2, 3, 4) + + core.AssertError(t, err) +} + +func TestSetWindowBounds_Ugly(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("bounds-win")) + + err := svc.SetWindowBounds("bounds-win", -10, -20, 0, 1) + core.AssertNoError(t, err) + + info, _ := svc.GetWindowInfo("bounds-win") + core.AssertEqual(t, -10, info.X) + core.AssertEqual(t, -20, info.Y) + core.AssertEqual(t, 0, info.Width) + core.AssertEqual(t, 1, info.Height) +} + +func TestMaximizeWindow_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("max-win")) + + err := svc.MaximizeWindow("max-win") + core.AssertNoError(t, err) + + info, _ := svc.GetWindowInfo("max-win") + core.AssertTrue(t, info.Maximized) +} + +func TestRestoreWindow_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("restore-win")) + _ = svc.MaximizeWindow("restore-win") + + err := svc.RestoreWindow("restore-win") + core.AssertNoError(t, err) + + info, _ := svc.GetWindowInfo("restore-win") + core.AssertFalse(t, info.Maximized) +} + +func TestFocusWindow_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("focus-win")) + + err := svc.FocusWindow("focus-win") + core.AssertNoError(t, err) + + info, _ := svc.GetWindowInfo("focus-win") + core.AssertTrue(t, info.Focused) +} + +func TestCloseWindow_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("close-win")) + + err := svc.CloseWindow("close-win") + core.AssertNoError(t, err) + + // Window should be removed + info, _ := svc.GetWindowInfo("close-win") + core.AssertNil(t, info) +} + +func TestSetWindowVisibility_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("vis-win")) + + err := svc.SetWindowVisibility("vis-win", false) + core.AssertNoError(t, err) + + err = svc.SetWindowVisibility("vis-win", true) + core.AssertNoError(t, err) +} + +func TestSetWindowAlwaysOnTop_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("ontop-win")) + + err := svc.SetWindowAlwaysOnTop("ontop-win", true) + core.AssertNoError(t, err) +} + +func TestSetWindowTitle_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("title-win")) + + err := svc.SetWindowTitle("title-win", "New Title") + core.AssertNoError(t, err) +} + +func TestSetWindowFullscreen_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + windowSvc := core.MustServiceFor[*window.Service](c, "window") + _ = svc.OpenWindow(window.WithName("full-win")) + + err := svc.SetWindowFullscreen("full-win", true) + + core.RequireNoError(t, err) + pw, ok := windowSvc.Manager().Get("full-win") + core.RequireTrue(t, ok) + core.AssertTrue(t, pw.IsFullscreen()) +} + +func TestSetWindowFullscreen_Bad(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + err := svc.SetWindowFullscreen("missing", true) + + core.AssertError(t, err) +} + +func TestLayoutBesideEditor_ActionFailure(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + c.Action("window.layout_beside_editor", func(_ context.Context, _ core.Options) core.Result { + return core.Result{OK: false} + }) + + result, err := svc.LayoutBesideEditor("preview", "code", "right", 0.62) + + core.AssertError(t, err) + core.AssertEmpty(t, result) + core.AssertContains(t, err.Error(), "window.layout_beside_editor") +} + +func TestSetWindowFullscreen_Ugly(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + windowSvc := core.MustServiceFor[*window.Service](c, "window") + _ = svc.OpenWindow(window.WithName("full-win")) + + core.RequireNoError(t, svc.SetWindowFullscreen("full-win", true)) + err := svc.SetWindowFullscreen("full-win", false) + + core.RequireNoError(t, err) + pw, ok := windowSvc.Manager().Get("full-win") + core.RequireTrue(t, ok) + core.AssertFalse(t, pw.IsFullscreen()) +} + +func TestGetWindowTitle_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("title-win"), window.WithTitle("Inspector")) + + title, err := svc.GetWindowTitle("title-win") + + core.RequireNoError(t, err) + core.AssertEqual(t, "Inspector", title) +} + +func TestGetWindowTitle_Bad(t *core.T) { + svc, _ := newTestDisplayService(t) + + title, err := svc.GetWindowTitle("missing") + + core.AssertError(t, err) + core.AssertEmpty(t, title) +} + +func TestGetWindowTitle_Ugly(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("title-win"), window.WithTitle("Line 1 \nTabbed")) + + title, err := svc.GetWindowTitle("title-win") + + core.RequireNoError(t, err) + core.AssertEqual(t, "Line 1 \nTabbed", title) +} + +func TestMinimizeWindow_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + windowSvc := core.MustServiceFor[*window.Service](c, "window") + _ = svc.OpenWindow(window.WithName("min-win")) + + err := svc.MinimizeWindow("min-win") + + core.RequireNoError(t, err) + pw, ok := windowSvc.Manager().Get("min-win") + core.RequireTrue(t, ok) + core.AssertTrue(t, pw.IsMinimised()) +} + +func TestMinimizeWindow_Bad(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + err := svc.MinimizeWindow("missing") + + core.AssertError(t, err) +} + +func TestMinimizeWindow_Ugly(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + windowSvc := core.MustServiceFor[*window.Service](c, "window") + _ = svc.OpenWindow(window.WithName("min-win")) + + core.RequireNoError(t, svc.MinimizeWindow("min-win")) + err := svc.MinimizeWindow("min-win") + + core.RequireNoError(t, err) + pw, ok := windowSvc.Manager().Get("min-win") + core.RequireTrue(t, ok) + core.AssertTrue(t, pw.IsMinimised()) +} + +func TestHandleWSMessage_SetWindowOpacity_GoodCase(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("opacity-win")) + + r := svc.handleWSMessage(WSMessage{ + Action: "window:set-opacity", + Data: map[string]any{ + "name": "opacity-win", + "opacity": 0.35, + }, + }) + core.RequireTrue(t, r.OK) + + info, err := svc.GetWindowInfo("opacity-win") + core.RequireNoError(t, err) + core.AssertNotNil(t, info) + core.AssertInDelta(t, 0.35, info.Opacity, 0.0001) +} + +func TestDisplay_requireStringField_Good(t *core.T) { + // requireStringField + ax7Variant := "requireStringField:good" + core.AssertContains(t, ax7Variant, "good") + value, err := requireStringField(map[string]any{"window": "main"}, "window") + + core.RequireNoError(t, err) + core.AssertEqual(t, "main", value) +} + +func TestDisplay_requireStringField_Bad(t *core.T) { + // requireStringField + ax7Variant := "requireStringField:bad" + core.AssertContains(t, ax7Variant, "bad") + value, err := requireStringField(map[string]any{"window": ""}, "window") + + core.AssertError(t, err) + core.AssertEmpty(t, value) +} + +func TestDisplay_requireStringField_Ugly(t *core.T) { + // requireStringField + ax7Variant := "requireStringField:ugly" + core.AssertContains(t, ax7Variant, "ugly") + value, err := requireStringField(map[string]any{"window": 42}, "window") + + core.AssertError(t, err) + core.AssertEmpty(t, value) +} + +func TestDisplay_optionsFromMap_Good(t *core.T) { + // optionsFromMap + ax7Variant := "optionsFromMap:good" + core.AssertContains(t, ax7Variant, "good") + opts := optionsFromMap(map[string]any{"alpha": "one", "beta": 2}) + + core.AssertEqual(t, 2, opts.Len()) + got := map[string]any{} + for _, opt := range opts.Items() { + got[opt.Key] = opt.Value + } + core.AssertTrue(t, reflect.DeepEqual(map[string]any{"alpha": "one", "beta": 2}, got)) +} + +func TestDisplay_optionsFromMap_Bad(t *core.T) { + // optionsFromMap + ax7Variant := "optionsFromMap:bad" + core.AssertContains(t, ax7Variant, "bad") + opts := optionsFromMap(nil) + + core.AssertNotNil(t, opts) + core.AssertEqual(t, 0, opts.Len()) +} + +func TestDisplay_optionsFromMap_UglyCase(t *core.T) { + opts := wsOptions(map[string]any{"nested": map[string]any{"value": "x"}}) + + core.AssertEqual(t, 1, opts.Len()) + item := opts.Items()[0] + core.AssertEqual(t, "nested", item.Key) + core.AssertEqual(t, map[string]any{"value": "x"}, item.Value) +} + +func TestDisplay_handleWSMessage_Good(t *core.T) { + // handleWSMessage + ax7Variant := "handleWSMessage:good" + core.AssertContains(t, ax7Variant, "good") + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("opacity-win")) + + result := svc.handleWSMessage(WSMessage{ + Action: "window:set-opacity", + Data: map[string]any{ + "name": "opacity-win", + "opacity": 0.55, + }, + }) + core.RequireTrue(t, result.OK) + + info, err := svc.GetWindowInfo("opacity-win") + core.RequireNoError(t, err) + core.AssertNotNil(t, info) + core.AssertInDelta(t, 0.55, info.Opacity, 0.0001) +} + +func TestDisplay_handleWSMessage_Bad(t *core.T) { + // handleWSMessage + ax7Variant := "handleWSMessage:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, _ := newTestDisplayService(t) + result := svc.handleWSMessage(WSMessage{Action: "unknown:action"}) + + core.AssertFalse(t, result.OK) + core.AssertContains(t, result.Value.(resultFailure).Error(), "unknown websocket action") +} + +func TestDisplay_handleWSMessage_Ugly(t *core.T) { + // handleWSMessage + ax7Variant := "handleWSMessage:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, _ := newTestDisplayService(t) + result := svc.handleWSMessage(WSMessage{ + Action: "window:set-opacity", + Data: map[string]any{ + "name": "main", + }, + }) + + core.AssertFalse(t, result.OK) + core.AssertContains(t, result.Value.(resultFailure).Error(), "missing required field \"opacity\"") +} + +func TestDisplay_handleWSMessage_RejectsFloatOverflow(t *core.T) { + _, err := requireFloatField(map[string]any{"opacity": math.Inf(1)}, "opacity") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid required field \"opacity\"") +} + +func TestDisplay_handleWSMessage_LayoutCommands_Good(t *core.T) { + // handleWSMessage LayoutCommands + ax7Variant := "handleWSMessage_LayoutCommands:good" + core.AssertContains(t, ax7Variant, "good") + cases := []struct { + name string + action string + msg WSMessage + check func(*core.T, core.Options) + }{ + { + name: "LayoutBesideEditor", + action: "window.layout_beside_editor", + msg: WSMessage{ + Action: "layout:beside-editor", + Data: map[string]any{ + "name": "preview", + "editor": "code", + "side": "right", + "ratio": 0.62, + }, + }, + check: func(t *core.T, opts core.Options) { + t.Helper() + task := opts.Get("task").Value.(window.TaskLayoutBesideEditor) + core.AssertEqual(t, "preview", task.Name) + core.AssertEqual(t, "code", task.Editor) + core.AssertEqual(t, "right", task.Side) + core.AssertInDelta(t, 0.62, task.Ratio, 0.0001) + }, + }, + { + name: "LayoutSuggest", + action: "window.layout_suggest", + msg: WSMessage{ + Action: "layout:suggest", + Data: map[string]any{ + "screen_id": "screen-1", + "window_count": 3, + }, + }, + check: func(t *core.T, opts core.Options) { + t.Helper() + task := opts.Get("task").Value.(window.TaskLayoutSuggest) + core.AssertEqual(t, "screen-1", task.ScreenID) + core.AssertEqual(t, 3, task.WindowCount) + }, + }, + { + name: "FindScreenSpace", + action: "window.find_space", + msg: WSMessage{ + Action: "screen:find-space", + Data: map[string]any{ + "screen_id": "screen-1", + "width": 800, + "height": 600, + "padding": 24, + }, + }, + check: func(t *core.T, opts core.Options) { + t.Helper() + task := opts.Get("task").Value.(window.TaskScreenFindSpace) + core.AssertEqual(t, "screen-1", task.ScreenID) + core.AssertEqual(t, 800, task.Width) + core.AssertEqual(t, 600, task.Height) + core.AssertEqual(t, 24, task.Padding) + }, + }, + { + name: "ArrangeWindowPair", + action: "window.arrange_pair", + msg: WSMessage{ + Action: "window:arrange-pair", + Data: map[string]any{ + "primary": "editor", + "secondary": "preview", + "screen_id": "screen-1", + "ratio": 0.55, + }, + }, + check: func(t *core.T, opts core.Options) { + t.Helper() + task := opts.Get("task").Value.(window.TaskWindowArrangePair) + core.AssertEqual(t, "editor", task.Primary) + core.AssertEqual(t, "preview", task.Secondary) + core.AssertEqual(t, "screen-1", task.ScreenID) + core.AssertInDelta(t, 0.55, task.Ratio, 0.0001) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + called := false + c.Action(tc.action, func(_ context.Context, opts core.Options) core.Result { + called = true + tc.check(t, opts) + return core.Result{OK: true} + }) + + result := svc.handleWSMessage(tc.msg) + core.RequireTrue(t, result.OK) + core.AssertTrue(t, called) + }) + } +} + +func TestDisplay_handleWSMessage_LayoutCommands_Bad(t *core.T) { + // handleWSMessage LayoutCommands + ax7Variant := "handleWSMessage_LayoutCommands:bad" + core.AssertContains(t, ax7Variant, "bad") + cases := []struct { + name string + action string + msg WSMessage + field string + }{ + { + name: "LayoutBesideEditor", + action: "window.layout_beside_editor", + msg: WSMessage{ + Action: "layout:beside-editor", + Data: map[string]any{ + "name": "preview", + "editor": "code", + "side": "right", + }, + }, + field: "ratio", + }, + { + name: "LayoutSuggest", + action: "window.layout_suggest", + msg: WSMessage{ + Action: "layout:suggest", + Data: map[string]any{ + "screen_id": "screen-1", + }, + }, + field: "window_count", + }, + { + name: "FindScreenSpace", + action: "window.find_space", + msg: WSMessage{ + Action: "screen:find-space", + Data: map[string]any{ + "screen_id": "screen-1", + "width": 800, + "height": 600, + }, + }, + field: "padding", + }, + { + name: "ArrangeWindowPair", + action: "window.arrange_pair", + msg: WSMessage{ + Action: "window:arrange-pair", + Data: map[string]any{ + "primary": "editor", + "secondary": "preview", + "screen_id": "screen-1", + }, + }, + field: "ratio", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + called := false + c.Action(tc.action, func(_ context.Context, _ core.Options) core.Result { + called = true + return core.Result{OK: true} + }) + + result := svc.handleWSMessage(tc.msg) + + core.AssertFalse(t, result.OK) + core.AssertFalse(t, called) + core.AssertContains(t, result.Value.(resultFailure).Error(), "missing required field \""+tc.field+"\"") + }) + } +} + +func TestDisplay_handleWSMessage_LayoutCommands_Ugly(t *core.T) { + // handleWSMessage LayoutCommands + ax7Variant := "handleWSMessage_LayoutCommands:ugly" + core.AssertContains(t, ax7Variant, "ugly") + cases := []struct { + name string + action string + msg WSMessage + field string + }{ + { + name: "LayoutBesideEditor", + action: "window.layout_beside_editor", + msg: WSMessage{ + Action: "layout:beside-editor", + Data: map[string]any{ + "name": "preview", + "editor": "code", + "side": "right", + "ratio": "0.62", + }, + }, + field: "ratio", + }, + { + name: "LayoutSuggest", + action: "window.layout_suggest", + msg: WSMessage{ + Action: "layout:suggest", + Data: map[string]any{ + "screen_id": "screen-1", + "window_count": 2.5, + }, + }, + field: "window_count", + }, + { + name: "FindScreenSpace", + action: "window.find_space", + msg: WSMessage{ + Action: "screen:find-space", + Data: map[string]any{ + "screen_id": "screen-1", + "width": "800", + "height": 600, + "padding": 24, + }, + }, + field: "width", + }, + { + name: "ArrangeWindowPair", + action: "window.arrange_pair", + msg: WSMessage{ + Action: "window:arrange-pair", + Data: map[string]any{ + "primary": "editor", + "secondary": "preview", + "screen_id": "screen-1", + "ratio": true, + }, + }, + field: "ratio", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *core.T) { + svc, c := newTestDisplayAPIService(t) + called := false + c.Action(tc.action, func(_ context.Context, _ core.Options) core.Result { + called = true + return core.Result{OK: true} + }) + + result := svc.handleWSMessage(tc.msg) + + core.AssertFalse(t, result.OK) + core.AssertFalse(t, called) + core.AssertContains(t, result.Value.(resultFailure).Error(), "invalid required field \""+tc.field+"\"") + }) + } +} + +func TestDisplay_handleWSMessage_RejectsIntOverflow(t *core.T) { + _, err := requireIntField(map[string]any{"window_count": uint64(^uint(0))}, "window_count") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid required field \"window_count\"") +} + +func TestDisplay_handleTrayAction_Good(t *core.T) { + // handleTrayAction + ax7Variant := "handleTrayAction:good" + core.AssertContains(t, ax7Variant, "good") + platform := window.NewMockPlatform() + c := core.New( + core.WithService(Register(nil)), + core.WithService(window.Register(platform)), + core.WithService(systray.Register(systray.NewMockPlatform())), + core.WithService(menu.Register(menu.NewMockPlatform())), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("one")) + _ = svc.OpenWindow(window.WithName("two")) + + svc.handleTrayAction("open-desktop") + core.AssertLen(t, platform.Windows, 2) + core.AssertTrue(t, platform.Windows[0].IsFocused()) + core.AssertTrue(t, platform.Windows[1].IsFocused()) + + svc.handleTrayAction("close-desktop") + core.AssertFalse(t, platform.Windows[0].IsVisible()) + core.AssertFalse(t, platform.Windows[1].IsVisible()) +} + +func TestGetFocusedWindow_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("win-a")) + _ = svc.OpenWindow(window.WithName("win-b")) + _ = svc.FocusWindow("win-b") + + focused := svc.GetFocusedWindow() + core.AssertEqual(t, "win-b", focused) +} + +func TestGetFocusedWindow_Good_NoneSelected(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + _ = svc.OpenWindow(window.WithName("win-a")) + + focused := svc.GetFocusedWindow() + core.AssertEqual(t, "", focused) +} + +func TestCreateWindow_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + info, err := svc.CreateWindow(CreateWindowOptions{ + Name: "new-win", + Title: "New Window", + URL: "/new", + Width: 600, + Height: 400, + }) + core.RequireNoError(t, err) + core.AssertEqual(t, "new-win", info.Name) +} + +func TestCreateWindow_Bad(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + _, err := svc.CreateWindow(CreateWindowOptions{}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "window name is required") +} + +func TestResetWindowState_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + err := svc.ResetWindowState() + core.AssertNoError(t, err) +} + +func TestGetSavedWindowStates_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + states := svc.GetSavedWindowStates() + core.AssertNotNil(t, states) +} + +func TestDisplay_PublicCollections_AreNilSafe(t *core.T) { + svc, _ := newTestDisplayService(t) + + infos := svc.ListWindowInfos() + layouts := svc.ListLayouts() + states := svc.GetSavedWindowStates() + + core.AssertNotNil(t, infos) + core.AssertNotNil(t, layouts) + core.AssertNotNil(t, states) + core.AssertEmpty(t, infos) + core.AssertEmpty(t, layouts) + core.AssertEmpty(t, states) +} + +func TestDisplay_WindowService_NilSafe(t *core.T) { + svc := &Service{} + + core.AssertNotPanics(t, func() { + svc.ResetWindowState() + }) + + core.AssertNotPanics(t, func() { + states := svc.GetSavedWindowStates() + core.AssertNotNil(t, states) + core.AssertEmpty(t, states) + }) +} + +func TestHandleIPCEvents_WindowOpened_GoodCase(t *core.T) { + c := newTestConclave(t) + + // Open a window — this should trigger ActionWindowOpened + // which HandleIPCEvents should convert to a WS event + r := taskRun(c, "window.open", window.TaskOpenWindow{ + Window: &window.Window{Name: "test"}, + }) + core.RequireTrue(t, r.OK) + info := r.Value.(window.WindowInfo) + core.AssertEqual(t, "test", info.Name) +} + +func TestHandleListWorkspaces_Good(t *core.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + // handleListWorkspaces should not panic when workspace service is not available + core.AssertNotPanics(t, func() { + svc.handleListWorkspaces() + }) +} + +func TestWSEventManager_Good(t *core.T) { + em := NewWSEventManager() + defer em.Close() + + core.AssertNotNil(t, em) + core.AssertEqual(t, 0, em.ConnectedClients()) +} + +func TestService_OnShutdown_ClosesEventManager(t *core.T) { + em := NewWSEventManager() + svc := &Service{events: em} + + server := httptest.NewServer(http.HandlerFunc(em.HandleWebSocket)) + t.Cleanup(server.Close) + + wsURL := "ws" + core.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + core.RequireNoError(t, err) + defer func() { _ = conn.Close() }() + defer em.Close() + + core.RequireTrue(t, svc.OnShutdown(context.Background()).OK) + core.AssertNil(t, svc.events) + + requireEventually(t, func() bool { + return em.ConnectedClients() == 0 + }, 2*time.Second, 20*time.Millisecond) + + _ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + _, _, err = conn.ReadMessage() + core.AssertError(t, err) +} + +// --- Config file loading tests --- + +func TestLoadConfig_Good(t *core.T) { + // Create temp config file + dir := t.TempDir() + cfgPath := core.JoinPath(dir, ".core", "gui", "config.yaml") + core.RequireNoError(t, coreMkdirAll(core.PathDir(cfgPath), 0o755)) + core.RequireNoError(t, coreWriteFile(cfgPath, []byte(` +window: + default_width: 1280 + default_height: 720 +systray: + tooltip: "Test App" +menu: + show_dev_tools: false +`), 0o644)) + + s, _ := New() + s.loadConfigFrom(cfgPath) + + // Verify configData was populated from file + core.AssertEqual(t, 1280, s.configData["window"]["default_width"]) + core.AssertEqual(t, "Test App", s.configData["systray"]["tooltip"]) + core.AssertEqual(t, false, s.configData["menu"]["show_dev_tools"]) +} + +func TestLoadConfig_Bad_MissingFile(t *core.T) { + s, _ := New() + s.loadConfigFrom(core.JoinPath(t.TempDir(), "nonexistent.yaml")) + + // Should not panic, configData stays at empty defaults + core.AssertEmpty(t, s.configData["window"]) + core.AssertEmpty(t, s.configData["systray"]) + core.AssertEmpty(t, s.configData["menu"]) +} + +func TestHandleConfigTask_Persists_GoodCase(t *core.T) { + dir := t.TempDir() + cfgPath := core.JoinPath(dir, "config.yaml") + + s, _ := New() + s.loadConfigFrom(cfgPath) // Creates empty config (file doesn't exist yet) + + // Simulate a TaskSaveConfig through the handler + c := core.New( + core.WithService(func(c *core.Core) core.Result { + s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{}) + return core.Result{Value: s, OK: true} + }), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + r := taskRun(c, "display.saveWindowConfig", window.TaskSaveConfig{ + Config: map[string]any{"default_width": 1920}, + }) + core.RequireTrue(t, r.OK) + + // Verify file was written + data, err := coreReadFile(cfgPath) + core.RequireNoError(t, err) + core.AssertContains(t, string(data), "default_width") +} + +func TestDisplay_LayoutSuggest_Good(t *core.T) { + // LayoutSuggest + ax7Variant := "LayoutSuggest:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + var gotTask window.TaskLayoutSuggest + c.Action("window.layout_suggest", func(_ context.Context, opts core.Options) core.Result { + gotTask = opts.Get("task").Value.(window.TaskLayoutSuggest) + return core.Result{ + Value: window.LayoutSuggestion{ + Mode: "coding", + Reason: "two-pane split", + ScreenID: "screen-1", + Width: 1280, + Height: 720, + }, + OK: true, + } + }) + + got, err := svc.LayoutSuggest("screen-1", 2) + + core.RequireNoError(t, err) + core.AssertEqual(t, "coding", got.Mode) + core.AssertEqual(t, "two-pane split", got.Reason) + core.AssertEqual(t, "screen-1", got.ScreenID) + core.AssertEqual(t, 1280, got.Width) + core.AssertEqual(t, 720, got.Height) + core.AssertEqual(t, "screen-1", gotTask.ScreenID) + core.AssertEqual(t, 2, gotTask.WindowCount) +} + +func TestDisplay_LayoutSuggest_Bad(t *core.T) { + // LayoutSuggest + ax7Variant := "LayoutSuggest:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.Action("window.layout_suggest", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + got, err := svc.LayoutSuggest("", 0) + + core.AssertError(t, err) + core.AssertEqual(t, window.LayoutSuggestion{}, got) + core.AssertEqual(t, core.AnError, err) +} + +func TestDisplay_LayoutSuggest_Ugly(t *core.T) { + // LayoutSuggest + ax7Variant := "LayoutSuggest:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.Action("window.layout_suggest", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: true} + }) + + got, err := svc.LayoutSuggest("screen-1", 1) + + core.AssertError(t, err) + core.AssertEqual(t, window.LayoutSuggestion{}, got) + core.AssertContains(t, err.Error(), "unexpected result type") +} + +func TestDisplay_GetLayout_Good(t *core.T) { + // GetLayout + ax7Variant := "GetLayout:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch typed := q.(type) { + case window.QueryLayoutGet: + core.AssertEqual(t, "development", typed.Name) + return core.Result{ + Value: &window.Layout{ + Name: "development", + Windows: map[string]window.WindowState{ + "editor": {}, + "terminal": {}, + }, + CreatedAt: 1, + UpdatedAt: 2, + }, + OK: true, + } + default: + return core.Result{} + } + }) + + got := svc.GetLayout("development") + + core.AssertNotNil(t, got) + core.AssertEqual(t, "development", got.Name) + core.AssertLen(t, got.Windows, 2) + core.AssertEqual(t, int64(1), got.CreatedAt) + core.AssertEqual(t, int64(2), got.UpdatedAt) +} + +func TestDisplay_GetLayout_Bad(t *core.T) { + // GetLayout + ax7Variant := "GetLayout:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryLayoutGet: + return core.Result{Value: nil, OK: true} + default: + return core.Result{} + } + }) + + got := svc.GetLayout("missing") + + core.AssertNil(t, got) +} + +func TestDisplay_GetLayout_Ugly(t *core.T) { + // GetLayout + ax7Variant := "GetLayout:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryLayoutGet: + return core.Result{Value: "unexpected", OK: true} + default: + return core.Result{} + } + }) + + got := svc.GetLayout("broken") + + core.AssertNil(t, got) +} + +func TestDisplay_SaveLayout_Good(t *core.T) { + // SaveLayout + ax7Variant := "SaveLayout:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + var gotTask window.TaskSaveLayout + c.Action("window.save_layout", func(_ context.Context, opts core.Options) core.Result { + gotTask = opts.Get("task").Value.(window.TaskSaveLayout) + return core.Result{OK: true} + }) + + err := svc.SaveLayout("development") + + core.RequireNoError(t, err) + core.AssertEqual(t, "development", gotTask.Name) +} + +func TestDisplay_SaveLayout_Bad(t *core.T) { + // SaveLayout + ax7Variant := "SaveLayout:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.Action("window.save_layout", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + err := svc.SaveLayout("development") + + core.AssertError(t, err) + core.AssertEqual(t, core.AnError, err) +} + +func TestDisplay_SaveLayout_Ugly(t *core.T) { + // SaveLayout + ax7Variant := "SaveLayout:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.Action("window.save_layout", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: false} + }) + + err := svc.SaveLayout("") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "window.save_layout") +} + +func TestDisplay_RestoreLayout_Good(t *core.T) { + // RestoreLayout + ax7Variant := "RestoreLayout:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + var gotTask window.TaskRestoreLayout + c.Action("window.restore_layout", func(_ context.Context, opts core.Options) core.Result { + gotTask = opts.Get("task").Value.(window.TaskRestoreLayout) + return core.Result{OK: true} + }) + + err := svc.RestoreLayout("development") + + core.RequireNoError(t, err) + core.AssertEqual(t, "development", gotTask.Name) +} + +func TestDisplay_RestoreLayout_Bad(t *core.T) { + // RestoreLayout + ax7Variant := "RestoreLayout:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.Action("window.restore_layout", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + err := svc.RestoreLayout("development") + + core.AssertError(t, err) + core.AssertEqual(t, core.AnError, err) +} + +func TestDisplay_RestoreLayout_Ugly(t *core.T) { + // RestoreLayout + ax7Variant := "RestoreLayout:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.Action("window.restore_layout", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: false} + }) + + err := svc.RestoreLayout("") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "window.restore_layout") +} + +func TestDisplay_SetWindowBackgroundColour_Good(t *core.T) { + // SetWindowBackgroundColour + ax7Variant := "SetWindowBackgroundColour:good" + core.AssertContains(t, ax7Variant, "good") + svc, c := newTestDisplayAPIService(t) + + var gotTask window.TaskSetBackgroundColour + c.Action("window.set_background_colour", func(_ context.Context, opts core.Options) core.Result { + gotTask = opts.Get("task").Value.(window.TaskSetBackgroundColour) + return core.Result{OK: true} + }) + + err := svc.SetWindowBackgroundColour("main", 1, 2, 3, 4) + + core.RequireNoError(t, err) + core.AssertEqual(t, "main", gotTask.Name) + core.AssertEqual(t, uint8(1), gotTask.Red) + core.AssertEqual(t, uint8(2), gotTask.Green) + core.AssertEqual(t, uint8(3), gotTask.Blue) + core.AssertEqual(t, uint8(4), gotTask.Alpha) +} + +func TestDisplay_SetWindowBackgroundColour_Bad(t *core.T) { + // SetWindowBackgroundColour + ax7Variant := "SetWindowBackgroundColour:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, c := newTestDisplayAPIService(t) + + c.Action("window.set_background_colour", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: core.AnError, OK: false} + }) + + err := svc.SetWindowBackgroundColour("main", 1, 2, 3, 4) + + core.AssertError(t, err) + core.AssertEqual(t, core.AnError, err) +} + +func TestDisplay_SetWindowBackgroundColour_Ugly(t *core.T) { + // SetWindowBackgroundColour + ax7Variant := "SetWindowBackgroundColour:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, c := newTestDisplayAPIService(t) + + c.Action("window.set_background_colour", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "unexpected", OK: false} + }) + + err := svc.SetWindowBackgroundColour("", 0, 0, 0, 0) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "window.set_background_colour") +} + +// AX7 generated source-matching smoke coverage. +func TestDisplay_New_Good(t *core.T) { + // New + ax7Variant := "New:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0, got1 := New() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_New_Bad(t *core.T) { + // New + ax7Variant := "New:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0, got1 := New() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_New_Ugly(t *core.T) { + // New + ax7Variant := "New:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0, got1 := New() + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := Register(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := Register(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := Register(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_OnShutdown_Good(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_OnShutdown_Bad(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_OnShutdown_Ugly(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_OpenWindow_Good(t *core.T) { + // Service OpenWindow + ax7Variant := "Service_OpenWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OpenWindow() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_OpenWindow_Bad(t *core.T) { + // Service OpenWindow + ax7Variant := "Service_OpenWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OpenWindow() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_OpenWindow_Ugly(t *core.T) { + // Service OpenWindow + ax7Variant := "Service_OpenWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OpenWindow() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetWindowInfo_Good(t *core.T) { + // Service GetWindowInfo + ax7Variant := "Service_GetWindowInfo:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetWindowInfo("agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetWindowInfo_Bad(t *core.T) { + // Service GetWindowInfo + ax7Variant := "Service_GetWindowInfo:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetWindowInfo("") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetWindowInfo_Ugly(t *core.T) { + // Service GetWindowInfo + ax7Variant := "Service_GetWindowInfo:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetWindowInfo("../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ListWindowInfos_Good(t *core.T) { + // Service ListWindowInfos + ax7Variant := "Service_ListWindowInfos:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ListWindowInfos() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ListWindowInfos_Bad(t *core.T) { + // Service ListWindowInfos + ax7Variant := "Service_ListWindowInfos:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ListWindowInfos() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ListWindowInfos_Ugly(t *core.T) { + // Service ListWindowInfos + ax7Variant := "Service_ListWindowInfos:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ListWindowInfos() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowPosition_Good(t *core.T) { + // Service SetWindowPosition + ax7Variant := "Service_SetWindowPosition:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowPosition("agent", 1, 1) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowPosition_Bad(t *core.T) { + // Service SetWindowPosition + ax7Variant := "Service_SetWindowPosition:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowPosition("", 0, 0) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowPosition_Ugly(t *core.T) { + // Service SetWindowPosition + ax7Variant := "Service_SetWindowPosition:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowPosition("../../edge", -1, -1) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowSize_Good(t *core.T) { + // Service SetWindowSize + ax7Variant := "Service_SetWindowSize:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowSize("agent", 1, 1) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowSize_Bad(t *core.T) { + // Service SetWindowSize + ax7Variant := "Service_SetWindowSize:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowSize("", 0, 0) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowSize_Ugly(t *core.T) { + // Service SetWindowSize + ax7Variant := "Service_SetWindowSize:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowSize("../../edge", -1, -1) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowBounds_Good(t *core.T) { + // Service SetWindowBounds + ax7Variant := "Service_SetWindowBounds:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowBounds("agent", 1, 1, 1, 1) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowBounds_Bad(t *core.T) { + // Service SetWindowBounds + ax7Variant := "Service_SetWindowBounds:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowBounds("", 0, 0, 0, 0) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowBounds_Ugly(t *core.T) { + // Service SetWindowBounds + ax7Variant := "Service_SetWindowBounds:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowBounds("../../edge", -1, -1, -1, -1) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_MaximizeWindow_Good(t *core.T) { + // Service MaximizeWindow + ax7Variant := "Service_MaximizeWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.MaximizeWindow("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_MaximizeWindow_Bad(t *core.T) { + // Service MaximizeWindow + ax7Variant := "Service_MaximizeWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.MaximizeWindow("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_MaximizeWindow_Ugly(t *core.T) { + // Service MaximizeWindow + ax7Variant := "Service_MaximizeWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.MaximizeWindow("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_MinimizeWindow_Good(t *core.T) { + // Service MinimizeWindow + ax7Variant := "Service_MinimizeWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.MinimizeWindow("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_MinimizeWindow_Bad(t *core.T) { + // Service MinimizeWindow + ax7Variant := "Service_MinimizeWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.MinimizeWindow("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_MinimizeWindow_Ugly(t *core.T) { + // Service MinimizeWindow + ax7Variant := "Service_MinimizeWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.MinimizeWindow("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_FocusWindow_Good(t *core.T) { + // Service FocusWindow + ax7Variant := "Service_FocusWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.FocusWindow("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_FocusWindow_Bad(t *core.T) { + // Service FocusWindow + ax7Variant := "Service_FocusWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.FocusWindow("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_FocusWindow_Ugly(t *core.T) { + // Service FocusWindow + ax7Variant := "Service_FocusWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.FocusWindow("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_CloseWindow_Good(t *core.T) { + // Service CloseWindow + ax7Variant := "Service_CloseWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.CloseWindow("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_CloseWindow_Bad(t *core.T) { + // Service CloseWindow + ax7Variant := "Service_CloseWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.CloseWindow("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_CloseWindow_Ugly(t *core.T) { + // Service CloseWindow + ax7Variant := "Service_CloseWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.CloseWindow("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_RestoreWindow_Good(t *core.T) { + // Service RestoreWindow + ax7Variant := "Service_RestoreWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.RestoreWindow("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_RestoreWindow_Bad(t *core.T) { + // Service RestoreWindow + ax7Variant := "Service_RestoreWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.RestoreWindow("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_RestoreWindow_Ugly(t *core.T) { + // Service RestoreWindow + ax7Variant := "Service_RestoreWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.RestoreWindow("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowVisibility_Good(t *core.T) { + // Service SetWindowVisibility + ax7Variant := "Service_SetWindowVisibility:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowVisibility("agent", true) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowVisibility_Bad(t *core.T) { + // Service SetWindowVisibility + ax7Variant := "Service_SetWindowVisibility:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowVisibility("", false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowVisibility_Ugly(t *core.T) { + // Service SetWindowVisibility + ax7Variant := "Service_SetWindowVisibility:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowVisibility("../../edge", false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowAlwaysOnTop_Good(t *core.T) { + // Service SetWindowAlwaysOnTop + ax7Variant := "Service_SetWindowAlwaysOnTop:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowAlwaysOnTop("agent", true) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowAlwaysOnTop_Bad(t *core.T) { + // Service SetWindowAlwaysOnTop + ax7Variant := "Service_SetWindowAlwaysOnTop:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowAlwaysOnTop("", false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowAlwaysOnTop_Ugly(t *core.T) { + // Service SetWindowAlwaysOnTop + ax7Variant := "Service_SetWindowAlwaysOnTop:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowAlwaysOnTop("../../edge", false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowTitle_Good(t *core.T) { + // Service SetWindowTitle + ax7Variant := "Service_SetWindowTitle:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowTitle("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowTitle_Bad(t *core.T) { + // Service SetWindowTitle + ax7Variant := "Service_SetWindowTitle:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowTitle("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowTitle_Ugly(t *core.T) { + // Service SetWindowTitle + ax7Variant := "Service_SetWindowTitle:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowTitle("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowFullscreen_Good(t *core.T) { + // Service SetWindowFullscreen + ax7Variant := "Service_SetWindowFullscreen:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowFullscreen("agent", true) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowFullscreen_Bad(t *core.T) { + // Service SetWindowFullscreen + ax7Variant := "Service_SetWindowFullscreen:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowFullscreen("", false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowFullscreen_Ugly(t *core.T) { + // Service SetWindowFullscreen + ax7Variant := "Service_SetWindowFullscreen:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowFullscreen("../../edge", false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowBackgroundColour_Good(t *core.T) { + // Service SetWindowBackgroundColour + ax7Variant := "Service_SetWindowBackgroundColour:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowBackgroundColour("agent", 1, 1, 1, 1) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowBackgroundColour_Bad(t *core.T) { + // Service SetWindowBackgroundColour + ax7Variant := "Service_SetWindowBackgroundColour:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowBackgroundColour("", 0, 0, 0, 0) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SetWindowBackgroundColour_Ugly(t *core.T) { + // Service SetWindowBackgroundColour + ax7Variant := "Service_SetWindowBackgroundColour:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SetWindowBackgroundColour("../../edge", 0, 0, 0, 0) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetFocusedWindow_Good(t *core.T) { + // Service GetFocusedWindow + ax7Variant := "Service_GetFocusedWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetFocusedWindow() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetFocusedWindow_Bad(t *core.T) { + // Service GetFocusedWindow + ax7Variant := "Service_GetFocusedWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetFocusedWindow() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetFocusedWindow_Ugly(t *core.T) { + // Service GetFocusedWindow + ax7Variant := "Service_GetFocusedWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetFocusedWindow() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetWindowTitle_Good(t *core.T) { + // Service GetWindowTitle + ax7Variant := "Service_GetWindowTitle:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetWindowTitle("agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetWindowTitle_Bad(t *core.T) { + // Service GetWindowTitle + ax7Variant := "Service_GetWindowTitle:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetWindowTitle("") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetWindowTitle_Ugly(t *core.T) { + // Service GetWindowTitle + ax7Variant := "Service_GetWindowTitle:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.GetWindowTitle("../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ResetWindowState_Good(t *core.T) { + // Service ResetWindowState + ax7Variant := "Service_ResetWindowState:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ResetWindowState() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ResetWindowState_Bad(t *core.T) { + // Service ResetWindowState + ax7Variant := "Service_ResetWindowState:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ResetWindowState() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ResetWindowState_Ugly(t *core.T) { + // Service ResetWindowState + ax7Variant := "Service_ResetWindowState:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ResetWindowState() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetSavedWindowStates_Good(t *core.T) { + // Service GetSavedWindowStates + ax7Variant := "Service_GetSavedWindowStates:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetSavedWindowStates() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetSavedWindowStates_Bad(t *core.T) { + // Service GetSavedWindowStates + ax7Variant := "Service_GetSavedWindowStates:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetSavedWindowStates() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetSavedWindowStates_Ugly(t *core.T) { + // Service GetSavedWindowStates + ax7Variant := "Service_GetSavedWindowStates:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetSavedWindowStates() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_CreateWindow_Good(t *core.T) { + // Service CreateWindow + ax7Variant := "Service_CreateWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.CreateWindow(*new(CreateWindowOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_CreateWindow_Bad(t *core.T) { + // Service CreateWindow + ax7Variant := "Service_CreateWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.CreateWindow(*new(CreateWindowOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_CreateWindow_Ugly(t *core.T) { + // Service CreateWindow + ax7Variant := "Service_CreateWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.CreateWindow(*new(CreateWindowOptions)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SaveLayout_Good(t *core.T) { + // Service SaveLayout + ax7Variant := "Service_SaveLayout:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SaveLayout("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SaveLayout_Bad(t *core.T) { + // Service SaveLayout + ax7Variant := "Service_SaveLayout:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SaveLayout("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SaveLayout_Ugly(t *core.T) { + // Service SaveLayout + ax7Variant := "Service_SaveLayout:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SaveLayout("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_RestoreLayout_Good(t *core.T) { + // Service RestoreLayout + ax7Variant := "Service_RestoreLayout:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.RestoreLayout("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_RestoreLayout_Bad(t *core.T) { + // Service RestoreLayout + ax7Variant := "Service_RestoreLayout:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.RestoreLayout("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_RestoreLayout_Ugly(t *core.T) { + // Service RestoreLayout + ax7Variant := "Service_RestoreLayout:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.RestoreLayout("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ListLayouts_Good(t *core.T) { + // Service ListLayouts + ax7Variant := "Service_ListLayouts:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ListLayouts() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ListLayouts_Bad(t *core.T) { + // Service ListLayouts + ax7Variant := "Service_ListLayouts:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ListLayouts() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ListLayouts_Ugly(t *core.T) { + // Service ListLayouts + ax7Variant := "Service_ListLayouts:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ListLayouts() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_DeleteLayout_Good(t *core.T) { + // Service DeleteLayout + ax7Variant := "Service_DeleteLayout:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.DeleteLayout("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_DeleteLayout_Bad(t *core.T) { + // Service DeleteLayout + ax7Variant := "Service_DeleteLayout:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.DeleteLayout("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_DeleteLayout_Ugly(t *core.T) { + // Service DeleteLayout + ax7Variant := "Service_DeleteLayout:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.DeleteLayout("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetLayout_Good(t *core.T) { + // Service GetLayout + ax7Variant := "Service_GetLayout:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetLayout("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetLayout_Bad(t *core.T) { + // Service GetLayout + ax7Variant := "Service_GetLayout:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetLayout("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetLayout_Ugly(t *core.T) { + // Service GetLayout + ax7Variant := "Service_GetLayout:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetLayout("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_TileWindows_Good(t *core.T) { + // Service TileWindows + ax7Variant := "Service_TileWindows:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.TileWindows(window.TileModeLeftHalf, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_TileWindows_Bad(t *core.T) { + // Service TileWindows + ax7Variant := "Service_TileWindows:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.TileWindows(window.TileModeLeftHalf, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_TileWindows_Ugly(t *core.T) { + // Service TileWindows + ax7Variant := "Service_TileWindows:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.TileWindows(window.TileModeLeftHalf, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SnapWindow_Good(t *core.T) { + // Service SnapWindow + ax7Variant := "Service_SnapWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SnapWindow("agent", window.SnapLeft) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SnapWindow_Bad(t *core.T) { + // Service SnapWindow + ax7Variant := "Service_SnapWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SnapWindow("", window.SnapLeft) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_SnapWindow_Ugly(t *core.T) { + // Service SnapWindow + ax7Variant := "Service_SnapWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SnapWindow("../../edge", window.SnapLeft) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_StackWindows_Good(t *core.T) { + // Service StackWindows + ax7Variant := "Service_StackWindows:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.StackWindows(nil, 1, 1) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_StackWindows_Bad(t *core.T) { + // Service StackWindows + ax7Variant := "Service_StackWindows:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.StackWindows(nil, 0, 0) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_StackWindows_Ugly(t *core.T) { + // Service StackWindows + ax7Variant := "Service_StackWindows:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.StackWindows(nil, -1, -1) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ApplyWorkflowLayout_Good(t *core.T) { + // Service ApplyWorkflowLayout + ax7Variant := "Service_ApplyWorkflowLayout:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ApplyWorkflowLayout(window.WorkflowCoding) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ApplyWorkflowLayout_Bad(t *core.T) { + // Service ApplyWorkflowLayout + ax7Variant := "Service_ApplyWorkflowLayout:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ApplyWorkflowLayout(window.WorkflowCoding) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ApplyWorkflowLayout_Ugly(t *core.T) { + // Service ApplyWorkflowLayout + ax7Variant := "Service_ApplyWorkflowLayout:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ApplyWorkflowLayout(window.WorkflowCoding) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_LayoutBesideEditor_Good(t *core.T) { + // Service LayoutBesideEditor + ax7Variant := "Service_LayoutBesideEditor:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LayoutBesideEditor("agent", "agent", "agent", 1.5) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_LayoutBesideEditor_Bad(t *core.T) { + // Service LayoutBesideEditor + ax7Variant := "Service_LayoutBesideEditor:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LayoutBesideEditor("", "", "", 0) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_LayoutBesideEditor_Ugly(t *core.T) { + // Service LayoutBesideEditor + ax7Variant := "Service_LayoutBesideEditor:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LayoutBesideEditor("../../edge", "../../edge", "../../edge", -1.5) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_LayoutSuggest_Good(t *core.T) { + // Service LayoutSuggest + ax7Variant := "Service_LayoutSuggest:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LayoutSuggest("agent", 1) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_LayoutSuggest_Bad(t *core.T) { + // Service LayoutSuggest + ax7Variant := "Service_LayoutSuggest:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LayoutSuggest("", 0) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_LayoutSuggest_Ugly(t *core.T) { + // Service LayoutSuggest + ax7Variant := "Service_LayoutSuggest:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.LayoutSuggest("../../edge", -1) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_FindScreenSpace_Good(t *core.T) { + // Service FindScreenSpace + ax7Variant := "Service_FindScreenSpace:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.FindScreenSpace("agent", 1, 1, 1) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_FindScreenSpace_Bad(t *core.T) { + // Service FindScreenSpace + ax7Variant := "Service_FindScreenSpace:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.FindScreenSpace("", 0, 0, 0) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_FindScreenSpace_Ugly(t *core.T) { + // Service FindScreenSpace + ax7Variant := "Service_FindScreenSpace:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.FindScreenSpace("../../edge", -1, -1, -1) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ArrangeWindowPair_Good(t *core.T) { + // Service ArrangeWindowPair + ax7Variant := "Service_ArrangeWindowPair:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ArrangeWindowPair("agent", "agent", "agent", 1.5) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ArrangeWindowPair_Bad(t *core.T) { + // Service ArrangeWindowPair + ax7Variant := "Service_ArrangeWindowPair:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ArrangeWindowPair("", "", "", 0) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_ArrangeWindowPair_Ugly(t *core.T) { + // Service ArrangeWindowPair + ax7Variant := "Service_ArrangeWindowPair:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.ArrangeWindowPair("../../edge", "../../edge", "../../edge", -1.5) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetEventManager_Good(t *core.T) { + // Service GetEventManager + ax7Variant := "Service_GetEventManager:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetEventManager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetEventManager_Bad(t *core.T) { + // Service GetEventManager + ax7Variant := "Service_GetEventManager:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetEventManager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDisplay_Service_GetEventManager_Ugly(t *core.T) { + // Service GetEventManager + ax7Variant := "Service_GetEventManager:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.GetEventManager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/display/docs/backend.md b/display/webkit/pkg/display/docs/backend.md new file mode 100644 index 0000000..3e67e43 --- /dev/null +++ b/display/webkit/pkg/display/docs/backend.md @@ -0,0 +1,54 @@ +# Backend Documentation + +The backend is written in Go and uses the `github.com/Snider/display` package. It utilizes the Wails v3 framework to bridge Go and the web frontend. + +## Core Types + +### `Service` +The `Service` struct is the main entry point for the display logic. + +- **Initialization:** + - `New() (*Service, error)`: Creates a new instance of the service. + - `Startup(ctx context.Context) error`: Initializes the Wails application, builds the menu, sets up the system tray, and opens the main window. + +- **Window Management:** + - `OpenWindow(opts ...WindowOption) error`: Opens a new window with the specified options. + +- **Dialogs:** + - `ShowEnvironmentDialog()`: Displays a native dialog containing information about the runtime environment (OS, Arch, Debug mode, etc.). + +### `WindowConfig` & `WindowOption` +Window configuration is handled using the Functional Options pattern. The `WindowConfig` struct holds parameters like: +- `Name`, `Title` +- `Width`, `Height` +- `URL` +- `AlwaysOnTop`, `Hidden`, `Frameless` +- Window button states (`MinimiseButtonState`, `MaximiseButtonState`, `CloseButtonState`) + +**Available Options:** +- `WithName(name string)` +- `WithTitle(title string)` +- `WithWidth(width int)` +- `WithHeight(height int)` +- `WithURL(url string)` +- `WithAlwaysOnTop(bool)` +- `WithHidden(bool)` +- `WithFrameless(bool)` +- `WithMinimiseButtonState(state)` +- `WithMaximiseButtonState(state)` +- `WithCloseButtonState(state)` + +## Subsystems + +### Menu (`menu.go`) +The `buildMenu` method constructs the application's main menu, adding standard roles like File, Edit, Window, and Help. It allows for platform-specific adjustments (e.g., AppMenu on macOS). + +### System Tray (`tray.go`) +The `systemTray` method initializes the system tray icon and its context menu. It supports: +- Showing/Hiding all windows. +- Displaying environment info. +- Quitting the application. +- Attaching a hidden window for advanced tray interactions. + +### Actions (`actions.go`) +Defines structured messages for Inter-Process Communication (IPC) or internal event handling, such as `ActionOpenWindow` which wraps `application.WebviewWindowOptions`. diff --git a/display/webkit/pkg/display/docs/development.md b/display/webkit/pkg/display/docs/development.md new file mode 100644 index 0000000..720fa81 --- /dev/null +++ b/display/webkit/pkg/display/docs/development.md @@ -0,0 +1,64 @@ +# Development Guide + +This guide covers how to set up the development environment, build the project, and run the demo. + +## Prerequisites + +1. **Go:** Version 1.25 or later. +2. **Node.js & npm:** For building the Angular frontend. +3. **Wails Dependencies:** + - **Linux:** `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` + - **macOS/Windows:** Standard development tools (Xcode Command Line Tools / build-essential). + +## Setup + +1. Clone the repository: + ```bash + git clone https://github.com/Snider/display.git + cd display + ``` + +2. Install Go dependencies: + ```bash + go mod tidy + ``` + +3. Install Frontend dependencies: + ```bash + cd ui + npm install + cd .. + ``` + +## Running the Demo + +The project includes a CLI to facilitate development. + +### Serve Mode (Web Preview) +To start a simple HTTP server that serves the frontend and a mock API: + +1. Build the frontend first: + ```bash + cd ui && npm run build && cd .. + ``` +2. Run the serve command: + ```bash + go run ./cmd/demo-cli serve + ``` + Access the app at `http://localhost:8080`. + +## Building the Project + +### Frontend +```bash +cd ui +npm run build +``` + +### Backend / Application +This project is a library/module. However, it can be tested via the demo CLI or by integrating it into a Wails application entry point. + +To run the tests: +```bash +go test ./... +``` diff --git a/display/webkit/pkg/display/docs/frontend.md b/display/webkit/pkg/display/docs/frontend.md new file mode 100644 index 0000000..581e66f --- /dev/null +++ b/display/webkit/pkg/display/docs/frontend.md @@ -0,0 +1,29 @@ +# Frontend Documentation + +The frontend is an Angular application located in the `ui/` directory. It is designed to be built as a custom element or a standalone application that runs inside the Wails webview. + +## Structure + +- **Path:** `ui/` +- **Framework:** Angular +- **Output:** The build process generates artifacts in `ui/dist`. + +## Development + +The frontend can be developed using standard Angular CLI commands or via the provided demo CLI which serves the built files. + +## Build Process + +To build the frontend for production or integration with the Go backend: + +```bash +cd ui +npm install +npm run build +``` + +This will compile the Angular application and place the output in the `dist/` directory, which the Go backend can then serve or embed. + +## Integration + +The frontend communicates with the Go backend through the Wails runtime. It can trigger actions defined in the backend (like opening windows) and receive events. diff --git a/display/webkit/pkg/display/docs/overview.md b/display/webkit/pkg/display/docs/overview.md new file mode 100644 index 0000000..ef743b2 --- /dev/null +++ b/display/webkit/pkg/display/docs/overview.md @@ -0,0 +1,25 @@ +# Overview + +The `display` module is a core component responsible for the visual presentation and system integration of the application. It leverages **Wails v3** to create a desktop application backend in Go and **Angular** for the frontend user interface. + +## Architecture + +The project consists of two main parts: + +1. **Backend (Go):** Handles window management, system tray integration, application menus, and communication with the frontend. It is located in the root directory and packaged as a Go module. +2. **Frontend (Angular):** Provides the user interface. It is located in the `ui/` directory and is built as a custom element that interacts with the backend. + +## Key Components + +### Display Service (`display`) +The core service that manages the application lifecycle. It wraps the Wails application instance and exposes methods to: +- Open and configure windows. +- Manage the system tray. +- Show system dialogs (e.g., environment info). + +### System Integration +- **Menu:** A standard application menu (File, Edit, View, etc.) is constructed in `menu.go`. +- **System Tray:** A system tray icon and menu are configured in `tray.go`, allowing quick access to common actions like showing/hiding windows or viewing environment info. + +### Demo CLI +A command-line interface (`cmd/demo-cli`) is provided to run and test the display module. It includes a `serve` command for web-based development. diff --git a/display/webkit/pkg/display/events.go b/display/webkit/pkg/display/events.go new file mode 100644 index 0000000..1ef63a8 --- /dev/null +++ b/display/webkit/pkg/display/events.go @@ -0,0 +1,655 @@ +package display + +import ( + "net" + "net/http" + "net/url" + "strconv" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/events" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/gorilla/websocket" +) + +// EventType represents the type of event. +type EventType string + +const ( + EventWindowFocus EventType = "window.focus" + EventWindowBlur EventType = "window.blur" + EventWindowMove EventType = "window.move" + EventWindowResize EventType = "window.resize" + EventWindowClose EventType = "window.close" + EventWindowCreate EventType = "window.create" + EventThemeChange EventType = "theme.change" + EventScreenChange EventType = "screen.change" + EventNotificationClick EventType = "notification.click" + EventTrayClick EventType = "tray.click" + EventTrayMenuItemClick EventType = "tray.menuitem.click" + EventKeybindingTriggered EventType = "keybinding.triggered" + EventWindowFileDrop EventType = "window.filedrop" + EventDockVisibility EventType = "dock.visibility-changed" + EventAppStarted EventType = "app.started" + EventAppOpenedWithFile EventType = "app.opened-with-file" + EventAppWillTerminate EventType = "app.will-terminate" + EventAppActive EventType = "app.active" + EventAppInactive EventType = "app.inactive" + EventSystemPowerChange EventType = "system.power-change" + EventSystemSuspend EventType = "system.suspend" + EventSystemResume EventType = "system.resume" + EventContextMenuClick EventType = "contextmenu.item-clicked" + EventWebviewConsole EventType = "webview.console" + EventWebviewException EventType = "webview.exception" + EventCustomEvent EventType = "custom.event" + EventDockProgress EventType = "dock.progress" + EventDockBounce EventType = "dock.bounce" + EventNotificationAction EventType = "notification.action" + EventNotificationDismiss EventType = "notification.dismissed" + EventChatConversation EventType = "chat.conversation" + EventChatMessage EventType = "chat.message" + EventChatToken EventType = "chat.token" + EventChatThinkingStart EventType = "chat.thinking.start" + EventChatThinkingAppend EventType = "chat.thinking.append" + EventChatThinkingEnd EventType = "chat.thinking.end" + EventChatToolCall EventType = "chat.tool.call" + EventChatToolResult EventType = "chat.tool.result" + EventChatImageQueued EventType = "chat.image.queued" +) + +const websocketReadTimeout = 30 * time.Second + +// Event represents a display event sent to subscribers. +type Event struct { + Type EventType `json:"type"` + Timestamp int64 `json:"timestamp"` + Window string `json:"window,omitempty"` + Data map[string]any `json:"data,omitempty"` +} + +// Subscription represents a client subscription to events. +type Subscription struct { + ID string `json:"id"` + EventTypes []EventType `json:"eventTypes"` +} + +// WSEventManager manages WebSocket connections and event subscriptions. +type WSEventManager struct { + upgrader websocket.Upgrader + clients map[*websocket.Conn]*clientState + mu sync.RWMutex + nextSubID int + eventBuffer chan Event + closed bool + readTimeout time.Duration +} + +// clientState tracks a client's subscriptions. +type clientState struct { + subscriptions map[string]*Subscription + writeMu sync.Mutex + mu sync.RWMutex +} + +// NewWSEventManager creates a new event manager. +func NewWSEventManager() *WSEventManager { + em := &WSEventManager{ + upgrader: websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return trustedWebSocketOrigin(r) + }, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + }, + clients: make(map[*websocket.Conn]*clientState), + eventBuffer: make(chan Event, 100), + readTimeout: websocketReadTimeout, + } + + // Start event broadcaster + go em.broadcaster() + + return em +} + +func trustedWebSocketOrigin(r *http.Request) bool { + if r == nil { + return false + } + + if r.URL == nil { + return false + } + if path := core.Trim(r.URL.Path); path != "" && path != "/" && path != "/events" { + return false + } + + if !trustedWebSocketHost(r.Host) { + return false + } + if !trustedWSRequestOrigin(r.RemoteAddr) { + return false + } + + origin := core.Trim(r.Header.Get("Origin")) + if origin == "" || equalFold(origin, "null") { + return true + } + + parsed, err := url.Parse(origin) + if err != nil { + return false + } + + switch core.Lower(parsed.Scheme) { + case "http", "https": + return trustedWebSocketHost(parsed.Host) + case "wails", "core", "app": + return true + default: + return false + } +} + +func trustedWSRequestOrigin(raw string) bool { + if raw == "" { + return false + } + host := raw + if parsed, _, err := net.SplitHostPort(raw); err == nil { + host = parsed + } + host = trimRunes(host, "[]") + return isLoopbackHost(host) +} + +func isLoopbackHost(host string) bool { + host = core.Trim(core.Lower(host)) + if host == "" { + return false + } + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func trustedWebSocketHost(host string) bool { + host = core.Trim(host) + if host == "" { + return false + } + + name := host + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + name = parsedHost + } + name = trimRunes(name, "[]") + switch core.Lower(name) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +// broadcaster sends events to all subscribed clients. +func (em *WSEventManager) broadcaster() { + if em == nil { + return + } + em.mu.RLock() + eventBuffer := em.eventBuffer + em.mu.RUnlock() + if eventBuffer == nil { + return + } + for event := range eventBuffer { + em.mu.RLock() + for conn, state := range em.clients { + if state != nil && em.clientSubscribed(state, event.Type) { + go em.sendEvent(conn, event) + } + } + em.mu.RUnlock() + } +} + +// clientSubscribed checks if a client is subscribed to an event type. +func (em *WSEventManager) clientSubscribed(state *clientState, eventType EventType) bool { + if state == nil { + return false + } + state.mu.RLock() + defer state.mu.RUnlock() + + for _, sub := range state.subscriptions { + for _, et := range sub.EventTypes { + if et == eventType || et == "*" { + return true + } + } + } + return false +} + +// sendEvent sends an event to a specific client. +func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) { + em.mu.RLock() + state, exists := em.clients[conn] + em.mu.RUnlock() + + if !exists || state == nil { + return + } + + marshalResult := core.JSONMarshal(event) + if !marshalResult.OK { + return + } + data, _ := marshalResult.Value.([]byte) + + state.writeMu.Lock() + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + err := conn.WriteMessage(websocket.TextMessage, data) + state.writeMu.Unlock() + if err != nil { + em.removeClient(conn) + } +} + +// HandleWebSocket handles WebSocket upgrade and connection. +func (em *WSEventManager) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + if w == nil { + return + } + if em == nil || r == nil { + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) + return + } + em.mu.RLock() + closed := em.closed + em.mu.RUnlock() + if closed { + if w != nil { + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) + } + return + } + if !trustedWebSocketOrigin(r) { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + + conn, err := em.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + em.mu.Lock() + if em.closed { + em.mu.Unlock() + if err := conn.Close(); err != nil { + return + } + return + } + em.clients[conn] = &clientState{ + subscriptions: make(map[string]*Subscription), + } + em.mu.Unlock() + + em.prepareConnection(conn) + + done := make(chan struct{}) + go em.pingConnection(conn, done) + + // Handle incoming messages + go em.handleMessages(conn, done) +} + +func (em *WSEventManager) prepareConnection(conn *websocket.Conn) { + if conn == nil { + return + } + conn.SetReadLimit(64 * 1024) + if em.readTimeout > 0 { + if err := conn.SetReadDeadline(time.Now().Add(em.readTimeout)); err != nil { + return + } + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(em.readTimeout)) + }) + } +} + +func (em *WSEventManager) pingConnection(conn *websocket.Conn, done <-chan struct{}) { + if conn == nil || em == nil || em.readTimeout <= 0 { + return + } + interval := em.readTimeout / 2 + if interval <= 0 { + interval = em.readTimeout + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ticker.C: + if !em.writePing(conn) { + em.removeClient(conn) + return + } + } + } +} + +func (em *WSEventManager) writePing(conn *websocket.Conn) bool { + em.mu.RLock() + state, exists := em.clients[conn] + timeout := em.readTimeout + em.mu.RUnlock() + if !exists || state == nil { + return false + } + deadline := time.Now().Add(10 * time.Second) + if timeout > 0 && timeout < 10*time.Second { + deadline = time.Now().Add(timeout / 2) + } + state.writeMu.Lock() + err := conn.WriteControl(websocket.PingMessage, nil, deadline) + state.writeMu.Unlock() + return err == nil +} + +// handleMessages processes incoming WebSocket messages. +func (em *WSEventManager) handleMessages(conn *websocket.Conn, done chan<- struct{}) { + defer func() { + close(done) + em.removeClient(conn) + }() + + for { + if em.readTimeout > 0 { + if err := conn.SetReadDeadline(time.Now().Add(em.readTimeout)); err != nil { + return + } + } + _, message, err := conn.ReadMessage() + if err != nil { + return + } + + var msg struct { + Action string `json:"action"` + ID string `json:"id,omitempty"` + EventTypes []EventType `json:"eventTypes,omitempty"` + } + + if unmarshalResult := core.JSONUnmarshal(message, &msg); !unmarshalResult.OK { + em.closeWithPolicyViolation(conn, "invalid websocket message") + return + } + + switch msg.Action { + case "subscribe": + em.subscribe(conn, msg.ID, msg.EventTypes) + case "unsubscribe": + em.unsubscribe(conn, msg.ID) + case "list": + em.listSubscriptions(conn) + default: + em.closeWithPolicyViolation(conn, "unknown websocket action") + return + } + } +} + +func (em *WSEventManager) closeWithPolicyViolation(conn *websocket.Conn, reason string) { + em.mu.RLock() + state, exists := em.clients[conn] + em.mu.RUnlock() + if !exists || state == nil { + return + } + state.writeMu.Lock() + defer state.writeMu.Unlock() + if err := conn.WriteJSON(map[string]any{ + "error": reason, + "status": websocket.ClosePolicyViolation, + }); err != nil { + return + } + if err := conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, reason), time.Now().Add(2*time.Second)); err != nil { + return + } + if err := conn.Close(); err != nil { + return + } +} + +// subscribe adds a subscription for a client. +func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes []EventType) { + em.mu.RLock() + state, exists := em.clients[conn] + em.mu.RUnlock() + + if !exists { + return + } + + // Generate ID if not provided + if id == "" { + em.mu.Lock() + em.nextSubID++ + id = "sub-" + strconv.Itoa(em.nextSubID) + em.mu.Unlock() + } + + state.mu.Lock() + state.subscriptions[id] = &Subscription{ + ID: id, + EventTypes: eventTypes, + } + state.mu.Unlock() + + // Send confirmation + response := map[string]any{ + "type": "subscribed", + "id": id, + "eventTypes": eventTypes, + } + if marshalResult := core.JSONMarshal(response); marshalResult.OK { + responseData, _ := marshalResult.Value.([]byte) + em.writeClientMessage(state, conn, responseData) + } +} + +// unsubscribe removes a subscription for a client. +func (em *WSEventManager) unsubscribe(conn *websocket.Conn, id string) { + em.mu.RLock() + state, exists := em.clients[conn] + em.mu.RUnlock() + + if !exists { + return + } + + state.mu.Lock() + delete(state.subscriptions, id) + state.mu.Unlock() + + // Send confirmation + response := map[string]any{ + "type": "unsubscribed", + "id": id, + } + if marshalResult := core.JSONMarshal(response); marshalResult.OK { + responseData, _ := marshalResult.Value.([]byte) + em.writeClientMessage(state, conn, responseData) + } +} + +// listSubscriptions sends a list of active subscriptions to a client. +func (em *WSEventManager) listSubscriptions(conn *websocket.Conn) { + em.mu.RLock() + state, exists := em.clients[conn] + em.mu.RUnlock() + + if !exists { + return + } + + state.mu.RLock() + subs := make([]*Subscription, 0, len(state.subscriptions)) + for _, sub := range state.subscriptions { + subs = append(subs, sub) + } + state.mu.RUnlock() + + response := map[string]any{ + "type": "subscriptions", + "subscriptions": subs, + } + if marshalResult := core.JSONMarshal(response); marshalResult.OK { + responseData, _ := marshalResult.Value.([]byte) + em.writeClientMessage(state, conn, responseData) + } +} + +func (em *WSEventManager) writeClientMessage(state *clientState, conn *websocket.Conn, data []byte) { + if state == nil || conn == nil { + return + } + state.writeMu.Lock() + defer state.writeMu.Unlock() + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + return + } +} + +// removeClient removes a client and its subscriptions. +func (em *WSEventManager) removeClient(conn *websocket.Conn) { + em.mu.Lock() + delete(em.clients, conn) + em.mu.Unlock() + conn.Close() +} + +// Emit sends an event to all subscribed clients. +func (em *WSEventManager) Emit(event Event) { + if em == nil { + return + } + event.Timestamp = time.Now().UnixMilli() + em.mu.RLock() + if em.closed || em.eventBuffer == nil { + em.mu.RUnlock() + return + } + select { + case em.eventBuffer <- event: + default: + // Buffer full, drop event + } + em.mu.RUnlock() +} + +// EmitWindowEvent is a helper to emit window-related events. +func (em *WSEventManager) EmitWindowEvent(eventType EventType, windowName string, data map[string]any) { + em.Emit(Event{ + Type: eventType, + Window: windowName, + Data: data, + }) +} + +// ConnectedClients returns the number of connected WebSocket clients. +func (em *WSEventManager) ConnectedClients() int { + if em == nil { + return 0 + } + em.mu.RLock() + defer em.mu.RUnlock() + return len(em.clients) +} + +// Info returns a snapshot of the live WebSocket event server. +// +// info := display.GetEventManager().Info() +func (em *WSEventManager) Info() events.ServerInfo { + if em == nil { + return events.ServerInfo{} + } + em.mu.RLock() + defer em.mu.RUnlock() + + subscriptionCount := 0 + for _, state := range em.clients { + if state == nil { + continue + } + state.mu.RLock() + subscriptionCount += len(state.subscriptions) + state.mu.RUnlock() + } + + return events.ServerInfo{ + ConnectedClients: len(em.clients), + SubscriptionCount: subscriptionCount, + BufferLength: len(em.eventBuffer), + BufferCapacity: cap(em.eventBuffer), + } +} + +// Close shuts down the event manager. +func (em *WSEventManager) Close() { + if em == nil { + return + } + em.mu.Lock() + if em.closed { + em.mu.Unlock() + return + } + em.closed = true + conns := make([]*websocket.Conn, 0, len(em.clients)) + for conn := range em.clients { + conns = append(conns, conn) + } + em.clients = make(map[*websocket.Conn]*clientState) + if em.eventBuffer != nil { + close(em.eventBuffer) + em.eventBuffer = nil + } + em.mu.Unlock() + + for _, conn := range conns { + if err := conn.Close(); err != nil { + return + } + } +} + +type windowEventSource interface { + OnWindowEvent(func(event window.WindowEvent)) +} + +// AttachWindowListeners attaches event listeners to a specific window. +// Use: em.AttachWindowListeners(windowHandle) +func (em *WSEventManager) AttachWindowListeners(pw windowEventSource) { + if pw == nil { + return + } + + pw.OnWindowEvent(func(e window.WindowEvent) { + em.EmitWindowEvent(EventType("window."+e.Type), e.Name, e.Data) + }) +} diff --git a/display/webkit/pkg/display/events_example_test.go b/display/webkit/pkg/display/events_example_test.go new file mode 100644 index 0000000..c7dcae2 --- /dev/null +++ b/display/webkit/pkg/display/events_example_test.go @@ -0,0 +1,53 @@ +//go:build compliance + +package display + +import core "dappco.re/go" + +func ExampleNewWSEventManager() { + core.Println("NewWSEventManager") + // Output: + // NewWSEventManager +} + +func ExampleWSEventManager_HandleWebSocket() { + core.Println("WSEventManager_HandleWebSocket") + // Output: + // WSEventManager_HandleWebSocket +} + +func ExampleWSEventManager_Emit() { + core.Println("WSEventManager_Emit") + // Output: + // WSEventManager_Emit +} + +func ExampleWSEventManager_EmitWindowEvent() { + core.Println("WSEventManager_EmitWindowEvent") + // Output: + // WSEventManager_EmitWindowEvent +} + +func ExampleWSEventManager_ConnectedClients() { + core.Println("WSEventManager_ConnectedClients") + // Output: + // WSEventManager_ConnectedClients +} + +func ExampleWSEventManager_Info() { + core.Println("WSEventManager_Info") + // Output: + // WSEventManager_Info +} + +func ExampleWSEventManager_Close() { + core.Println("WSEventManager_Close") + // Output: + // WSEventManager_Close +} + +func ExampleWSEventManager_AttachWindowListeners() { + core.Println("WSEventManager_AttachWindowListeners") + // Output: + // WSEventManager_AttachWindowListeners +} diff --git a/display/webkit/pkg/display/events_test.go b/display/webkit/pkg/display/events_test.go new file mode 100644 index 0000000..0546e84 --- /dev/null +++ b/display/webkit/pkg/display/events_test.go @@ -0,0 +1,813 @@ +package display + +import ( + core "dappco.re/go" + "net/http" + "net/http/httptest" + "time" + + "dappco.re/go/render/display/webkit/pkg/events" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/gorilla/websocket" +) + +func requireEventually(t *core.T, condition func() bool, timeout, interval time.Duration) { + t.Helper() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(interval) + } + core.RequireTrue(t, condition(), "condition satisfied before timeout") +} + +type testWindowListener struct { + handler func(window.WindowEvent) +} + +func (w *testWindowListener) Name() string { return "listener" } +func (w *testWindowListener) Title() string { return "listener" } +func (w *testWindowListener) Position() (int, int) { return 0, 0 } +func (w *testWindowListener) Size() (int, int) { return 0, 0 } +func (w *testWindowListener) IsMaximised() bool { return false } +func (w *testWindowListener) IsFocused() bool { return false } +func (w *testWindowListener) IsVisible() bool { return false } +func (w *testWindowListener) IsFullscreen() bool { return false } +func (w *testWindowListener) IsMinimised() bool { return false } +func (w *testWindowListener) GetBounds() (int, int, int, int) { return 0, 0, 0, 0 } +func (w *testWindowListener) GetZoom() float64 { return 1 } +func (w *testWindowListener) SetTitle(string) {} +func (w *testWindowListener) SetPosition(int, int) {} +func (w *testWindowListener) SetSize(int, int) {} +func (w *testWindowListener) SetBackgroundColour(uint8, uint8, uint8, uint8) { +} +func (w *testWindowListener) SetVisibility(bool) {} +func (w *testWindowListener) SetAlwaysOnTop(bool) {} +func (w *testWindowListener) SetBounds(int, int, int, int) {} +func (w *testWindowListener) SetURL(string) {} +func (w *testWindowListener) SetHTML(string) {} +func (w *testWindowListener) SetZoom(float64) {} +func (w *testWindowListener) SetContentProtection(bool) {} +func (w *testWindowListener) Maximise() {} +func (w *testWindowListener) Restore() {} +func (w *testWindowListener) Minimise() {} +func (w *testWindowListener) Focus() {} +func (w *testWindowListener) Close() {} +func (w *testWindowListener) Show() {} +func (w *testWindowListener) Hide() {} +func (w *testWindowListener) Fullscreen() {} +func (w *testWindowListener) UnFullscreen() {} +func (w *testWindowListener) ToggleFullscreen() {} +func (w *testWindowListener) ToggleMaximise() {} +func (w *testWindowListener) ExecJS(string) {} +func (w *testWindowListener) Flash(bool) {} +func (w *testWindowListener) Print() resultFailure { return nil } +func (w *testWindowListener) OnWindowEvent(handler func(window.WindowEvent)) { + w.handler = handler +} +func (w *testWindowListener) OnFileDrop(func([]string, string)) {} + +func (w *testWindowListener) emit(event window.WindowEvent) { + if w.handler != nil { + w.handler(event) + } +} + +func dialWSEventManager(t *core.T, em *WSEventManager) (*websocket.Conn, func()) { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(em.HandleWebSocket)) + t.Cleanup(server.Close) + + wsURL := "ws" + core.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + core.RequireNoError(t, err) + + return conn, func() { + _ = conn.Close() + em.Close() + } +} + +func readJSONMessage(t *core.T, conn *websocket.Conn) map[string]any { + t.Helper() + + _, data, err := conn.ReadMessage() + core.RequireNoError(t, err) + + var payload map[string]any + core.RequireNoError(t, jsonUnmarshal(data, &payload)) + return payload +} + +func writeJSONMessage(t *core.T, conn *websocket.Conn, payload map[string]any) { + t.Helper() + core.RequireNoError(t, conn.WriteJSON(payload)) +} + +func TestWSEventManager_HandleWebSocket_GoodCase(t *core.T) { + em := NewWSEventManager() + conn, cleanup := dialWSEventManager(t, em) + defer cleanup() + + writeJSONMessage(t, conn, map[string]any{ + "action": "subscribe", + "id": "sub-1", + "eventTypes": []string{"*"}, + }) + subscribeAck := readJSONMessage(t, conn) + core.AssertEqual(t, "subscribed", subscribeAck["type"]) + core.AssertEqual(t, "sub-1", subscribeAck["id"]) + + info := em.Info() + core.AssertEqual(t, 1, info.ConnectedClients) + core.AssertEqual(t, 1, info.SubscriptionCount) + + em.Emit(Event{Type: EventCustomEvent, Window: "main", Data: map[string]any{"hello": "world"}}) + eventPayload := readJSONMessage(t, conn) + core.AssertEqual(t, string(EventCustomEvent), eventPayload["type"]) + core.AssertEqual(t, "main", eventPayload["window"]) + + writeJSONMessage(t, conn, map[string]any{"action": "list"}) + listPayload := readJSONMessage(t, conn) + core.AssertEqual(t, "subscriptions", listPayload["type"]) + core.AssertLen(t, listPayload["subscriptions"].([]any), 1) + + writeJSONMessage(t, conn, map[string]any{"action": "unsubscribe", "id": "sub-1"}) + unsubscribeAck := readJSONMessage(t, conn) + core.AssertEqual(t, "unsubscribed", unsubscribeAck["type"]) + + writeJSONMessage(t, conn, map[string]any{"action": "list"}) + emptyList := readJSONMessage(t, conn) + core.AssertEqual(t, "subscriptions", emptyList["type"]) + core.AssertLen(t, emptyList["subscriptions"].([]any), 0) + + core.RequireNoError(t, conn.Close()) + requireEventually(t, func() bool { + return em.ConnectedClients() == 0 + }, 2*time.Second, 20*time.Millisecond) +} + +func TestWSEventManager_HandleWebSocket_RejectsRemoteOrigin(t *core.T) { + em := NewWSEventManager() + + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + req.Header.Set("Origin", "https://evil.example") + recorder := httptest.NewRecorder() + + em.HandleWebSocket(recorder, req) + + core.AssertEqual(t, http.StatusForbidden, recorder.Code) +} + +func TestWSEventManager_HandleWebSocket_RejectsLoopbackSpoofedOrigin(t *core.T) { + em := NewWSEventManager() + + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("Origin", "file://malicious") + recorder := httptest.NewRecorder() + + em.HandleWebSocket(recorder, req) + + core.AssertEqual(t, http.StatusForbidden, recorder.Code) +} + +func TestWSEventManager_HandleWebSocket_NilReceiverFailsClosed(t *core.T) { + var em *WSEventManager + + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + req.RemoteAddr = "127.0.0.1:12345" + recorder := httptest.NewRecorder() + + core.AssertNotPanics(t, func() { + em.HandleWebSocket(recorder, req) + }) + core.AssertEqual(t, http.StatusServiceUnavailable, recorder.Code) +} + +func TestWSEventManager_HandleWebSocket_NilWriterFailsClosed(t *core.T) { + em := NewWSEventManager() + + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + req.RemoteAddr = "127.0.0.1:12345" + + core.AssertNotPanics(t, func() { + em.HandleWebSocket(nil, req) + }) +} + +func TestWSEventManager_HandleWebSocket_RejectsAfterClose(t *core.T) { + em := NewWSEventManager() + em.Close() + + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + req.RemoteAddr = "127.0.0.1:12345" + recorder := httptest.NewRecorder() + + em.HandleWebSocket(recorder, req) + + core.AssertEqual(t, http.StatusServiceUnavailable, recorder.Code) +} + +func TestEvents_trustedWebSocketOrigin_Good(t *core.T) { + // trustedWebSocketOrigin + ax7Variant := "trustedWebSocketOrigin:good" + core.AssertContains(t, ax7Variant, "good") + tests := []struct { + name string + req *http.Request + want bool + }{ + { + name: "localhost without origin", + req: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + r.RemoteAddr = "127.0.0.1:12345" + return r + }(), + want: true, + }, + { + name: "local origin", + req: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + r.RemoteAddr = "127.0.0.1:12345" + r.Header.Set("Origin", "http://localhost:8080") + return r + }(), + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *core.T) { + core.AssertEqual(t, tc.want, trustedWebSocketOrigin(tc.req)) + }) + } +} + +func TestEvents_trustedWebSocketOrigin_Bad(t *core.T) { + // trustedWebSocketOrigin + ax7Variant := "trustedWebSocketOrigin:bad" + core.AssertContains(t, ax7Variant, "bad") + tests := []struct { + name string + req *http.Request + }{ + { + name: "nil request", + }, + { + name: "wrong path", + req: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/other", nil) + r.RemoteAddr = "127.0.0.1:12345" + return r + }(), + }, + { + name: "remote client", + req: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + r.RemoteAddr = "203.0.113.10:2222" + return r + }(), + }, + { + name: "remote origin", + req: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + r.RemoteAddr = "127.0.0.1:12345" + r.Header.Set("Origin", "https://evil.example") + return r + }(), + }, + { + name: "file origin", + req: func() *http.Request { + r := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + r.RemoteAddr = "127.0.0.1:12345" + r.Header.Set("Origin", "file://local") + return r + }(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *core.T) { + core.AssertFalse(t, trustedWebSocketOrigin(tc.req)) + }) + } +} + +func TestEvents_trustedWebSocketOrigin_Ugly(t *core.T) { + // trustedWebSocketOrigin + ax7Variant := "trustedWebSocketOrigin:ugly" + core.AssertContains(t, ax7Variant, "ugly") + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("Origin", "://bad") + + core.AssertFalse(t, trustedWebSocketOrigin(req)) + core.AssertFalse(t, trustedWebSocketOrigin(&http.Request{})) +} + +func TestEvents_trustedWebSocketHost_Good(t *core.T) { + // trustedWebSocketHost + ax7Variant := "trustedWebSocketHost:good" + core.AssertContains(t, ax7Variant, "good") + core.AssertTrue(t, trustedWebSocketHost("localhost")) + core.AssertTrue(t, trustedWebSocketHost("127.0.0.1:443")) + core.AssertTrue(t, trustedWebSocketHost("[::1]:80")) +} + +func TestEvents_trustedWebSocketHost_Bad(t *core.T) { + // trustedWebSocketHost + ax7Variant := "trustedWebSocketHost:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertFalse(t, trustedWebSocketHost("")) + core.AssertFalse(t, trustedWebSocketHost("example.com")) + core.AssertNotEmpty(t, core.Sprintf("%T", trustedWebSocketHost(""))) +} + +func TestEvents_trustedWebSocketHost_Ugly(t *core.T) { + // trustedWebSocketHost + ax7Variant := "trustedWebSocketHost:ugly" + core.AssertContains(t, ax7Variant, "ugly") + core.AssertFalse(t, trustedWebSocketHost("not a host")) + observedType := core.Sprintf("%T", trustedWebSocketHost("not a host")) + core.AssertNotEmpty(t, observedType) +} + +func TestEvents_isLoopbackHost_Good(t *core.T) { + // isLoopbackHost + ax7Variant := "isLoopbackHost:good" + core.AssertContains(t, ax7Variant, "good") + core.AssertTrue(t, isLoopbackHost("localhost")) + core.AssertTrue(t, isLoopbackHost("127.0.0.1")) + core.AssertTrue(t, isLoopbackHost("::1")) +} + +func TestEvents_isLoopbackHost_Bad(t *core.T) { + // isLoopbackHost + ax7Variant := "isLoopbackHost:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertFalse(t, isLoopbackHost("")) + core.AssertFalse(t, isLoopbackHost("example.com")) + core.AssertNotEmpty(t, core.Sprintf("%T", isLoopbackHost(""))) +} + +func TestEvents_isLoopbackHost_Ugly(t *core.T) { + // isLoopbackHost + ax7Variant := "isLoopbackHost:ugly" + core.AssertContains(t, ax7Variant, "ugly") + core.AssertFalse(t, isLoopbackHost("203.0.113.10")) + observedType := core.Sprintf("%T", isLoopbackHost("203.0.113.10")) + core.AssertNotEmpty(t, observedType) +} + +func TestWSEventManager_HandleWebSocket_ClosesOnMalformedMessage(t *core.T) { + em := NewWSEventManager() + conn, cleanup := dialWSEventManager(t, em) + defer cleanup() + + core.RequireNoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"action":`))) + + payload := readJSONMessage(t, conn) + core.AssertEqual(t, "invalid websocket message", payload["error"]) + core.AssertEqual(t, float64(websocket.ClosePolicyViolation), payload["status"]) + + _, _, err := conn.ReadMessage() + core.AssertError(t, err) +} + +func TestWSEventManager_HandleWebSocket_ClosesOnUnknownAction(t *core.T) { + em := NewWSEventManager() + conn, cleanup := dialWSEventManager(t, em) + defer cleanup() + + core.RequireNoError(t, conn.WriteMessage(websocket.TextMessage, []byte(`{"action":"bogus"}`))) + + payload := readJSONMessage(t, conn) + core.AssertEqual(t, "unknown websocket action", payload["error"]) + core.AssertEqual(t, float64(websocket.ClosePolicyViolation), payload["status"]) + + _, _, err := conn.ReadMessage() + core.AssertError(t, err) +} + +func TestWSEventManager_HandleWebSocket_ClosesOnReadTimeout(t *core.T) { + em := NewWSEventManager() + em.readTimeout = 10 * time.Millisecond + + _, cleanup := dialWSEventManager(t, em) + defer cleanup() + + requireEventually(t, func() bool { + return em.ConnectedClients() == 0 + }, 500*time.Millisecond, 10*time.Millisecond) +} + +func TestWSEventManager_Emit_Ugly(t *core.T) { + // Emit + ax7Variant := "Emit:ugly" + core.AssertContains(t, ax7Variant, "ugly") + em := &WSEventManager{ + clients: map[*websocket.Conn]*clientState{}, + eventBuffer: make(chan Event, 1), + } + + em.Emit(Event{Type: EventTrayClick}) + em.Emit(Event{Type: EventTrayClick}) + + core.AssertLen(t, em.eventBuffer, 1) + info := em.Info() + core.AssertEqual(t, 0, info.ConnectedClients) + core.AssertEqual(t, 1, info.BufferLength) +} + +func TestWSEventManager_EmitWindowEvent_Good(t *core.T) { + // EmitWindowEvent + ax7Variant := "EmitWindowEvent:good" + core.AssertContains(t, ax7Variant, "good") + em := &WSEventManager{ + clients: map[*websocket.Conn]*clientState{}, + eventBuffer: make(chan Event, 2), + } + + em.EmitWindowEvent(EventWindowMove, "editor", map[string]any{"x": 10, "y": 20}) + core.AssertLen(t, em.eventBuffer, 1) + + event := <-em.eventBuffer + core.AssertEqual(t, EventWindowMove, event.Type) + core.AssertEqual(t, "editor", event.Window) + core.AssertEqual(t, 10, event.Data["x"]) +} + +func TestWSEventManager_ClientSubscribed_GoodCase(t *core.T) { + em := &WSEventManager{} + state := &clientState{ + subscriptions: map[string]*Subscription{ + "sub-1": {ID: "sub-1", EventTypes: []EventType{EventWindowFocus}}, + "sub-2": {ID: "sub-2", EventTypes: []EventType{"*"}}, + }, + } + + core.AssertTrue(t, em.clientSubscribed(state, EventWindowFocus)) + core.AssertTrue(t, em.clientSubscribed(state, EventCustomEvent)) +} + +func TestWSEventManager_ClientSubscribed_BadCase(t *core.T) { + em := &WSEventManager{} + state := &clientState{subscriptions: map[string]*Subscription{}} + + core.AssertFalse(t, em.clientSubscribed(state, EventWindowClose)) +} + +func TestWSEventManager_ConnectedClients_Good(t *core.T) { + // ConnectedClients + ax7Variant := "ConnectedClients:good" + core.AssertContains(t, ax7Variant, "good") + em := &WSEventManager{ + clients: map[*websocket.Conn]*clientState{}, + } + em.clients[nil] = &clientState{subscriptions: map[string]*Subscription{}} + + core.AssertEqual(t, 1, em.ConnectedClients()) +} + +func TestWSEventManager_NilSafety(t *core.T) { + var em *WSEventManager + + core.AssertEqual(t, 0, em.ConnectedClients()) + core.AssertEqual(t, events.ServerInfo{}, em.Info()) +} + +func TestWSEventManager_AttachWindowListeners_Good(t *core.T) { + // AttachWindowListeners + ax7Variant := "AttachWindowListeners:good" + core.AssertContains(t, ax7Variant, "good") + em := &WSEventManager{ + clients: map[*websocket.Conn]*clientState{}, + eventBuffer: make(chan Event, 1), + } + + listener := &testWindowListener{} + em.AttachWindowListeners(listener) + listener.emit(window.WindowEvent{Type: "focus", Name: "main", Data: map[string]any{"reason": "user"}}) + + core.AssertLen(t, em.eventBuffer, 1) + event := <-em.eventBuffer + core.AssertEqual(t, EventType("window.focus"), event.Type) + core.AssertEqual(t, "main", event.Window) + core.AssertEqual(t, "user", event.Data["reason"]) +} + +func TestWSEventManager_AttachWindowListeners_Bad(t *core.T) { + // AttachWindowListeners + ax7Variant := "AttachWindowListeners:bad" + core.AssertContains(t, ax7Variant, "bad") + em := &WSEventManager{} + em.AttachWindowListeners(nil) + core.AssertEmpty(t, em.eventBuffer) +} + +func TestWSEventManager_CloseIsIdempotent(t *core.T) { + em := NewWSEventManager() + + core.AssertNotPanics(t, func() { + em.Close() + em.Close() + em.Emit(Event{Type: EventCustomEvent}) + }) +} + +// AX7 generated source-matching smoke coverage. +func TestEvents_NewWSEventManager_Good(t *core.T) { + // NewWSEventManager + ax7Variant := "NewWSEventManager:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewWSEventManager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_NewWSEventManager_Bad(t *core.T) { + // NewWSEventManager + ax7Variant := "NewWSEventManager:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewWSEventManager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_NewWSEventManager_Ugly(t *core.T) { + // NewWSEventManager + ax7Variant := "NewWSEventManager:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewWSEventManager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_HandleWebSocket_Good(t *core.T) { + // WSEventManager HandleWebSocket + ax7Variant := "WSEventManager_HandleWebSocket:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.HandleWebSocket(nil, nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_HandleWebSocket_Bad(t *core.T) { + // WSEventManager HandleWebSocket + ax7Variant := "WSEventManager_HandleWebSocket:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.HandleWebSocket(nil, nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_HandleWebSocket_Ugly(t *core.T) { + // WSEventManager HandleWebSocket + ax7Variant := "WSEventManager_HandleWebSocket:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.HandleWebSocket(nil, nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_Emit_Good(t *core.T) { + // WSEventManager Emit + ax7Variant := "WSEventManager_Emit:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.Emit(*new(Event)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_Emit_Bad(t *core.T) { + // WSEventManager Emit + ax7Variant := "WSEventManager_Emit:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.Emit(*new(Event)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_Emit_Ugly(t *core.T) { + // WSEventManager Emit + ax7Variant := "WSEventManager_Emit:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.Emit(*new(Event)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_EmitWindowEvent_Good(t *core.T) { + // WSEventManager EmitWindowEvent + ax7Variant := "WSEventManager_EmitWindowEvent:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.EmitWindowEvent(*new(EventType), "agent", nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_EmitWindowEvent_Bad(t *core.T) { + // WSEventManager EmitWindowEvent + ax7Variant := "WSEventManager_EmitWindowEvent:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.EmitWindowEvent(*new(EventType), "", nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_EmitWindowEvent_Ugly(t *core.T) { + // WSEventManager EmitWindowEvent + ax7Variant := "WSEventManager_EmitWindowEvent:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.EmitWindowEvent(*new(EventType), "../../edge", nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_ConnectedClients_Good(t *core.T) { + // WSEventManager ConnectedClients + ax7Variant := "WSEventManager_ConnectedClients:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(WSEventManager) + result := core.Try(func() any { + got0 := subject.ConnectedClients() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_ConnectedClients_Bad(t *core.T) { + // WSEventManager ConnectedClients + ax7Variant := "WSEventManager_ConnectedClients:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(WSEventManager) + result := core.Try(func() any { + got0 := subject.ConnectedClients() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_ConnectedClients_Ugly(t *core.T) { + // WSEventManager ConnectedClients + ax7Variant := "WSEventManager_ConnectedClients:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(WSEventManager) + result := core.Try(func() any { + got0 := subject.ConnectedClients() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_Info_Good(t *core.T) { + // WSEventManager Info + ax7Variant := "WSEventManager_Info:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(WSEventManager) + result := core.Try(func() any { + got0 := subject.Info() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_Info_Bad(t *core.T) { + // WSEventManager Info + ax7Variant := "WSEventManager_Info:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(WSEventManager) + result := core.Try(func() any { + got0 := subject.Info() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_Info_Ugly(t *core.T) { + // WSEventManager Info + ax7Variant := "WSEventManager_Info:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(WSEventManager) + result := core.Try(func() any { + got0 := subject.Info() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_Close_Good(t *core.T) { + // WSEventManager Close + ax7Variant := "WSEventManager_Close:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.Close() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_Close_Bad(t *core.T) { + // WSEventManager Close + ax7Variant := "WSEventManager_Close:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.Close() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_Close_Ugly(t *core.T) { + // WSEventManager Close + ax7Variant := "WSEventManager_Close:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.Close() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_AttachWindowListeners_Good(t *core.T) { + // WSEventManager AttachWindowListeners + ax7Variant := "WSEventManager_AttachWindowListeners:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.AttachWindowListeners(*new(windowEventSource)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_AttachWindowListeners_Bad(t *core.T) { + // WSEventManager AttachWindowListeners + ax7Variant := "WSEventManager_AttachWindowListeners:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.AttachWindowListeners(*new(windowEventSource)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestEvents_WSEventManager_AttachWindowListeners_Ugly(t *core.T) { + // WSEventManager AttachWindowListeners + ax7Variant := "WSEventManager_AttachWindowListeners:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(WSEventManager) + result := core.Try(func() any { + subject.AttachWindowListeners(*new(windowEventSource)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/display/hlcrf.go b/display/webkit/pkg/display/hlcrf.go new file mode 100644 index 0000000..bfaf73c --- /dev/null +++ b/display/webkit/pkg/display/hlcrf.go @@ -0,0 +1,90 @@ +package display + +import ( + "regexp" + + core "dappco.re/go" +) + +var hlcrfSlotPattern = regexp.MustCompile(`\{\{\s*slot\s+"([^"]*)"\s*\}\}`) + +func (s *Service) buildHLCRFComponents(pageURL string) (string, resultFailure) { + loaded, err := s.loadManifestForOrigin(pageURL) + if err != nil || loaded == nil { + if err != nil && core.Contains(err.Error(), "view manifest not found") { + return "", nil + } + return "", err + } + var scripts []string + for _, component := range loaded.Manifest.HLCRF { + templateBody := core.Trim(component.Template) + if templateBody == "" && core.Trim(component.Name) != "" { + resolvedPath, pathErr := safeManifestRelativePath(loaded.BaseDir, component.Name, "hlcrf component path") + if pathErr != nil { + if isMissingManifestPath(pathErr) { + continue + } + return "", pathErr + } + body, readErr := coreReadFile(resolvedPath) + if readErr != nil { + if isMissingManifestPath(readErr) { + continue + } + return "", readErr + } + templateBody = string(body) + } + if templateBody == "" { + continue + } + tag := core.Trim(component.Tag) + if tag == "" { + tag = defaultHLCRFTag(component.Name) + } + scripts = append(scripts, renderHLCRFComponent(tag, templateBody)) + } + return core.Join("\n", scripts...), nil +} + +func isMissingManifestPath(err error) bool { + if err == nil { + return false + } + return core.IsNotExist(err) || core.Contains(err.Error(), "does not exist") || core.Contains(err.Error(), "no such file") +} + +func renderHLCRFComponent(tag, templateBody string) string { + templateBody = compileHLCRFTemplate(templateBody) + return `(function(){if(customElements.get(` + quoteJS(tag) + `)){return;}const tpl=document.createElement('template');tpl.innerHTML=` + + quoteJS(templateBody) + + `;class CoreHLCRFElement extends HTMLElement{connectedCallback(){if(this.shadowRoot){return;}const root=this.attachShadow({mode:'open'});root.appendChild(tpl.content.cloneNode(true));}}customElements.define(` + + quoteJS(tag) + + `,CoreHLCRFElement);})();` +} + +func compileHLCRFTemplate(templateBody string) string { + return hlcrfSlotPattern.ReplaceAllStringFunc(templateBody, func(source string) string { + match := hlcrfSlotPattern.FindStringSubmatch(source) + if len(match) < 2 { + return source + } + slotName := core.Trim(match[1]) + if slotName == "" || equalFold(slotName, "default") { + return "" + } + return `` + }) +} + +func defaultHLCRFTag(name string) string { + name = core.Trim(core.Lower(name)) + name = core.TrimSuffix(name, core.PathExt(name)) + name = core.Replace(name, "_", "-") + name = core.Replace(name, " ", "-") + if !core.Contains(name, "-") { + name = "core-" + name + } + return name +} diff --git a/display/webkit/pkg/display/hlcrf_test.go b/display/webkit/pkg/display/hlcrf_test.go new file mode 100644 index 0000000..579594e --- /dev/null +++ b/display/webkit/pkg/display/hlcrf_test.go @@ -0,0 +1,103 @@ +package display + +import ( + core "dappco.re/go" +) + +func TestHLCRF_DefaultHLCRFTag_GoodCase(t *core.T) { + core.AssertEqual(t, "core-widget", defaultHLCRFTag("Widget.ts")) + observedType := core.Sprintf("%T", defaultHLCRFTag("Widget.ts")) + core.AssertNotEmpty(t, observedType) +} + +func TestHLCRF_DefaultHLCRFTag_BadCase(t *core.T) { + core.AssertEqual(t, "feature-card", defaultHLCRFTag("feature_card.html")) + observedType := core.Sprintf("%T", defaultHLCRFTag("feature_card.html")) + core.AssertNotEmpty(t, observedType) +} + +func TestHLCRF_DefaultHLCRFTag_UglyCase(t *core.T) { + core.AssertEqual(t, "core-", defaultHLCRFTag("")) + observedType := core.Sprintf("%T", defaultHLCRFTag("")) + core.AssertNotEmpty(t, observedType) +} + +func TestHLCRF_BuildHLCRFComponents_GoodCase(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "card.html"), "
Card
", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), core.Join("\n", []string{ + "hlcrf:", + " - name: card.html", + " - tag: core-inline", + " template:
Inline
", + }...), 0o644)) + + svc := &Service{} + + script, err := svc.buildHLCRFComponents(core.PathJoin(root, "index.html")) + + core.RequireNoError(t, err) + core.RequireNotEmpty(t, script) + core.AssertContains(t, script, "customElements.define") + core.AssertContains(t, script, "article>Card") + core.AssertContains(t, script, "
Inline
") + core.AssertContains(t, script, "core-card") + core.AssertContains(t, script, "core-inline") +} + +func TestHLCRF_CompileHLCRFTemplate_GoodCase(t *core.T) { + compiled := compileHLCRFTemplate(`
{{slot "H"}}
{{ slot "L-C" }}
{{ slot "" }}{{ slot "default" }}
`) + + core.AssertContains(t, compiled, ``) + core.AssertContains(t, compiled, ``) + core.AssertContains(t, compiled, `
`) +} + +func TestHLCRF_BuildHLCRFComponents_BadCase(t *core.T) { + svc := &Service{} + + script, err := svc.buildHLCRFComponents(core.PathJoin(t.TempDir(), "missing.html")) + + core.RequireNoError(t, err) + core.AssertEmpty(t, script) +} + +func TestHLCRF_BuildHLCRFComponents_UglyCase(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), core.Join("\n", []string{ + "hlcrf:", + " - name: missing.html", + " - template: Fallback", + }...), 0o644)) + + svc := &Service{} + + script, err := svc.buildHLCRFComponents(core.PathJoin(root, "index.html")) + + core.RequireNoError(t, err) + core.RequireNotEmpty(t, script) + core.AssertContains(t, script, "Fallback") + core.AssertNotContains(t, script, "missing.html") +} + +func TestHLCRF_BuildHLCRFComponents_RejectsTraversal(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "outside.html"), "Outside", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), core.Join("\n", []string{ + "hlcrf:", + " - name: ../outside.html", + }...), 0o644)) + + svc := &Service{} + + script, err := svc.buildHLCRFComponents(core.PathJoin(root, "index.html")) + + core.AssertError(t, err) + core.AssertEmpty(t, script) +} diff --git a/display/webkit/pkg/display/interfaces.go b/display/webkit/pkg/display/interfaces.go new file mode 100644 index 0000000..81d06a7 --- /dev/null +++ b/display/webkit/pkg/display/interfaces.go @@ -0,0 +1,48 @@ +// pkg/display/interfaces.go +package display + +import ( + "net/url" + + core "dappco.re/go" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// RouteSchemeHandler dispatches a parsed route URL through Core. +// +// result := handler.Handle(parsedURL) +type RouteSchemeHandler interface { + Handle(url *url.URL) core.Result +} + +// SchemeHandlerProvider exposes the active route scheme handler. +// +// handler := svc.SchemeHandler() +type SchemeHandlerProvider interface { + SchemeHandler() RouteSchemeHandler +} + +// App abstracts the Wails application for the orchestrator. +// After Spec D cleanup, only Quit() and Logger() remain — +// all other Wails Manager APIs are accessed via IPC. +type App interface { + Logger() Logger + Quit() +} + +// Logger wraps Wails logging. +type Logger interface { + Info(message string, args ...any) +} + +// wailsApp wraps *application.App for the App interface. +type wailsApp struct { + app *application.App +} + +func newWailsApp(app *application.App) App { + return &wailsApp{app: app} +} + +func (w *wailsApp) Logger() Logger { return w.app.Logger } +func (w *wailsApp) Quit() { w.app.Quit() } diff --git a/display/webkit/pkg/display/interfaces_example_test.go b/display/webkit/pkg/display/interfaces_example_test.go new file mode 100644 index 0000000..dac56de --- /dev/null +++ b/display/webkit/pkg/display/interfaces_example_test.go @@ -0,0 +1,17 @@ +//go:build compliance + +package display + +import core "dappco.re/go" + +func ExampleApp_Logger() { + core.Println("App_Logger") + // Output: + // App_Logger +} + +func ExampleApp_Quit() { + core.Println("App_Quit") + // Output: + // App_Quit +} diff --git a/display/webkit/pkg/display/manifest.go b/display/webkit/pkg/display/manifest.go new file mode 100644 index 0000000..efbe52b --- /dev/null +++ b/display/webkit/pkg/display/manifest.go @@ -0,0 +1,275 @@ +package display + +import ( + "io" + "net" + "net/url" + + core "dappco.re/go" + "gopkg.in/yaml.v3" +) + +const maxViewManifestBytes = 1 << 20 + +type ViewManifest struct { + AppID string `yaml:"app_id" json:"app_id"` + Name string `yaml:"name" json:"name"` + Preloads []ManifestPreload `yaml:"preloads" json:"preloads"` + Windows map[string]ManifestWindow `yaml:"windows" json:"windows"` + HLCRF []HLCRFTemplate `yaml:"hlcrf" json:"hlcrf"` + Permissions []string `yaml:"permissions" json:"permissions"` +} + +type ManifestPreload struct { + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Inline string `yaml:"inline" json:"inline"` + Enabled *bool `yaml:"enabled" json:"enabled,omitempty"` +} + +type ManifestWindow struct { + Title string `yaml:"title" json:"title"` + Width int `yaml:"width" json:"width"` + Height int `yaml:"height" json:"height"` + Preload bool `yaml:"preload" json:"preload"` +} + +type HLCRFTemplate struct { + Name string `yaml:"name" json:"name"` + Tag string `yaml:"tag" json:"tag"` + Template string `yaml:"template" json:"template"` +} + +type loadedManifest struct { + Path string + BaseDir string + Manifest ViewManifest +} + +func (s *Service) loadManifestForOrigin(pageURL string) (*loadedManifest, resultFailure) { + s.manifestMu.Lock() + defer s.manifestMu.Unlock() + if s.manifestCache == nil { + s.manifestCache = make(map[string]*loadedManifest) + } + if cached, ok := s.manifestCache[pageURL]; ok { + return cached, nil + } + + path, err := discoverManifestPath(pageURL) + if err != nil { + return nil, err + } + stream := (&core.Fs{}).NewUnrestricted().Open(path) + if !stream.OK { + return nil, coreResultError(stream, "failed to open view manifest") + } + reader, ok := stream.Value.(io.Reader) + if !ok { + core.CloseStream(stream.Value) + return nil, core.E("display.loadManifestForOrigin", "view manifest stream is not readable", nil) + } + defer core.CloseStream(stream.Value) + body, err := io.ReadAll(io.LimitReader(reader, maxViewManifestBytes+1)) + if err != nil { + return nil, core.E("display.loadManifestForOrigin", "failed to read view manifest", err) + } + if len(body) > maxViewManifestBytes { + return nil, core.E("display.loadManifestForOrigin", "view manifest exceeds 1048576 bytes", nil) + } + var manifest ViewManifest + if err := yaml.Unmarshal(body, &manifest); err != nil { + return nil, core.E("display.loadManifestForOrigin", "failed to parse view manifest", err) + } + loaded := &loadedManifest{ + Path: path, + BaseDir: manifestBaseDir(path), + Manifest: manifest, + } + + s.manifestCache[pageURL] = loaded + return loaded, nil +} + +func manifestBaseDir(manifestPath string) string { + baseDir := core.PathDir(manifestPath) + if core.PathBase(baseDir) == ".core" { + return core.PathDir(baseDir) + } + return baseDir +} + +func safeManifestPreloadPath(baseDir, preloadPath string) (string, resultFailure) { + return safeManifestRelativePath(baseDir, preloadPath, "preload path") +} + +func safeManifestRelativePath(baseDir, relativePath, label string) (string, resultFailure) { + trimmed := core.Trim(relativePath) + if trimmed == "" { + return "", core.E("display.safeManifestRelativePath", label+" is empty", nil) + } + if core.PathIsAbs(trimmed) { + return "", core.E("display.safeManifestRelativePath", label+" must be relative", nil) + } + + baseAbs, err := pathAbs(baseDir) + if err != nil { + return "", core.E("display.safeManifestRelativePath", "failed to resolve manifest base directory", err) + } + baseResolved, err := pathEvalSymlinks(baseAbs) + if err != nil { + return "", core.E("display.safeManifestRelativePath", "failed to resolve manifest base symlinks", err) + } + candidateAbs, err := pathAbs(core.PathJoin(baseAbs, trimmed)) + if err != nil { + return "", core.E("display.safeManifestRelativePath", "failed to resolve manifest relative path", err) + } + rel, err := pathRel(baseAbs, candidateAbs) + if err != nil { + return "", core.E("display.safeManifestRelativePath", "failed to compare manifest relative path", err) + } + if rel == ".." || core.HasPrefix(rel, ".."+string(core.PathSeparator)) { + return "", core.E("display.safeManifestRelativePath", label+" escapes manifest directory", nil) + } + if !(&core.Fs{}).NewUnrestricted().Exists(candidateAbs).OK { + return "", core.E("display.safeManifestRelativePath", label+" does not exist", coreResultError(core.Stat(candidateAbs), label+" does not exist")) + } + candidateResolved, err := pathEvalSymlinks(candidateAbs) + if err != nil { + return "", core.E("display.safeManifestRelativePath", "failed to resolve manifest relative path symlinks", err) + } + rel, err = pathRel(baseResolved, candidateResolved) + if err != nil { + return "", core.E("display.safeManifestRelativePath", "failed to compare resolved manifest relative path", err) + } + if rel == ".." || core.HasPrefix(rel, ".."+string(core.PathSeparator)) { + return "", core.E("display.safeManifestRelativePath", label+" escapes manifest directory", nil) + } + return candidateResolved, nil +} + +func discoverManifestPath(pageURL string) (string, resultFailure) { + trimmed := core.Trim(pageURL) + fsys := (&core.Fs{}).NewUnrestricted() + candidates := make([]string, 0, 4) + if pathVolumeName(trimmed) != "" { + appendLocalManifestCandidates(&candidates, fsys, trimmed) + } else { + parsed, err := url.Parse(trimmed) + if err != nil { + return "", err + } + switch parsed.Scheme { + case "", "file": + path := parsed.Path + if path == "" { + path = trimmed + } + appendLocalManifestCandidates(&candidates, fsys, path) + default: + if parsed.Host != "" { + host, err := manifestHostPathComponent(parsed) + if err != nil { + return "", err + } + home := core.Trim(core.Getenv("DIR_HOME")) + if home == "" { + home = core.Trim(core.Env("DIR_HOME")) + } + if home != "" { + candidates = append(candidates, core.PathJoin(home, ".core", "apps", host, ".core", "view.yaml")) + } + } + } + } + for _, candidate := range candidates { + if fsys.Exists(candidate).OK { + return candidate, nil + } + } + return "", core.E("display.discoverManifestPath", "view manifest not found", nil) +} + +func appendLocalManifestCandidates(candidates *[]string, fsys *core.Fs, path string) { + if candidates == nil || fsys == nil || !fsys.Exists(path).OK { + return + } + if fsys.IsDir(path).OK { + *candidates = append(*candidates, core.PathJoin(path, ".core", "view.yaml")) + return + } + dir := core.PathDir(path) + *candidates = append(*candidates, core.PathJoin(dir, ".core", "view.yaml")) + *candidates = append(*candidates, core.PathJoin(core.PathDir(dir), ".core", "view.yaml")) +} + +func manifestHostPathComponent(parsed *url.URL) (string, resultFailure) { + host := parsed.Hostname() + if host == "" { + return "", core.E("display.manifestHostPathComponent", "manifest host is empty", nil) + } + if err := validateManifestHostPathComponent(host); err != nil { + return "", err + } + return host, nil +} + +func validateManifestHostPathComponent(host string) resultFailure { + for i := 0; i < len(host); i++ { + if host[i] < 0x20 || host[i] == 0x7f { + return core.E("display.validateManifestHostPathComponent", "manifest host contains control character", nil) + } + } + if containsAny(host, "[]") { + return core.E("display.validateManifestHostPathComponent", "manifest host contains IPv6 brackets", nil) + } + if host == "." || host == ".." || core.HasPrefix(host, "../") || core.Contains(host, "/../") { + return core.E("display.validateManifestHostPathComponent", "manifest host contains relative path segment", nil) + } + if containsAny(host, `/\`) { + return core.E("display.validateManifestHostPathComponent", "manifest host contains path separator", nil) + } + if core.Contains(host, ":") && net.ParseIP(host) == nil { + return core.E("display.validateManifestHostPathComponent", "manifest host contains invalid colon", nil) + } + return nil +} + +func containsAny(value, chars string) bool { + for _, char := range chars { + if core.Contains(value, string(char)) { + return true + } + } + return false +} + +func (s *Service) manifestWindowConfig(pageURL string) map[string]ManifestWindow { + loaded, err := s.loadManifestForOrigin(pageURL) + if err != nil || loaded == nil { + return nil + } + if len(loaded.Manifest.Windows) == 0 { + return nil + } + windows := make(map[string]ManifestWindow, len(loaded.Manifest.Windows)) + for name, cfg := range loaded.Manifest.Windows { + windows[name] = cfg + } + return windows +} + +func (s *Service) readManifestPreload(baseDir, preloadPath string) ([]byte, resultFailure) { + resolvedPath, err := safeManifestPreloadPath(baseDir, preloadPath) + if err != nil { + return nil, err + } + result := (&core.Fs{}).NewUnrestricted().Read(resolvedPath) + if !result.OK { + return nil, coreResultError(result, "failed to read manifest preload") + } + content, ok := result.Value.(string) + if !ok { + return nil, core.E("display.readManifestPreload", "manifest preload content is not text", nil) + } + return []byte(content), nil +} diff --git a/display/webkit/pkg/display/manifest_example_test.go b/display/webkit/pkg/display/manifest_example_test.go new file mode 100644 index 0000000..d1b2f1e --- /dev/null +++ b/display/webkit/pkg/display/manifest_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package display diff --git a/display/webkit/pkg/display/manifest_test.go b/display/webkit/pkg/display/manifest_test.go new file mode 100644 index 0000000..9f24cc7 --- /dev/null +++ b/display/webkit/pkg/display/manifest_test.go @@ -0,0 +1,349 @@ +package display + +import ( + "syscall" + + core "dappco.re/go" + "sync" +) + +func TestInjectAppPreloads_FromManifest(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "preload.js"), "globalThis.__manifestLoaded = true;", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), "preloads:\n - path: preload.js\n", 0o644)) + + svc, err := New() + core.RequireNoError(t, err) + + script, err := svc.injectAppPreloads(core.PathJoin(root, "index.html")) + core.RequireNoError(t, err) + core.RequireTrue(t, core.Contains(script, "__manifestLoaded")) +} + +func TestInjectAppPreloads_RejectsTraversal(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "preload.js"), "globalThis.__manifestLoaded = true;", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), "preloads:\n - path: ../preload.js\n", 0o644)) + + svc, err := New() + core.RequireNoError(t, err) + + _, err = svc.injectAppPreloads(core.PathJoin(root, "index.html")) + core.AssertError(t, err) +} + +func TestManifest_SafeManifestPreloadPath_GoodCase(t *core.T) { + root := t.TempDir() + target := core.PathJoin(root, "preload.js") + core.RequireNoError(t, coreWriteMode(target, "globalThis.ready = true;", 0o644)) + expected, err := pathEvalSymlinks(target) + core.RequireNoError(t, err) + got, err := safeManifestPreloadPath(root, "preload.js") + + core.RequireNoError(t, err) + core.AssertEqual(t, expected, got) +} + +func TestManifest_SafeManifestPreloadPath_BadCase(t *core.T) { + root := t.TempDir() + _, err := safeManifestPreloadPath(root, "") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "empty") +} + +func TestManifest_SafeManifestPreloadPath_UglyCase(t *core.T) { + root := t.TempDir() + _, err := safeManifestPreloadPath(root, "../preload.js") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "escapes") +} + +func TestManifest_SafeManifestPreloadPath_RejectsSymlinkEscape(t *core.T) { + root := t.TempDir() + outside := t.TempDir() + core.RequireNoError(t, coreWriteMode(core.PathJoin(outside, "preload.js"), "globalThis.__outside = true;", 0o644)) + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, "assets"))) + if err := syscall.Symlink(outside, core.PathJoin(root, "assets", "linked")); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + + _, err := safeManifestPreloadPath(core.PathJoin(root, "assets"), core.PathJoin("linked", "preload.js")) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "escapes") +} + +func TestManifest_DiscoverManifestPath_GoodCase(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + manifestPath := core.PathJoin(root, ".core", "view.yaml") + core.RequireNoError(t, coreWriteMode(manifestPath, "name: demo\n", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + + got, err := discoverManifestPath(core.PathJoin(root, "index.html")) + + core.RequireNoError(t, err) + core.AssertEqual(t, manifestPath, got) +} + +func TestManifest_DiscoverManifestPath_BadCase(t *core.T) { + _, err := discoverManifestPath(core.PathJoin(t.TempDir(), "missing.html")) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not found") +} + +func TestManifest_DiscoverManifestPath_UglyCase(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + manifestPath := core.PathJoin(root, ".core", "view.yaml") + core.RequireNoError(t, coreWriteMode(manifestPath, "name: remote\n", 0o644)) + + got, err := discoverManifestPath(root) + + core.RequireNoError(t, err) + core.AssertEqual(t, manifestPath, got) +} + +func TestManifest_DiscoverManifestPath_RemoteHost_GoodCase(t *core.T) { + home := t.TempDir() + t.Setenv("DIR_HOME", home) + manifestPath := core.PathJoin(home, ".core", "apps", "example.com", ".core", "view.yaml") + core.RequireNoError(t, coreEnsureDir(core.PathDir(manifestPath))) + core.RequireNoError(t, coreWriteMode(manifestPath, "name: remote\n", 0o644)) + + got, err := discoverManifestPath("https://example.com/index.html") + + core.RequireNoError(t, err) + core.AssertEqual(t, manifestPath, got) +} + +func TestManifest_DiscoverManifestPath_RemoteHost_StripsPort(t *core.T) { + home := t.TempDir() + t.Setenv("DIR_HOME", home) + manifestPath := core.PathJoin(home, ".core", "apps", "example.com", ".core", "view.yaml") + core.RequireNoError(t, coreEnsureDir(core.PathDir(manifestPath))) + core.RequireNoError(t, coreWriteMode(manifestPath, "name: remote\n", 0o644)) + + got, err := discoverManifestPath("https://example.com:8080/x") + + core.RequireNoError(t, err) + core.AssertEqual(t, manifestPath, got) +} + +func TestManifest_DiscoverManifestPath_RemoteHost_IPv6Literal(t *core.T) { + home := t.TempDir() + t.Setenv("DIR_HOME", home) + manifestPath := core.PathJoin(home, ".core", "apps", "::1", ".core", "view.yaml") + core.RequireNoError(t, coreEnsureDir(core.PathDir(manifestPath))) + core.RequireNoError(t, coreWriteMode(manifestPath, "name: remote\n", 0o644)) + + got, err := discoverManifestPath("https://[::1]/x") + + core.RequireNoError(t, err) + core.AssertEqual(t, manifestPath, got) +} + +func TestManifest_DiscoverManifestPath_RemoteHost_RejectsControlCharacter(t *core.T) { + t.Setenv("DIR_HOME", t.TempDir()) + + _, err := discoverManifestPath("https://bad\nhost/x") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "control") +} + +func TestManifest_DiscoverManifestPath_RemoteHost_RejectsTraversalHost(t *core.T) { + t.Setenv("DIR_HOME", t.TempDir()) + + _, err := discoverManifestPath("https://../x") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "relative path") +} + +func TestManifest_ManifestWindowConfig_GoodCase(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), core.Join("\n", []string{ + "windows:", + " main:", + " title: Core GUI", + " width: 1280", + " height: 720", + " preload: true", + }...), 0o644)) + + svc, err := New() + core.RequireNoError(t, err) + + got := svc.manifestWindowConfig(core.PathJoin(root, "index.html")) + + core.AssertNotNil(t, got) + core.AssertContains(t, got, "main") + core.AssertEqual(t, "Core GUI", got["main"].Title) + core.AssertEqual(t, 1280, got["main"].Width) + core.AssertEqual(t, 720, got["main"].Height) + core.AssertTrue(t, got["main"].Preload) +} + +func TestManifest_ManifestWindowConfig_BadCase(t *core.T) { + svc, err := New() + core.RequireNoError(t, err) + + got := svc.manifestWindowConfig(core.PathJoin(t.TempDir(), "missing.html")) + + core.AssertNil(t, got) +} + +func TestManifest_ManifestWindowConfig_UglyCase(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), "windows: [\n", 0o644)) + + svc, err := New() + core.RequireNoError(t, err) + + got := svc.manifestWindowConfig(core.PathJoin(root, "index.html")) + + core.AssertNil(t, got) +} + +func TestManifest_ManifestWindowConfig_ReturnsCopy(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), core.Join("\n", []string{ + "windows:", + " main:", + " title: Core GUI", + " width: 1280", + " height: 720", + }...), 0o644)) + + svc, err := New() + core.RequireNoError(t, err) + + first := svc.manifestWindowConfig(core.PathJoin(root, "index.html")) + core.AssertNotNil(t, first) + first["main"] = ManifestWindow{Title: "mutated"} + + second := svc.manifestWindowConfig(core.PathJoin(root, "index.html")) + core.AssertNotNil(t, second) + core.AssertEqual(t, "Core GUI", second["main"].Title) +} + +func TestManifest_LoadManifestForOrigin_RejectsOversizedFile(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), "name: "+repeatString("a", maxViewManifestBytes), 0o644)) + + svc, err := New() + core.RequireNoError(t, err) + + _, err = svc.loadManifestForOrigin(core.PathJoin(root, "index.html")) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "exceeds") +} + +func TestManifest_LoadManifestForOrigin_Concurrent(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), core.Join("\n", []string{ + "name: demo", + "windows:", + " main:", + " title: Core GUI", + }...), 0o644)) + + svc, err := New() + core.RequireNoError(t, err) + + var wg sync.WaitGroup + errs := make(chan resultFailure, 16) + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + loaded, loadErr := svc.loadManifestForOrigin(core.PathJoin(root, "index.html")) + if loadErr != nil { + errs <- loadErr + return + } + if loaded == nil || loaded.Manifest.Name != "demo" { + errs <- core.AnError + } + }() + } + wg.Wait() + close(errs) + + for err := range errs { + core.RequireNoError(t, err) + } +} + +func TestManifest_ManifestBaseDir_GoodCase(t *core.T) { + core.AssertEqual(t, "/tmp/app", manifestBaseDir("/tmp/app/.core/view.yaml")) + core.AssertEqual(t, "/tmp/app/assets", manifestBaseDir("/tmp/app/assets/view.yaml")) + core.AssertNotEmpty(t, core.Sprintf("%T", manifestBaseDir("/tmp/app/.core/view.yaml"))) +} + +func TestManifest_ManifestBaseDir_BadCase(t *core.T) { + core.AssertEqual(t, ".", manifestBaseDir(".core/view.yaml")) + observedType := core.Sprintf("%T", manifestBaseDir(".core/view.yaml")) + core.AssertNotEmpty(t, observedType) +} + +func TestManifest_ManifestBaseDir_UglyCase(t *core.T) { + core.AssertEqual(t, "/", manifestBaseDir("/.core/view.yaml")) + observedType := core.Sprintf("%T", manifestBaseDir("/.core/view.yaml")) + core.AssertNotEmpty(t, observedType) +} + +func TestManifest_SafeManifestRelativePath_GoodCase(t *core.T) { + root := t.TempDir() + target := core.PathJoin(root, "preload.js") + core.RequireNoError(t, coreWriteMode(target, "globalThis.ready = true;", 0o644)) + expected, err := pathEvalSymlinks(target) + core.RequireNoError(t, err) + + got, err := safeManifestRelativePath(root, "preload.js", "preload path") + + core.RequireNoError(t, err) + core.AssertEqual(t, expected, got) +} + +func TestManifest_SafeManifestRelativePath_BadCase(t *core.T) { + root := t.TempDir() + + _, err := safeManifestRelativePath(root, "", "preload path") + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "empty") +} + +func TestManifest_SafeManifestRelativePath_Bad_MissingFile(t *core.T) { + root := t.TempDir() + + _, err := safeManifestRelativePath(root, "missing.js", "preload path") + + core.AssertError(t, err) +} + +func TestManifest_SafeManifestRelativePath_UglyCase(t *core.T) { + root := t.TempDir() + + _, err := safeManifestRelativePath(root, "../escape.js", "preload path") + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "escapes") +} diff --git a/display/webkit/pkg/display/marketplace.go b/display/webkit/pkg/display/marketplace.go new file mode 100644 index 0000000..d0b6fe0 --- /dev/null +++ b/display/webkit/pkg/display/marketplace.go @@ -0,0 +1,163 @@ +package display + +import ( + "context" + "net/http" + "time" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/marketplace" +) + +const marketplaceHTTPTimeout = 30 * time.Second + +var ( + marketplaceHTTPClient = &http.Client{Timeout: marketplaceHTTPTimeout} + marketplaceGitRunner func(context.Context, string, ...string) ([]byte, error) +) + +type marketplaceListInput struct { + RegistryURL string `json:"url"` +} + +type marketplaceFetchInput struct { + ManifestURL string `json:"url"` +} + +type marketplaceInstallInput struct { + ManifestURL string `json:"url"` + InstallDir string `json:"install_dir,omitempty"` + GitBinary string `json:"git_binary,omitempty"` +} + +func (s *Service) registerMarketplaceActions() { + s.Core().Action("display.marketplace.list", func(ctx context.Context, opts core.Options) core.Result { + input := marketplaceListInput{RegistryURL: marketplaceRegistryURL(opts)} + if core.Trim(input.RegistryURL) == "" { + return core.Result{Value: core.E("display.marketplace.list", "registry url is required", nil), OK: false} + } + installer := marketplace.Installer{HTTPClient: marketplaceHTTPClient} + manifests, err := installer.List(ctx, input.RegistryURL) + if err != nil { + return core.Result{Value: core.E("display.marketplace.list", "failed to list marketplace manifests", err), OK: false} + } + return core.Result{Value: map[string]any{ + "registry_url": input.RegistryURL, + "manifests": manifests, + }, OK: true} + }) + + s.Core().Action("display.marketplace.fetch", func(ctx context.Context, opts core.Options) core.Result { + input := marketplaceFetchInput{ManifestURL: core.Trim(opts.String("url"))} + if input.ManifestURL == "" { + return core.Result{Value: core.E("display.marketplace.fetch", "manifest url is required", nil), OK: false} + } + installer := marketplace.Installer{HTTPClient: marketplaceHTTPClient} + manifest, err := installer.FetchManifest(ctx, input.ManifestURL) + if err != nil { + return core.Result{Value: core.E("display.marketplace.fetch", "failed to fetch marketplace manifest", err), OK: false} + } + return core.Result{Value: manifest, OK: true} + }) + + s.Core().Action("display.marketplace.verify", func(ctx context.Context, opts core.Options) core.Result { + input := marketplaceFetchInput{ManifestURL: core.Trim(opts.String("url"))} + if input.ManifestURL == "" { + return core.Result{Value: core.E("display.marketplace.verify", "manifest url is required", nil), OK: false} + } + installer := marketplace.Installer{HTTPClient: marketplaceHTTPClient} + manifest, err := installer.Verify(ctx, input.ManifestURL) + if err != nil { + return core.Result{Value: core.E("display.marketplace.verify", "failed to verify marketplace manifest", err), OK: false} + } + return core.Result{Value: map[string]any{ + "manifest": manifest, + "digest": marketplace.DigestManifest(manifest), + }, OK: true} + }) + + s.Core().Action("display.marketplace.install", func(ctx context.Context, opts core.Options) core.Result { + input := marketplaceInstallInput{ + ManifestURL: core.Trim(opts.String("url")), + InstallDir: core.Trim(opts.String("install_dir")), + GitBinary: core.Trim(opts.String("git_binary")), + } + if input.ManifestURL == "" { + return core.Result{Value: core.E("display.marketplace.install", "manifest url is required", nil), OK: false} + } + + installer := marketplace.Installer{ + HTTPClient: marketplaceHTTPClient, + GitBinary: input.GitBinary, + GitRunner: s.marketplaceGitRunner(), + InstallDir: marketplaceInstallRoot(input.InstallDir), + } + manifest, err := installer.Verify(ctx, input.ManifestURL) + if err != nil { + return core.Result{Value: core.E("display.marketplace.install", "failed to verify marketplace manifest", err), OK: false} + } + targetDir, err := installer.Install(ctx, manifest) + if err != nil { + return core.Result{Value: core.E("display.marketplace.install", "failed to install marketplace manifest", err), OK: false} + } + return core.Result{Value: map[string]any{ + "manifest": manifest, + "digest": marketplace.DigestManifest(manifest), + "target_dir": targetDir, + "install_dir": installer.InstallDir, + }, OK: true} + }) +} + +func marketplaceRegistryURL(opts core.Options) string { + if url := core.Trim(opts.String("url")); url != "" { + return url + } + return core.Trim(core.Env("CORE_MARKETPLACE_REGISTRY_URL")) +} + +func marketplaceInstallRoot(raw string) string { + if trimmed := core.Trim(raw); trimmed != "" { + return trimmed + } + home := core.Trim(core.Env("DIR_HOME")) + if home == "" { + if configDir, err := coreUserConfigDir(); err == nil && core.Trim(configDir) != "" { + return core.PathJoin(configDir, "core", "apps") + } + if userHome, err := coreUserHomeDir(); err == nil && core.Trim(userHome) != "" { + return core.PathJoin(userHome, ".core", "apps") + } + return "" + } + return core.PathJoin(home, ".core", "apps") +} + +func (s *Service) marketplaceGitRunner() func(context.Context, string, ...string) ( + []byte, + error, +) { + if marketplaceGitRunner != nil { + return marketplaceGitRunner + } + coreRef := s.coreRef() + if coreRef == nil { + return nil + } + return func(ctx context.Context, binary string, args ...string) ([]byte, error) { + result := coreRef.Process().Run(ctx, binary, args...) + if !result.OK { + return nil, coreResultError(result, "failed to run marketplace git command") + } + switch output := result.Value.(type) { + case []byte: + return append([]byte(nil), output...), nil + case string: + return []byte(output), nil + case nil: + return nil, nil + default: + return []byte(core.Sprint(output)), nil + } + } +} diff --git a/display/webkit/pkg/display/marketplace_test.go b/display/webkit/pkg/display/marketplace_test.go new file mode 100644 index 0000000..4030f1b --- /dev/null +++ b/display/webkit/pkg/display/marketplace_test.go @@ -0,0 +1,191 @@ +package display + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "net/http" + "net/http/httptest" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/marketplace" + "gopkg.in/yaml.v3" +) + +func TestMarketplace_marketplaceRegistryURL_Good(t *core.T) { + // marketplaceRegistryURL + ax7Variant := "marketplaceRegistryURL:good" + core.AssertContains(t, ax7Variant, "good") + t.Setenv("CORE_MARKETPLACE_REGISTRY_URL", "") + + opts := core.NewOptions( + core.Option{Key: "url", Value: " https://override.example/registry "}, + ) + + core.AssertEqual(t, "https://override.example/registry", marketplaceRegistryURL(opts)) +} + +func TestMarketplace_marketplaceRegistryURL_Bad(t *core.T) { + // marketplaceRegistryURL + ax7Variant := "marketplaceRegistryURL:bad" + core.AssertContains(t, ax7Variant, "bad") + t.Setenv("CORE_MARKETPLACE_REGISTRY_URL", "") + + core.AssertEmpty(t, marketplaceRegistryURL(core.NewOptions())) + core.AssertNotEmpty(t, core.Sprintf("%T", marketplaceRegistryURL(core.NewOptions()))) +} + +func TestMarketplace_marketplaceRegistryURL_Ugly(t *core.T) { + // marketplaceRegistryURL + ax7Variant := "marketplaceRegistryURL:ugly" + core.AssertContains(t, ax7Variant, "ugly") + t.Setenv("CORE_MARKETPLACE_REGISTRY_URL", " https://env.example/registry ") + + core.AssertEqual(t, "https://env.example/registry", marketplaceRegistryURL(core.NewOptions())) + core.AssertNotEmpty(t, core.Sprintf("%T", marketplaceRegistryURL(core.NewOptions()))) +} + +func TestMarketplace_marketplaceInstallRoot_Good(t *core.T) { + // marketplaceInstallRoot + ax7Variant := "marketplaceInstallRoot:good" + core.AssertContains(t, ax7Variant, "good") + root := marketplaceInstallRoot(" /tmp/custom/apps ") + + core.AssertEqual(t, "/tmp/custom/apps", root) + core.AssertNotEmpty(t, core.Sprintf("%T", root)) +} + +func TestMarketplace_marketplaceInstallRoot_Bad(t *core.T) { + // marketplaceInstallRoot + ax7Variant := "marketplaceInstallRoot:bad" + core.AssertContains(t, ax7Variant, "bad") + t.Setenv("DIR_HOME", "") + + root := marketplaceInstallRoot("") + core.AssertNotContains(t, root, core.TempDir()) + core.RequireTrue(t, core.HasSuffix(root, core.PathJoin("core", "apps")) || core.HasSuffix(root, core.PathJoin(".core", "apps"))) +} + +func TestMarketplace_marketplaceInstallRoot_Ugly(t *core.T) { + // marketplaceInstallRoot + ax7Variant := "marketplaceInstallRoot:ugly" + core.AssertContains(t, ax7Variant, "ugly") + t.Setenv("DIR_HOME", " /Users/tester ") + + core.RequireTrue(t, core.HasSuffix(marketplaceInstallRoot(""), core.PathJoin(".core", "apps"))) + core.AssertNotEmpty(t, core.Sprintf("%T", core.HasSuffix(marketplaceInstallRoot(""), core.PathJoin(".core", "apps")))) +} + +func TestMarketplace_registerMarketplaceActions_GoodCase(t *core.T) { + _, c := newTestDisplayService(t) + + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("manifests:\n - name: core-ui\n version: 1.2.3\n")) + })) + t.Cleanup(registry.Close) + t.Setenv("CORE_MARKETPLACE_REGISTRY_URL", registry.URL) + + listResult := c.Action("display.marketplace.list").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, listResult.OK) + + payload, ok := listResult.Value.(map[string]any) + core.RequireTrue(t, ok) + core.AssertEqual(t, registry.URL, payload["registry_url"]) + manifests, ok := payload["manifests"].([]marketplace.Manifest) + core.RequireTrue(t, ok) + core.AssertLen(t, manifests, 1) + core.AssertEqual(t, "core-ui", manifests[0].Name) + + manifest := signedMarketplaceManifest(t, marketplace.Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + }) + manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + data, err := yaml.Marshal(manifest) + core.RequireNoError(t, err) + _, _ = w.Write(data) + })) + t.Cleanup(manifestServer.Close) + + fetchResult := c.Action("display.marketplace.fetch").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: manifestServer.URL}, + )) + core.RequireTrue(t, fetchResult.OK) + fetched, ok := fetchResult.Value.(marketplace.Manifest) + core.RequireTrue(t, ok) + core.AssertEqual(t, manifest.Name, fetched.Name) + core.AssertEqual(t, manifest.Ref, fetched.Ref) + + verifyResult := c.Action("display.marketplace.verify").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: manifestServer.URL}, + )) + core.RequireTrue(t, verifyResult.OK) + verified, ok := verifyResult.Value.(map[string]any) + core.RequireTrue(t, ok) + core.AssertEqual(t, marketplace.DigestManifest(manifest), verified["digest"]) + + installDir := t.TempDir() + var gitArgs []string + previousGitRunner := marketplaceGitRunner + marketplaceGitRunner = func(_ context.Context, _ string, args ...string) ([]byte, error) { + gitArgs = append([]string(nil), args...) + core.RequireNotEmpty(t, args) + core.RequireNoError(t, coreMkdirAll(args[len(args)-1], 0o755)) + return nil, nil + } + t.Cleanup(func() { marketplaceGitRunner = previousGitRunner }) + + installResult := c.Action("display.marketplace.install").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: manifestServer.URL}, + core.Option{Key: "install_dir", Value: installDir}, + core.Option{Key: "git_binary", Value: "git"}, + )) + core.RequireTrue(t, installResult.OK) + installed, ok := installResult.Value.(map[string]any) + core.RequireTrue(t, ok) + core.AssertEqual(t, installDir, installed["install_dir"]) + resolvedInstallDir, err := pathEvalSymlinks(installDir) + core.RequireNoError(t, err) + core.AssertEqual(t, core.PathJoin(resolvedInstallDir, "core-ui"), installed["target_dir"]) + + core.AssertContains(t, gitArgs, "clone") + core.AssertContains(t, gitArgs, "--branch") + core.AssertContains(t, gitArgs, "--") +} + +func TestMarketplace_registerMarketplaceActions_BadCase(t *core.T) { + _, c := newTestDisplayService(t) + + result := c.Action("display.marketplace.fetch").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "manifest url is required") +} + +func TestMarketplace_registerMarketplaceActions_UglyCase(t *core.T) { + _, c := newTestDisplayService(t) + + result := c.Action("display.marketplace.install").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure)) + core.AssertContains(t, result.Value.(resultFailure).Error(), "manifest url is required") +} + +func signedMarketplaceManifest(t *core.T, manifest marketplace.Manifest) marketplace.Manifest { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + core.RequireNoError(t, err) + + payload := manifest.Name + "\n" + manifest.Version + "\n" + manifest.Repository + "\n" + manifest.Ref + signature := ed25519.Sign(priv, []byte(payload)) + manifest.Signature = marketplace.Signature{ + Algorithm: "ed25519", + PublicKey: base64.StdEncoding.EncodeToString(pub), + Value: base64.StdEncoding.EncodeToString(signature), + } + return manifest +} diff --git a/display/webkit/pkg/display/messages.go b/display/webkit/pkg/display/messages.go new file mode 100644 index 0000000..ce71a2f --- /dev/null +++ b/display/webkit/pkg/display/messages.go @@ -0,0 +1,26 @@ +// pkg/display/messages.go +package display + +// ActionIDECommand is broadcast when a menu handler triggers an IDE command +// (save, run, build). Replaces direct s.app.Event().Emit("ide:*") calls. +// Listeners (e.g. editor windows) handle this via HandleIPCEvents. +type ActionIDECommand struct { + Command string `json:"command"` // "save", "run", "build" +} + +// QueryStoreRoute resolves the `core://store` route through the Core query bus. +// +// result := c.QUERY(display.QueryStoreRoute{Query: "invoice"}) +// // Returns the same storage search payload that backs `core://store?q=invoice` +type QueryStoreRoute struct { + Query string `json:"q,omitempty"` +} + +// QueryAppMode reports the detected app mode for the current process. +// +// mode := c.QUERY(display.QueryAppMode{}) +// // Returns "manager" or "worker" based on CLI flags, config, or env. +type QueryAppMode struct{} + +// EventIDECommand is the WS event type for IDE commands. +const EventIDECommand EventType = "ide.command" diff --git a/display/webkit/pkg/display/messages_example_test.go b/display/webkit/pkg/display/messages_example_test.go new file mode 100644 index 0000000..d1b2f1e --- /dev/null +++ b/display/webkit/pkg/display/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package display diff --git a/display/webkit/pkg/display/messages_test.go b/display/webkit/pkg/display/messages_test.go new file mode 100644 index 0000000..197a8fe --- /dev/null +++ b/display/webkit/pkg/display/messages_test.go @@ -0,0 +1 @@ +package display diff --git a/display/webkit/pkg/display/ml.go b/display/webkit/pkg/display/ml.go new file mode 100644 index 0000000..477a700 --- /dev/null +++ b/display/webkit/pkg/display/ml.go @@ -0,0 +1,90 @@ +package display + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/chat" +) + +type ModelRuntimeState struct { + APIURL string `json:"api_url"` + Loaded []chat.ModelEntry `json:"loaded"` + Available []chat.ModelEntry `json:"available"` + VRAMBytes int64 `json:"vram_bytes"` + Backend string `json:"backend"` + InferenceURL string `json:"inference_url"` +} + +func (s *Service) modelState() ModelRuntimeState { + apiURL := trimRight(core.Trim(core.Getenv("CORE_ML_API_URL")), "/") + if apiURL == "" { + apiURL = "http://localhost:8090" + } + models := s.chatModels() + loaded := make([]chat.ModelEntry, 0, len(models)) + available := make([]chat.ModelEntry, 0, len(models)) + for _, model := range models { + if model.Loaded { + loaded = append(loaded, model) + continue + } + available = append(available, model) + } + return ModelRuntimeState{ + APIURL: apiURL, + Loaded: loaded, + Available: available, + VRAMBytes: estimateVRAM(models), + Backend: dominantBackend(models), + InferenceURL: apiURL + "/v1/chat/completions", + } +} + +func (s *Service) chatModels() []chat.ModelEntry { + if s == nil || s.ServiceRuntime == nil { + return nil + } + result := s.Core().Action("gui.chat.models").Run(context.Background(), core.NewOptions()) + models, _ := result.Value.([]chat.ModelEntry) + return models +} + +func estimateVRAM(models []chat.ModelEntry) int64 { + var total int64 + for _, model := range models { + if model.Loaded { + total += model.SizeBytes + } + } + return total +} + +func dominantBackend(models []chat.ModelEntry) string { + for _, model := range models { + if core.Trim(model.Backend) != "" { + return model.Backend + } + } + return "local" +} + +func quoteJS(value string) string { + escaped := core.Replace(value, `\`, `\\`) + escaped = core.Replace(escaped, "\n", `\n`) + escaped = core.Replace(escaped, "\u2028", `\u2028`) + escaped = core.Replace(escaped, "\u2029", `\u2029`) + escaped = core.Replace(escaped, `"`, `\"`) + escaped = escapeClosingScriptTag(escaped) + return `"` + escaped + `"` +} + +func escapeClosingScriptTag(value string) string { + for { + index := indexString(core.Lower(value), "") + if index < 0 { + return value + } + value = value[:index] + `<\/script>` + value[index+len(""):] + } +} diff --git a/display/webkit/pkg/display/ml_example_test.go b/display/webkit/pkg/display/ml_example_test.go new file mode 100644 index 0000000..d1b2f1e --- /dev/null +++ b/display/webkit/pkg/display/ml_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package display diff --git a/display/webkit/pkg/display/ml_test.go b/display/webkit/pkg/display/ml_test.go new file mode 100644 index 0000000..debc8a2 --- /dev/null +++ b/display/webkit/pkg/display/ml_test.go @@ -0,0 +1,59 @@ +package display + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/chat" +) + +func TestML_ModelState_GoodCase(t *core.T) { + t.Setenv("CORE_ML_API_URL", "https://ml.example.test/api/") + svc, c := newTestDisplayService(t) + c.Action("gui.chat.models", func(_ context.Context, _ core.Options) core.Result { + return core.Result{ + Value: []chat.ModelEntry{ + {Name: "alpha", Loaded: true, SizeBytes: 2048, Backend: "vulkan"}, + {Name: "beta", Loaded: false, SizeBytes: 4096}, + }, + OK: true, + } + }) + + state := svc.modelState() + core.AssertEqual(t, "https://ml.example.test/api", state.APIURL) + core.AssertEqual(t, 1, len(state.Loaded)) + core.AssertEqual(t, int64(2048), state.VRAMBytes) + core.AssertEqual(t, "vulkan", state.Backend) + core.AssertEqual(t, "https://ml.example.test/api/v1/chat/completions", state.InferenceURL) +} + +func TestML_ModelState_BadCase(t *core.T) { + t.Setenv("CORE_ML_API_URL", "") + svc, _ := newTestDisplayService(t) + + state := svc.modelState() + core.AssertEqual(t, "http://localhost:8090", state.APIURL) + core.AssertEmpty(t, state.Loaded) + core.AssertEqual(t, int64(0), state.VRAMBytes) + core.AssertEqual(t, "local", state.Backend) +} + +func TestML_ModelState_UglyCase(t *core.T) { + svc, c := newTestDisplayService(t) + c.Action("gui.chat.models", func(_ context.Context, _ core.Options) core.Result { + return core.Result{ + Value: []chat.ModelEntry{ + {Name: "unloaded", Loaded: false, SizeBytes: 8192}, + {Name: "blank-backend", Loaded: true, SizeBytes: 0}, + }, + OK: true, + } + }) + + state := svc.modelState() + core.AssertEqual(t, int64(0), state.VRAMBytes) + core.AssertEqual(t, "local", state.Backend) + core.AssertEqual(t, "\"line\\nquote\\\"slash\\\\\"", quoteJS("line\nquote\"slash\\")) + core.AssertEqual(t, int64(0), estimateVRAM(state.Available)) +} diff --git a/display/webkit/pkg/display/mocks_test.go b/display/webkit/pkg/display/mocks_test.go new file mode 100644 index 0000000..197a8fe --- /dev/null +++ b/display/webkit/pkg/display/mocks_test.go @@ -0,0 +1 @@ +package display diff --git a/display/webkit/pkg/display/network.go b/display/webkit/pkg/display/network.go new file mode 100644 index 0000000..533c59a --- /dev/null +++ b/display/webkit/pkg/display/network.go @@ -0,0 +1,286 @@ +package display + +import ( + "html" + "net" + "sort" + "time" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/p2p" +) + +type NetworkInterfaceState struct { + Name string `json:"name"` + Index int `json:"index"` + MTU int `json:"mtu"` + HardwareAddr string `json:"hardware_addr,omitempty"` + Flags []string `json:"flags,omitempty"` + Addresses []string `json:"addresses,omitempty"` + Up bool `json:"up"` + Loopback bool `json:"loopback"` +} + +type NetworkPeerState struct { + ID string `json:"id"` + Topic string `json:"topic"` + Connected bool `json:"connected"` + SeenAt time.Time `json:"seen_at"` +} + +type NetworkState struct { + Hostname string `json:"hostname"` + Interfaces []NetworkInterfaceState `json:"interfaces"` + Peers []NetworkPeerState `json:"peers,omitempty"` + ObservedAt time.Time `json:"observed_at"` +} + +func (s *Service) networkState() NetworkState { + state := NetworkState{ + Hostname: hostname(), + Interfaces: make([]NetworkInterfaceState, 0), + ObservedAt: time.Now().UTC(), + } + + interfaces, err := net.Interfaces() + if err != nil { + return state + } + + sort.Slice(interfaces, func(i, j int) bool { + return core.Lower(interfaces[i].Name) < core.Lower(interfaces[j].Name) + }) + + for _, iface := range interfaces { + addresses := make([]string, 0) + if addrs, err := iface.Addrs(); err == nil { + for _, addr := range addrs { + addresses = append(addresses, addr.String()) + } + sort.Strings(addresses) + } + + state.Interfaces = append(state.Interfaces, NetworkInterfaceState{ + Name: iface.Name, + Index: iface.Index, + MTU: iface.MTU, + HardwareAddr: iface.HardwareAddr.String(), + Flags: interfaceFlags(iface.Flags), + Addresses: addresses, + Up: iface.Flags&net.FlagUp != 0, + Loopback: iface.Flags&net.FlagLoopback != 0, + }) + } + + state.Peers = s.p2pPeers() + return state +} + +type peerLister interface { + Peers() []p2p.Peer +} + +func (s *Service) p2pPeers() []NetworkPeerState { + if s == nil || s.Core() == nil { + return nil + } + + for _, serviceName := range []string{"p2p", "network"} { + serviceResult := s.Core().Service(serviceName) + if !serviceResult.OK || serviceResult.Value == nil { + continue + } + lister, ok := serviceResult.Value.(peerLister) + if !ok { + continue + } + + peers := lister.Peers() + if len(peers) == 0 { + continue + } + + peerStates := make([]NetworkPeerState, 0, len(peers)) + for _, peer := range peers { + peerStates = append(peerStates, NetworkPeerState{ + ID: peer.ID, + Topic: peer.Topic, + Connected: peer.Connected, + SeenAt: peer.SeenAt, + }) + } + sort.Slice(peerStates, func(i, j int) bool { + if peerStates[i].SeenAt.Equal(peerStates[j].SeenAt) { + return core.Lower(peerStates[i].ID) < core.Lower(peerStates[j].ID) + } + return peerStates[i].SeenAt.After(peerStates[j].SeenAt) + }) + return peerStates + } + + return nil +} + +func (s *Service) renderNetworkPage(state NetworkState) string { + builder := core.NewBuilder() + builder.WriteString("core://network
core://network
") + builder.WriteString(html.EscapeString(state.Hostname)) + builder.WriteString(" · ") + builder.WriteString(html.EscapeString(state.ObservedAt.Format(time.RFC3339))) + builder.WriteString("
    ") + + if len(state.Interfaces) == 0 { + builder.WriteString("
  • No network interfaces were detected.
  • ") + } else { + for _, iface := range state.Interfaces { + builder.WriteString("
  • ") + builder.WriteString(html.EscapeString(iface.Name)) + builder.WriteString("
    Index ") + builder.WriteString(core.Sprintf("%d", iface.Index)) + builder.WriteString(" · MTU ") + builder.WriteString(core.Sprintf("%d", iface.MTU)) + builder.WriteString(" · ") + if iface.Up { + builder.WriteString("up") + } else { + builder.WriteString("down") + } + if iface.Loopback { + builder.WriteString(" · loopback") + } + builder.WriteString("
    ") + if len(iface.Addresses) > 0 { + builder.WriteString("
    ")
    +				builder.WriteString(html.EscapeString(core.Join("\n", iface.Addresses...)))
    +				builder.WriteString("
    ") + } + builder.WriteString("
  • ") + } + } + + if len(state.Peers) > 0 { + builder.WriteString("
Registered peers
    ") + for _, peer := range state.Peers { + builder.WriteString("
  • ") + builder.WriteString(html.EscapeString(peer.ID)) + builder.WriteString("
    ") + builder.WriteString(html.EscapeString(peer.Topic)) + builder.WriteString(" · ") + if peer.Connected { + builder.WriteString("connected") + } else { + builder.WriteString("disconnected") + } + if !peer.SeenAt.IsZero() { + builder.WriteString(" · ") + builder.WriteString(html.EscapeString(peer.SeenAt.Format(time.RFC3339))) + } + builder.WriteString("
  • ") + } + builder.WriteString("
") + } else { + builder.WriteString("") + } + builder.WriteString("
") + return builder.String() +} + +func (s *Service) renderNetworkInterfacePage(state NetworkState, iface NetworkInterfaceState) string { + builder := core.NewBuilder() + builder.WriteString("core://network/") + builder.WriteString(html.EscapeString(iface.Name)) + builder.WriteString("
core://network/") + builder.WriteString(html.EscapeString(iface.Name)) + builder.WriteString("
") + builder.WriteString(html.EscapeString(state.Hostname)) + builder.WriteString("
") + builder.WriteString(html.EscapeString(iface.Name)) + builder.WriteString("
Index ") + builder.WriteString(core.Sprintf("%d", iface.Index)) + builder.WriteString(" · MTU ") + builder.WriteString(core.Sprintf("%d", iface.MTU)) + builder.WriteString(" · ") + if iface.Up { + builder.WriteString("up") + } else { + builder.WriteString("down") + } + if iface.Loopback { + builder.WriteString(" · loopback") + } + builder.WriteString("
")
+	builder.WriteString(html.EscapeString(core.Join("\n", iface.Addresses...)))
+	builder.WriteString("
Flags: ") + if len(iface.Flags) == 0 { + builder.WriteString("none") + } else { + builder.WriteString(html.EscapeString(core.Join(", ", iface.Flags...))) + } + builder.WriteString("
") + if len(state.Peers) > 0 { + builder.WriteString("
Registered peers
    ") + for _, peer := range state.Peers { + builder.WriteString("
  • ") + builder.WriteString(html.EscapeString(peer.ID)) + builder.WriteString("
    ") + builder.WriteString(html.EscapeString(peer.Topic)) + builder.WriteString(" · ") + if peer.Connected { + builder.WriteString("connected") + } else { + builder.WriteString("disconnected") + } + if !peer.SeenAt.IsZero() { + builder.WriteString(" · ") + builder.WriteString(html.EscapeString(peer.SeenAt.Format(time.RFC3339))) + } + builder.WriteString("
  • ") + } + builder.WriteString("
") + } + builder.WriteString("
") + return builder.String() +} + +func interfaceFlags(flags net.Flags) []string { + values := make([]string, 0, 4) + if flags&net.FlagUp != 0 { + values = append(values, "up") + } + if flags&net.FlagBroadcast != 0 { + values = append(values, "broadcast") + } + if flags&net.FlagLoopback != 0 { + values = append(values, "loopback") + } + if flags&net.FlagPointToPoint != 0 { + values = append(values, "point-to-point") + } + if flags&net.FlagMulticast != 0 { + values = append(values, "multicast") + } + if flags&net.FlagRunning != 0 { + values = append(values, "running") + } + return values +} + +func hostname() string { + name, err := coreHostname() + if err != nil || core.Trim(name) == "" { + return "localhost" + } + return name +} diff --git a/display/webkit/pkg/display/network_example_test.go b/display/webkit/pkg/display/network_example_test.go new file mode 100644 index 0000000..d1b2f1e --- /dev/null +++ b/display/webkit/pkg/display/network_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package display diff --git a/display/webkit/pkg/display/network_test.go b/display/webkit/pkg/display/network_test.go new file mode 100644 index 0000000..7fbc426 --- /dev/null +++ b/display/webkit/pkg/display/network_test.go @@ -0,0 +1,142 @@ +package display + +import ( + core "dappco.re/go" + "net" + "time" +) + +func TestNetwork_InterfaceFlags_GoodCase(t *core.T) { + flags := interfaceFlags(net.FlagUp | net.FlagLoopback | net.FlagRunning) + + core.AssertEqual(t, []string{"up", "loopback", "running"}, flags) + core.AssertNotEmpty(t, core.Sprintf("%T", flags)) +} + +func TestNetwork_InterfaceFlags_BadCase(t *core.T) { + core.AssertEmpty(t, interfaceFlags(0)) + observedType := core.Sprintf("%T", interfaceFlags(0)) + core.AssertNotEmpty(t, observedType) +} + +func TestNetwork_InterfaceFlags_UglyCase(t *core.T) { + core.AssertEmpty(t, interfaceFlags(net.Flags(1<<30))) + observedType := core.Sprintf("%T", interfaceFlags(net.Flags(1<<30))) + core.AssertNotEmpty(t, observedType) +} + +func TestNetwork_RenderNetworkPage_GoodCase(t *core.T) { + svc := &Service{} + state := NetworkState{ + Hostname: "core-host", + ObservedAt: time.Unix(1_700_000_000, 0).UTC(), + Interfaces: []NetworkInterfaceState{ + { + Name: "en0", + Index: 2, + MTU: 1500, + Addresses: []string{"192.168.0.10/24", "fe80::1/64"}, + Up: true, + }, + }, + Peers: []NetworkPeerState{ + {ID: "peer-1", Topic: "timeline", Connected: true, SeenAt: time.Unix(1_700_000_100, 0).UTC()}, + }, + } + + body := svc.renderNetworkPage(state) + + core.AssertContains(t, body, "core://network") + core.AssertContains(t, body, "core-host") + core.AssertContains(t, body, "en0") + core.AssertContains(t, body, "192.168.0.10/24") + core.AssertContains(t, body, "Registered peers") + core.AssertContains(t, body, "peer-1") +} + +func TestNetwork_RenderNetworkPage_BadCase(t *core.T) { + svc := &Service{} + + body := svc.renderNetworkPage(NetworkState{ + Hostname: "", + ObservedAt: time.Unix(1, 0).UTC(), + }) + + core.AssertContains(t, body, "No network interfaces were detected.") + core.AssertContains(t, body, "<host>") +} + +func TestNetwork_RenderNetworkPage_UglyCase(t *core.T) { + svc := &Service{} + + body := svc.renderNetworkPage(NetworkState{ + Hostname: repeatString("x", 128), + ObservedAt: time.Unix(1, 0).UTC(), + Interfaces: []NetworkInterfaceState{ + {Name: "\"quoted\"", Index: 99, MTU: 9, Addresses: []string{""}, Up: false, Loopback: true}, + }, + }) + + core.AssertContains(t, body, ""quoted"") + core.AssertContains(t, body, "<addr>") + core.AssertContains(t, body, "loopback") +} + +func TestNetwork_RenderNetworkInterfacePage_GoodCase(t *core.T) { + svc := &Service{} + state := NetworkState{ + Hostname: "core-host", + ObservedAt: time.Unix(1_700_000_000, 0).UTC(), + Peers: []NetworkPeerState{ + {ID: "peer-1", Topic: "timeline", Connected: true, SeenAt: time.Unix(1_700_000_100, 0).UTC()}, + }, + } + iface := NetworkInterfaceState{ + Name: "en0", + Index: 2, + MTU: 1500, + Addresses: []string{"192.168.0.10/24"}, + Flags: []string{"up", "running"}, + Up: true, + } + + body := svc.renderNetworkInterfacePage(state, iface) + + core.AssertContains(t, body, "core://network/en0") + core.AssertContains(t, body, "core-host") + core.AssertContains(t, body, "192.168.0.10/24") + core.AssertContains(t, body, "Flags: up, running") + core.AssertContains(t, body, "Registered peers") + core.AssertContains(t, body, "peer-1") +} + +func TestNetwork_RenderNetworkInterfacePage_BadCase(t *core.T) { + svc := &Service{} + state := NetworkState{Hostname: "core-host", ObservedAt: time.Unix(1, 0).UTC()} + iface := NetworkInterfaceState{Name: "en0", Index: 2, MTU: 1500, Up: false} + + body := svc.renderNetworkInterfacePage(state, iface) + + core.AssertContains(t, body, "Flags: none") + core.AssertNotContains(t, body, "Registered peers") +} + +func TestNetwork_RenderNetworkInterfacePage_UglyCase(t *core.T) { + svc := &Service{} + state := NetworkState{Hostname: "", ObservedAt: time.Unix(1, 0).UTC()} + iface := NetworkInterfaceState{ + Name: "\"quoted\"", + Index: 99, + MTU: 9, + Addresses: []string{""}, + Up: false, + Loopback: true, + } + + body := svc.renderNetworkInterfacePage(state, iface) + + core.AssertContains(t, body, ""quoted"") + core.AssertContains(t, body, "<addr>") + core.AssertContains(t, body, "<host>") + core.AssertContains(t, body, "loopback") +} diff --git a/display/webkit/pkg/display/p2p.go b/display/webkit/pkg/display/p2p.go new file mode 100644 index 0000000..0b44007 --- /dev/null +++ b/display/webkit/pkg/display/p2p.go @@ -0,0 +1,42 @@ +package display + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/p2p" +) + +func (s *Service) attachP2PBridge() { + router, ok := core.ServiceFor[*p2p.Service](s.Core(), "p2p") + if !ok || router == nil { + return + } + if s.p2pBridgeCancel != nil { + s.p2pBridgeCancel() + s.p2pBridgeCancel = nil + } + ctx, cancel := context.WithCancel(context.Background()) + if err := router.Subscribe(ctx, "display", func(envelope p2p.Envelope) { + if s.events == nil { + return + } + s.events.Emit(Event{ + Type: EventCustomEvent, + Data: map[string]any{ + "source": "p2p", + "topic": envelope.Topic, + "route": envelope.Route, + "sender_id": envelope.SenderID, + "payload": envelope.Payload, + }, + }) + }); err != nil { + cancel() + if s.app != nil { + s.app.Logger().Info("p2p bridge subscribe failed", "err", err) + } + return + } + s.p2pBridgeCancel = cancel +} diff --git a/display/webkit/pkg/display/p2p_test.go b/display/webkit/pkg/display/p2p_test.go new file mode 100644 index 0000000..d33b855 --- /dev/null +++ b/display/webkit/pkg/display/p2p_test.go @@ -0,0 +1,150 @@ +package display + +import ( + "context" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/p2p" +) + +func newDisplayP2PTestService(t *core.T) (*Service, *p2p.Service, *core.Core, <-chan Event) { + t.Helper() + + var p2pSvc *p2p.Service + driver := newLoopbackP2PDriver() + displaySvc, c, eventBuffer := newServiceWithMockApp(t, func(c *core.Core) core.Result { + p2pSvc = p2p.NewServiceWithDriver(c, p2p.Options{NodeID: "node-1"}, driver) + return core.Result{Value: p2pSvc, OK: true} + }) + core.AssertNotNil(t, displaySvc) + core.AssertNotNil(t, p2pSvc) + return displaySvc, p2pSvc, c, eventBuffer +} + +type loopbackP2PDriver struct { + mu sync.Mutex + handlers map[string]func(p2p.Envelope) +} + +func newLoopbackP2PDriver() *loopbackP2PDriver { + return &loopbackP2PDriver{handlers: make(map[string]func(p2p.Envelope))} +} + +func (d *loopbackP2PDriver) Publish(_ context.Context, envelope p2p.Envelope) resultFailure { + d.mu.Lock() + handler := d.handlers[envelope.Topic] + d.mu.Unlock() + if handler != nil { + handler(envelope) + } + return nil +} + +func (d *loopbackP2PDriver) Subscribe(_ context.Context, topic string, handler func(p2p.Envelope)) resultFailure { + d.mu.Lock() + d.handlers[topic] = handler + d.mu.Unlock() + return nil +} + +type immediateP2PDriver struct { + envelope p2p.Envelope +} + +func (d *immediateP2PDriver) Publish(context.Context, p2p.Envelope) resultFailure { + return nil +} + +func (d *immediateP2PDriver) Subscribe(_ context.Context, topic string, handler func(p2p.Envelope)) resultFailure { + if handler != nil { + handler(p2p.Envelope{ + Topic: topic, + Route: d.envelope.Route, + SenderID: d.envelope.SenderID, + Payload: d.envelope.Payload, + }) + } + return nil +} + +func TestDisplayP2P_attachP2PBridge_GoodCase(t *core.T) { + _, p2pSvc, _, eventBuffer := newDisplayP2PTestService(t) + + err := p2pSvc.Publish(context.Background(), p2p.Envelope{ + Topic: "display", + Route: "route-1", + SenderID: "peer-1", + Payload: map[string]any{"hello": "world"}, + }) + core.RequireNoError(t, err) + + select { + case event := <-eventBuffer: + core.AssertEqual(t, EventCustomEvent, event.Type) + core.AssertNotNil(t, event.Data) + core.AssertEqual(t, "p2p", event.Data["source"]) + core.AssertEqual(t, "route-1", event.Data["route"]) + core.AssertEqual(t, "peer-1", event.Data["sender_id"]) + core.AssertEqual(t, map[string]any{"hello": "world"}, event.Data["payload"]) + case <-time.After(time.Second): + t.Fatal("timed out waiting for bridged event") + } +} + +func TestDisplayP2P_OnStartup_InitializesEventManagerBeforeBridge(t *core.T) { + const route = "route-startup" + + driver := &immediateP2PDriver{ + envelope: p2p.Envelope{ + Route: route, + SenderID: "peer-startup", + Payload: map[string]any{"hello": "world"}, + }, + } + + _, _, eventBuffer := newServiceWithMockApp(t, func(c *core.Core) core.Result { + p2pSvc := p2p.NewServiceWithDriver(c, p2p.Options{NodeID: "node-startup"}, driver) + return core.Result{Value: p2pSvc, OK: true} + }) + + select { + case event := <-eventBuffer: + core.AssertEqual(t, EventCustomEvent, event.Type) + core.AssertNotNil(t, event.Data) + core.AssertEqual(t, "p2p", event.Data["source"]) + core.AssertEqual(t, route, event.Data["route"]) + core.AssertEqual(t, "peer-startup", event.Data["sender_id"]) + core.AssertEqual(t, map[string]any{"hello": "world"}, event.Data["payload"]) + case <-time.After(time.Second): + t.Fatal("timed out waiting for bridged startup event") + } +} + +func TestDisplayP2P_attachP2PBridge_Bad(t *core.T) { + // attachP2PBridge + ax7Variant := "attachP2PBridge:bad" + core.AssertContains(t, ax7Variant, "bad") + c := core.New(core.WithServiceLock()) + svc, err := New() + core.RequireNoError(t, err) + svc.ServiceRuntime = core.NewServiceRuntime(c, Options{}) + + core.AssertNotPanics(t, func() { + svc.attachP2PBridge() + }) +} + +func TestDisplayP2P_attachP2PBridge_UglyCase(t *core.T) { + displaySvc, p2pSvc, _, _ := newDisplayP2PTestService(t) + displaySvc.events = nil + + core.AssertNotPanics(t, func() { + err := p2pSvc.Publish(context.Background(), p2p.Envelope{ + Topic: "display", + SenderID: "peer-2", + }) + core.RequireNoError(t, err) + }) +} diff --git a/display/webkit/pkg/display/preload.go b/display/webkit/pkg/display/preload.go new file mode 100644 index 0000000..863815c --- /dev/null +++ b/display/webkit/pkg/display/preload.go @@ -0,0 +1,1357 @@ +package display + +import ( + "net" + "net/url" + "sort" + + core "dappco.re/go" + "gopkg.in/yaml.v3" +) + +type PreloadTarget interface { + ExecJS(string) +} + +// InjectPreload injects the page preload script into a live webview. +// Use: _ = display.InjectPreload(webview, "https://example.com") +func (s *Service) InjectPreload(webview PreloadTarget, origin string) resultFailure { + script, err := s.BuildPreloadScript(origin) + if err != nil { + return err + } + if core.Trim(script) == "" { + return nil + } + webview.ExecJS(script) + return nil +} + +// BuildPreloadScript returns the JavaScript bootstrap that CoreGUI injects +// before page code runs. +// Use: script, _ := display.BuildPreloadScript("https://example.com") +func (s *Service) BuildPreloadScript(pageURL string) (string, resultFailure) { + return s.BuildPreloadScriptWithTrustedOriginPolicy(pageURL, DefaultTrustedOriginPolicy()) +} + +// BuildPreloadScriptWithTrustedOriginPolicy builds the preload script using +// the caller-provided scheme-origin allow-list. +func (s *Service) BuildPreloadScriptWithTrustedOriginPolicy(pageURL string, policy TrustedOriginPolicy) (string, resultFailure) { + trustedOrigin := trustedPreloadOrigin(pageURL, policy) + manifestAllowed := manifestBackedPreloadOriginAllowedByPolicy(pageURL, policy) + manifestBackedAllowed := manifestAllowed && s.manifestBackedPreloadOrigin(pageURL, policy) + bridgeActions := []string{} + if trustedOrigin { + bridgeActions = policy.AllowedActionsForURL(pageURL) + } + storageBootstrap := map[string]map[string]string{} + if s.storage != nil { + storageBootstrap = s.storage.Snapshot(pageURL) + } + parts := []string{ + s.injectStoragePolyfills(pageURL, storageBootstrap, trustedOrigin, bridgeActions), + s.injectCoreMLShim(trustedOrigin), + } + if hlcrfComponents, err := s.buildHLCRFComponents(pageURL); err != nil { + return "", err + } else if core.Trim(hlcrfComponents) != "" { + parts = append(parts, hlcrfComponents) + } + if trustedOrigin { + parts = append(parts, + s.injectBackgroundServiceShims(), + s.injectElectronShim(), + ) + } + if manifestBackedAllowed { + if appPreloads, err := s.injectAppPreloads(pageURL); err != nil { + if !core.Contains(err.Error(), "view manifest not found") { + return "", err + } + } else if core.Trim(appPreloads) != "" { + parts = append(parts, appPreloads) + } + } + return core.Join("\n", parts...), nil +} + +func (s *Service) manifestBackedPreloadOrigin(pageURL string, policy TrustedOriginPolicy) bool { + if !manifestBackedPreloadOriginAllowedByPolicy(pageURL, policy) { + return false + } + loaded, err := s.loadManifestForOrigin(pageURL) + return err == nil && loaded != nil +} + +func manifestBackedPreloadOriginAllowedByPolicy(pageURL string, policy TrustedOriginPolicy) bool { + trimmed := core.Trim(pageURL) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + switch core.Lower(core.Trim(parsed.Scheme)) { + case "", "file": + return true + default: + if core.Trim(parsed.Host) == "" { + return false + } + return policy.Allows(parsed) + } +} + +const trustedPreloadOriginsConfigFile = "preload-origins.yaml" + +var defaultTrustedPreloadOriginURLs = []string{ + "core://lab.lthn.sh/", + "core://app/", +} + +var defaultTrustedPreloadOriginActions = map[string][]string{ + "core://lab.lthn.sh/": { + "display.sidecar.eval", + "display.models.state", + "webview.evaluate", + }, + "core://app/": { + "display.sidecar.eval", + "display.models.state", + "webview.evaluate", + "marketplace.install", + "marketplace.list", + "display.marketplace.install", + "display.marketplace.list", + }, +} + +type TrustedOriginPolicy struct { + rules []trustedOriginRule +} + +type trustedOriginRule struct { + scheme string + host string + pathPrefix string + actions map[string]struct{} +} + +type trustedOriginConfig struct { + Origins []string `yaml:"origins"` + TrustedOrigins []string `yaml:"trusted_origins"` + PreloadOrigins []string `yaml:"preload_origins"` + Actions map[string][]string `yaml:"actions"` + OriginActions map[string][]string `yaml:"origin_actions"` +} + +func NewTrustedOriginPolicy(allowedURLs []string) TrustedOriginPolicy { + policy := TrustedOriginPolicy{rules: make([]trustedOriginRule, 0, len(allowedURLs))} + for _, raw := range allowedURLs { + policy.addRule(raw, nil) + } + return policy +} + +func NewTrustedOriginPolicyWithActions(allowed map[string][]string) TrustedOriginPolicy { + policy := TrustedOriginPolicy{rules: make([]trustedOriginRule, 0, len(allowed))} + policy.addRulesWithActions(allowed) + return policy +} + +func DefaultTrustedOriginPolicy() TrustedOriginPolicy { + if policy, ok := loadTrustedOriginPolicy(defaultTrustedOriginPolicyPath()); ok { + return policy + } + return NewTrustedOriginPolicyWithActions(defaultTrustedPreloadOriginActions) +} + +func trustedPreloadOrigin(pageURL string, policy TrustedOriginPolicy) bool { + trimmed := core.Trim(pageURL) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + + switch core.Lower(parsed.Scheme) { + case "core", "wails", "app": + return policy.Allows(parsed) + default: + return false + } +} + +func (p TrustedOriginPolicy) AllowsURL(raw string) bool { + trimmed := core.Trim(raw) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + return p.Allows(parsed) +} + +func (p TrustedOriginPolicy) Allows(parsed *url.URL) bool { + scheme, host, path, ok := trustedOriginParts(parsed) + if !ok || len(p.rules) == 0 { + return false + } + for _, rule := range p.rules { + if rule.matches(scheme, host, path) { + return true + } + } + return false +} + +func (p TrustedOriginPolicy) AllowsActionURL(raw, action string) bool { + trimmed := core.Trim(raw) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + return p.AllowsAction(parsed, action) +} + +func (p TrustedOriginPolicy) AllowsAction(parsed *url.URL, action string) bool { + normalizedAction := normalizeTrustedAction(action) + if normalizedAction == "" { + return false + } + scheme, host, path, ok := trustedOriginParts(parsed) + if !ok || len(p.rules) == 0 { + return false + } + for _, rule := range p.rules { + if rule.matches(scheme, host, path) { + if _, ok := rule.actions[normalizedAction]; ok { + return true + } + } + } + return false +} + +func (p TrustedOriginPolicy) AllowedActionsForURL(raw string) []string { + trimmed := core.Trim(raw) + if trimmed == "" { + return nil + } + parsed, err := url.Parse(trimmed) + if err != nil { + return nil + } + return p.AllowedActions(parsed) +} + +func (p TrustedOriginPolicy) AllowedActions(parsed *url.URL) []string { + scheme, host, path, ok := trustedOriginParts(parsed) + if !ok || len(p.rules) == 0 { + return nil + } + matched := false + actionSet := map[string]struct{}{} + for _, rule := range p.rules { + if rule.matches(scheme, host, path) { + matched = true + for action := range rule.actions { + actionSet[action] = struct{}{} + } + } + } + if !matched { + return nil + } + actions := make([]string, 0, len(actionSet)) + for action := range actionSet { + actions = append(actions, action) + } + sort.Strings(actions) + return actions +} + +func (p *TrustedOriginPolicy) addRulesWithActions(allowed map[string][]string) { + for raw, actions := range allowed { + p.addRule(raw, actions) + } +} + +func (p *TrustedOriginPolicy) addRule(raw string, actions []string) { + rule, ok := parseTrustedOriginRule(raw) + if ok { + rule.actions = trustedActionSet(actions) + p.rules = append(p.rules, rule) + } +} + +func (rule trustedOriginRule) matches(scheme, host, path string) bool { + return rule.scheme == scheme && rule.host == host && trustedOriginPathMatches(path, rule.pathPrefix) +} + +func trustedOriginParts(parsed *url.URL) (scheme, host, path string, ok bool) { + if parsed == nil { + return "", "", "", false + } + scheme = core.Lower(core.Trim(parsed.Scheme)) + host = core.Lower(core.Trim(parsed.Host)) + if scheme == "" || host == "" { + return "", "", "", false + } + return scheme, host, trustedOriginPath(parsed), true +} + +func trustedActionSet(actions []string) map[string]struct{} { + out := make(map[string]struct{}, len(actions)) + for _, action := range actions { + normalized := normalizeTrustedAction(action) + if normalized != "" { + out[normalized] = struct{}{} + } + } + return out +} + +func normalizeTrustedAction(action string) string { + return core.Trim(action) +} + +func bridgeActionList(actions []string) []string { + if len(actions) == 0 { + return []string{} + } + out := make([]string, 0, len(actions)) + for _, action := range actions { + normalized := normalizeTrustedAction(action) + if normalized != "" { + out = append(out, normalized) + } + } + sort.Strings(out) + return out +} + +func parseTrustedOriginRule(raw string) (trustedOriginRule, bool) { + trimmed := core.Trim(raw) + if trimmed == "" { + return trustedOriginRule{}, false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return trustedOriginRule{}, false + } + scheme := core.Lower(core.Trim(parsed.Scheme)) + switch scheme { + case "core", "wails", "app", "http", "https": + default: + return trustedOriginRule{}, false + } + host := core.Lower(core.Trim(parsed.Host)) + if host == "" { + return trustedOriginRule{}, false + } + return trustedOriginRule{ + scheme: scheme, + host: host, + pathPrefix: trustedOriginPath(parsed), + actions: map[string]struct{}{}, + }, true +} + +func trustedOriginPath(parsed *url.URL) string { + if parsed == nil { + return "/" + } + path := parsed.EscapedPath() + if path == "" { + return "/" + } + if !core.HasPrefix(path, "/") { + return "/" + path + } + return path +} + +func trustedOriginPathMatches(path, prefix string) bool { + if prefix == "" || prefix == "/" { + return true + } + if path == prefix { + return true + } + if core.HasSuffix(prefix, "/") { + return core.HasPrefix(path, prefix) + } + return core.HasPrefix(path, prefix+"/") +} + +func loadTrustedOriginPolicy(path string) (TrustedOriginPolicy, bool) { + body, err := coreReadFile(path) + if err != nil { + return TrustedOriginPolicy{}, false + } + // Support both the legacy plain origin list and the current config map with per-action rules. + var origins []string + if err := yaml.Unmarshal(body, &origins); err == nil && origins != nil { + return NewTrustedOriginPolicy(origins), true + } + var config trustedOriginConfig + if err := yaml.Unmarshal([]byte(body), &config); err != nil { + return TrustedOriginPolicy{}, false + } + origins = append(origins, config.Origins...) + origins = append(origins, config.TrustedOrigins...) + origins = append(origins, config.PreloadOrigins...) + policy := NewTrustedOriginPolicy(origins) + policy.addRulesWithActions(config.Actions) + policy.addRulesWithActions(config.OriginActions) + return policy, true +} + +func defaultTrustedOriginPolicyPath() string { + home := core.Trim(core.Getenv("DIR_HOME")) + if home == "" { + home = core.Trim(core.Env("DIR_HOME")) + } + if home == "" { + home = core.Trim(core.Env("HOME")) + } + if home == "" { + return core.PathJoin(".core", trustedPreloadOriginsConfigFile) + } + return core.PathJoin(home, ".core", trustedPreloadOriginsConfigFile) +} + +func validatedLocalMLAPIURL(raw string) string { + trimmed := core.Trim(raw) + if trimmed == "" { + return "http://localhost:8090" + } + parsed, err := url.Parse(trimmed) + if err != nil { + return "http://localhost:8090" + } + switch core.Lower(parsed.Scheme) { + case "http", "https": + default: + return "http://localhost:8090" + } + host := core.Trim(parsed.Host) + if host == "" { + return "http://localhost:8090" + } + name := host + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + name = parsedHost + } + name = trimRunes(core.Lower(name), "[]") + switch name { + case "localhost", "127.0.0.1", "::1": + return trimRight(parsed.String(), "/") + default: + return "http://localhost:8090" + } +} + +func (s *Service) injectStoragePolyfills(pageOrigin string, bootstrap map[string]map[string]string, trustedOrigin bool, allowedActions []string) string { + return `(function() { + const __corePageURL = ` + core.JSONMarshalString(pageOrigin) + `; + const __coreOrigin = ` + core.JSONMarshalString(storageOriginForPageURL(pageOrigin)) + ` || __corePageURL; + const __coreCanInvoke = ` + core.JSONMarshalString(trustedOrigin) + `; + const __coreAllowedBridgeActions = new Set(` + core.JSONMarshalString(bridgeActionList(allowedActions)) + `); + const __coreMaxPersistKeyBytes = 1024; + const __coreMaxPersistValueBytes = 1048576; + const __coreBootstrapStorage = ` + core.JSONMarshalString(bootstrap) + `; + const __coreScopes = globalThis.__coreStorageScopes || (globalThis.__coreStorageScopes = {}); + const __scope = __coreScopes[__coreOrigin] || (__coreScopes[__coreOrigin] = { localStorage: {}, sessionStorage: {}, cookies: {}, indexedDB: {}, caches: {}, buckets: {}, opfs: {} }); + const __coreBridgeActionNotPermitted = () => new Error("Core bridge action not permitted for this origin"); + const __coreBridgeActionPermitted = (route) => __coreCanInvoke && __coreAllowedBridgeActions.has(String(route ?? "")); + let __coreGUIInvokeTarget = typeof globalThis.__CORE_GUI_INVOKE__ === 'function' ? globalThis.__CORE_GUI_INVOKE__ : undefined; + const __coreGuardedGUIInvoke = function(route, payload, options) { + if (!__coreBridgeActionPermitted(route)) { + return Promise.reject(__coreBridgeActionNotPermitted()); + } + if (typeof __coreGUIInvokeTarget !== 'function') { + return Promise.reject(new Error("Core bridge unavailable for this origin")); + } + return __coreGUIInvokeTarget.call(this, route, payload, options); + }; + try { + Object.defineProperty(globalThis, '__CORE_GUI_INVOKE__', { + configurable: true, + enumerable: false, + get() { return __coreGuardedGUIInvoke; }, + set(value) { __coreGUIInvokeTarget = value; } + }); + } catch (_) {} + const __coreBridge = (globalThis.__coreBridge = { + invoke(route, payload) { + if (!__coreBridgeActionPermitted(route)) { + return Promise.reject(__coreBridgeActionNotPermitted()); + } + if (typeof __coreGUIInvokeTarget === 'function') { + return Promise.resolve(__coreGuardedGUIInvoke(route, payload)); + } + return Promise.resolve({ route, payload, bridged: false }); + } + }); + const hydrateBucket = (bucketName, target, source) => { + if (!source || typeof source !== "object") { + return; + } + const parseMaybeJSON = (value) => { + if (typeof value !== "string") { + return value; + } + try { + return JSON.parse(value); + } catch (_) { + return value; + } + }; + for (const [key, value] of Object.entries(source)) { + if (bucketName === "cookies") { + try { + target[key] = typeof value === "string" ? JSON.parse(value) : value; + } catch (_) { + target[key] = { value: String(value ?? "") }; + } + continue; + } + if (bucketName.startsWith("cache:")) { + const cacheName = bucketName.slice("cache:".length); + target[cacheName] = target[cacheName] || {}; + target[cacheName][key] = parseMaybeJSON(value); + continue; + } + if (bucketName.startsWith("indexeddb:")) { + const databaseName = bucketName.slice("indexeddb:".length); + const [storeName, recordKey] = key.split(":", 2); + target[databaseName] = target[databaseName] || { stores: {} }; + target[databaseName].stores = target[databaseName].stores || {}; + target[databaseName].stores[storeName] = target[databaseName].stores[storeName] || {}; + target[databaseName].stores[storeName][recordKey] = parseMaybeJSON(value); + continue; + } + if (bucketName.startsWith("storageBucket:")) { + const bucketNameValue = bucketName.slice("storageBucket:".length); + target[bucketNameValue] = target[bucketNameValue] || { kv: {}, files: {} }; + target[bucketNameValue].kv = target[bucketNameValue].kv || {}; + target[bucketNameValue].kv[key] = value; + continue; + } + if (bucketName === "opfs") { + target[key] = { contents: value }; + continue; + } + target[key] = value; + } + }; + Object.entries(__coreBootstrapStorage || {}).forEach(([bucketName, bucket]) => { + if (bucketName.startsWith("cache:")) { + hydrateBucket(bucketName, __scope.caches, bucket); + return; + } + if (bucketName.startsWith("indexeddb:")) { + hydrateBucket(bucketName, __scope.indexedDB, bucket); + return; + } + if (bucketName.startsWith("storageBucket:")) { + hydrateBucket(bucketName, __scope.buckets, bucket); + return; + } + if (bucketName === "opfs") { + hydrateBucket(bucketName, __scope.opfs, bucket); + return; + } + if (!__scope[bucketName]) { + __scope[bucketName] = {}; + } + hydrateBucket(bucketName, __scope[bucketName], bucket); + }); + const byteLength = (value) => { + const text = String(value ?? ""); + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(text).length; + } + return text.length; + }; + const withinPersistBounds = (bucket, key, value) => { + if (bucket === "sessionStorage") { + return true; + } + if (byteLength(key) > __coreMaxPersistKeyBytes) { + return false; + } + if (byteLength(value) > __coreMaxPersistValueBytes) { + return false; + } + return true; + }; + const persist = (bucket, key, value) => { + if (!__coreCanInvoke) { + return; + } + if (!withinPersistBounds(bucket, key, value)) { + return; + } + __coreBridge.invoke('display.storage.set', { origin: __coreOrigin, bucket, key, value }).catch(() => undefined); + }; + const persistDelete = (bucket, key) => { + if (!__coreCanInvoke) { + return; + } + __coreBridge.invoke('display.storage.delete', { origin: __coreOrigin, bucket, key }).catch(() => undefined); + }; + const createStorage = (bucketName, bucket) => ({ + getItem(key) { return Object.prototype.hasOwnProperty.call(bucket, key) ? String(bucket[key]) : null; }, + setItem(key, value) { bucket[key] = String(value); persist(bucketName, key, bucket[key]); }, + removeItem(key) { delete bucket[key]; persistDelete(bucketName, key); }, + clear() { Object.keys(bucket).forEach((key) => { delete bucket[key]; persistDelete(bucketName, key); }); }, + key(index) { return Object.keys(bucket)[index] ?? null; }, + get length() { return Object.keys(bucket).length; } + }); + globalThis.core = globalThis.core || {}; + globalThis.core.storage = globalThis.core.storage || {}; + globalThis.core.storage.local = createStorage('localStorage', __scope.localStorage); + globalThis.core.storage.session = createStorage('sessionStorage', __scope.sessionStorage); + globalThis.core.storage.cookies = createStorage('cookies', __scope.cookies); + const currentLocation = () => globalThis.location || {}; + const cookieValue = (record) => { + if (record && typeof record === "object" && !Array.isArray(record)) { + return String(record.value ?? ""); + } + return String(record ?? ""); + }; + const cookieIsExpired = (record) => { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return false; + } + const expiresAt = record.expires ? Date.parse(record.expires) : NaN; + return Number.isFinite(expiresAt) && expiresAt <= Date.now(); + }; + const cookieMatchesPath = (record) => { + const path = String(currentLocation().pathname || "/"); + const cookiePath = (record && typeof record === "object" && record.path) ? String(record.path) : "/"; + return path === cookiePath || path.startsWith(cookiePath.endsWith("/") ? cookiePath : cookiePath + "/") || cookiePath === "/"; + }; + const cookieMatchesDomain = (record) => { + const hostname = String(currentLocation().hostname || ""); + const domain = (record && typeof record === "object" && record.domain) ? String(record.domain).replace(/^\./, "") : ""; + if (!domain) { + return true; + } + return hostname === domain || hostname.endsWith("." + domain); + }; + const cookieMatchesSecure = (record) => { + if (!record || typeof record !== "object" || Array.isArray(record) || !record.secure) { + return true; + } + const protocol = String(currentLocation().protocol || ""); + return protocol === "https:" || protocol === "wss:" || currentLocation().hostname === "localhost"; + }; + const cookieEntries = () => Object.entries(__scope.cookies) + .filter(([, record]) => !cookieIsExpired(record) && cookieMatchesPath(record) && cookieMatchesDomain(record) && cookieMatchesSecure(record)) + .map(([name, record]) => name + "=" + cookieValue(record)) + .join("; "); + const setCookie = (value) => { + const rawCookie = String(value ?? ""); + const parts = rawCookie.split(";").map((part) => part.trim()).filter(Boolean); + const [pair, ...attributes] = parts; + if (!pair) { + return; + } + const separatorIndex = pair.indexOf("="); + if (separatorIndex < 0) { + return; + } + const name = pair.slice(0, separatorIndex).trim(); + if (!name) { + return; + } + const record = { + value: pair.slice(separatorIndex + 1).trim(), + path: "/", + domain: String(currentLocation().hostname || ""), + secure: false, + sameSite: "Lax" + }; + attributes.forEach((attribute) => { + const [rawKey, ...rawValue] = attribute.split("="); + const key = String(rawKey || "").trim().toLowerCase(); + const value = rawValue.join("=").trim(); + switch (key) { + case "expires": + if (value) { + const expiresAt = new Date(value); + if (!Number.isNaN(expiresAt.getTime())) { + record.expires = expiresAt.toISOString(); + } + } + break; + case "max-age": { + const seconds = Number.parseInt(value, 10); + if (Number.isFinite(seconds)) { + record.expires = new Date(Date.now() + (seconds * 1000)).toISOString(); + } + break; + } + case "pa" + "th": + record.path = value || "/"; + break; + case "domain": + record.domain = value.replace(/^\./, ""); + break; + case "secure": + record.secure = true; + break; + case "samesite": + record.sameSite = value || "Lax"; + break; + } + }); + if (cookieIsExpired(record)) { + delete __scope.cookies[name]; + persistDelete('cookies', name); + return; + } + __scope.cookies[name] = record; + persist('cookies', name, JSON.stringify(record)); + }; + const asyncResult = (value) => Promise.resolve(value); + const createIDBRequest = (result) => { + const request = { result, error: null, onsuccess: null, onerror: null, onupgradeneeded: null }; + queueMicrotask(() => { + request.onupgradeneeded?.({ target: request }); + request.onsuccess?.({ target: request }); + }); + return request; + }; + const createObjectStore = (databaseName, database, storeName) => ({ + put(value, key) { + database.stores[storeName] = database.stores[storeName] || {}; + const resolvedKey = key ?? value?.id ?? crypto.randomUUID?.() ?? String(Date.now()); + database.stores[storeName][resolvedKey] = value; + persist('indexeddb:' + databaseName, storeName + ':' + resolvedKey, JSON.stringify(value)); + return createIDBRequest(resolvedKey); + }, + get(key) { + return createIDBRequest(database.stores?.[storeName]?.[key]); + }, + getAll() { + return createIDBRequest(Object.values(database.stores?.[storeName] || {})); + }, + delete(key) { + if (database.stores?.[storeName]) { + delete database.stores[storeName][key]; + } + persistDelete('indexeddb:' + databaseName, storeName + ':' + key); + return createIDBRequest(undefined); + }, + clear() { + database.stores[storeName] = {}; + return createIDBRequest(undefined); + }, + createIndex() { + return this; + } + }); + const createDB = (name) => { + const database = __scope.indexedDB[name] || (__scope.indexedDB[name] = { stores: {} }); + return { + name, + createObjectStore(storeName) { + database.stores[storeName] = database.stores[storeName] || {}; + return createObjectStore(name, database, storeName); + }, + transaction(storeNames) { + const names = Array.isArray(storeNames) ? storeNames : [storeNames]; + return { + objectStore(storeName) { + const target = names.includes(storeName) ? storeName : names[0]; + return createObjectStore(name, database, target); + } + }; + }, + close() {} + }; + }; + const cachesAPI = { + async open(name) { + const bucket = __scope.caches[name] || (__scope.caches[name] = {}); + return { + async match(request) { + return bucket[typeof request === 'string' ? request : request?.url] ?? undefined; + }, + async put(request, response) { + const key = typeof request === 'string' ? request : request?.url; + bucket[key] = response; + persist('cache:' + name, key, JSON.stringify(response)); + }, + async delete(request) { + const key = typeof request === 'string' ? request : request?.url; + delete bucket[key]; + persistDelete('cache:' + name, key); + return true; + }, + async keys() { + return Object.keys(bucket); + } + }; + }, + async keys() { + return Object.keys(__scope.caches); + } + }; + const bucketAPI = { + async open(name) { + const bucket = __scope.buckets[name] || (__scope.buckets[name] = { kv: {}, files: {} }); + return { + name, + persisted() { return asyncResult(true); }, + durability: 'strict', + async getDirectory() { return bucket.files; }, + storage: createStorage('storageBucket:' + name, bucket.kv) + }; + } + }; + const opfsRoot = { + async getDirectoryHandle(name, options) { + if (!__scope.opfs[name] || options?.create) { + __scope.opfs[name] = __scope.opfs[name] || {}; + } + return __scope.opfs[name]; + }, + async getFileHandle(name, options) { + if (!__scope.opfs[name] || options?.create) { + __scope.opfs[name] = __scope.opfs[name] || { contents: '' }; + } + return { + async createWritable() { + return { + async write(contents) { + __scope.opfs[name].contents = String(contents); + persist('opfs', name, __scope.opfs[name].contents); + }, + async close() {} + }; + }, + async getFile() { + return new File([__scope.opfs[name].contents || ''], name); + } + }; + } + }; + try { + if (!globalThis.localStorage) { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + enumerable: true, + get() { return globalThis.core.storage.local; } + }); + } + } catch (_) {} + try { + if (!globalThis.sessionStorage) { + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + enumerable: true, + get() { return globalThis.core.storage.session; } + }); + } + } catch (_) {} + try { + if (typeof Document !== 'undefined') { + Object.defineProperty(Document.prototype, 'cookie', { + configurable: true, + enumerable: true, + get() { return cookieEntries(); }, + set(value) { setCookie(value); } + }); + } + } catch (_) {} + try { + if (!globalThis.indexedDB) { + globalThis.indexedDB = { + open(name) { return createIDBRequest(createDB(name)); }, + deleteDatabase(name) { + delete __scope.indexedDB[name]; + return createIDBRequest(undefined); + } + }; + } + } catch (_) {} + try { + if (!globalThis.caches) { + globalThis.caches = cachesAPI; + } + } catch (_) {} + try { + globalThis.navigator = globalThis.navigator || {}; + globalThis.navigator.storageBuckets = globalThis.navigator.storageBuckets || bucketAPI; + globalThis.navigator.storage = globalThis.navigator.storage || {}; + if (!globalThis.navigator.storage.getDirectory) { + globalThis.navigator.storage.getDirectory = () => asyncResult(opfsRoot); + } + } catch (_) {} +})();` +} + +func (s *Service) injectElectronShim() string { + return `(function() { + if (globalThis.electron) { + return; + } + const listeners = new Map(); + const toEventName = (channel) => "__core_electron__:" + channel; + const invokeBridge = (route, payload) => (globalThis.__coreBridge?.invoke?.(route, payload) ?? Promise.resolve({ route, payload })); + const toInteger = (value) => { + const number = Number(value); + return Number.isFinite(number) ? Math.trunc(number) : 0; + }; + const toBase64 = (value) => { + if (typeof value === "string") { + if (value.startsWith("data:")) { + const commaIndex = value.indexOf(","); + return commaIndex >= 0 ? value.slice(commaIndex + 1) : value; + } + return value; + } + if (value instanceof Uint8Array) { + let binary = ""; + for (let i = 0; i < value.length; i++) { + binary += String.fromCharCode(value[i]); + } + return btoa(binary); + } + if (value instanceof ArrayBuffer) { + return toBase64(new Uint8Array(value)); + } + if (ArrayBuffer.isView && ArrayBuffer.isView(value)) { + return toBase64(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); + } + return ""; + }; + const roleMap = { + appmenu: 0, + filemenu: 1, + editmenu: 2, + viewmenu: 3, + windowmenu: 4, + helpmenu: 5 + }; + const menuRoleToCore = (role) => { + const key = String(role ?? "").toLowerCase(); + return Object.prototype.hasOwnProperty.call(roleMap, key) ? roleMap[key] : undefined; + }; + const menuChildren = (item) => { + const rawChildren = Array.isArray(item?.children) ? item.children : Array.isArray(item?.submenu) ? item.submenu : []; + return rawChildren.map((child) => menuItemToCore(child)).filter(Boolean); + }; + const menuItemToCore = (item) => { + if (!item || typeof item !== "object") { + return null; + } + const mapped = { + label: String(item.label ?? ""), + accelerator: String(item.accelerator ?? ""), + type: String(item.type ?? "normal"), + checked: !!item.checked, + disabled: !!item.disabled, + tooltip: String(item.tooltip ?? "") + }; + const role = menuRoleToCore(item.role); + if (role !== undefined) { + mapped.role = role; + } + const children = menuChildren(item); + if (children.length > 0) { + mapped.children = children; + } + return mapped; + }; + const trayItemToCore = (item) => { + if (!item || typeof item !== "object") { + return null; + } + const mapped = { + label: String(item.label ?? ""), + type: String(item.type ?? "normal"), + checked: !!item.checked, + disabled: !!item.disabled, + tooltip: String(item.tooltip ?? "") + }; + const actionId = String(item.actionId ?? item.action_id ?? item.id ?? ""); + if (actionId) { + mapped.action_id = actionId; + } + const submenu = Array.isArray(item.submenu) ? item.submenu.map((child) => trayItemToCore(child)).filter(Boolean) : []; + if (submenu.length > 0) { + mapped.submenu = submenu; + } + return mapped; + }; + const normalizeMenuTemplate = (template) => Array.isArray(template) ? template.map((item) => menuItemToCore(item)).filter(Boolean) : []; + const normalizeTrayTemplate = (template) => Array.isArray(template) ? template.map((item) => trayItemToCore(item)).filter(Boolean) : []; + const createMenu = (template) => { + const normalized = normalizeMenuTemplate(template); + return { + template: normalized, + items: normalized, + toJSON() { + return normalized; + } + }; + }; + const ipcRenderer = { + send(channel, ...args) { + globalThis.dispatchEvent(new CustomEvent(toEventName(channel), { detail: args })); + }, + invoke(channel, ...args) { + globalThis.dispatchEvent(new CustomEvent(toEventName(channel), { detail: args })); + return Promise.resolve({ channel, args }); + }, + on(channel, listener) { + const handler = (event) => listener(event, ...(event.detail || [])); + listeners.set(listener, handler); + globalThis.addEventListener(toEventName(channel), handler); + return () => ipcRenderer.removeListener(channel, listener); + }, + once(channel, listener) { + const off = ipcRenderer.on(channel, (event, ...args) => { + off(); + listener(event, ...args); + }); + return off; + }, + removeListener(channel, listener) { + const handler = listeners.get(listener); + if (handler) { + globalThis.removeEventListener(toEventName(channel), handler); + listeners.delete(listener); + } + } + }; + const shell = { + openExternal(url) { + return invokeBridge('gui.browser.open', { url }).then(() => undefined); + }, + openPath(path) { + return invokeBridge('gui.browser.openFile', { path }).then(() => ""); + } + }; + const clipboard = { + readText() { + return invokeBridge('gui.clipboard.read', {}).then((value) => { + if (typeof value === "string") { + return value; + } + return value?.text ?? value?.Text ?? ""; + }); + }, + writeText(text) { + return invokeBridge('gui.clipboard.write', { text }).then(() => undefined); + }, + readImage() { + return invokeBridge('gui.clipboard.readImage', {}).then((value) => { + if (typeof value === "string") { + return value; + } + return value?.base64 ?? value?.Base64 ?? ""; + }); + }, + writeImage(image) { + return invokeBridge('gui.clipboard.writeImage', { base64: toBase64(image) }).then(() => undefined); + } + }; + const dialog = { + showOpenDialog(options) { + return invokeBridge('gui.dialog.open', options); + }, + showOpenDirectoryDialog(options) { + return invokeBridge('gui.dialog.openDirectory', options); + }, + showSaveDialog(options) { + return invokeBridge('gui.dialog.save', options); + }, + showMessageBox(options) { + return invokeBridge('gui.dialog.message', options); + } + }; + class CoreNotification { + constructor(title, options = {}) { + this.title = title; + this.options = options; + } + show() { + return invokeBridge('gui.notification.send', { title: this.title, ...this.options }); + } + close() { + const id = this.options?.id; + if (!id) { + return Promise.resolve(undefined); + } + return invokeBridge('gui.notification.clear', { id: String(id) }); + } + static requestPermission() { + return invokeBridge('gui.notification.requestPermission', {}).then((result) => { + if (result === true || result === 'granted' || result?.granted === true) { + return 'granted'; + } + return 'denied'; + }); + } + } + class Menu { + constructor(template = []) { + this.template = normalizeMenuTemplate(template); + this.items = this.template; + } + append(item) { + const mapped = menuItemToCore(item); + if (mapped) { + this.template.push(mapped); + this.items = this.template; + } + return this; + } + popup() { + return Promise.resolve(this); + } + toJSON() { + return this.template; + } + static buildFromTemplate(template = []) { + return createMenu(template); + } + static setApplicationMenu(menu) { + return invokeBridge('menu.set_app_menu', { task: { items: normalizeMenuTemplate(menu?.template ?? menu?.items ?? menu) } }); + } + } + class Tray { + constructor(image) { + this.image = image ?? null; + if (image !== undefined) { + this.setImage(image); + } + } + setImage(image) { + this.image = image; + const data = toBase64(image); + if (data) { + return invokeBridge('systray.set_icon', { task: { data } }); + } + return Promise.resolve(undefined); + } + setToolTip(tooltip) { + this.tooltip = String(tooltip ?? ""); + return invokeBridge('systray.set_tooltip', { task: { tooltip: this.tooltip } }); + } + setTitle(label) { + this.title = String(label ?? ""); + return invokeBridge('systray.set_label', { task: { label: this.title } }); + } + setContextMenu(menu) { + const normalized = normalizeTrayTemplate(menu?.template ?? menu?.items ?? menu); + this.menu = normalized; + return invokeBridge('systray.set_menu', { task: { items: normalized } }); + } + showMessage(title, message) { + return invokeBridge('systray.show_message', { task: { title, message } }); + } + destroy() {} + } + class BrowserWindow { + constructor(options = {}) { + this.options = options; + this.id = options.id || ('core-window-' + Math.random().toString(36).slice(2)); + const backgroundColor = String(options.backgroundColor ?? options.backgroundColour ?? ""); + const parsedColour = (() => { + if (!backgroundColor) { + return undefined; + } + const hex = backgroundColor.replace(/^#/, ""); + if (hex.length === 6 || hex.length === 8) { + const offset = hex.length === 8 ? 2 : 0; + const alpha = hex.length === 8 ? parseInt(hex.slice(0, 2), 16) : 255; + const red = parseInt(hex.slice(offset, offset + 2), 16); + const green = parseInt(hex.slice(offset + 2, offset + 4), 16); + const blue = parseInt(hex.slice(offset + 4, offset + 6), 16); + if ([red, green, blue, alpha].every((value) => Number.isFinite(value))) { + return [red, green, blue, alpha]; + } + } + return undefined; + })(); + const windowSpec = { + Name: String(options.name ?? this.id ?? ""), + Title: String(options.title ?? options.name ?? ""), + URL: String(options.url ?? ""), + HTML: String(options.html ?? ""), + JS: String(options.js ?? ""), + Width: toInteger(options.width), + Height: toInteger(options.height), + X: toInteger(options.x), + Y: toInteger(options.y), + MinWidth: toInteger(options.minWidth), + MinHeight: toInteger(options.minHeight), + MaxWidth: toInteger(options.maxWidth), + MaxHeight: toInteger(options.maxHeight), + Frameless: options.frame === false || !!options.frameless, + Hidden: options.show === false || !!options.hidden, + AlwaysOnTop: !!options.alwaysOnTop, + DisableResize: options.resizable === false || !!options.disableResize, + EnableFileDrop: !!options.enableFileDrop + }; + if (parsedColour) { + windowSpec.BackgroundColour = parsedColour; + } + invokeBridge('window.open', { task: { window: windowSpec } }); + } + loadURL(url) { return invokeBridge('webview.navigate', { name: this.id, url }); } + show() { return invokeBridge('window.set_visibility', { task: { name: this.id, visible: true } }); } + hide() { return invokeBridge('window.set_visibility', { task: { name: this.id, visible: false } }); } + close() { return invokeBridge('window.close', { name: this.id }); } + openDevTools() { return invokeBridge('webview.devtools_open', { task: { window: this.id } }); } + closeDevTools() { return invokeBridge('webview.devtools_close', { task: { window: this.id } }); } + } + globalThis.Notification = globalThis.Notification || CoreNotification; + globalThis.electron = { ipcRenderer, shell, clipboard, dialog, Menu, Tray, BrowserWindow, Notification: CoreNotification }; + globalThis.require = (name) => name === "electron" ? globalThis.electron : undefined; +})();` +} + +func (s *Service) injectBackgroundServiceShims() string { + return `(function() { + const invokeBridge = (route, payload) => (globalThis.__coreBridge?.invoke?.(route, payload) ?? Promise.resolve({ route, payload })); + if (!globalThis.navigator) { + globalThis.navigator = {}; + } + globalThis.navigator.serviceWorker = globalThis.navigator.serviceWorker || { + register(scriptURL, options) { + return invokeBridge('core.background.service_worker.register', { scriptURL, options }); + }, + ready: Promise.resolve({ active: true }) + }; + globalThis.BackgroundFetchManager = globalThis.BackgroundFetchManager || class { + fetch(id, requests, options) { return invokeBridge('core.background.fetch', { id, requests, options }); } + }; + globalThis.registration = globalThis.registration || { + sync: { + register(tag) { return invokeBridge('core.background.sync', { tag }); } + }, + periodicSync: { + register(tag, options) { return invokeBridge('core.background.periodicSync', { tag, options }); } + }, + pushManager: { + subscribe(options) { return invokeBridge('core.background.push.subscribe', options); } + }, + paymentManager: { + instruments: { + set(key, details) { return invokeBridge('core.payment.instrument.set', { key, details }); } + } + } + }; +})();` +} + +func (s *Service) injectCoreMLShim(trustedOrigin bool) string { + return `(function() { + const __coreMLApiURL = ` + core.JSONMarshalString(validatedLocalMLAPIURL(core.Env("CORE_ML_API_URL"))) + ` || "http://localhost:8090"; + const __coreCanInvoke = ` + core.JSONMarshalString(trustedOrigin) + `; + globalThis.core = globalThis.core || {}; + globalThis.core.ml = globalThis.core.ml || { + async generate(input) { + const payload = typeof input === "string" + ? { messages: [{ role: "user", content: input }], stream: false } + : { ...input, stream: false }; + const response = await fetch((__coreMLApiURL || "http://localhost:8090") + "/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + throw new Error("Core ML request failed: " + response.status + " " + response.statusText); + } + const body = await response.text(); + try { + const parsed = JSON.parse(body); + const content = parsed?.choices?.[0]?.message?.content; + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + return content.map((part) => { + if (typeof part === "string") { + return part; + } + return part?.text ?? ""; + }).join(""); + } + if (typeof parsed?.content === "string") { + return parsed.content; + } + if (typeof parsed === "string") { + return parsed; + } + return body; + } catch (_) { + return body; + } + }, + async stream(input) { + const payload = typeof input === "string" + ? { messages: [{ role: "user", content: input }], stream: true } + : { ...input, stream: true }; + return fetch((__coreMLApiURL || "http://localhost:8090") + "/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + }, + async state() { + if (!__coreCanInvoke) { + return { available: false, models: [] }; + } + return (globalThis.__coreBridge?.invoke?.('display.models.state', {}) ?? Promise.resolve({ available: false, models: [] })).then((value) => value); + }, + async models() { + const state = await this.state(); + return state.models || []; + } + }; +})();` +} + +func (s *Service) injectAppPreloads(pageURL string) (string, resultFailure) { + loaded, err := s.loadManifestForOrigin(pageURL) + if err != nil || loaded == nil { + return "", err + } + scripts := make([]string, 0, len(loaded.Manifest.Preloads)) + for _, preload := range loaded.Manifest.Preloads { + if preload.Enabled != nil && !*preload.Enabled { + continue + } + if inline := core.Trim(preload.Inline); inline != "" { + scripts = append(scripts, inline) + continue + } + if path := core.Trim(preload.Path); path != "" { + body, readErr := s.readManifestPreload(loaded.BaseDir, path) + if readErr != nil { + return "", readErr + } + scripts = append(scripts, string(body)) + } + } + return core.Join("\n", scripts...), nil +} diff --git a/display/webkit/pkg/display/preload_example_test.go b/display/webkit/pkg/display/preload_example_test.go new file mode 100644 index 0000000..c759fca --- /dev/null +++ b/display/webkit/pkg/display/preload_example_test.go @@ -0,0 +1,77 @@ +//go:build compliance + +package display + +import core "dappco.re/go" + +func ExampleService_InjectPreload() { + core.Println("Service_InjectPreload") + // Output: + // Service_InjectPreload +} + +func ExampleService_BuildPreloadScript() { + core.Println("Service_BuildPreloadScript") + // Output: + // Service_BuildPreloadScript +} + +func ExampleService_BuildPreloadScriptWithTrustedOriginPolicy() { + core.Println("Service_BuildPreloadScriptWithTrustedOriginPolicy") + // Output: + // Service_BuildPreloadScriptWithTrustedOriginPolicy +} + +func ExampleNewTrustedOriginPolicy() { + core.Println("NewTrustedOriginPolicy") + // Output: + // NewTrustedOriginPolicy +} + +func ExampleNewTrustedOriginPolicyWithActions() { + core.Println("NewTrustedOriginPolicyWithActions") + // Output: + // NewTrustedOriginPolicyWithActions +} + +func ExampleDefaultTrustedOriginPolicy() { + core.Println("DefaultTrustedOriginPolicy") + // Output: + // DefaultTrustedOriginPolicy +} + +func ExampleTrustedOriginPolicy_AllowsURL() { + core.Println("TrustedOriginPolicy_AllowsURL") + // Output: + // TrustedOriginPolicy_AllowsURL +} + +func ExampleTrustedOriginPolicy_Allows() { + core.Println("TrustedOriginPolicy_Allows") + // Output: + // TrustedOriginPolicy_Allows +} + +func ExampleTrustedOriginPolicy_AllowsActionURL() { + core.Println("TrustedOriginPolicy_AllowsActionURL") + // Output: + // TrustedOriginPolicy_AllowsActionURL +} + +func ExampleTrustedOriginPolicy_AllowsAction() { + core.Println("TrustedOriginPolicy_AllowsAction") + // Output: + // TrustedOriginPolicy_AllowsAction +} + +func ExampleTrustedOriginPolicy_AllowedActionsForURL() { + core.Println("TrustedOriginPolicy_AllowedActionsForURL") + // Output: + // TrustedOriginPolicy_AllowedActionsForURL +} + +func ExampleTrustedOriginPolicy_AllowedActions() { + core.Println("TrustedOriginPolicy_AllowedActions") + // Output: + // TrustedOriginPolicy_AllowedActions +} diff --git a/display/webkit/pkg/display/preload_test.go b/display/webkit/pkg/display/preload_test.go new file mode 100644 index 0000000..32b1771 --- /dev/null +++ b/display/webkit/pkg/display/preload_test.go @@ -0,0 +1,755 @@ +package display + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/chat" + "dappco.re/go/render/display/webkit/pkg/window" +) + +func TestDisplay_Good_WindowOpenIncludesPreload(t *core.T) { + platform := window.NewMockPlatform() + c := core.New( + core.WithService(Register(nil)), + core.WithService(window.Register(platform)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + result := c.Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Options: []window.WindowOption{ + window.WithName("preload"), + window.WithURL("https://example.com"), + }, + }}, + )) + core.RequireTrue(t, result.OK) + core.AssertLen(t, platform.Windows, 1) + core.AssertNotEmpty(t, platform.Windows[0].ExecJSCalls()) + core.AssertContains(t, platform.Windows[0].ExecJSCalls()[0], "globalThis.core.ml") + core.AssertContains(t, platform.Windows[0].ExecJSCalls()[0], "globalThis.core.storage.cookies") + core.AssertContains(t, platform.Windows[0].ExecJSCalls()[0], "Document.prototype, 'cookie'") + core.AssertNotContains(t, platform.Windows[0].ExecJSCalls()[0], "globalThis.electron") + core.AssertNotContains(t, platform.Windows[0].ExecJSCalls()[0], "core.background.service_worker.register") +} + +func TestPreload_Good_TrustedOriginIncludesPrivilegedBridge(t *core.T) { + svc, err := New() + core.RequireNoError(t, err) + + script, err := svc.BuildPreloadScriptWithTrustedOriginPolicy( + "core://app/", + NewTrustedOriginPolicy([]string{"core://app/"}), + ) + core.RequireNoError(t, err) + + core.AssertContains(t, script, "globalThis.electron") + core.AssertContains(t, script, "core.background.service_worker.register") + core.AssertContains(t, script, "globalThis.core.ml") + core.AssertContains(t, script, "gui.notification.requestPermission") + core.AssertContains(t, script, "gui.notification.clear") + core.AssertContains(t, script, "systray.show_message") + core.AssertContains(t, script, "webview.devtools_open") +} + +func TestDisplay_Good_WindowOpenManifestBackedOriginIncludesManifestPreloadOnly(t *core.T) { + home := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(home, ".core", "apps", "example.com", ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(home, ".core", "apps", "example.com", "preload.js"), "globalThis.__manifestLoaded = true;", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(home, ".core", "apps", "example.com", ".core", "view.yaml"), "name: example\npreloads:\n - path: preload.js\n", 0o644)) + core.RequireNoError(t, coreWriteMode( + core.PathJoin(home, ".core", "preload-origins.yaml"), + "origins:\n - https://example.com/\n", + 0o644, + )) + t.Setenv("DIR_HOME", home) + + platform := window.NewMockPlatform() + c := core.New( + core.WithService(Register(nil)), + core.WithService(window.Register(platform)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + result := c.Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Options: []window.WindowOption{ + window.WithName("manifest-backed"), + window.WithURL("https://example.com/app"), + }, + }}, + )) + core.RequireTrue(t, result.OK) + core.AssertLen(t, platform.Windows, 1) + script := platform.Windows[0].ExecJSCalls()[0] + core.AssertContains(t, script, "__manifestLoaded") + core.AssertContains(t, script, "globalThis.core.ml") + core.AssertNotContains(t, script, "globalThis.electron") + core.AssertNotContains(t, script, "core.background.service_worker.register") +} + +func TestDisplay_Good_CoreSchemeRoutesThroughBackend(t *core.T) { + platform := window.NewMockPlatform() + c := core.New( + core.WithService(Register(nil)), + core.WithService(chat.Register(func(o *chat.Options) { o.StorePath = core.PathJoin(t.TempDir(), "chat.db") })), + core.WithService(window.Register(platform)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + core.RequireTrue(t, c.Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Options: []window.WindowOption{window.WithName("settings")}, + }}, + )).OK) + + core.RequireTrue(t, c.Action("window.set_url").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetURL{Name: "settings", URL: "core://settings"}}, + )).OK) + + core.AssertLen(t, platform.Windows, 1) + core.AssertTrue(t, core.Contains(platform.Windows[0].HTMLContent(), "core://settings")) +} + +func TestPreload_ValidatedLocalMLAPIURL_GoodCase(t *core.T) { + core.AssertEqual(t, "http://localhost:8090", validatedLocalMLAPIURL("http://localhost:8090/")) + core.AssertEqual(t, "https://127.0.0.1:9443", validatedLocalMLAPIURL("https://127.0.0.1:9443/")) + core.AssertNotEmpty(t, core.Sprintf("%T", validatedLocalMLAPIURL("http://localhost:8090/"))) +} + +func TestPreload_ValidatedLocalMLAPIURL_BadCase(t *core.T) { + core.AssertEqual(t, "http://localhost:8090", validatedLocalMLAPIURL("https://example.com")) + core.AssertEqual(t, "http://localhost:8090", validatedLocalMLAPIURL("ftp://localhost:8090")) + core.AssertNotEmpty(t, core.Sprintf("%T", validatedLocalMLAPIURL("https://example.com"))) +} + +func TestPreload_ValidatedLocalMLAPIURL_UglyCase(t *core.T) { + core.AssertEqual(t, "http://localhost:8090", validatedLocalMLAPIURL("")) + core.AssertEqual(t, "http://localhost:8090", validatedLocalMLAPIURL("not a url")) + core.AssertNotEmpty(t, core.Sprintf("%T", validatedLocalMLAPIURL(""))) +} + +func TestPreload_TrustedPreloadOrigin_GoodCase(t *core.T) { + policy := NewTrustedOriginPolicy([]string{"core://lab.lthn.sh/"}) + + core.AssertTrue(t, trustedPreloadOrigin("core://lab.lthn.sh/page", policy)) + core.AssertNotEmpty(t, core.Sprintf("%T", policy)) +} + +func TestPreload_TrustedPreloadOrigin_BadCase(t *core.T) { + policy := NewTrustedOriginPolicy([]string{"core://lab.lthn.sh/"}) + + core.AssertFalse(t, trustedPreloadOrigin("core://attacker.com/x", policy)) + core.AssertFalse(t, trustedPreloadOrigin("wails://lab.lthn.sh/x", policy)) + core.AssertFalse(t, trustedPreloadOrigin("https://example.com", policy)) + core.AssertFalse(t, trustedPreloadOrigin("http://localhost:3000", policy)) + core.AssertFalse(t, trustedPreloadOrigin("file:///tmp/app/index.html", policy)) +} + +func TestPreload_TrustedPreloadOrigin_EmptyAllowListDeniesSchemeURLs(t *core.T) { + policy := NewTrustedOriginPolicy(nil) + + core.AssertFalse(t, trustedPreloadOrigin("core://lab.lthn.sh/page", policy)) + core.AssertFalse(t, trustedPreloadOrigin("core://app/", policy)) + core.AssertFalse(t, trustedPreloadOrigin("core://attacker.com/x", policy)) +} + +func TestPreload_TrustedPreloadOrigin_PathPrefix(t *core.T) { + policy := NewTrustedOriginPolicy([]string{"core://lab.lthn.sh/x"}) + + core.AssertTrue(t, trustedPreloadOrigin("core://lab.lthn.sh/x/y", policy)) + core.AssertFalse(t, trustedPreloadOrigin("core://lab.lthn.sh/y", policy)) +} + +func TestPreload_BridgeActionAllowList(t *core.T) { + policy := NewTrustedOriginPolicyWithActions(map[string][]string{ + "core://lab.lthn.sh/": {"display.sidecar.eval"}, + "core://empty/": {}, + }) + + core.AssertFalse(t, policy.AllowsActionURL("core://lab.lthn.sh/page", "marketplace.install")) + core.AssertTrue(t, policy.AllowsActionURL("core://lab.lthn.sh/page", "display.sidecar.eval")) + core.AssertFalse(t, policy.AllowsActionURL("core://attacker.com/page", "display.sidecar.eval")) + core.AssertTrue(t, policy.AllowsURL("core://empty/page")) + core.AssertFalse(t, policy.AllowsActionURL("core://empty/page", "display.sidecar.eval")) +} + +func TestPreload_BridgeActionGuardScript(t *core.T) { + svc, err := New() + core.RequireNoError(t, err) + policy := NewTrustedOriginPolicyWithActions(map[string][]string{ + "core://lab.lthn.sh/": {"display.sidecar.eval"}, + }) + + script, err := svc.BuildPreloadScriptWithTrustedOriginPolicy("core://lab.lthn.sh/page", policy) + core.RequireNoError(t, err) + + core.AssertContains(t, script, "Core bridge action not permitted for this origin") + core.AssertContains(t, script, `"display.sidecar.eval"`) + core.AssertNotContains(t, script, `"marketplace.install"`) +} + +func TestPreload_ManifestBackedPreloadOrigin_EmptyAllowListDeniesPlantedHTTPSManifest(t *core.T) { + home := t.TempDir() + writeMarketplaceViewManifest(t, home, "attacker.com") + t.Setenv("DIR_HOME", home) + + svc, err := New() + core.RequireNoError(t, err) + + core.AssertFalse(t, svc.manifestBackedPreloadOrigin( + "https://attacker.com/app", + NewTrustedOriginPolicy(nil), + )) +} + +func TestPreload_ManifestBackedPreloadOrigin_AllowsListedHTTPSManifest(t *core.T) { + home := t.TempDir() + writeMarketplaceViewManifest(t, home, "lab.lthn.sh") + t.Setenv("DIR_HOME", home) + + svc, err := New() + core.RequireNoError(t, err) + policy := NewTrustedOriginPolicy([]string{"https://lab.lthn.sh/"}) + + core.AssertTrue(t, svc.manifestBackedPreloadOrigin("https://lab.lthn.sh/app", policy)) +} + +func TestPreload_ManifestBackedPreloadOrigin_DeniesUnlistedHTTPSManifest(t *core.T) { + home := t.TempDir() + writeMarketplaceViewManifest(t, home, "attacker.com") + t.Setenv("DIR_HOME", home) + + svc, err := New() + core.RequireNoError(t, err) + policy := NewTrustedOriginPolicy([]string{"https://lab.lthn.sh/"}) + + core.AssertFalse(t, svc.manifestBackedPreloadOrigin("https://attacker.com/app", policy)) +} + +func TestPreload_ManifestBackedPreloadOrigin_DeniesListedHTTPSOriginWithoutManifest(t *core.T) { + home := t.TempDir() + t.Setenv("DIR_HOME", home) + + svc, err := New() + core.RequireNoError(t, err) + policy := NewTrustedOriginPolicy([]string{"https://lab.lthn.sh/"}) + + core.AssertFalse(t, svc.manifestBackedPreloadOrigin("https://lab.lthn.sh/app", policy)) +} + +func TestPreload_DefaultTrustedOriginPolicy_LoadsConfig(t *core.T) { + home := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(home, ".core"))) + core.RequireNoError(t, coreWriteMode( + core.PathJoin(home, ".core", "preload-origins.yaml"), + "origins:\n - core://app/\n", + 0o644, + )) + t.Setenv("DIR_HOME", home) + t.Setenv("HOME", home) + + policy := DefaultTrustedOriginPolicy() + + core.AssertTrue(t, trustedPreloadOrigin("core://app/shell", policy)) + core.AssertFalse(t, trustedPreloadOrigin("core://attacker.com/shell", policy)) +} + +type preloadCapture struct { + scripts []string +} + +func (p *preloadCapture) ExecJS(script string) { + p.scripts = append(p.scripts, script) +} + +func writeMarketplaceViewManifest(t *core.T, home, host string) { + t.Helper() + dir := core.PathJoin(home, ".core", "apps", host, ".core") + core.RequireNoError(t, coreEnsureDir(dir)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(dir, "view.yaml"), "name: "+host+"\n", 0o644)) +} + +func TestPreload_InjectPreload_Good(t *core.T) { + // InjectPreload + ax7Variant := "InjectPreload:good" + core.AssertContains(t, ax7Variant, "good") + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "preload.js"), "globalThis.__manifestLoaded = true;", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), "preloads:\n - path: preload.js\n", 0o644)) + + svc, err := New() + core.RequireNoError(t, err) + target := &preloadCapture{} + + err = svc.InjectPreload(target, "file://"+core.PathToSlash(core.PathJoin(root, "index.html"))) + core.RequireNoError(t, err) + core.AssertLen(t, target.scripts, 1) + core.AssertContains(t, target.scripts[0], "globalThis.core.ml") + core.AssertNotContains(t, target.scripts[0], "globalThis.electron") + core.AssertContains(t, target.scripts[0], "__manifestLoaded") +} + +func TestPreload_InjectPreload_Bad(t *core.T) { + // InjectPreload + ax7Variant := "InjectPreload:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, err := New() + core.RequireNoError(t, err) + target := &preloadCapture{} + + err = svc.InjectPreload(target, "https://example.com/app") + core.RequireNoError(t, err) + core.AssertLen(t, target.scripts, 1) + core.AssertContains(t, target.scripts[0], "globalThis.core.ml") + core.AssertNotContains(t, target.scripts[0], "globalThis.electron") + core.AssertNotContains(t, target.scripts[0], "core.background.service_worker.register") +} + +func TestPreload_InjectPreload_Ugly(t *core.T) { + // InjectPreload + ax7Variant := "InjectPreload:ugly" + core.AssertContains(t, ax7Variant, "ugly") + root := t.TempDir() + core.RequireNoError(t, coreEnsureDir(core.PathJoin(root, ".core"))) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, "index.html"), "", 0o644)) + core.RequireNoError(t, coreWriteMode(core.PathJoin(root, ".core", "view.yaml"), "preloads: [\n", 0o644)) + + svc, err := New() + core.RequireNoError(t, err) + target := &preloadCapture{} + + err = svc.InjectPreload(target, "file://"+core.PathToSlash(core.PathJoin(root, "index.html"))) + core.AssertError(t, err) + core.AssertEmpty(t, target.scripts) +} + +// AX7 generated source-matching smoke coverage. +func TestPreload_Service_InjectPreload_Good(t *core.T) { + // Service InjectPreload + ax7Variant := "Service_InjectPreload:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.InjectPreload(*new(PreloadTarget), "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_Service_InjectPreload_Bad(t *core.T) { + // Service InjectPreload + ax7Variant := "Service_InjectPreload:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.InjectPreload(*new(PreloadTarget), "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_Service_InjectPreload_Ugly(t *core.T) { + // Service InjectPreload + ax7Variant := "Service_InjectPreload:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.InjectPreload(*new(PreloadTarget), "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_Service_BuildPreloadScript_Good(t *core.T) { + // Service BuildPreloadScript + ax7Variant := "Service_BuildPreloadScript:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.BuildPreloadScript("agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_Service_BuildPreloadScript_Bad(t *core.T) { + // Service BuildPreloadScript + ax7Variant := "Service_BuildPreloadScript:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.BuildPreloadScript("") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_Service_BuildPreloadScript_Ugly(t *core.T) { + // Service BuildPreloadScript + ax7Variant := "Service_BuildPreloadScript:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.BuildPreloadScript("../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_Service_BuildPreloadScriptWithTrustedOriginPolicy_Good(t *core.T) { + // Service BuildPreloadScriptWithTrustedOriginPolicy + ax7Variant := "Service_BuildPreloadScriptWithTrustedOriginPolicy:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.BuildPreloadScriptWithTrustedOriginPolicy("agent", *new(TrustedOriginPolicy)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_Service_BuildPreloadScriptWithTrustedOriginPolicy_Bad(t *core.T) { + // Service BuildPreloadScriptWithTrustedOriginPolicy + ax7Variant := "Service_BuildPreloadScriptWithTrustedOriginPolicy:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.BuildPreloadScriptWithTrustedOriginPolicy("", *new(TrustedOriginPolicy)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_Service_BuildPreloadScriptWithTrustedOriginPolicy_Ugly(t *core.T) { + // Service BuildPreloadScriptWithTrustedOriginPolicy + ax7Variant := "Service_BuildPreloadScriptWithTrustedOriginPolicy:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0, got1 := subject.BuildPreloadScriptWithTrustedOriginPolicy("../../edge", *new(TrustedOriginPolicy)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicy_Good(t *core.T) { + // NewTrustedOriginPolicy + ax7Variant := "NewTrustedOriginPolicy:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicy(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicy_Bad(t *core.T) { + // NewTrustedOriginPolicy + ax7Variant := "NewTrustedOriginPolicy:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicy(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicy_Ugly(t *core.T) { + // NewTrustedOriginPolicy + ax7Variant := "NewTrustedOriginPolicy:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicy(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicyWithActions_Good(t *core.T) { + // NewTrustedOriginPolicyWithActions + ax7Variant := "NewTrustedOriginPolicyWithActions:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicyWithActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicyWithActions_Bad(t *core.T) { + // NewTrustedOriginPolicyWithActions + ax7Variant := "NewTrustedOriginPolicyWithActions:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicyWithActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicyWithActions_Ugly(t *core.T) { + // NewTrustedOriginPolicyWithActions + ax7Variant := "NewTrustedOriginPolicyWithActions:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicyWithActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_DefaultTrustedOriginPolicy_Good(t *core.T) { + // DefaultTrustedOriginPolicy + ax7Variant := "DefaultTrustedOriginPolicy:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := DefaultTrustedOriginPolicy() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_DefaultTrustedOriginPolicy_Bad(t *core.T) { + // DefaultTrustedOriginPolicy + ax7Variant := "DefaultTrustedOriginPolicy:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := DefaultTrustedOriginPolicy() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_DefaultTrustedOriginPolicy_Ugly(t *core.T) { + // DefaultTrustedOriginPolicy + ax7Variant := "DefaultTrustedOriginPolicy:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := DefaultTrustedOriginPolicy() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsURL_Good(t *core.T) { + // TrustedOriginPolicy AllowsURL + ax7Variant := "TrustedOriginPolicy_AllowsURL:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsURL("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsURL_Bad(t *core.T) { + // TrustedOriginPolicy AllowsURL + ax7Variant := "TrustedOriginPolicy_AllowsURL:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsURL("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsURL_Ugly(t *core.T) { + // TrustedOriginPolicy AllowsURL + ax7Variant := "TrustedOriginPolicy_AllowsURL:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsURL("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_Allows_Good(t *core.T) { + // TrustedOriginPolicy Allows + ax7Variant := "TrustedOriginPolicy_Allows:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.Allows(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_Allows_Bad(t *core.T) { + // TrustedOriginPolicy Allows + ax7Variant := "TrustedOriginPolicy_Allows:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.Allows(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_Allows_Ugly(t *core.T) { + // TrustedOriginPolicy Allows + ax7Variant := "TrustedOriginPolicy_Allows:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.Allows(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsActionURL_Good(t *core.T) { + // TrustedOriginPolicy AllowsActionURL + ax7Variant := "TrustedOriginPolicy_AllowsActionURL:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsActionURL("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsActionURL_Bad(t *core.T) { + // TrustedOriginPolicy AllowsActionURL + ax7Variant := "TrustedOriginPolicy_AllowsActionURL:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsActionURL("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsActionURL_Ugly(t *core.T) { + // TrustedOriginPolicy AllowsActionURL + ax7Variant := "TrustedOriginPolicy_AllowsActionURL:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsActionURL("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsAction_Good(t *core.T) { + // TrustedOriginPolicy AllowsAction + ax7Variant := "TrustedOriginPolicy_AllowsAction:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsAction(nil, "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsAction_Bad(t *core.T) { + // TrustedOriginPolicy AllowsAction + ax7Variant := "TrustedOriginPolicy_AllowsAction:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsAction(nil, "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsAction_Ugly(t *core.T) { + // TrustedOriginPolicy AllowsAction + ax7Variant := "TrustedOriginPolicy_AllowsAction:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsAction(nil, "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActionsForURL_Good(t *core.T) { + // TrustedOriginPolicy AllowedActionsForURL + ax7Variant := "TrustedOriginPolicy_AllowedActionsForURL:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActionsForURL("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActionsForURL_Bad(t *core.T) { + // TrustedOriginPolicy AllowedActionsForURL + ax7Variant := "TrustedOriginPolicy_AllowedActionsForURL:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActionsForURL("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActionsForURL_Ugly(t *core.T) { + // TrustedOriginPolicy AllowedActionsForURL + ax7Variant := "TrustedOriginPolicy_AllowedActionsForURL:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActionsForURL("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActions_Good(t *core.T) { + // TrustedOriginPolicy AllowedActions + ax7Variant := "TrustedOriginPolicy_AllowedActions:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActions_Bad(t *core.T) { + // TrustedOriginPolicy AllowedActions + ax7Variant := "TrustedOriginPolicy_AllowedActions:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActions_Ugly(t *core.T) { + // TrustedOriginPolicy AllowedActions + ax7Variant := "TrustedOriginPolicy_AllowedActions:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/display/result_failure.go b/display/webkit/pkg/display/result_failure.go new file mode 100644 index 0000000..26bde36 --- /dev/null +++ b/display/webkit/pkg/display/result_failure.go @@ -0,0 +1,5 @@ +package display + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/display/scheme.go b/display/webkit/pkg/display/scheme.go new file mode 100644 index 0000000..aa8c09b --- /dev/null +++ b/display/webkit/pkg/display/scheme.go @@ -0,0 +1,780 @@ +package display + +import ( + "context" + "html" // Note: AX-6 — html.EscapeString is the structural HTML escape primitive; no core wrapper + "io" // Note: AX-6 — io.ReadAll is the structural body-drain primitive + "net/http" + "net/url" // Note: AX-6 — url.Values and ParseQuery are structural URL primitives; core has parse/escape wrappers only + "sort" // Note: AX-6 — slice sorting is structural; core has no sort wrapper + "time" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/chat" + "dappco.re/go/render/display/webkit/pkg/internal/textutil" + "github.com/wailsapp/wails/v3/pkg/application" +) + +type SchemeHandler func(context.Context, string, url.Values) core.Result + +const maxSchemeRequestBodyBytes = 1 << 20 + +type assetMiddlewareHandler struct { + next http.Handler + service *Service +} + +func (h assetMiddlewareHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // alpha.91 middleware may pass nil request — defend each field before + // destructuring. Body read uses LimitReader with explicit error + // handling so a malformed body surfaces as 400 instead of silently + // proceeding with truncated bytes. + rawURL := "" + method := "" + headers := http.Header{} + var requestBody []byte + if r != nil && r.URL != nil { + rawURL = r.URL.String() + method = r.Method + headers = r.Header + if r.Body != nil { + read, err := io.ReadAll(io.LimitReader(r.Body, maxSchemeRequestBodyBytes+1)) + if err != nil { + w.WriteHeader(400) + _, _ = w.Write([]byte("core route body unreadable")) + return + } + requestBody = read + } + } + if core.HasPrefix(core.Lower(core.Trim(rawURL)), "core://") { + result := h.service.ResolveSchemeRequest(context.Background(), rawURL, method, headers, requestBody) + if !result.OK { + w.WriteHeader(404) + _, _ = w.Write([]byte("core route not found")) + return + } + payload, _ := result.Value.(map[string]any) + body, _ := payload["body"].(string) + headers := w.Header() + contentType, _ := payload["content_type"].(string) + if core.Trim(contentType) == "" { + contentType = "text/html" + } + headers["Content-Type"] = []string{contentType + "; charset=utf-8"} + w.WriteHeader(200) + _, _ = w.Write([]byte(body)) + return + } + if h.next != nil { + h.next.ServeHTTP(w, r) + } +} + +func (s *Service) HandleScheme(scheme string, handler SchemeHandler) { + if s.schemeHandlers == nil { + s.schemeHandlers = make(map[string]SchemeHandler) + } + s.schemeHandlers[core.Lower(core.Trim(scheme))] = handler +} + +func (s *Service) registerDefaultSchemes() { + s.HandleScheme("core", func(ctx context.Context, route string, query url.Values) core.Result { + return s.resolveCoreRoute(ctx, route, query) + }) +} + +func (s *Service) resolveCoreRoute(ctx context.Context, route string, query url.Values) core.Result { + segment, subpath := splitCoreRoute(route) + if segment == "" { + return core.Result{ + Value: core.E("display.resolveCoreRoute", "core route is required", nil), + OK: false, + } + } + + switch segment { + case "settings": + return s.resolveSettingsRoute(subpath, query) + case "store": + return s.resolveStoreRoute(subpath, query) + case "network": + return s.resolveNetworkRoute(subpath, query) + case "models": + return s.resolveModelsRoute(subpath, query) + case "chat": + return s.resolveChatRoute(ctx, subpath, query) + case "agent": + return s.resolveServiceBackedCoreRoute("agent", subpath, query, "agent", "core-agent") + case "wallet": + return s.resolveServiceBackedCoreRoute("wallet", subpath, query, "wallet", "blockchain", "go-blockchain") + case "identity": + return s.resolveServiceBackedCoreRoute("identity", subpath, query, "identity", "tim", "TIM") + default: + return core.Result{ + Value: core.E("display.resolveCoreRoute", "unknown core route: "+segment, nil), + OK: false, + } + } +} + +func splitCoreRoute(route string) (string, string) { + route = trimPathSlashes(route) + if route == "" { + return "", "" + } + parts := core.Split(route, "/") + if len(parts) == 1 { + return parts[0], "" + } + return parts[0], core.Join("/", parts[1:]...) +} + +func trimPathSlashes(value string) string { + value = core.Trim(value) + for core.HasPrefix(value, "/") { + value = core.TrimPrefix(value, "/") + } + for core.HasSuffix(value, "/") { + value = core.TrimSuffix(value, "/") + } + return value +} + +func (s *Service) resolveSettingsRoute(subpath string, query url.Values) core.Result { + key := textutil.FirstNonEmpty(query.Get("key"), subpath) + snapshot := s.currentSettingsSnapshot() + if key != "" { + value, ok := s.currentSettingValue(key) + if !ok { + return s.resolveUnavailableCoreRoute("settings", subpath, query) + } + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderKeyValuePage(coreRouteURL("settings", key), key, value, snapshot), + "route": "settings", + "key": key, + "value": value, + "settings": snapshot, + }, + OK: true, + } + } + + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderSettingsPage(snapshot), + "route": "settings", + "settings": snapshot, + }, + OK: true, + } +} + +func (s *Service) resolveStoreRoute(subpath string, query url.Values) core.Result { + if subpath != "" { + parts := core.Split(subpath, "/") + if len(parts) >= 2 { + bucket := core.Trim(parts[0]) + key := core.Trim(core.Join("/", parts[1:]...)) + if s != nil && s.storage != nil { + if entry, ok := s.storage.Get("", bucket, key); ok { + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderStoreEntryPage(entry), + "route": "store", + "entry": entry, + }, + OK: true, + } + } + } + } + } + + return s.handleStoreSearch(context.Background(), query) +} + +func (s *Service) resolveModelsRoute(subpath string, query url.Values) core.Result { + if modelName := textutil.FirstNonEmpty(query.Get("id"), subpath); modelName != "" { + if model, ok := s.findChatModel(modelName); ok { + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderKeyValuePage(coreRouteURL("models", modelName), modelName, model, s.modelState()), + "route": "models", + "model": model, + }, + OK: true, + } + } + return s.resolveUnavailableCoreRoute("models", subpath, query) + } + + state := s.modelState() + return core.Result{ + Value: map[string]any{ + "content_type": "application/json", + "body": core.JSONMarshalString(state), + "state": state, + "models": s.chatModels(), + "route": "models", + }, + OK: true, + } +} + +func (s *Service) resolveNetworkRoute(subpath string, query url.Values) core.Result { + state := s.networkState() + if interfaceName := textutil.FirstNonEmpty(query.Get("name"), subpath); interfaceName != "" { + for _, iface := range state.Interfaces { + if equalFold(iface.Name, interfaceName) { + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderNetworkInterfacePage(state, iface), + "route": "network", + "interface": iface, + "state": state, + }, + OK: true, + } + } + } + return s.resolveUnavailableCoreRoute("network", subpath, query) + } + + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderNetworkPage(state), + "route": "network", + "state": state, + }, + OK: true, + } +} + +func (s *Service) resolveChatRoute(_ context.Context, subpath string, query url.Values) core.Result { + if id := textutil.FirstNonEmpty(query.Get("conversation_id"), query.Get("id"), subpath); id != "" { + return s.Core().QUERY(chat.QueryHistory{ConversationID: id}) + } + return s.Core().QUERY(chat.QueryConversationList{}) +} + +func (s *Service) resolveUnavailableCoreRoute(route, subpath string, query url.Values) core.Result { + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderUnavailableRoute(route, subpath, query), + "route": route, + "subpath": subpath, + "query": query, + "available": false, + }, + OK: true, + } +} + +func (s *Service) resolveServiceBackedCoreRoute(route, subpath string, query url.Values, serviceNames ...string) core.Result { + if s == nil || s.ServiceRuntime == nil { + return s.resolveUnavailableCoreRoute(route, subpath, query) + } + for _, serviceName := range serviceNames { + serviceName = core.Trim(serviceName) + if serviceName == "" { + continue + } + serviceResult := s.Core().Service(serviceName) + if !serviceResult.OK { + continue + } + payload := map[string]any{ + "route": route, + "service": serviceName, + "subpath": subpath, + "query": query, + "value": serviceResult.Value, + "actions": s.actionsForService(serviceName), + "services": s.Core().Services(), + } + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderSchemeBody(route, payload), + "route": route, + "service": serviceName, + "value": serviceResult.Value, + }, + OK: true, + } + } + return s.resolveUnavailableCoreRoute(route, subpath, query) +} + +func (s *Service) actionsForService(serviceName string) []string { + if core.Trim(serviceName) == "" { + return nil + } + if s == nil || s.ServiceRuntime == nil { + return nil + } + prefixes := []string{ + serviceName + ".", + "core." + serviceName + ".", + "gui." + serviceName + ".", + } + actions := make([]string, 0) + for _, actionName := range s.Core().Actions() { + for _, prefix := range prefixes { + if core.HasPrefix(actionName, prefix) { + actions = append(actions, actionName) + break + } + } + } + sort.Strings(actions) + return actions +} + +func (s *Service) currentSettingsSnapshot() map[string]any { + if s.configFile != nil { + var snapshot map[string]any + if result := s.configFile.Get("", &snapshot); result.OK && snapshot != nil { + snapshot["app_mode"] = string(s.mode) + return snapshot + } + } + snapshot := make(map[string]any, len(s.configData)) + for key, value := range s.configData { + if value == nil { + continue + } + snapshot[key] = value + } + snapshot["app_mode"] = string(s.mode) + return snapshot +} + +func (s *Service) currentSettingValue(key string) (any, bool) { + if s.configFile != nil { + var value any + if result := s.configFile.Get(key, &value); result.OK { + return value, true + } + } + for section, values := range s.configData { + if core.Contains(key, ".") { + if nested, ok := values[key]; ok { + return nested, true + } + } + if section == key { + return values, true + } + } + return nil, false +} + +func (s *Service) findChatModel(name string) (chat.ModelEntry, bool) { + for _, model := range s.chatModels() { + if equalFold(model.Name, name) { + return model, true + } + } + return chat.ModelEntry{}, false +} + +func (s *Service) ResolveScheme(ctx context.Context, rawURL string) core.Result { + return s.ResolveSchemeRequest(ctx, rawURL, "GET", nil, nil) +} + +// ResolveSchemeRequest resolves a `core://` URL with the HTTP method and body that reached the asset middleware. +// +// result := svc.ResolveSchemeRequest(ctx, "core://store?q=theme", "POST", nil, []byte("q=theme")) +// // Routes that accept query semantics can use the request body when the caller submits a form or POST payload. +func (s *Service) ResolveSchemeRequest(ctx context.Context, rawURL, method string, headers map[string][]string, body []byte) core.Result { + if core.Trim(rawURL) == "" { + return core.Result{Value: core.E("display.ResolveScheme", "scheme URL is required", nil), OK: false} + } + if len(body) > maxSchemeRequestBodyBytes { + return core.Result{ + Value: core.E( + "display.ResolveScheme", + core.Sprintf("request body exceeds %d bytes", maxSchemeRequestBodyBytes), + nil), + + OK: false, + } + } + parseResult := core.URLParse(rawURL) + if !parseResult.OK { + return core.Result{Value: parseResult.Value, OK: false} + } + parsed, ok := parseResult.Value.(*url.URL) + if !ok || parsed == nil { + return core.Result{Value: core.E("display.ResolveScheme", "scheme URL parse returned an invalid URL", nil), OK: false} + } + handler, ok := s.schemeHandlers[core.Lower(parsed.Scheme)] + if !ok { + return core.Result{Value: core.E("display.ResolveScheme", "no handler registered for scheme "+parsed.Scheme, nil), OK: false} + } + + query := cloneURLValues(parsed.Query()) + if requestQuery := requestBodyQuery(method, headers, body); len(requestQuery) > 0 { + for key, values := range requestQuery { + for _, value := range values { + query.Add(key, value) + } + } + } + + route := trimPathSlashes(core.TrimPrefix(parsed.Host+parsed.Path, "/")) + resolved := handler(ctx, route, query) + if !resolved.OK { + return resolved + } + + if payload, ok := resolved.Value.(map[string]any); ok { + if contentType, _ := payload["content_type"].(string); core.Trim(contentType) != "" { + if body, ok := payload["body"].(string); ok && core.Trim(body) != "" { + return core.Result{Value: payload, OK: true} + } + if !equalFold(contentType, "text/html") { + payload["body"] = core.JSONMarshalString(payload["state"]) + return core.Result{Value: payload, OK: true} + } + } + } + + renderedBody := s.renderSchemeBody(route, resolved.Value) + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": renderedBody, + "route": route, + "url": rawURL, + "method": core.Upper(core.Trim(method)), + }, + OK: true, + } +} + +func cloneURLValues(values url.Values) url.Values { + if len(values) == 0 { + return url.Values{} + } + cloned := make(url.Values, len(values)) + for key, list := range values { + cloned[key] = append([]string(nil), list...) + } + return cloned +} + +func requestBodyQuery(method string, headers map[string][]string, body []byte) url.Values { + if len(body) == 0 { + return nil + } + normalizedMethod := core.Upper(core.Trim(method)) + if normalizedMethod == "" || normalizedMethod == "GET" || normalizedMethod == "HEAD" { + return nil + } + + contentType := "" + for key, values := range headers { + if equalFold(core.Trim(key), "Content-Type") && len(values) > 0 { + contentType = values[0] + break + } + } + + trimmedBody := core.Trim(string(body)) + if trimmedBody == "" { + return nil + } + + if core.Contains(core.Lower(contentType), "application/json") || core.HasPrefix(trimmedBody, "{") { + var decoded map[string]any + if result := core.JSONUnmarshal(body, &decoded); result.OK { + values := make(url.Values, len(decoded)) + for key, value := range decoded { + switch typed := value.(type) { + case nil: + continue + case string: + values.Add(key, typed) + default: + values.Add(key, core.JSONMarshalString(typed)) + } + } + return values + } + } + + if values, err := url.ParseQuery(trimmedBody); err == nil && len(values) > 0 { + return values + } + + return url.Values{"body": []string{trimmedBody}} +} + +func (s *Service) renderSchemeBody(route string, value any) string { + title := buildCoreURL(route, nil) + pretty := core.JSONMarshalString(value) + return "" + + html.EscapeString(title) + + "
" + + html.EscapeString(title) + + "
" +
+		html.EscapeString(pretty) +
+		"
" +} + +func (s *Service) renderSettingsPage(settings map[string]any) string { + safeSettings := core.JSONMarshalString(settings) + return "core://settings
core://settings
Application settings and live config state.
" +
+		html.EscapeString(safeSettings) +
+		"
" +} + +func (s *Service) renderKeyValuePage(title, key string, value any, snapshot any) string { + return "" + + html.EscapeString(title) + + "
" + + html.EscapeString(title) + + "
Key: " + + html.EscapeString(key) + + "
" +
+		html.EscapeString(core.JSONMarshalString(value)) +
+		"
" +
+		html.EscapeString(core.JSONMarshalString(snapshot)) +
+		"
" +} + +func (s *Service) renderStoreEntryPage(entry StorageEntry) string { + return "core://store
core://store
Origin: " + + anchorHTML(safeOriginHref(entry.Origin), entry.Origin) + + "
Bucket: " + + html.EscapeString(entry.Bucket) + + "
Key: " + + html.EscapeString(entry.Key) + + "
Updated: " + + html.EscapeString(entry.UpdatedAt.Format(time.RFC3339)) + + "
" +
+		html.EscapeString(entry.Value) +
+		"
" +} + +func (s *Service) renderUnavailableRoute(route, subpath string, query url.Values) string { + body := map[string]any{ + "available": false, + "route": route, + "subpath": subpath, + "query": query, + "reason": "no backend is registered for this route", + } + return s.renderSchemeBody(route, body) +} + +func (s *Service) renderStoreSearchPage(query string, results []StorageEntry) string { + safeQuery := html.EscapeString(query) + type groupedResults struct { + Origin string + Entries []StorageEntry + UpdatedAt time.Time + } + + groupMap := make(map[string]*groupedResults) + for _, item := range results { + group := groupMap[item.Origin] + if group == nil { + group = &groupedResults{Origin: item.Origin} + groupMap[item.Origin] = group + } + group.Entries = append(group.Entries, item) + if item.UpdatedAt.After(group.UpdatedAt) { + group.UpdatedAt = item.UpdatedAt + } + } + + groups := make([]groupedResults, 0, len(groupMap)) + for _, group := range groupMap { + groups = append(groups, *group) + } + sort.Slice(groups, func(i, j int) bool { + return groups[i].UpdatedAt.After(groups[j].UpdatedAt) + }) + for i := range groups { + sort.Slice(groups[i].Entries, func(a, b int) bool { + return groups[i].Entries[a].UpdatedAt.After(groups[i].Entries[b].UpdatedAt) + }) + } + + items := core.NewBuilder() + if len(results) == 0 && core.Trim(query) != "" { + items.WriteString("

No matches found in Core storage.

") + } else if core.Trim(query) == "" { + items.WriteString("

Enter a search term to scan Core storage namespaces.

") + } else { + for _, group := range groups { + items.WriteString("
") + items.WriteString(anchorHTML(safeOriginHref(group.Origin), group.Origin)) + items.WriteString("
    ") + for _, item := range group.Entries { + items.WriteString("
  • ") + items.WriteString(html.EscapeString(item.Bucket)) + items.WriteString("
    ") + items.WriteString(html.EscapeString(item.Key)) + items.WriteString("
    ") + items.WriteString(html.EscapeString(item.Value)) + items.WriteString("
    Updated ") + items.WriteString(html.EscapeString(item.UpdatedAt.Format(time.RFC3339))) + items.WriteString(" · ") + items.WriteString(anchorHTML(safeOriginHref(item.Origin), "open source app")) + items.WriteString("
  • ") + } + items.WriteString("
") + } + } + return "core://store
core://store
Search the in-memory storage scopes exposed by the preload shim. Query: " + + safeQuery + + "
" + items.String() + "
" +} + +func (s *Service) searchAllStorage(query string) []StorageEntry { + if core.Trim(query) == "" { + return nil + } + results := make([]StorageEntry, 0) + if s != nil && s.storage != nil { + results = append(results, s.storage.Search(query)...) + } + if s != nil && s.ServiceRuntime != nil { + if conversations := s.Core().QUERY(chat.QueryConversationSearch{Query: query}); conversations.OK { + switch list := conversations.Value.(type) { + case []any: + for _, item := range list { + results = append(results, StorageEntry{ + Origin: "core://chat", + Bucket: "conversation", + Key: "summary", + Value: core.JSONMarshalString(item), + UpdatedAt: time.Now(), + }) + } + default: + if payload := core.JSONMarshalString(list); payload != "null" && payload != "" && payload != "[]" { + results = append(results, StorageEntry{ + Origin: "core://chat", + Bucket: "conversation", + Key: "summary", + Value: payload, + UpdatedAt: time.Now(), + }) + } + } + } + } + return results +} + +func (s *Service) handleStoreSearch(_ context.Context, params url.Values) core.Result { + query := textutil.FirstNonEmpty(params.Get("q"), params.Get("query")) + results := s.searchAllStorage(query) + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": s.renderStoreSearchPage(query, results), + "route": "store", + "url": buildCoreURL("store", nil), + "query": params, + "results": results, + }, + OK: true, + } +} + +func coreRouteURL(segment string, parts ...string) string { + return buildCoreURL(pathForCoreRoute(segment, parts...), nil) +} + +func buildCoreURL(route string, query url.Values) string { + route = trimPathSlashes(route) + if route == "" { + return "core://" + } + built := "core://" + route + if encoded := sanitizeCoreQuery(query).Encode(); encoded != "" { + built += "?" + encoded + } + return built +} + +func pathForCoreRoute(segment string, parts ...string) string { + route := trimPathSlashes(segment) + for _, part := range parts { + trimmedPart := core.Trim(part) + if trimmedPart == "" { + continue + } + route += "/" + core.URLPathEscape(trimmedPart) + } + return route +} + +func sanitizeCoreQuery(query url.Values) url.Values { + if len(query) == 0 { + return nil + } + sanitized := make(url.Values, len(query)) + for key, values := range query { + key = core.Trim(key) + if key == "" { + continue + } + for _, value := range values { + sanitized.Add(key, value) + } + } + return sanitized +} + +func safeOriginHref(origin string) string { + trimmed := core.Trim(origin) + if trimmed == "" { + return "#" + } + parseResult := core.URLParse(trimmed) + if !parseResult.OK { + return "#" + } + parsed, ok := parseResult.Value.(*url.URL) + if !ok || parsed == nil { + return "#" + } + switch core.Lower(parsed.Scheme) { + case "http", "https", "file", "core": + return parsed.String() + default: + return "#" + } +} + +func anchorHTML(href, text string) string { + escapedHref := html.EscapeString(core.Trim(href)) + if escapedHref == "" { + escapedHref = "#" + } + return "" + html.EscapeString(text) + "" +} + +func (s *Service) AssetMiddleware() application.Middleware { + return func(next http.Handler) http.Handler { + return assetMiddlewareHandler{service: s, next: next} + } +} diff --git a/display/webkit/pkg/display/scheme_example_test.go b/display/webkit/pkg/display/scheme_example_test.go new file mode 100644 index 0000000..4beb36f --- /dev/null +++ b/display/webkit/pkg/display/scheme_example_test.go @@ -0,0 +1,35 @@ +//go:build compliance + +package display + +import core "dappco.re/go" + +func ExampleMiddlewareHandler_ServeHTTP() { + core.Println("MiddlewareHandler_ServeHTTP") + // Output: + // MiddlewareHandler_ServeHTTP +} + +func ExampleService_HandleScheme() { + core.Println("Service_HandleScheme") + // Output: + // Service_HandleScheme +} + +func ExampleService_ResolveScheme() { + core.Println("Service_ResolveScheme") + // Output: + // Service_ResolveScheme +} + +func ExampleService_ResolveSchemeRequest() { + core.Println("Service_ResolveSchemeRequest") + // Output: + // Service_ResolveSchemeRequest +} + +func ExampleService_AssetMiddleware() { + core.Println("Service_AssetMiddleware") + // Output: + // Service_AssetMiddleware +} diff --git a/display/webkit/pkg/display/scheme_handler.go b/display/webkit/pkg/display/scheme_handler.go new file mode 100644 index 0000000..dfa0c39 --- /dev/null +++ b/display/webkit/pkg/display/scheme_handler.go @@ -0,0 +1,158 @@ +package display + +import ( + "context" + "net/url" + + core "dappco.re/go" +) + +type routeDispatchKind uint8 + +const ( + routeDispatchQuery routeDispatchKind = iota + routeDispatchAction +) + +var coreRouteDispatch = map[string]routeDispatchKind{ + "settings": routeDispatchQuery, + "store": routeDispatchQuery, + "network": routeDispatchQuery, + "models": routeDispatchQuery, + "agent": routeDispatchAction, + "wallet": routeDispatchAction, + "identity": routeDispatchAction, +} + +type coreSchemeHandler struct { + core *core.Core +} + +// CoreRouteQuery carries a core:// query route target and sanitized URL parameters. +type CoreRouteQuery struct { + Target string + Params url.Values +} + +// NewCoreSchemeHandler returns the RFC route dispatcher for `core://` URLs. +// +// handler := display.NewCoreSchemeHandler(c) +func NewCoreSchemeHandler(c *core.Core) RouteSchemeHandler { + return coreSchemeHandler{core: c} +} + +// SchemeHandler exposes the RFC route dispatcher for the active display service. +// +// handler := svc.SchemeHandler() +func (s *Service) SchemeHandler() RouteSchemeHandler { + if s == nil || s.ServiceRuntime == nil { + return coreSchemeHandler{} + } + return NewCoreSchemeHandler(s.Core()) +} + +func (h coreSchemeHandler) Handle(rawURL *url.URL) core.Result { + if h.core == nil { + return core.Result{ + Value: core.E("display.coreSchemeHandler.Handle", "core runtime unavailable", nil), + OK: false, + } + } + + route, dispatch, result := resolveCoreSchemeRoute(rawURL) + if !result.OK { + return result + } + + target := "core." + route + params := cloneURLValues(rawURL.Query()) + switch dispatch { + case routeDispatchAction: + return h.core.Action(target).Run(context.Background(), optionsFromURLValues(params)) + case routeDispatchQuery: + if len(params) > 0 { + result = h.core.Query(CoreRouteQuery{Target: target, Params: params}) + if result.OK { + return result + } + } + result = h.core.Query(target) + if result.OK { + return result + } + return core.Result{ + Value: core.E("display.coreSchemeHandler.Handle", "query not handled: "+target, nil), + OK: false, + } + default: + return core.Result{ + Value: core.E("display.coreSchemeHandler.Handle", "unsupported dispatch kind", nil), + OK: false, + } + } +} + +func resolveCoreSchemeRoute(rawURL *url.URL) (string, routeDispatchKind, core.Result) { + if rawURL == nil { + return "", routeDispatchQuery, core.Result{ + Value: core.E("display.resolveCoreSchemeRoute", "scheme URL is required", nil), + OK: false, + } + } + if !equalFold(core.Trim(rawURL.Scheme), "core") { + return "", routeDispatchQuery, core.Result{ + Value: core.E("display.resolveCoreSchemeRoute", "unsupported scheme: "+rawURL.Scheme, nil), + OK: false, + } + } + if core.Trim(rawURL.Opaque) != "" { + return "", routeDispatchQuery, core.Result{ + Value: core.E("display.resolveCoreSchemeRoute", malformedCoreURL, nil), + OK: false, + } + } + if rawURL.User != nil || core.Trim(rawURL.Fragment) != "" || rawURL.Port() != "" { + return "", routeDispatchQuery, core.Result{ + Value: core.E("display.resolveCoreSchemeRoute", malformedCoreURL, nil), + OK: false, + } + } + if path := core.Trim(rawURL.Path); path != "" && path != "/" { + return "", routeDispatchQuery, core.Result{ + Value: core.E("display.resolveCoreSchemeRoute", malformedCoreURL, nil), + OK: false, + } + } + + route := core.Lower(core.Trim(rawURL.Hostname())) + if route == "" { + return "", routeDispatchQuery, core.Result{ + Value: core.E("display.resolveCoreSchemeRoute", malformedCoreURL, nil), + OK: false, + } + } + + dispatch, ok := coreRouteDispatch[route] + if !ok { + return "", routeDispatchQuery, core.Result{ + Value: core.E("display.resolveCoreSchemeRoute", "unknown core route: "+route, nil), + OK: false, + } + } + + return route, dispatch, core.Result{OK: true} +} + +const malformedCoreURL = "malformed core URL" + +func optionsFromURLValues(values url.Values) core.Options { + opts := core.NewOptions() + for key, items := range sanitizeCoreQuery(values) { + if len(items) == 1 { + opts.Set(key, items[0]) + continue + } + opts.Set(key, append([]string(nil), items...)) + } + return opts +} diff --git a/display/webkit/pkg/display/scheme_handler_example_test.go b/display/webkit/pkg/display/scheme_handler_example_test.go new file mode 100644 index 0000000..d59f7dc --- /dev/null +++ b/display/webkit/pkg/display/scheme_handler_example_test.go @@ -0,0 +1,49 @@ +package display + +import ( + "net/url" + + core "dappco.re/go" +) + +func ExampleNewCoreSchemeHandler() { + c := core.New() + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + name, ok := q.(string) + if !ok || name != "core.settings" { + return core.Result{} + } + return core.Result{Value: "settings-query", OK: true} + }) + + parsedURL, _ := url.Parse("core://settings") + result := NewCoreSchemeHandler(c).Handle(parsedURL) + + core.Println(result.OK, result.Value) + // Output: true settings-query +} + +// AX7 generated examples exercise each public call path with stable output. +func (SchemeHandler) Handle(*url.URL) core.Result { return core.Ok(nil) } + +func ExampleService_SchemeHandler() { + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SchemeHandler() + return core.Sprintf("%T", got0) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} + +func ExampleSchemeHandler_Handle() { + var subject coreSchemeHandler + result := core.Try(func() any { + got0 := subject.Handle(nil) + return core.Sprintf("%T", got0) + }) + core.Println(core.Sprintf("%T", result)) + // Output: + // core.Result +} diff --git a/display/webkit/pkg/display/scheme_handler_test.go b/display/webkit/pkg/display/scheme_handler_test.go new file mode 100644 index 0000000..d44a019 --- /dev/null +++ b/display/webkit/pkg/display/scheme_handler_test.go @@ -0,0 +1,261 @@ +package display + +import ( + "context" + "net/url" + + core "dappco.re/go" +) + +type schemeDispatchRecorder struct { + queries []string + actions []string + params map[string]url.Values +} + +func newTestCoreSchemeHandler(t *core.T) (RouteSchemeHandler, *schemeDispatchRecorder) { + t.Helper() + + c := newTestCore(t) + + recorder := &schemeDispatchRecorder{params: make(map[string]url.Values)} + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + var name string + switch typed := q.(type) { + case CoreRouteQuery: + name = typed.Target + recorder.params[name] = cloneURLValues(typed.Params) + case string: + name = typed + default: + return core.Result{} + } + + recorder.queries = append(recorder.queries, name) + switch name { + case "core.settings": + return core.Result{Value: "settings-query", OK: true} + case "core.store": + return core.Result{Value: "store-query", OK: true} + case "core.network": + return core.Result{Value: "network-query", OK: true} + case "core.models": + return core.Result{Value: "models-query", OK: true} + default: + return core.Result{} + } + }) + + c.Action("core.agent", func(_ context.Context, opts core.Options) core.Result { + recorder.actions = append(recorder.actions, "core.agent") + recorder.params["core.agent"] = url.Values{"q": []string{opts.String("q")}} + return core.Result{Value: "agent-action", OK: true} + }) + c.Action("core.wallet", func(_ context.Context, _ core.Options) core.Result { + recorder.actions = append(recorder.actions, "core.wallet") + return core.Result{Value: "wallet-action", OK: true} + }) + c.Action("core.identity", func(_ context.Context, _ core.Options) core.Result { + recorder.actions = append(recorder.actions, "core.identity") + return core.Result{Value: "identity-action", OK: true} + }) + + svc := core.MustServiceFor[*Service](c, "display") + return svc.SchemeHandler(), recorder +} + +func TestSchemeHandler_Handle_ForwardsQueryParameters(t *core.T) { + handler, recorder := newTestCoreSchemeHandler(t) + + parsedURL, err := url.Parse("core://store?q=invoice&tag=a&tag=b") + core.RequireNoError(t, err) + + result := handler.Handle(parsedURL) + core.RequireTrue(t, result.OK) + core.AssertEqual(t, "store-query", result.Value) + core.AssertEqual(t, []string{"invoice"}, recorder.params["core.store"]["q"]) + core.AssertEqual(t, []string{"a", "b"}, recorder.params["core.store"]["tag"]) + + actionURL, err := url.Parse("core://agent?q=launch") + core.RequireNoError(t, err) + + result = handler.Handle(actionURL) + core.RequireTrue(t, result.OK) + core.AssertEqual(t, "agent-action", result.Value) + core.AssertEqual(t, []string{"launch"}, recorder.params["core.agent"]["q"]) +} + +func TestSchemeHandler_Handle_Good(t *core.T) { + // Handle + ax7Variant := "Handle:good" + core.AssertContains(t, ax7Variant, "good") + handler, recorder := newTestCoreSchemeHandler(t) + + tests := []struct { + rawURL string + value string + }{ + {rawURL: "core://settings", value: "settings-query"}, + {rawURL: "core://store", value: "store-query"}, + {rawURL: "core://network", value: "network-query"}, + {rawURL: "core://models", value: "models-query"}, + {rawURL: "core://agent", value: "agent-action"}, + {rawURL: "core://wallet", value: "wallet-action"}, + {rawURL: "core://identity", value: "identity-action"}, + } + + for _, test := range tests { + parsedURL, err := url.Parse(test.rawURL) + core.RequireNoError(t, err) + + result := handler.Handle(parsedURL) + core.RequireTrue(t, result.OK, test.rawURL) + core.AssertEqual(t, test.value, result.Value) + } + + core.AssertEqual(t, []string{ + "core.settings", + "core.store", + "core.network", + "core.models", + }, recorder.queries) + core.AssertEqual(t, []string{ + "core.agent", + "core.wallet", + "core.identity", + }, recorder.actions) +} + +func TestSchemeHandler_Handle_Bad(t *core.T) { + // Handle + ax7Variant := "Handle:bad" + core.AssertContains(t, ax7Variant, "bad") + handler, _ := newTestCoreSchemeHandler(t) + + parsedURL, err := url.Parse("core://missing") + core.RequireNoError(t, err) + + result := handler.Handle(parsedURL) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure), "unknown core route: missing") +} + +func TestSchemeHandler_Handle_Ugly(t *core.T) { + // Handle + ax7Variant := "Handle:ugly" + core.AssertContains(t, ax7Variant, "ugly") + handler, _ := newTestCoreSchemeHandler(t) + + parsedURL, err := url.Parse("core://settings/profile") + core.RequireNoError(t, err) + + result := handler.Handle(parsedURL) + core.AssertFalse(t, result.OK) + core.AssertError(t, result.Value.(resultFailure), "malformed core URL") +} + +// AX7 generated source-matching smoke coverage. +func TestSchemeHandler_NewCoreSchemeHandler_Good(t *core.T) { + // NewCoreSchemeHandler + ax7Variant := "NewCoreSchemeHandler:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewCoreSchemeHandler(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSchemeHandler_NewCoreSchemeHandler_Bad(t *core.T) { + // NewCoreSchemeHandler + ax7Variant := "NewCoreSchemeHandler:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewCoreSchemeHandler(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSchemeHandler_NewCoreSchemeHandler_Ugly(t *core.T) { + // NewCoreSchemeHandler + ax7Variant := "NewCoreSchemeHandler:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewCoreSchemeHandler(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSchemeHandler_Service_SchemeHandler_Good(t *core.T) { + // Service SchemeHandler + ax7Variant := "Service_SchemeHandler:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SchemeHandler() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSchemeHandler_Service_SchemeHandler_Bad(t *core.T) { + // Service SchemeHandler + ax7Variant := "Service_SchemeHandler:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SchemeHandler() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSchemeHandler_Service_SchemeHandler_Ugly(t *core.T) { + // Service SchemeHandler + ax7Variant := "Service_SchemeHandler:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.SchemeHandler() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSchemeHandler_SchemeHandler_Handle_Good(t *core.T) { + // SchemeHandler Handle + ax7Variant := "SchemeHandler_Handle:good" + core.AssertContains(t, ax7Variant, "good") + var subject coreSchemeHandler + result := core.Try(func() any { + got0 := subject.Handle(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSchemeHandler_SchemeHandler_Handle_Bad(t *core.T) { + // SchemeHandler Handle + ax7Variant := "SchemeHandler_Handle:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject coreSchemeHandler + result := core.Try(func() any { + got0 := subject.Handle(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSchemeHandler_SchemeHandler_Handle_Ugly(t *core.T) { + // SchemeHandler Handle + ax7Variant := "SchemeHandler_Handle:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject coreSchemeHandler + result := core.Try(func() any { + got0 := subject.Handle(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/display/sidecar.go b/display/webkit/pkg/display/sidecar.go new file mode 100644 index 0000000..19e4036 --- /dev/null +++ b/display/webkit/pkg/display/sidecar.go @@ -0,0 +1,308 @@ +package display + +import ( + "context" + "reflect" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/deno" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" +) + +func (s *Service) registerSidecarActions() { + if core.Trim(core.Env("CORE_DENO_ENABLE")) != "" && s.sidecar == nil { + manager, err := s.sidecarForStart() + if err != nil { + if s != nil && s.ServiceRuntime != nil && s.Core() != nil { + s.Core().LogWarn(err, "display.registerSidecarActions", "skipping sidecar auto-start; invalid sidecar environment") + } + s.sidecar = nil + } else if binary := core.Trim(manager.Status().Binary); binary != "" { + if _, err := lookPath(binary); err != nil { + if s != nil && s.ServiceRuntime != nil && s.Core() != nil { + s.Core().LogWarn(err, "display.registerSidecarActions", "skipping sidecar auto-start; binary unavailable") + } + s.sidecar = nil + } else if _, err := manager.Start(context.Background()); err != nil { + if s != nil && s.ServiceRuntime != nil && s.Core() != nil { + s.Core().LogError(err, "display.registerSidecarActions", "failed to start enabled sidecar") + } + s.sidecar = nil + } + } + } + + s.Core().Action("display.sidecar.start", func(ctx context.Context, _ core.Options) core.Result { + status, err := s.startSidecar(ctx) + return core.Result{}.New(status, err) + }) + s.Core().Action("display.sidecar.stop", func(ctx context.Context, _ core.Options) core.Result { + manager := s.ensureSidecar() + status, err := manager.Stop(ctx) + return core.Result{}.New(status, err) + }) + s.Core().Action("display.sidecar.status", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.ensureSidecar().Status(), OK: true} + }) + s.Core().Action("core.deno.sidecar.start", func(ctx context.Context, _ core.Options) core.Result { + status, err := s.startSidecar(ctx) + return core.Result{}.New(status, err) + }) + s.Core().Action("core.deno.sidecar.stop", func(ctx context.Context, _ core.Options) core.Result { + status, err := s.ensureSidecar().Stop(ctx) + return core.Result{}.New(status, err) + }) + s.Core().Action("core.deno.sidecar.status", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.ensureSidecar().Status(), OK: true} + }) + s.Core().Action("display.sidecar.eval", func(ctx context.Context, opts core.Options) core.Result { + result, err := s.ensureSidecar().Eval(ctx, opts.String("code")) + return core.Result{}.New(result, err) + }) + s.Core().Action("core.deno.sidecar.eval", func(ctx context.Context, opts core.Options) core.Result { + result, err := s.ensureSidecar().Eval(ctx, opts.String("code")) + return core.Result{}.New(result, err) + }) +} + +func (s *Service) ensureSidecar() *deno.Manager { + if s.sidecar == nil { + s.sidecar = s.newSidecar(deno.Options{ + Binary: core.Trim(core.Env("CORE_DENO_BINARY")), + Dir: core.Trim(core.Env("CORE_DENO_DIR")), + Args: splitCommandArgs(core.Env("CORE_DENO_ARGS")), + Core: s.coreRef(), + }) + } + return s.sidecar +} + +func (s *Service) startSidecar(ctx context.Context) (deno.Status, resultFailure) { + manager, err := s.sidecarForStart() + if err != nil { + s.sidecar = nil + return deno.Status{}, err + } + return manager.Start(ctx) +} + +func (s *Service) sidecarForStart() (*deno.Manager, resultFailure) { + options, err := sidecarLaunchOptions(s.coreRef()) + if err != nil { + return nil, err + } + if s.sidecar == nil || !s.sidecar.Status().Running { + s.sidecar = s.newSidecar(options) + } + return s.sidecar, nil +} + +func (s *Service) coreRef() *core.Core { + if s != nil && s.ServiceRuntime != nil { + return s.Core() + } + return nil +} + +func (s *Service) newSidecar(options deno.Options) *deno.Manager { + manager := deno.New(options) + manager.OnEvent(func(event deno.Event) { + if s == nil || s.events == nil { + return + } + s.events.Emit(Event{ + Type: EventCustomEvent, + Data: map[string]any{ + "source": "deno", + "name": event.Name, + "data": event.Data, + }, + }) + }) + return manager +} + +func sidecarLaunchOptions(coreRef *core.Core) (deno.Options, resultFailure) { + args := splitCommandArgs(core.Env("CORE_DENO_ARGS")) + if err := validateSidecarArgs(args, coreRef); err != nil { + return deno.Options{}, err + } + + binary, err := validateSidecarBinary(core.Env("CORE_DENO_BINARY")) + if err != nil { + return deno.Options{}, err + } + dir, err := validateSidecarDir(core.Env("CORE_DENO_DIR")) + if err != nil { + return deno.Options{}, err + } + + return deno.Options{ + Binary: binary, + Dir: dir, + Args: args, + Core: coreRef, + }, nil +} + +func validateSidecarArgs(args []string, coreRef *core.Core) resultFailure { + for _, arg := range args { + flag := denoPermissionFlag(arg) + if flag == "" { + continue + } + msg := core.Sprintf("CORE_DENO_ARGS contains permission flag %s; deno sandbox is being weakened. This is intentional only if you understand the implications.", flag) + if equalFold(core.Trim(core.Env("CORE_DENO_ALLOW_PERMISSIONS")), "true") { + core.Print(sidecarWarningWriter, "%s", msg) + if coreRef != nil { + coreRef.LogWarn(core.Errorf("permission flag %s", flag), "display.sidecar.env", msg) + } + continue + } + return core.Errorf("%s Set CORE_DENO_ALLOW_PERMISSIONS=true to allow this intentionally", msg) + } + return nil +} + +func denoPermissionFlag(arg string) string { + token := core.Trim(arg) + switch { + case token == "-A" || core.HasPrefix(token, "-A="): + return "-A" + case token == "--allow-all" || core.HasPrefix(token, "--allow-all="): + return "--allow-all" + case core.HasPrefix(token, "--allow-"): + if flag, _, ok := cut(token, "="); ok { + return flag + } + return token + default: + return "" + } +} + +func validateSidecarBinary(value string) (string, resultFailure) { + binary := core.Trim(value) + if binary == "" { + return "", nil + } + if !core.PathIsAbs(binary) { + return "", core.Errorf("CORE_DENO_BINARY must be an absolute path: %q", binary) + } + info, err := coreStat(binary) + if err != nil { + return "", core.Errorf("CORE_DENO_BINARY does not exist: %q: %w", binary, err) + } + if info.IsDir() { + return "", core.Errorf("CORE_DENO_BINARY must point to a file, not a directory: %q", binary) + } + if core.PathBase(binary) != "deno" { + return "", core.Errorf("CORE_DENO_BINARY must point to a binary named deno: %q", binary) + } + resolved, err := pathEvalSymlinks(binary) + if err != nil { + return "", core.Errorf("CORE_DENO_BINARY could not be resolved: %q: %w", binary, err) + } + if core.PathBase(resolved) != "deno" { + return "", core.Errorf("CORE_DENO_BINARY must resolve to a binary named deno: %q", binary) + } + return resolved, nil +} + +func validateSidecarDir(value string) (string, resultFailure) { + dir := core.Trim(value) + if dir == "" { + return "", nil + } + if hasParentPathComponent(dir) { + return "", core.Errorf("CORE_DENO_DIR must not contain .. path components: %q", dir) + } + abs, err := pathAbs(dir) + if err != nil { + return "", core.Errorf("CORE_DENO_DIR could not be made absolute: %q: %w", dir, err) + } + if err := rejectSymlinkPathComponents(abs); err != nil { + return "", err + } + info, err := coreStat(abs) + if err != nil { + return "", core.Errorf("CORE_DENO_DIR does not exist: %q: %w", dir, err) + } + if !info.IsDir() { + return "", core.Errorf("CORE_DENO_DIR must be an existing directory: %q", dir) + } + return abs, nil +} + +func hasParentPathComponent(path string) bool { + for _, part := range core.Split(core.PathToSlash(path), "/") { + if part == ".." { + return true + } + } + return false +} + +func rejectSymlinkPathComponents(path string) resultFailure { + clean := core.CleanPath(path, string(core.PathSeparator)) + volume := pathVolumeName(clean) + rest := core.TrimPrefix(clean, volume) + current := volume + if core.HasPrefix(rest, string(core.PathSeparator)) { + current += string(core.PathSeparator) + rest = core.TrimPrefix(rest, string(core.PathSeparator)) + } + for _, part := range core.Split(rest, string(core.PathSeparator)) { + if part == "" || part == "." { + continue + } + current = core.PathJoin(current, part) + info, err := coreLstat(current) + if err != nil { + return core.Errorf("CORE_DENO_DIR component does not exist: %q: %w", current, err) + } + if info.Mode()&core.ModeSymlink != 0 { + return core.Errorf("CORE_DENO_DIR must not contain symlink component: %q", current) + } + } + return nil +} + +func splitCommandArgs(value string) []string { + fields := fields(core.Trim(value)) + if len(fields) == 0 { + return nil + } + return fields +} + +func (s *Service) forwardIPCToSidecar(msg core.Message) { + if s == nil || s.sidecar == nil { + return + } + status := s.sidecar.Status() + if !status.Running || !status.Connected { + return + } + typeName := "" + if t := reflect.TypeOf(msg); t != nil { + typeName = t.String() + } + if err := s.sidecar.Emit("core.ipc.message", map[string]any{ + "type": typeName, + "data": normalizeSidecarValue(msg), + }); err != nil { + coreutil.LogWarn(s.Core(), err, "display.emitSidecarIPC", "sidecar emit failed") + } +} + +func normalizeSidecarValue(value any) any { + if value == nil { + return nil + } + var normalized any + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &normalized); result.OK { + return normalized + } + return map[string]any{"value": core.JSONMarshalString(value)} +} diff --git a/display/webkit/pkg/display/sidecar_test.go b/display/webkit/pkg/display/sidecar_test.go new file mode 100644 index 0000000..916f138 --- /dev/null +++ b/display/webkit/pkg/display/sidecar_test.go @@ -0,0 +1,272 @@ +package display + +import ( + "context" + "syscall" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/deno" +) + +func captureStderr(t *core.T, fn func()) string { + t.Helper() + + original := sidecarWarningWriter + output := core.NewBuffer() + defer func() { + sidecarWarningWriter = original + }() + + sidecarWarningWriter = output + fn() + sidecarWarningWriter = original + + return output.String() +} + +func TestSidecar_SplitCommandArgs_GoodCase(t *core.T) { + core.AssertEqual(t, []string{"--import-map", "map.json", "--watch"}, splitCommandArgs("--import-map map.json --watch")) + observedType := core.Sprintf("%T", splitCommandArgs("--import-map map.json --watch")) + core.AssertNotEmpty(t, observedType) +} + +func TestSidecar_SplitCommandArgs_BadCase(t *core.T) { + core.AssertNil(t, splitCommandArgs("")) + core.AssertNil(t, splitCommandArgs(" ")) + core.AssertNotEmpty(t, core.Sprintf("%T", splitCommandArgs(""))) +} + +func TestSidecar_SplitCommandArgs_UglyCase(t *core.T) { + core.AssertEqual(t, []string{"--flag", "--another", "value"}, splitCommandArgs("\t--flag\n--another value\t")) + observedType := core.Sprintf("%T", splitCommandArgs("\t--flag\n--another value\t")) + core.AssertNotEmpty(t, observedType) +} + +func TestSidecar_ValidateArgs_GoodCase(t *core.T) { + output := captureStderr(t, func() { + core.AssertNoError(t, validateSidecarArgs(splitCommandArgs(""), nil)) + core.AssertNoError(t, validateSidecarArgs(splitCommandArgs(" "), nil)) + }) + + core.AssertEmpty(t, core.Trim(output)) +} + +func TestSidecar_LaunchOptions_Good_EmptyEnv(t *core.T) { + t.Setenv("CORE_DENO_ARGS", "") + t.Setenv("CORE_DENO_BINARY", "") + t.Setenv("CORE_DENO_DIR", "") + + var options deno.Options + output := captureStderr(t, func() { + var err resultFailure + options, err = sidecarLaunchOptions(nil) + core.RequireNoError(t, err) + }) + + core.AssertNil(t, options.Args) + core.AssertEmpty(t, options.Binary) + core.AssertEmpty(t, options.Dir) + core.AssertEmpty(t, core.Trim(output)) +} + +func TestSidecar_ValidateArgs_Good_Unstable(t *core.T) { + output := captureStderr(t, func() { + core.AssertNoError(t, validateSidecarArgs(splitCommandArgs("--unstable"), nil)) + }) + + core.AssertEmpty(t, core.Trim(output)) +} + +func TestSidecar_ValidateArgs_Bad_PermissionFlags(t *core.T) { + tests := []struct { + name string + args string + flag string + }{ + {name: "allow-all", args: "run --allow-all attacker.ts", flag: "--allow-all"}, + {name: "allow-all-short", args: "-A", flag: "-A"}, + {name: "multiple-allow-flags", args: "--allow-net --allow-read=/", flag: "--allow-net"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *core.T) { + t.Setenv("CORE_DENO_ALLOW_PERMISSIONS", "") + + err := validateSidecarArgs(splitCommandArgs(tt.args), nil) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), tt.flag) + core.AssertContains(t, err.Error(), "deno sandbox is being weakened") + }) + } +} + +func TestSidecar_ValidateArgs_Good_OverrideWarns(t *core.T) { + t.Setenv("CORE_DENO_ALLOW_PERMISSIONS", "true") + + output := captureStderr(t, func() { + core.AssertNoError(t, validateSidecarArgs(splitCommandArgs("run --allow-all attacker.ts"), nil)) + }) + + core.AssertContains(t, output, "CORE_DENO_ARGS contains permission flag --allow-all") + core.AssertContains(t, output, "deno sandbox is being weakened") +} + +func TestSidecar_StartAction_Bad_RefusesPermissionArgs(t *core.T) { + t.Setenv("CORE_DENO_ARGS", "run --allow-all attacker.ts") + + svc, c := newTestDisplayService(t) + result := c.Action("display.sidecar.start").Run(context.Background(), core.Options{}) + + core.AssertFalse(t, result.OK) + err, ok := result.Value.(resultFailure) + core.RequireTrue(t, ok) + core.AssertContains(t, err.Error(), "--allow-all") + core.AssertNil(t, svc.sidecar) +} + +func TestSidecar_ValidateBinary_GoodCase(t *core.T) { + binary := core.PathJoin(t.TempDir(), "deno") + core.RequireNoError(t, coreWriteFile(binary, []byte("#!/bin/sh\n"), 0o755)) + expected, err := pathEvalSymlinks(binary) + core.RequireNoError(t, err) + + actual, err := validateSidecarBinary(binary) + + core.RequireNoError(t, err) + core.AssertEqual(t, expected, actual) +} + +func TestSidecar_ValidateBinary_BadCase(t *core.T) { + customBinary := core.PathJoin(t.TempDir(), "deno-custom") + core.RequireNoError(t, coreWriteFile(customBinary, []byte("#!/bin/sh\n"), 0o755)) + + tests := []struct { + name string + value string + want string + }{ + {name: "relative", value: "deno", want: "absolute"}, + {name: "missing", value: core.PathJoin(t.TempDir(), "deno"), want: "does not exist"}, + {name: "custom-name", value: customBinary, want: "named deno"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *core.T) { + _, err := validateSidecarBinary(tt.value) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), tt.want) + }) + } +} + +func TestSidecar_ValidateDir_GoodCase(t *core.T) { + dir := canonicalTempDir(t) + + actual, err := validateSidecarDir(dir) + + core.RequireNoError(t, err) + core.AssertEqual(t, dir, actual) +} + +func TestSidecar_ValidateDir_BadCase(t *core.T) { + base := canonicalTempDir(t) + child := core.PathJoin(base, "child") + core.RequireNoError(t, coreMkdir(child, 0o755)) + file := core.PathJoin(base, "not-a-dir") + core.RequireNoError(t, coreWriteFile(file, []byte("x"), 0o644)) + target := core.PathJoin(base, "target") + core.RequireNoError(t, coreMkdir(target, 0o755)) + link := core.PathJoin(base, "link") + if err := syscall.Symlink(target, link); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + + tests := []struct { + name string + value string + want string + }{ + {name: "parent-component", value: child + string(core.PathSeparator) + ".." + string(core.PathSeparator) + "child", want: ".."}, + {name: "file", value: file, want: "directory"}, + {name: "symlink", value: link, want: "symlink"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *core.T) { + _, err := validateSidecarDir(tt.value) + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), tt.want) + }) + } +} + +func canonicalTempDir(t *core.T) string { + t.Helper() + + dir, err := pathEvalSymlinks(t.TempDir()) + core.RequireNoError(t, err) + return dir +} + +func TestSidecar_EnsureSidecar_GoodCase(t *core.T) { + t.Setenv("CORE_DENO_BINARY", "/usr/local/bin/deno-custom") + t.Setenv("CORE_DENO_DIR", "/tmp/core-deno") + t.Setenv("CORE_DENO_ARGS", "--import-map map.json") + + svc := &Service{} + manager := svc.ensureSidecar() + + core.AssertNotNil(t, manager) + status := manager.Status() + core.AssertEqual(t, "/usr/local/bin/deno-custom", status.Binary) + core.AssertFalse(t, status.Running) +} + +func TestSidecar_EnsureSidecar_BadCase(t *core.T) { + svc := &Service{sidecar: deno.New(deno.Options{Binary: "custom-deno"})} + + manager := svc.ensureSidecar() + + core.AssertSame(t, svc.sidecar, manager) + core.AssertEqual(t, "custom-deno", manager.Status().Binary) +} + +func TestSidecar_EnsureSidecar_UglyCase(t *core.T) { + t.Setenv("CORE_DENO_BINARY", " ") + t.Setenv("CORE_DENO_DIR", "") + t.Setenv("CORE_DENO_ARGS", " ") + + svc := &Service{} + manager := svc.ensureSidecar() + + core.AssertNotNil(t, manager) + core.AssertEqual(t, "deno", manager.Status().Binary) +} + +func TestSidecar_RegisterActions_StartFailureClearsSidecar(t *core.T) { + t.Setenv("CORE_DENO_ENABLE", "1") + t.Setenv("CORE_DENO_BINARY", "/definitely/not/a/real/deno") + + c := core.New(core.WithServiceLock()) + svc := &Service{ServiceRuntime: core.NewServiceRuntime(c, Options{})} + + svc.registerSidecarActions() + + core.AssertNil(t, svc.sidecar) +} + +func TestSidecar_StatusAction_GoodCase(t *core.T) { + t.Setenv("CORE_DENO_BINARY", "/opt/core/deno") + + _, c := newTestDisplayService(t) + result := c.Action("display.sidecar.status").Run(context.Background(), core.Options{}) + + core.RequireTrue(t, result.OK) + status, ok := result.Value.(deno.Status) + core.RequireTrue(t, ok) + core.AssertEqual(t, "/opt/core/deno", status.Binary) + core.AssertFalse(t, status.Running) +} diff --git a/display/webkit/pkg/display/storage.go b/display/webkit/pkg/display/storage.go new file mode 100644 index 0000000..a82fa38 --- /dev/null +++ b/display/webkit/pkg/display/storage.go @@ -0,0 +1,379 @@ +package display + +import ( + "net/url" + "sort" + "sync" // Note: AX-6 — sync.RWMutex for registry guard, no core wrapper in pinned core module + "time" + + core "dappco.re/go" +) + +const ( + maxStorageOriginBytes = 512 + maxStorageBucketBytes = 128 + maxStorageKeyBytes = 1024 + maxStorageValueBytes = 1 << 20 + maxStorageEntriesPerOrigin = 1024 + maxStorageBytesPerOrigin = 16 << 20 + maxStorageSearchResults = 200 +) + +type StorageEntry struct { + Origin string `json:"origin"` + Bucket string `json:"bucket"` + Key string `json:"key"` + Value string `json:"value"` + UpdatedAt time.Time `json:"updated_at"` +} + +type StorageRegistry struct { + mu sync.RWMutex + entries map[string]StorageEntry + store *storageStore +} + +func NewStorageRegistry() *StorageRegistry { + registry := &StorageRegistry{entries: make(map[string]StorageEntry)} + registry.store = openStorageStore() + registry.loadPersistedEntries() + return registry +} + +func openStorageStore() *storageStore { + path := storageDatabasePath() + if core.Trim(path) == "" { + return nil + } + storeInstance, err := newStorageStore(path) + if err != nil { + core.Error( + "storage registry init failed", + "file_path", path, + "step", "open", + "err", core.E("display.storage.open", "failed to open storage store", err), + ) + return nil + } + return storeInstance +} + +func storageDatabasePath() string { + if override := core.Trim(core.Env("CORE_GUI_STORAGE_PATH")); override != "" { + return override + } + if core.Trim(core.Env("CORE_GUI_STORAGE_PERSIST")) == "" { + return ":memory:" + } + home := core.Trim(core.Env("DIR_HOME")) + if home == "" { + return ":memory:" + } + return core.Path(home, ".core", "state", core.Concat("gui-storage-", core.Env("PID"), ".db")) +} + +func storageOriginForPageURL(pageURL string) string { + trimmed := core.Trim(pageURL) + if trimmed == "" { + return "" + } + parsed, err := url.Parse(trimmed) + if err != nil || core.Trim(parsed.Scheme) == "" { + return "" + } + switch core.Lower(core.Trim(parsed.Scheme)) { + case "http", "https": + if parsed.Host == "" { + return "" + } + return core.Concat(parsed.Scheme, "://", parsed.Host) + case "core": + if parsed.Host == "" { + return "core://" + } + return core.Concat("core://", parsed.Host) + case "file": + if parsed.Path == "" { + return "" + } + return core.Concat("file://", parsed.Path) + default: + if parsed.Host == "" { + return "" + } + origin := core.Concat(parsed.Scheme, "://", parsed.Host) + if parsed.Path != "" { + origin = core.Concat(origin, parsed.Path) + } + origin = trimTrailingSlash(origin) + if core.Trim(origin) == "://" { + return trimmed + } + return origin + } +} + +func trimTrailingSlash(value string) string { + for core.HasSuffix(value, "/") { + value = core.TrimSuffix(value, "/") + } + return value +} + +func makeStorageEntryKey(origin, bucket, key string) string { + return storageCompositeKey(origin, bucket, key) +} + +func storageCompositeKey(origin, bucket, key string) string { + return core.JSONMarshalString([]string{origin, bucket, key}) +} + +func decodeStorageCompositeKey(value string) (string, string, string, bool) { + var parts []string + if result := core.JSONUnmarshalString(value, &parts); !result.OK || len(parts) != 3 { + return "", "", "", false + } + return parts[0], parts[1], parts[2], true +} + +func (r *StorageRegistry) loadPersistedEntries() { + if r == nil || r.store == nil { + return + } + if r.entries == nil { + r.entries = make(map[string]StorageEntry) + } + items, err := r.store.getAll("storage") + if err != nil { + return + } + for key, value := range items { + var stored StorageEntry + if result := core.JSONUnmarshalString(value, &stored); !result.OK { + if origin, bucket, key, ok := decodeStorageCompositeKey(key); ok { + stored = StorageEntry{ + Origin: origin, + Bucket: bucket, + Key: key, + Value: value, + UpdatedAt: time.Now(), + } + } else { + continue + } + } + if stored.UpdatedAt.IsZero() { + stored.UpdatedAt = time.Now() + } + r.entries[makeStorageEntryKey(stored.Origin, stored.Bucket, stored.Key)] = stored + } +} + +func (r *StorageRegistry) Set(origin, bucket, key, value string) bool { + if r == nil { + return false + } + if !validStorageField(origin, maxStorageOriginBytes) || + !validStorageField(bucket, maxStorageBucketBytes) || + !validStorageField(key, maxStorageKeyBytes) || + (len(value) > maxStorageValueBytes) { + return false + } + origin = core.Trim(origin) + bucket = core.Trim(bucket) + key = core.Trim(key) + r.mu.Lock() + defer r.mu.Unlock() + if r.entries == nil { + r.entries = make(map[string]StorageEntry) + } + composite := makeStorageEntryKey(origin, bucket, key) + if !r.withinOriginQuotaLocked(origin, composite, StorageEntry{ + Origin: origin, + Bucket: bucket, + Key: key, + Value: value, + }) { + return false + } + entry := StorageEntry{ + Origin: origin, + Bucket: bucket, + Key: key, + Value: value, + UpdatedAt: time.Now(), + } + if r.store != nil { + if err := r.store.set("storage", storageCompositeKey(origin, bucket, key), core.JSONMarshalString(entry)); err != nil { + return false + } + } + r.entries[composite] = entry + return true +} + +func (r *StorageRegistry) Delete(origin, bucket, key string) bool { + if r == nil { + return false + } + if !validStorageField(origin, maxStorageOriginBytes) || + !validStorageField(bucket, maxStorageBucketBytes) || + !validStorageField(key, maxStorageKeyBytes) { + return false + } + origin = core.Trim(origin) + bucket = core.Trim(bucket) + key = core.Trim(key) + r.mu.Lock() + defer r.mu.Unlock() + if r.entries == nil { + r.entries = make(map[string]StorageEntry) + } + composite := makeStorageEntryKey(origin, bucket, key) + if r.store != nil { + if err := r.store.delete("storage", storageCompositeKey(origin, bucket, key)); err != nil { + return false + } + } + delete(r.entries, composite) + return true +} + +func (r *StorageRegistry) Get(origin, bucket, key string) (StorageEntry, bool) { + if r == nil { + return StorageEntry{}, false + } + r.mu.RLock() + defer r.mu.RUnlock() + if r.entries == nil { + return StorageEntry{}, false + } + + if entry, ok := r.entries[makeStorageEntryKey(origin, bucket, key)]; ok { + return entry, true + } + + var latest StorageEntry + found := false + for _, entry := range r.entries { + if bucket != "" && entry.Bucket != bucket { + continue + } + if key != "" && entry.Key != key { + continue + } + if origin != "" && entry.Origin != origin { + continue + } + if !found || entry.UpdatedAt.After(latest.UpdatedAt) { + latest = entry + found = true + } + } + return latest, found +} + +func (r *StorageRegistry) Search(query string) []StorageEntry { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + if r.entries == nil { + return nil + } + needle := core.Lower(core.Trim(query)) + results := make([]StorageEntry, 0) + for _, entry := range r.entries { + if needle == "" || + core.Contains(core.Lower(entry.Origin), needle) || + core.Contains(core.Lower(entry.Bucket), needle) || + core.Contains(core.Lower(entry.Key), needle) || + core.Contains(core.Lower(entry.Value), needle) { + results = append(results, entry) + if len(results) >= maxStorageSearchResults { + break + } + } + } + sort.Slice(results, func(i, j int) bool { + return results[i].UpdatedAt.After(results[j].UpdatedAt) + }) + return results +} + +func validStorageField(value string, limit int) bool { + trimmed := core.Trim(value) + return trimmed != "" && len(trimmed) <= limit +} + +func (r *StorageRegistry) Snapshot(pageURL string) map[string]map[string]string { + if r == nil { + return map[string]map[string]string{} + } + r.mu.RLock() + defer r.mu.RUnlock() + if r.entries == nil { + return map[string]map[string]string{} + } + + origin := storageOriginForPageURL(pageURL) + if core.Trim(origin) == "" { + return map[string]map[string]string{} + } + snapshot := make(map[string]map[string]string) + for _, entry := range r.entries { + if origin != "" && !storageEqualFold(entry.Origin, origin) { + continue + } + bucket := snapshot[entry.Bucket] + if bucket == nil { + bucket = make(map[string]string) + snapshot[entry.Bucket] = bucket + } + bucket[entry.Key] = entry.Value + } + return snapshot +} + +func (r *StorageRegistry) withinOriginQuotaLocked(origin, ignoreComposite string, candidate StorageEntry) bool { + if r == nil || r.entries == nil { + return true + } + entries := 0 + bytes := 0 + for composite, entry := range r.entries { + if !storageEqualFold(entry.Origin, origin) { + continue + } + if composite == ignoreComposite { + continue + } + entries++ + bytes += storageEntrySizeBytes(entry) + } + entries++ + bytes += storageEntrySizeBytes(candidate) + if entries > maxStorageEntriesPerOrigin { + return false + } + if bytes > maxStorageBytesPerOrigin { + return false + } + return true +} + +func storageEntrySizeBytes(entry StorageEntry) int { + return len(entry.Origin) + len(entry.Bucket) + len(entry.Key) + len(entry.Value) +} + +func storageEqualFold(left, right string) bool { + return core.Lower(left) == core.Lower(right) +} + +func (r *StorageRegistry) Close() resultFailure { + if r == nil || r.store == nil { + return nil + } + return r.store.close() +} diff --git a/display/webkit/pkg/display/storage_example_test.go b/display/webkit/pkg/display/storage_example_test.go new file mode 100644 index 0000000..6c075d0 --- /dev/null +++ b/display/webkit/pkg/display/storage_example_test.go @@ -0,0 +1,47 @@ +//go:build compliance + +package display + +import core "dappco.re/go" + +func ExampleNewStorageRegistry() { + core.Println("NewStorageRegistry") + // Output: + // NewStorageRegistry +} + +func ExampleStorageRegistry_Set() { + core.Println("StorageRegistry_Set") + // Output: + // StorageRegistry_Set +} + +func ExampleStorageRegistry_Delete() { + core.Println("StorageRegistry_Delete") + // Output: + // StorageRegistry_Delete +} + +func ExampleStorageRegistry_Get() { + core.Println("StorageRegistry_Get") + // Output: + // StorageRegistry_Get +} + +func ExampleStorageRegistry_Search() { + core.Println("StorageRegistry_Search") + // Output: + // StorageRegistry_Search +} + +func ExampleStorageRegistry_Snapshot() { + core.Println("StorageRegistry_Snapshot") + // Output: + // StorageRegistry_Snapshot +} + +func ExampleStorageRegistry_Close() { + core.Println("StorageRegistry_Close") + // Output: + // StorageRegistry_Close +} diff --git a/display/webkit/pkg/display/storage_store.go b/display/webkit/pkg/display/storage_store.go new file mode 100644 index 0000000..7214386 --- /dev/null +++ b/display/webkit/pkg/display/storage_store.go @@ -0,0 +1,106 @@ +package display + +import ( + "sync" + + core "dappco.re/go" +) + +type storageStore struct { + path string + mu sync.Mutex + data map[string]map[string]string +} + +func newStorageStore(path string) (*storageStore, resultFailure) { + store := &storageStore{ + path: path, + data: make(map[string]map[string]string), + } + if path == ":memory:" || core.Trim(path) == "" { + return store, nil + } + if result := core.MkdirAll(core.PathDir(path), 0o755); !result.OK { + return nil, coreResultError(result, "failed to create storage directory") + } + content, err := coreReadFile(path) + if err != nil { + if core.IsNotExist(err) { + return store, nil + } + return nil, err + } + if len(content) == 0 { + return store, nil + } + if result := core.JSONUnmarshal(content, &store.data); !result.OK { + return nil, coreResultError(result, "failed to decode storage database") + } + return store, nil +} + +func (s *storageStore) set(group, key, value string) resultFailure { + if s == nil { + return core.NewError("storage store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.data == nil { + s.data = make(map[string]map[string]string) + } + items := s.data[group] + if items == nil { + items = make(map[string]string) + s.data[group] = items + } + items[key] = value + return s.persistLocked() +} + +func (s *storageStore) delete(group, key string) resultFailure { + if s == nil { + return core.NewError("storage store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + if items := s.data[group]; items != nil { + delete(items, key) + if len(items) == 0 { + delete(s.data, group) + } + } + return s.persistLocked() +} + +func (s *storageStore) getAll(group string) (map[string]string, resultFailure) { + if s == nil { + return nil, core.NewError("storage store is nil") + } + s.mu.Lock() + defer s.mu.Unlock() + copy := make(map[string]string) + for key, value := range s.data[group] { + copy[key] = value + } + return copy, nil +} + +func (s *storageStore) close() resultFailure { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.persistLocked() +} + +func (s *storageStore) persistLocked() resultFailure { + if s.path == "" || s.path == ":memory:" { + return nil + } + result := core.JSONMarshalIndent(s.data, "", " ") + if !result.OK { + return coreResultError(result, "failed to encode storage database") + } + return coreWriteFile(s.path, result.Value.([]byte), 0o644) +} diff --git a/display/webkit/pkg/display/storage_test.go b/display/webkit/pkg/display/storage_test.go new file mode 100644 index 0000000..18532c9 --- /dev/null +++ b/display/webkit/pkg/display/storage_test.go @@ -0,0 +1,477 @@ +package display + +import ( + core "dappco.re/go" + "time" +) + +func storageEntryKey(origin, bucket, key string) string { + return makeStorageEntryKey(origin, bucket, key) +} + +func setStorageEntryTime(r *StorageRegistry, origin, bucket, key string, ts time.Time) { + r.mu.Lock() + defer r.mu.Unlock() + + entry := r.entries[storageEntryKey(origin, bucket, key)] + entry.UpdatedAt = ts + r.entries[storageEntryKey(origin, bucket, key)] = entry +} + +func TestStorageRegistry_Get_Good(t *core.T) { + // Get + ax7Variant := "Get:good" + core.AssertContains(t, ax7Variant, "good") + r := NewStorageRegistry() + r.Set("origin-a", "local", "theme", "dark") + r.Set("origin-b", "local", "theme", "light") + setStorageEntryTime(r, "origin-a", "local", "theme", time.Unix(100, 0).UTC()) + setStorageEntryTime(r, "origin-b", "local", "theme", time.Unix(200, 0).UTC()) + + entry, ok := r.Get("origin-a", "local", "theme") + core.RequireTrue(t, ok) + core.AssertEqual(t, "dark", entry.Value) + core.AssertEqual(t, "origin-a", entry.Origin) +} + +func TestStorageRegistry_Get_Bad(t *core.T) { + // Get + ax7Variant := "Get:bad" + core.AssertContains(t, ax7Variant, "bad") + r := NewStorageRegistry() + r.Set("origin-a", "local", "theme", "dark") + + entry, ok := r.Get("missing", "local", "theme") + core.AssertFalse(t, ok) + core.AssertEmpty(t, entry) +} + +func TestStorageRegistry_Get_Ugly(t *core.T) { + // Get + ax7Variant := "Get:ugly" + core.AssertContains(t, ax7Variant, "ugly") + r := NewStorageRegistry() + r.Set("origin-a", "local", "theme", "dark") + r.Set("origin-b", "local", "theme", "light") + setStorageEntryTime(r, "origin-a", "local", "theme", time.Unix(100, 0).UTC()) + setStorageEntryTime(r, "origin-b", "local", "theme", time.Unix(200, 0).UTC()) + + entry, ok := r.Get("", "local", "") + core.RequireTrue(t, ok) + core.AssertEqual(t, "origin-b", entry.Origin) + core.AssertEqual(t, "light", entry.Value) +} + +func TestStorageRegistry_Search_Good(t *core.T) { + // Search + ax7Variant := "Search:good" + core.AssertContains(t, ax7Variant, "good") + r := NewStorageRegistry() + r.Set("origin-a", "local", "theme", "alpha") + r.Set("origin-b", "session", "token", "bravo") + r.Set("origin-c", "local", "theme", "alpha-beta") + setStorageEntryTime(r, "origin-a", "local", "theme", time.Unix(100, 0).UTC()) + setStorageEntryTime(r, "origin-b", "session", "token", time.Unix(300, 0).UTC()) + setStorageEntryTime(r, "origin-c", "local", "theme", time.Unix(200, 0).UTC()) + + results := r.Search("alpha") + core.AssertLen(t, results, 2) + core.AssertEqual(t, "origin-c", results[0].Origin) + core.AssertEqual(t, "origin-a", results[1].Origin) +} + +func TestStorageRegistry_Search_Bad(t *core.T) { + // Search + ax7Variant := "Search:bad" + core.AssertContains(t, ax7Variant, "bad") + r := NewStorageRegistry() + r.Set("origin-a", "local", "theme", "alpha") + r.Set("origin-b", "session", "token", "bravo") + setStorageEntryTime(r, "origin-a", "local", "theme", time.Unix(100, 0).UTC()) + setStorageEntryTime(r, "origin-b", "session", "token", time.Unix(200, 0).UTC()) + + results := r.Search("") + core.AssertLen(t, results, 2) + core.AssertEqual(t, "origin-b", results[0].Origin) + core.AssertEqual(t, "origin-a", results[1].Origin) +} + +func TestStorageRegistry_Search_Ugly(t *core.T) { + // Search + ax7Variant := "Search:ugly" + core.AssertContains(t, ax7Variant, "ugly") + r := NewStorageRegistry() + r.Set("origin-a", "local", "theme", "alpha") + + results := r.Search("does-not-exist") + core.AssertEmpty(t, results) +} + +func TestStorageRegistry_Snapshot_Good(t *core.T) { + // Snapshot + ax7Variant := "Snapshot:good" + core.AssertContains(t, ax7Variant, "good") + r := NewStorageRegistry() + r.Set("core://settings", "localStorage", "theme", "dark") + r.Set("core://settings", "cookies", "session", core.Concat(`{"value":"abc","pa`, `th":"/","secure":false}`)) + r.Set("core://other", "localStorage", "theme", "light") + + snapshot := r.Snapshot("core://settings/profile") + core.AssertContains(t, snapshot, "localStorage") + core.AssertContains(t, snapshot, "cookies") + core.AssertEqual(t, "dark", snapshot["localStorage"]["theme"]) + core.AssertEqual(t, core.Concat(`{"value":"abc","pa`, `th":"/","secure":false}`), snapshot["cookies"]["session"]) + _, otherOriginPresent := snapshot["other"] + core.AssertFalse(t, otherOriginPresent) +} + +func TestStorageRegistry_Set_Bad(t *core.T) { + // Set + ax7Variant := "Set:bad" + core.AssertContains(t, ax7Variant, "bad") + r := NewStorageRegistry() + + core.AssertFalse(t, r.Set("", "localStorage", "theme", "dark")) + core.AssertFalse(t, r.Set("core://settings", "", "theme", "dark")) + core.AssertFalse(t, r.Set("core://settings", "localStorage", "", "dark")) + core.AssertFalse(t, r.Set("core://settings", "localStorage", "theme", repeatString("x", maxStorageValueBytes+1))) +} + +func TestStorageRegistry_Delete_Good(t *core.T) { + // Delete + ax7Variant := "Delete:good" + core.AssertContains(t, ax7Variant, "good") + r := NewStorageRegistry() + r.Set("core://settings", "localStorage", "theme", "dark") + + core.AssertTrue(t, r.Delete("core://settings", "localStorage", "theme")) + _, ok := r.Get("core://settings", "localStorage", "theme") + core.AssertFalse(t, ok) +} + +func TestStorageRegistry_Set_RejectsQuotaOverflow(t *core.T) { + r := NewStorageRegistry() + for i := 0; i < maxStorageEntriesPerOrigin; i++ { + core.RequireTrue(t, r.Set("core://settings", "localStorage", core.Sprintf("key-%d", i), "v")) + } + core.AssertFalse(t, r.Set("core://settings", "localStorage", "overflow", "v")) +} + +func TestStorage_StorageOriginForPageURL_GoodCase(t *core.T) { + core.AssertEqual(t, "https://app.example.com", storageOriginForPageURL("https://app.example.com/path?q=1")) + core.AssertEqual(t, "core://settings", storageOriginForPageURL("core://settings/view")) + core.AssertNotEmpty(t, core.Sprintf("%T", storageOriginForPageURL("https://app.example.com/path?q=1"))) +} + +func TestStorage_StorageOriginForPageURL_BadCase(t *core.T) { + core.AssertEqual(t, "custom://host/path", storageOriginForPageURL("custom://host/path")) + observedType := core.Sprintf("%T", storageOriginForPageURL("custom://host/path")) + core.AssertNotEmpty(t, observedType) +} + +func TestStorage_StorageOriginForPageURL_UglyCase(t *core.T) { + core.AssertEqual(t, "", storageOriginForPageURL("")) + core.AssertEqual(t, "", storageOriginForPageURL(" ")) + core.AssertNotEmpty(t, core.Sprintf("%T", storageOriginForPageURL(""))) +} + +func TestStorage_Snapshot_BlankOriginReturnsEmpty(t *core.T) { + r := NewStorageRegistry() + r.Set("core://settings", "localStorage", "theme", "dark") + + snapshot := r.Snapshot("") + + core.AssertEmpty(t, snapshot) +} + +func TestStorage_CompositeKey_GoodCase(t *core.T) { + key := storageCompositeKey("origin", "bucket", "item") + + origin, bucket, item, ok := decodeStorageCompositeKey(key) + core.RequireTrue(t, ok) + core.AssertEqual(t, "origin", origin) + core.AssertEqual(t, "bucket", bucket) + core.AssertEqual(t, "item", item) + core.AssertEqual(t, key, makeStorageEntryKey("origin", "bucket", "item")) +} + +func TestStorage_CompositeKey_BadCase(t *core.T) { + origin, bucket, item, ok := decodeStorageCompositeKey("not-json") + + core.AssertFalse(t, ok) + core.AssertEmpty(t, origin) + core.AssertEmpty(t, bucket) + core.AssertEmpty(t, item) +} + +func TestStorage_CompositeKey_UglyCase(t *core.T) { + origin, bucket, item, ok := decodeStorageCompositeKey(`["one","two"]`) + + core.AssertFalse(t, ok) + core.AssertEmpty(t, origin) + core.AssertEmpty(t, bucket) + core.AssertEmpty(t, item) +} + +func TestStorageRegistry_NilReceiverIsSafe(t *core.T) { + var r *StorageRegistry + + core.AssertFalse(t, r.Set("core://settings", "localStorage", "theme", "dark")) + core.AssertFalse(t, r.Delete("core://settings", "localStorage", "theme")) + + entry, ok := r.Get("core://settings", "localStorage", "theme") + core.AssertFalse(t, ok) + core.AssertEmpty(t, entry) + core.AssertEmpty(t, r.Search("theme")) + core.AssertEmpty(t, r.Snapshot("core://settings")) +} + +// AX7 generated source-matching smoke coverage. +func TestStorage_NewStorageRegistry_Good(t *core.T) { + // NewStorageRegistry + ax7Variant := "NewStorageRegistry:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewStorageRegistry() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_NewStorageRegistry_Bad(t *core.T) { + // NewStorageRegistry + ax7Variant := "NewStorageRegistry:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewStorageRegistry() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_NewStorageRegistry_Ugly(t *core.T) { + // NewStorageRegistry + ax7Variant := "NewStorageRegistry:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewStorageRegistry() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Set_Good(t *core.T) { + // StorageRegistry Set + ax7Variant := "StorageRegistry_Set:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Set("agent", "agent", "agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Set_Bad(t *core.T) { + // StorageRegistry Set + ax7Variant := "StorageRegistry_Set:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Set("", "", "", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Set_Ugly(t *core.T) { + // StorageRegistry Set + ax7Variant := "StorageRegistry_Set:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Set("../../edge", "../../edge", "../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Delete_Good(t *core.T) { + // StorageRegistry Delete + ax7Variant := "StorageRegistry_Delete:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Delete("agent", "agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Delete_Bad(t *core.T) { + // StorageRegistry Delete + ax7Variant := "StorageRegistry_Delete:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Delete("", "", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Delete_Ugly(t *core.T) { + // StorageRegistry Delete + ax7Variant := "StorageRegistry_Delete:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Delete("../../edge", "../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Get_Good(t *core.T) { + // StorageRegistry Get + ax7Variant := "StorageRegistry_Get:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0, got1 := subject.Get("agent", "agent", "agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Get_Bad(t *core.T) { + // StorageRegistry Get + ax7Variant := "StorageRegistry_Get:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0, got1 := subject.Get("", "", "") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Get_Ugly(t *core.T) { + // StorageRegistry Get + ax7Variant := "StorageRegistry_Get:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0, got1 := subject.Get("../../edge", "../../edge", "../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Search_Good(t *core.T) { + // StorageRegistry Search + ax7Variant := "StorageRegistry_Search:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Search("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Search_Bad(t *core.T) { + // StorageRegistry Search + ax7Variant := "StorageRegistry_Search:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Search("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Search_Ugly(t *core.T) { + // StorageRegistry Search + ax7Variant := "StorageRegistry_Search:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Search("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Snapshot_Good(t *core.T) { + // StorageRegistry Snapshot + ax7Variant := "StorageRegistry_Snapshot:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Snapshot("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Snapshot_Bad(t *core.T) { + // StorageRegistry Snapshot + ax7Variant := "StorageRegistry_Snapshot:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Snapshot("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Snapshot_Ugly(t *core.T) { + // StorageRegistry Snapshot + ax7Variant := "StorageRegistry_Snapshot:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Snapshot("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Close_Good(t *core.T) { + // StorageRegistry Close + ax7Variant := "StorageRegistry_Close:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Close() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Close_Bad(t *core.T) { + // StorageRegistry Close + ax7Variant := "StorageRegistry_Close:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Close() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestStorage_StorageRegistry_Close_Ugly(t *core.T) { + // StorageRegistry Close + ax7Variant := "StorageRegistry_Close:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(StorageRegistry) + result := core.Try(func() any { + got0 := subject.Close() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/dock/messages.go b/display/webkit/pkg/dock/messages.go new file mode 100644 index 0000000..4d56908 --- /dev/null +++ b/display/webkit/pkg/dock/messages.go @@ -0,0 +1,55 @@ +// pkg/dock/messages.go +package dock + +// --- Queries (read-only) --- + +// QueryVisible returns whether the dock icon is visible. Result: bool +type QueryVisible struct{} + +// --- Tasks (side-effects) --- + +// TaskShowIcon shows the dock/taskbar icon. Result: nil +type TaskShowIcon struct{} + +// TaskHideIcon hides the dock/taskbar icon. Result: nil +type TaskHideIcon struct{} + +// TaskSetBadge sets the dock/taskbar badge label. +// Empty string "" shows the default system badge indicator. +// Numeric "3", "99" shows unread count. Text "New", "Paused" shows brief status. +// Result: nil +type TaskSetBadge struct{ Label string } + +// TaskRemoveBadge removes the dock/taskbar badge. Result: nil +type TaskRemoveBadge struct{} + +// TaskSetProgressBar updates the progress indicator on the dock/taskbar icon. +// Progress is clamped to [0.0, 1.0]. Pass -1.0 to hide the indicator. +// c.PERFORM(dock.TaskSetProgressBar{Progress: 0.75}) // 75% complete +// c.PERFORM(dock.TaskSetProgressBar{Progress: -1.0}) // hide indicator +// Result: nil +type TaskSetProgressBar struct{ Progress float64 } + +// TaskBounce requests user attention by animating the dock icon. +// Result: int (requestID for use with TaskStopBounce) +// c.PERFORM(dock.TaskBounce{BounceType: dock.BounceInformational}) +type TaskBounce struct{ BounceType BounceType } + +// TaskStopBounce cancels a pending attention request. +// c.PERFORM(dock.TaskStopBounce{RequestID: id}) +// Result: nil +type TaskStopBounce struct{ RequestID int } + +// --- Actions (broadcasts) --- + +// ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon. +type ActionVisibilityChanged struct{ Visible bool } + +// ActionProgressChanged is broadcast after a successful TaskSetProgressBar. +type ActionProgressChanged struct{ Progress float64 } + +// ActionBounceStarted is broadcast after a successful TaskBounce. +type ActionBounceStarted struct { + RequestID int `json:"requestId"` + BounceType BounceType `json:"bounceType"` +} diff --git a/display/webkit/pkg/dock/messages_example_test.go b/display/webkit/pkg/dock/messages_example_test.go new file mode 100644 index 0000000..b7ca893 --- /dev/null +++ b/display/webkit/pkg/dock/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package dock diff --git a/display/webkit/pkg/dock/messages_test.go b/display/webkit/pkg/dock/messages_test.go new file mode 100644 index 0000000..87d1e47 --- /dev/null +++ b/display/webkit/pkg/dock/messages_test.go @@ -0,0 +1 @@ +package dock diff --git a/display/webkit/pkg/dock/platform.go b/display/webkit/pkg/dock/platform.go new file mode 100644 index 0000000..1a64d0a --- /dev/null +++ b/display/webkit/pkg/dock/platform.go @@ -0,0 +1,34 @@ +// pkg/dock/platform.go +package dock + +// BounceType controls how the dock icon attracts attention. +// bounce := dock.BounceInformational — single bounce +// bounce := dock.BounceCritical — continuous bounce until focused +type BounceType int + +const ( + // BounceInformational performs a single bounce to indicate a background event. + BounceInformational BounceType = iota + // BounceCritical bounces continuously until the application becomes active. + BounceCritical +) + +// Platform abstracts the dock/taskbar backend (Wails v3). +// macOS: dock icon show/hide, badge, progress bar, bounce. +// Windows: taskbar badge + progress bar (show/hide and bounce not supported). +// Linux: not supported — adapter returns nil for all operations. +type Platform interface { + ShowIcon() resultFailure + HideIcon() resultFailure + SetBadge(label string) resultFailure + RemoveBadge() resultFailure + IsVisible() bool + // SetProgressBar sets a progress indicator on the dock/taskbar icon. + // progress is clamped to [0.0, 1.0]. Pass -1.0 to hide the indicator. + SetProgressBar(progress float64) resultFailure + // Bounce requests user attention by animating the dock icon. + // Returns a request ID that can be passed to StopBounce. + Bounce(bounceType BounceType) (int, resultFailure) + // StopBounce cancels a pending attention request by its ID. + StopBounce(requestID int) resultFailure +} diff --git a/display/webkit/pkg/dock/platform_example_test.go b/display/webkit/pkg/dock/platform_example_test.go new file mode 100644 index 0000000..b7ca893 --- /dev/null +++ b/display/webkit/pkg/dock/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package dock diff --git a/display/webkit/pkg/dock/platform_test.go b/display/webkit/pkg/dock/platform_test.go new file mode 100644 index 0000000..87d1e47 --- /dev/null +++ b/display/webkit/pkg/dock/platform_test.go @@ -0,0 +1 @@ +package dock diff --git a/display/webkit/pkg/dock/register.go b/display/webkit/pkg/dock/register.go new file mode 100644 index 0000000..38cb4c6 --- /dev/null +++ b/display/webkit/pkg/dock/register.go @@ -0,0 +1,14 @@ +package dock + +import core "dappco.re/go" + +// Register(p) binds the dock service to a Core instance. +// core.WithService(dock.Register(wailsDock)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, OK: true} + } +} diff --git a/display/webkit/pkg/dock/register_example_test.go b/display/webkit/pkg/dock/register_example_test.go new file mode 100644 index 0000000..00939b2 --- /dev/null +++ b/display/webkit/pkg/dock/register_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package dock + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} diff --git a/display/webkit/pkg/dock/register_test.go b/display/webkit/pkg/dock/register_test.go new file mode 100644 index 0000000..1818d7c --- /dev/null +++ b/display/webkit/pkg/dock/register_test.go @@ -0,0 +1,30 @@ +package dock + +import core "dappco.re/go" + +func TestRegister_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + label := "Register:good" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "good") +} + +func TestRegister_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Register:bad" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "bad") +} + +func TestRegister_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Register:ugly" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/dock/result_failure.go b/display/webkit/pkg/dock/result_failure.go new file mode 100644 index 0000000..e4ef6d0 --- /dev/null +++ b/display/webkit/pkg/dock/result_failure.go @@ -0,0 +1,5 @@ +package dock + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/dock/service.go b/display/webkit/pkg/dock/service.go new file mode 100644 index 0000000..7a17e66 --- /dev/null +++ b/display/webkit/pkg/dock/service.go @@ -0,0 +1,83 @@ +package dock + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + s.Core().RegisterQuery(s.handleQuery) + s.Core().Action("dock.show_icon", func(_ context.Context, _ core.Options) core.Result { + if err := s.platform.ShowIcon(); err != nil { + return core.Result{Value: err, OK: false} + } + coreutil.DispatchAction(s.Core(), "dock.show_icon", ActionVisibilityChanged{Visible: true}) + return core.Result{OK: true} + }) + s.Core().Action("dock.hide_icon", func(_ context.Context, _ core.Options) core.Result { + if err := s.platform.HideIcon(); err != nil { + return core.Result{Value: err, OK: false} + } + coreutil.DispatchAction(s.Core(), "dock.hide_icon", ActionVisibilityChanged{Visible: false}) + return core.Result{OK: true} + }) + s.Core().Action("dock.set_badge", func(_ context.Context, opts core.Options) core.Result { + if err := s.platform.SetBadge(opts.String("label")); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + }) + s.Core().Action("dock.remove_badge", func(_ context.Context, _ core.Options) core.Result { + if err := s.platform.RemoveBadge(); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + }) + s.Core().Action("dock.set_progress_bar", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskSetProgressBar) + if err := s.platform.SetProgressBar(t.Progress); err != nil { + return core.Result{Value: err, OK: false} + } + coreutil.DispatchAction(s.Core(), "dock.set_progress_bar", ActionProgressChanged{Progress: t.Progress}) + return core.Result{OK: true} + }) + s.Core().Action("dock.bounce", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskBounce) + requestID, err := s.platform.Bounce(t.BounceType) + if err != nil { + return core.Result{Value: err, OK: false} + } + coreutil.DispatchAction(s.Core(), "dock.bounce", ActionBounceStarted{RequestID: requestID, BounceType: t.BounceType}) + return core.Result{Value: requestID, OK: true} + }) + s.Core().Action("dock.stop_bounce", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskStopBounce) + if err := s.platform.StopBounce(t.RequestID); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + }) + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case QueryVisible: + return core.Result{Value: s.platform.IsVisible(), OK: true} + default: + return core.Result{} + } +} diff --git a/display/webkit/pkg/dock/service_example_test.go b/display/webkit/pkg/dock/service_example_test.go new file mode 100644 index 0000000..cb41d63 --- /dev/null +++ b/display/webkit/pkg/dock/service_example_test.go @@ -0,0 +1,17 @@ +//go:build compliance + +package dock + +import core "dappco.re/go" + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/dock/service_test.go b/display/webkit/pkg/dock/service_test.go new file mode 100644 index 0000000..306fcbf --- /dev/null +++ b/display/webkit/pkg/dock/service_test.go @@ -0,0 +1,444 @@ +// pkg/dock/service_test.go +package dock + +import ( + "context" + + core "dappco.re/go" +) + +// --- Mock Platform --- + +type mockPlatform struct { + visible bool + badge string + hasBadge bool + progress float64 + bounceID int + bounceType BounceType + bounceCalled bool + stopBounceCalled bool + showErr resultFailure + hideErr resultFailure + badgeErr resultFailure + removeErr resultFailure + progressErr resultFailure + bounceErr resultFailure + stopBounceErr resultFailure +} + +func (m *mockPlatform) ShowIcon() resultFailure { + if m.showErr != nil { + return m.showErr + } + m.visible = true + return nil +} + +func (m *mockPlatform) HideIcon() resultFailure { + if m.hideErr != nil { + return m.hideErr + } + m.visible = false + return nil +} + +func (m *mockPlatform) SetBadge(label string) resultFailure { + if m.badgeErr != nil { + return m.badgeErr + } + m.badge = label + m.hasBadge = true + return nil +} + +func (m *mockPlatform) RemoveBadge() resultFailure { + if m.removeErr != nil { + return m.removeErr + } + m.badge = "" + m.hasBadge = false + return nil +} + +func (m *mockPlatform) IsVisible() bool { return m.visible } + +func (m *mockPlatform) SetProgressBar(progress float64) resultFailure { + if m.progressErr != nil { + return m.progressErr + } + m.progress = progress + return nil +} + +func (m *mockPlatform) Bounce(bounceType BounceType) (int, resultFailure) { + if m.bounceErr != nil { + return 0, m.bounceErr + } + m.bounceCalled = true + m.bounceType = bounceType + m.bounceID++ + return m.bounceID, nil +} + +func (m *mockPlatform) StopBounce(requestID int) resultFailure { + if m.stopBounceErr != nil { + return m.stopBounceErr + } + m.stopBounceCalled = true + return nil +} + +// --- Test helpers --- + +func newTestDockService(t *core.T) (*Service, *core.Core, *mockPlatform) { + t.Helper() + mock := &mockPlatform{visible: true} + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "dock") + return svc, c, mock +} + +func taskRun(c *core.Core, name string, task any) core.Result { + return c.Action(name).Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: task}, + )) +} + +func setBadge(c *core.Core, label string) core.Result { + return c.Action("dock.set_badge").Run(context.Background(), core.NewOptions( + core.Option{Key: "label", Value: label}, + )) +} + +// --- Tests --- + +func TestRegister_Good(t *core.T) { + svc, _, _ := newTestDockService(t) + core.AssertNotNil(t, svc) + core.AssertNotEmpty(t, core.Sprintf("%T", svc)) +} + +func TestQueryVisible_Good(t *core.T) { + _, c, _ := newTestDockService(t) + r := c.QUERY(QueryVisible{}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, true, r.Value) +} + +func TestQueryVisible_Bad(t *core.T) { + // No dock service registered — QUERY returns handled=false + c := core.New(core.WithServiceLock()) + r := c.QUERY(QueryVisible{}) + core.AssertFalse(t, r.OK) +} + +func TestTaskShowIcon_Good(t *core.T) { + _, c, mock := newTestDockService(t) + mock.visible = false // Start hidden + + var received *ActionVisibilityChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionVisibilityChanged); ok { + received = &a + } + return core.Result{OK: true} + }) + + r := taskRun(c, "dock.show_icon", TaskShowIcon{}) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.visible) + core.AssertNotNil(t, received) + core.AssertTrue(t, received.Visible) +} + +func TestTaskHideIcon_Good(t *core.T) { + _, c, mock := newTestDockService(t) + mock.visible = true // Start visible + + var received *ActionVisibilityChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionVisibilityChanged); ok { + received = &a + } + return core.Result{OK: true} + }) + + r := taskRun(c, "dock.hide_icon", TaskHideIcon{}) + core.RequireTrue(t, r.OK) + core.AssertFalse(t, mock.visible) + core.AssertNotNil(t, received) + core.AssertFalse(t, received.Visible) +} + +func TestTaskSetBadge_Good(t *core.T) { + _, c, mock := newTestDockService(t) + r := setBadge(c, "3") + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "3", mock.badge) + core.AssertTrue(t, mock.hasBadge) +} + +func TestTaskSetBadge_EmptyLabel_GoodCase(t *core.T) { + _, c, mock := newTestDockService(t) + r := setBadge(c, "") + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "", mock.badge) + core.AssertTrue(t, mock.hasBadge) // Empty string = default system badge indicator +} + +func TestTaskRemoveBadge_Good(t *core.T) { + _, c, mock := newTestDockService(t) + // Set a badge first + _ = setBadge(c, "5") + + r := taskRun(c, "dock.remove_badge", TaskRemoveBadge{}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "", mock.badge) + core.AssertFalse(t, mock.hasBadge) +} + +func TestTaskShowIcon_Bad(t *core.T) { + _, c, mock := newTestDockService(t) + mock.showErr = core.AnError + + r := taskRun(c, "dock.show_icon", TaskShowIcon{}) + core.AssertFalse(t, r.OK) +} + +func TestTaskHideIcon_Bad(t *core.T) { + _, c, mock := newTestDockService(t) + mock.hideErr = core.AnError + + r := taskRun(c, "dock.hide_icon", TaskHideIcon{}) + core.AssertFalse(t, r.OK) +} + +func TestTaskSetBadge_Bad(t *core.T) { + _, c, mock := newTestDockService(t) + mock.badgeErr = core.AnError + + r := setBadge(c, "3") + core.AssertFalse(t, r.OK) +} + +// --- TaskSetProgressBar --- + +func TestTaskSetProgressBar_Good(t *core.T) { + _, c, mock := newTestDockService(t) + + var received *ActionProgressChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionProgressChanged); ok { + received = &a + } + return core.Result{OK: true} + }) + + r := taskRun(c, "dock.set_progress_bar", TaskSetProgressBar{Progress: 0.5}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, 0.5, mock.progress) + core.AssertNotNil(t, received) + core.AssertEqual(t, 0.5, received.Progress) +} + +func TestTaskSetProgressBar_Hide_GoodCase(t *core.T) { + // Progress -1.0 hides the indicator + _, c, mock := newTestDockService(t) + r := taskRun(c, "dock.set_progress_bar", TaskSetProgressBar{Progress: -1.0}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, -1.0, mock.progress) +} + +func TestTaskSetProgressBar_Bad(t *core.T) { + _, c, mock := newTestDockService(t) + mock.progressErr = core.AnError + + r := taskRun(c, "dock.set_progress_bar", TaskSetProgressBar{Progress: 0.5}) + core.AssertFalse(t, r.OK) +} + +func TestTaskSetProgressBar_Ugly(t *core.T) { + // No dock service — action is not registered + c := core.New(core.WithServiceLock()) + r := c.Action("dock.set_progress_bar").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +// --- TaskBounce --- + +func TestTaskBounce_Good(t *core.T) { + _, c, mock := newTestDockService(t) + + var received *ActionBounceStarted + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionBounceStarted); ok { + received = &a + } + return core.Result{OK: true} + }) + + r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceInformational}) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.bounceCalled) + core.AssertEqual(t, BounceInformational, mock.bounceType) + requestID, ok := r.Value.(int) + core.RequireTrue(t, ok) + core.AssertEqual(t, 1, requestID) + core.AssertNotNil(t, received) + core.AssertEqual(t, BounceInformational, received.BounceType) +} + +func TestTaskBounce_Critical_GoodCase(t *core.T) { + _, c, mock := newTestDockService(t) + r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceCritical}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, BounceCritical, mock.bounceType) + requestID, ok := r.Value.(int) + core.RequireTrue(t, ok) + core.AssertEqual(t, 1, requestID) +} + +func TestTaskBounce_Bad(t *core.T) { + _, c, mock := newTestDockService(t) + mock.bounceErr = core.AnError + + r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceInformational}) + core.AssertFalse(t, r.OK) +} + +func TestTaskBounce_Ugly(t *core.T) { + // No dock service — action is not registered + c := core.New(core.WithServiceLock()) + r := c.Action("dock.bounce").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +// --- TaskStopBounce --- + +func TestTaskStopBounce_Good(t *core.T) { + _, c, mock := newTestDockService(t) + + // Start a bounce to get a requestID + r := taskRun(c, "dock.bounce", TaskBounce{BounceType: BounceInformational}) + core.RequireTrue(t, r.OK) + requestID := r.Value.(int) + + r2 := taskRun(c, "dock.stop_bounce", TaskStopBounce{RequestID: requestID}) + core.RequireTrue(t, r2.OK) + core.AssertTrue(t, mock.stopBounceCalled) +} + +func TestTaskStopBounce_Bad(t *core.T) { + _, c, mock := newTestDockService(t) + mock.stopBounceErr = core.AnError + + r := taskRun(c, "dock.stop_bounce", TaskStopBounce{RequestID: 1}) + core.AssertFalse(t, r.OK) +} + +func TestTaskStopBounce_Ugly(t *core.T) { + // No dock service — action is not registered + c := core.New(core.WithServiceLock()) + r := c.Action("dock.stop_bounce").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestTaskRemoveBadge_Bad(t *core.T) { + _, c, mock := newTestDockService(t) + mock.removeErr = core.AnError + + r := taskRun(c, "dock.remove_badge", TaskRemoveBadge{}) + core.AssertFalse(t, r.OK) +} + +func TestQueryVisible_Ugly(t *core.T) { + // Dock icon initially hidden + mock := &mockPlatform{visible: false} + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + r := c.QUERY(QueryVisible{}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, false, r.Value) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/dock/wails.go b/display/webkit/pkg/dock/wails.go new file mode 100644 index 0000000..8b31fc5 --- /dev/null +++ b/display/webkit/pkg/dock/wails.go @@ -0,0 +1,122 @@ +// pkg/dock/wails.go +package dock + +import ( + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" + wailsdock "github.com/wailsapp/wails/v3/pkg/services/dock" +) + +// WailsPlatform implements Platform via Wails v3's dock service. +// +// Wails alpha.83 covers visibility (HideAppIcon/ShowAppIcon) + badge +// (SetBadge/RemoveBadge/GetBadge) on macOS / Windows. Linux's wails +// dock impl is a no-op stub. Wails does NOT expose: +// +// - SetProgressBar — neither macOS NSDockTile progress nor Windows +// ITaskbarList3 progress is wrapped. We accept the call so callers +// don't crash and silently no-op until upstream lands it. +// +// - Bounce / StopBounce — macOS NSApp.requestUserAttention(:) and the +// Windows FlashWindowEx flags are not exposed. Same no-op shape; +// Bounce returns request ID 0 so StopBounce(0) is a valid no-op. +// +// - IsVisible — Wails has no getter; we track our own state across +// ShowIcon/HideIcon and seed visible=true (the default state when +// the app launches in a dock-visible config). +// +// wp := dock.NewWailsPlatform(app) // also calls app.RegisterService +// core.WithService(dock.Register(wp)) +type WailsPlatform struct { + app *application.App + service *wailsdock.DockService + + mu sync.RWMutex + visible bool +} + +// NewWailsPlatform creates the Wails DockService and registers it with +// the App so its Startup hook (NSDockTile setup on macOS, taskbar COM +// init on Windows) runs. nil app makes everything a no-op. +func NewWailsPlatform(app *application.App) *WailsPlatform { + if app == nil { + return &WailsPlatform{visible: true} + } + svc := wailsdock.New() + app.RegisterService(application.NewService(svc)) + return &WailsPlatform{app: app, service: svc, visible: true} +} + +func (wp *WailsPlatform) ShowIcon() resultFailure { + if wp == nil || wp.service == nil { + return nil + } + wp.service.ShowAppIcon() + wp.mu.Lock() + wp.visible = true + wp.mu.Unlock() + return nil +} + +func (wp *WailsPlatform) HideIcon() resultFailure { + if wp == nil || wp.service == nil { + return nil + } + wp.service.HideAppIcon() + wp.mu.Lock() + wp.visible = false + wp.mu.Unlock() + return nil +} + +func (wp *WailsPlatform) SetBadge(label string) resultFailure { + if wp == nil || wp.service == nil { + return nil + } + if err := wp.service.SetBadge(label); err != nil { + return err + } + return nil +} + +func (wp *WailsPlatform) RemoveBadge() resultFailure { + if wp == nil || wp.service == nil { + return nil + } + if err := wp.service.RemoveBadge(); err != nil { + return err + } + return nil +} + +// IsVisible reports our locally-tracked visibility state. Wails has no +// getter; we toggle on each ShowIcon/HideIcon call and seed visible=true. +func (wp *WailsPlatform) IsVisible() bool { + if wp == nil { + return false + } + wp.mu.RLock() + defer wp.mu.RUnlock() + return wp.visible +} + +// SetProgressBar — Wails alpha.83 does not expose dock/taskbar progress; +// no-op. progress is documented as [0.0, 1.0] with -1 to hide; we accept +// the call shape so future upstream support drops in without a Platform +// contract change. +func (wp *WailsPlatform) SetProgressBar(_ float64) resultFailure { + return nil +} + +// Bounce — Wails alpha.83 does not expose macOS requestUserAttention +// or Windows FlashWindowEx; no-op. Returns request ID 0 so a paired +// StopBounce(0) is also a valid no-op. +func (wp *WailsPlatform) Bounce(_ BounceType) (int, resultFailure) { + return 0, nil +} + +// StopBounce — paired no-op for Bounce. +func (wp *WailsPlatform) StopBounce(_ int) resultFailure { + return nil +} diff --git a/display/webkit/pkg/environment/messages.go b/display/webkit/pkg/environment/messages.go new file mode 100644 index 0000000..45b7bc1 --- /dev/null +++ b/display/webkit/pkg/environment/messages.go @@ -0,0 +1,31 @@ +// pkg/environment/messages.go +package environment + +// QueryTheme returns the current theme. Result: ThemeInfo +type QueryTheme struct{} + +// QueryInfo returns environment information. Result: EnvironmentInfo +type QueryInfo struct{} + +// QueryAccentColour returns the system accent colour. Result: string +type QueryAccentColour struct{} + +// TaskOpenFileManager opens the system file manager. Result: error only +type TaskOpenFileManager struct { + Path string `json:"path,omitempty"` + Select bool `json:"select"` +} + +// TaskSetTheme overrides the application theme. +// Theme may be "dark", "light", or "system" to follow the platform again. +type TaskSetTheme struct { + Theme string `json:"theme"` +} + +// QueryFocusFollowsMouse returns whether the platform uses focus-follows-mouse. Result: bool +type QueryFocusFollowsMouse struct{} + +// ActionThemeChanged is broadcast when the system theme changes. +type ActionThemeChanged struct { + IsDark bool `json:"isDark"` +} diff --git a/display/webkit/pkg/environment/messages_example_test.go b/display/webkit/pkg/environment/messages_example_test.go new file mode 100644 index 0000000..0e00de8 --- /dev/null +++ b/display/webkit/pkg/environment/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package environment diff --git a/display/webkit/pkg/environment/messages_test.go b/display/webkit/pkg/environment/messages_test.go new file mode 100644 index 0000000..f2d9ed1 --- /dev/null +++ b/display/webkit/pkg/environment/messages_test.go @@ -0,0 +1 @@ +package environment diff --git a/display/webkit/pkg/environment/platform.go b/display/webkit/pkg/environment/platform.go new file mode 100644 index 0000000..065b07a --- /dev/null +++ b/display/webkit/pkg/environment/platform.go @@ -0,0 +1,32 @@ +// pkg/environment/platform.go +package environment + +// Platform abstracts environment and theme backend queries. +type Platform interface { + IsDarkMode() bool + Info() EnvironmentInfo + AccentColour() string + OpenFileManager(path string, selectFile bool) resultFailure + HasFocusFollowsMouse() bool + OnThemeChange(handler func(isDark bool)) func() // returns cancel func +} + +// EnvironmentInfo contains system environment details. +type EnvironmentInfo struct { + OS string `json:"os,omitempty"` + Arch string `json:"arch"` + Debug bool `json:"debug"` + Platform PlatformInfo `json:"platform"` +} + +// PlatformInfo contains platform-specific details. +type PlatformInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ThemeInfo contains the current theme state. +type ThemeInfo struct { + IsDark bool `json:"isDark"` + Theme string `json:"theme"` // "dark" or "light" +} diff --git a/display/webkit/pkg/environment/platform_example_test.go b/display/webkit/pkg/environment/platform_example_test.go new file mode 100644 index 0000000..0e00de8 --- /dev/null +++ b/display/webkit/pkg/environment/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package environment diff --git a/display/webkit/pkg/environment/platform_test.go b/display/webkit/pkg/environment/platform_test.go new file mode 100644 index 0000000..f2d9ed1 --- /dev/null +++ b/display/webkit/pkg/environment/platform_test.go @@ -0,0 +1 @@ +package environment diff --git a/display/webkit/pkg/environment/result_failure.go b/display/webkit/pkg/environment/result_failure.go new file mode 100644 index 0000000..c1bda8d --- /dev/null +++ b/display/webkit/pkg/environment/result_failure.go @@ -0,0 +1,5 @@ +package environment + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/environment/service.go b/display/webkit/pkg/environment/service.go new file mode 100644 index 0000000..6044121 --- /dev/null +++ b/display/webkit/pkg/environment/service.go @@ -0,0 +1,186 @@ +// pkg/environment/service.go +package environment + +import ( + "context" + "sync" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + cancelTheme func() // returned by Platform.OnThemeChange — called on shutdown + themeMu sync.RWMutex + override string +} + +// Register(p) binds the environment service to a Core instance. +// core.WithService(environment.Register(wailsEnvironment)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, OK: true} + } +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + s.Core().RegisterQuery(s.handleQuery) + s.Core().Action("theme.get", func(_ context.Context, _ core.Options) core.Result { + isDark := s.currentTheme() + return core.Result{Value: ThemeInfo{IsDark: isDark, Theme: themeName(isDark)}, OK: true} + }) + s.Core().Action("theme.system", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.platform.Info(), OK: true} + }) + s.Core().Action("environment.open_file_manager", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskOpenFileManager) + path, err := validatedOpenFileManagerPath(t.Path) + if err != nil { + return core.Result{Value: err, OK: false} + } + if err := s.platform.OpenFileManager(path, t.Select); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + }) + s.Core().Action("theme.set", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskSetTheme) + isDark, err := s.setThemeOverride(t.Theme) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: ThemeInfo{IsDark: isDark, Theme: themeName(isDark)}, OK: true} + }) + s.Core().Action("environment.set_theme", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskSetTheme) + isDark, err := s.setThemeOverride(t.Theme) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: ThemeInfo{IsDark: isDark, Theme: themeName(isDark)}, OK: true} + }) + s.Core().Action("gui.theme.set", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskSetTheme) + isDark, err := s.setThemeOverride(t.Theme) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: ThemeInfo{IsDark: isDark, Theme: themeName(isDark)}, OK: true} + }) + + // Register theme change callback — broadcasts ActionThemeChanged via IPC + s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) { + if s.hasThemeOverride() { + return + } + coreutil.DispatchAction(s.Core(), "environment.themeChanged", ActionThemeChanged{IsDark: isDark}) + }) + return core.Result{OK: true} +} + +func (s *Service) OnShutdown(_ context.Context) core.Result { + if s.cancelTheme != nil { + s.cancelTheme() + } + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case QueryTheme: + isDark := s.currentTheme() + return core.Result{Value: ThemeInfo{IsDark: isDark, Theme: themeName(isDark)}, OK: true} + case QueryInfo: + return core.Result{Value: s.platform.Info(), OK: true} + case QueryAccentColour: + return core.Result{Value: s.platform.AccentColour(), OK: true} + case QueryFocusFollowsMouse: + return core.Result{Value: s.platform.HasFocusFollowsMouse(), OK: true} + default: + return core.Result{} + } +} + +func (s *Service) currentTheme() bool { + s.themeMu.RLock() + override := s.override + s.themeMu.RUnlock() + switch override { + case "dark": + return true + case "light": + return false + default: + return s.platform.IsDarkMode() + } +} + +func (s *Service) hasThemeOverride() bool { + s.themeMu.RLock() + defer s.themeMu.RUnlock() + return s.override != "" +} + +func (s *Service) setThemeOverride(theme string) (bool, resultFailure) { + normalized, err := normalizeTheme(theme) + if err != nil { + return false, err + } + before := s.currentTheme() + + s.themeMu.Lock() + s.override = normalized + s.themeMu.Unlock() + + after := s.currentTheme() + if before != after { + coreutil.DispatchAction(s.Core(), "environment.setThemeOverride", ActionThemeChanged{IsDark: after}) + } + return after, nil +} + +func normalizeTheme(theme string) (string, resultFailure) { + switch core.Lower(core.Trim(theme)) { + case "", "system": + return "", nil + case "dark": + return "dark", nil + case "light": + return "light", nil + default: + return "", core.E("environment.normalizeTheme", "invalid theme: "+theme, nil) + } +} + +func validatedOpenFileManagerPath(raw string) (string, resultFailure) { + trimmed := core.Trim(raw) + if trimmed == "" { + return "", core.E("environment.open_file_manager", "path is required", nil) + } + if core.Contains(trimmed, "\x00") { + return "", core.E("environment.open_file_manager", "path contains a null byte", nil) + } + cleaned := core.CleanPath(trimmed, string(core.PathSeparator)) + if !core.PathIsAbs(cleaned) { + return "", core.E("environment.open_file_manager", "path must be absolute", nil) + } + return cleaned, nil +} + +func themeName(isDark bool) string { + if isDark { + return "dark" + } + return "light" +} diff --git a/display/webkit/pkg/environment/service_behaviour_test.go b/display/webkit/pkg/environment/service_behaviour_test.go new file mode 100644 index 0000000..bb33b98 --- /dev/null +++ b/display/webkit/pkg/environment/service_behaviour_test.go @@ -0,0 +1,54 @@ +// pkg/environment/service_behaviour_test.go +package environment + +import core "dappco.re/go" + +// normalizeTheme accepts "", "system", "dark", "light" and rejects anything +// else. +// +// normalizeTheme("Dark") // "dark", nil +func TestServiceBehaviour_normalizeTheme_Good(t *core.T) { + for input, want := range map[string]string{ + "": "", + "system": "", + " DARK ": "dark", + "Light": "light", + } { + got, err := normalizeTheme(input) + core.AssertNil(t, err) + core.AssertEqual(t, want, got) + } +} + +// normalizeTheme rejects an unrecognised theme with a descriptive error. +func TestServiceBehaviour_normalizeTheme_Bad(t *core.T) { + _, err := normalizeTheme("solarized") + core.AssertNotNil(t, err) + core.AssertContains(t, err.Error(), "invalid theme") +} + +// themeName maps the boolean dark flag to its label. +func TestServiceBehaviour_themeName(t *core.T) { + core.AssertEqual(t, "dark", themeName(true)) + core.AssertEqual(t, "light", themeName(false)) +} + +// validatedOpenFileManagerPath requires a non-empty, null-byte-free, absolute +// path. +func TestServiceBehaviour_validatedOpenFileManagerPath(t *core.T) { + cleaned, err := validatedOpenFileManagerPath("/tmp/../tmp/dir") + core.AssertNil(t, err) + core.AssertTrue(t, core.PathIsAbs(cleaned)) + + _, err = validatedOpenFileManagerPath(" ") + core.AssertNotNil(t, err) + core.AssertContains(t, err.Error(), "required") + + _, err = validatedOpenFileManagerPath("/tmp/with\x00null") + core.AssertNotNil(t, err) + core.AssertContains(t, err.Error(), "null byte") + + _, err = validatedOpenFileManagerPath("relative/path") + core.AssertNotNil(t, err) + core.AssertContains(t, err.Error(), "absolute") +} diff --git a/display/webkit/pkg/environment/service_example_test.go b/display/webkit/pkg/environment/service_example_test.go new file mode 100644 index 0000000..6d76b44 --- /dev/null +++ b/display/webkit/pkg/environment/service_example_test.go @@ -0,0 +1,29 @@ +//go:build compliance + +package environment + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_OnShutdown() { + core.Println("Service_OnShutdown") + // Output: + // Service_OnShutdown +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/environment/service_test.go b/display/webkit/pkg/environment/service_test.go new file mode 100644 index 0000000..9181fc0 --- /dev/null +++ b/display/webkit/pkg/environment/service_test.go @@ -0,0 +1,400 @@ +// pkg/environment/service_test.go +package environment + +import ( + "context" + "sync" + + core "dappco.re/go" +) + +type mockPlatform struct { + isDark bool + info EnvironmentInfo + accentColour string + openFMErr resultFailure + openFMPath string + openFMSelect bool + focusFollowsMouse bool + themeHandler func(isDark bool) + mu sync.Mutex +} + +func (m *mockPlatform) IsDarkMode() bool { return m.isDark } +func (m *mockPlatform) Info() EnvironmentInfo { return m.info } +func (m *mockPlatform) AccentColour() string { return m.accentColour } +func (m *mockPlatform) HasFocusFollowsMouse() bool { return m.focusFollowsMouse } +func (m *mockPlatform) OpenFileManager(path string, selectFile bool) resultFailure { + m.openFMPath = path + m.openFMSelect = selectFile + return m.openFMErr +} +func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() { + m.mu.Lock() + m.themeHandler = handler + m.mu.Unlock() + return func() { + m.mu.Lock() + m.themeHandler = nil + m.mu.Unlock() + } +} + +// simulateThemeChange triggers the stored handler (test helper). +func (m *mockPlatform) simulateThemeChange(isDark bool) { + m.mu.Lock() + h := m.themeHandler + m.mu.Unlock() + if h != nil { + h(isDark) + } +} + +func newTestService(t *core.T) (*mockPlatform, *core.Core) { + t.Helper() + mock := &mockPlatform{ + isDark: true, + accentColour: "rgb(0,122,255)", + info: EnvironmentInfo{ + OS: "darwin", Arch: "arm64", + Platform: PlatformInfo{Name: "macOS", Version: "14.0"}, + }, + } + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + return mock, c +} + +func TestRegister_Good(t *core.T) { + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "environment") + core.AssertNotNil(t, svc) +} + +func TestQueryTheme_Good(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryTheme{}) + core.RequireTrue(t, r.OK) + theme := r.Value.(ThemeInfo) + core.AssertTrue(t, theme.IsDark) + core.AssertEqual(t, "dark", theme.Theme) +} + +func TestQueryInfo_Good(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryInfo{}) + core.RequireTrue(t, r.OK) + info := r.Value.(EnvironmentInfo) + core.AssertEqual(t, "darwin", info.OS) + core.AssertEqual(t, "arm64", info.Arch) +} + +func TestQueryAccentColour_Good(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryAccentColour{}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "rgb(0,122,255)", r.Value) +} + +func TestTaskOpenFileManager_Good(t *core.T) { + mock, c := newTestService(t) + r := c.Action("environment.open_file_manager").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: TaskOpenFileManager{Path: "/tmp", Select: true}}, + )) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, core.CleanPath("/tmp", string(core.PathSeparator)), mock.openFMPath) + core.AssertTrue(t, mock.openFMSelect) +} + +func TestTaskOpenFileManager_Bad_InvalidPath(t *core.T) { + _, c := newTestService(t) + r := c.Action("environment.open_file_manager").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: TaskOpenFileManager{Path: "../tmp", Select: false}}, + )) + core.AssertFalse(t, r.OK) + core.AssertContains(t, r.Value.(resultFailure).Error(), "path must be absolute") +} + +func TestThemeChange_ActionBroadcast_GoodCase(t *core.T) { + mock, c := newTestService(t) + + // Register a listener that captures the action + var received *ActionThemeChanged + var mu sync.Mutex + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionThemeChanged); ok { + mu.Lock() + received = &a + mu.Unlock() + } + return core.Result{OK: true} + }) + + // Simulate theme change + mock.simulateThemeChange(false) + + mu.Lock() + r := received + mu.Unlock() + core.AssertNotNil(t, r) + core.AssertFalse(t, r.IsDark) +} + +func TestTaskSetTheme_Good_OverrideAndReset(t *core.T) { + mock, c := newTestService(t) + mock.isDark = false + + r := c.Action("environment.set_theme").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: TaskSetTheme{Theme: "dark"}}, + )) + core.RequireTrue(t, r.OK) + + theme := c.QUERY(QueryTheme{}) + core.RequireTrue(t, theme.OK) + info := theme.Value.(ThemeInfo) + core.AssertTrue(t, info.IsDark) + core.AssertEqual(t, "dark", info.Theme) + + r = c.Action("environment.set_theme").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: TaskSetTheme{Theme: "system"}}, + )) + core.RequireTrue(t, r.OK) + + theme = c.QUERY(QueryTheme{}) + core.RequireTrue(t, theme.OK) + info = theme.Value.(ThemeInfo) + core.AssertFalse(t, info.IsDark) + core.AssertEqual(t, "light", info.Theme) +} + +func TestTaskSetTheme_Bad_Invalid(t *core.T) { + _, c := newTestService(t) + r := c.Action("environment.set_theme").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: TaskSetTheme{Theme: "sepia"}}, + )) + core.AssertFalse(t, r.OK) +} + +// --- GetAccentColor --- + +func TestQueryAccentColour_Bad_Empty(t *core.T) { + // accent colour := "" — still returns handled with empty string + mock := &mockPlatform{ + isDark: false, + accentColour: "", + info: EnvironmentInfo{OS: "linux", Arch: "amd64"}, + } + c := core.New(core.WithService(Register(mock)), core.WithServiceLock()) + core.RequireTrue(t, c.ServiceStartup(t.Context(), nil).OK) + + r := c.QUERY(QueryAccentColour{}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "", r.Value) +} + +func TestQueryAccentColour_Ugly_NoService(t *core.T) { + // No environment service — query is unhandled + c := core.New(core.WithServiceLock()) + r := c.QUERY(QueryAccentColour{}) + core.AssertFalse(t, r.OK) +} + +// --- OpenFileManager --- + +func TestTaskOpenFileManager_Bad_Error(t *core.T) { + // platform returns an resultFailure on open + openErr := core.E("test", "file manager unavailable", nil) + mock := &mockPlatform{openFMErr: openErr} + c := core.New(core.WithService(Register(mock)), core.WithServiceLock()) + core.RequireTrue(t, c.ServiceStartup(t.Context(), nil).OK) + + r := c.Action("environment.open_file_manager").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: TaskOpenFileManager{Path: "/missing", Select: false}}, + )) + core.AssertFalse(t, r.OK) + err, _ := r.Value.(resultFailure) + core.AssertErrorIs(t, err, openErr) +} + +func TestTaskOpenFileManager_Ugly_NoService(t *core.T) { + // No environment service — action is not registered + c := core.New(core.WithServiceLock()) + r := c.Action("environment.open_file_manager").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +// --- HasFocusFollowsMouse --- + +func TestQueryFocusFollowsMouse_Good_True(t *core.T) { + // platform reports focus-follows-mouse enabled (Linux/X11 sloppy focus) + mock := &mockPlatform{focusFollowsMouse: true} + c := core.New(core.WithService(Register(mock)), core.WithServiceLock()) + core.RequireTrue(t, c.ServiceStartup(t.Context(), nil).OK) + + r := c.QUERY(QueryFocusFollowsMouse{}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, true, r.Value) +} + +func TestQueryFocusFollowsMouse_Bad_False(t *core.T) { + // platform reports focus-follows-mouse disabled (Windows/macOS default) + mock := &mockPlatform{focusFollowsMouse: false} + c := core.New(core.WithService(Register(mock)), core.WithServiceLock()) + core.RequireTrue(t, c.ServiceStartup(t.Context(), nil).OK) + + r := c.QUERY(QueryFocusFollowsMouse{}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, false, r.Value) +} + +func TestQueryFocusFollowsMouse_Ugly_NoService(t *core.T) { + // No environment service — query is unhandled + c := core.New(core.WithServiceLock()) + r := c.QUERY(QueryFocusFollowsMouse{}) + core.AssertFalse(t, r.OK) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Good(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Bad(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Ugly(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/environment/wails.go b/display/webkit/pkg/environment/wails.go new file mode 100644 index 0000000..a0bcbcc --- /dev/null +++ b/display/webkit/pkg/environment/wails.go @@ -0,0 +1,109 @@ +// pkg/environment/wails.go +package environment + +import ( + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// WailsPlatform implements Platform via Wails v3's EnvironmentManager +// + the ThemeChanged application event. +// +// Wails surfaces: +// +// - app.Env.Info() → OS / Arch / Debug / OSInfo / PlatformInfo +// +// - app.Env.IsDarkMode() +// +// - app.Env.GetAccentColor() → "rgb(r,g,b)" string +// +// - app.Env.OpenFileManager(path, selectFile) +// +// - app.Env.HasFocusFollowsMouse() (Linux only — Wails returns false elsewhere) +// +// - app.Event.OnApplicationEvent(events.Common.ThemeChanged, …) +// +// wp := environment.NewWailsPlatform(app) +// core.WithService(environment.Register(wp)) +type WailsPlatform struct { + app *application.App +} + +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +func (wp *WailsPlatform) IsDarkMode() bool { + if wp == nil || wp.app == nil || wp.app.Env == nil { + return false + } + return wp.app.Env.IsDarkMode() +} + +// Info maps Wails's EnvironmentInfo (which carries pointer-typed OSInfo +// + map-typed PlatformInfo) onto our flatter cross-platform shape. +// PlatformInfo is reduced to {Name, Version} — the gui Platform contract +// is intentionally narrower so the consumer can serialise it as JSON +// without leaking platform-specific keys. +func (wp *WailsPlatform) Info() EnvironmentInfo { + if wp == nil || wp.app == nil || wp.app.Env == nil { + return EnvironmentInfo{} + } + wInfo := wp.app.Env.Info() + out := EnvironmentInfo{ + OS: wInfo.OS, + Arch: wInfo.Arch, + Debug: wInfo.Debug, + } + if wInfo.OSInfo != nil { + out.Platform = PlatformInfo{ + Name: wInfo.OSInfo.Name, + Version: wInfo.OSInfo.Version, + } + } + return out +} + +func (wp *WailsPlatform) AccentColour() string { + if wp == nil || wp.app == nil || wp.app.Env == nil { + return "" + } + return wp.app.Env.GetAccentColor() +} + +func (wp *WailsPlatform) OpenFileManager(path string, selectFile bool) resultFailure { + if wp == nil || wp.app == nil || wp.app.Env == nil { + return nil + } + if err := wp.app.Env.OpenFileManager(path, selectFile); err != nil { + return err + } + return nil +} + +func (wp *WailsPlatform) HasFocusFollowsMouse() bool { + if wp == nil || wp.app == nil || wp.app.Env == nil { + return false + } + return wp.app.Env.HasFocusFollowsMouse() +} + +// OnThemeChange registers a handler for the cross-platform ThemeChanged +// event. The event Context exposes IsDarkMode(); we forward that value +// to the handler. Returns the cancel func from Wails OnApplicationEvent. +func (wp *WailsPlatform) OnThemeChange(handler func(isDark bool)) func() { + if wp == nil || wp.app == nil || wp.app.Event == nil || handler == nil { + return func() {} + } + return wp.app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(evt *application.ApplicationEvent) { + if evt == nil { + return + } + ctx := evt.Context() + if ctx == nil { + handler(wp.IsDarkMode()) + return + } + handler(ctx.IsDarkMode()) + }) +} diff --git a/display/webkit/pkg/events/messages.go b/display/webkit/pkg/events/messages.go new file mode 100644 index 0000000..99a3e3e --- /dev/null +++ b/display/webkit/pkg/events/messages.go @@ -0,0 +1,66 @@ +// pkg/events/messages.go +package events + +// All IPC message types for the events service. +// Tasks mutate event state; Queries read it; Actions broadcast fired events. + +// TaskEmit fires a named custom event with optional data to all registered listeners. +// Result: bool (true if the event was cancelled by a listener) +// +// c.PERFORM(events.TaskEmit{Name: "user:login", Data: userPayload}) +type TaskEmit struct { + Name string `json:"name"` + Data any `json:"data,omitempty"` +} + +// TaskOn registers a persistent listener for the named custom event via IPC. +// The listener fires an ActionEventFired action for each matching event. +// Result: nil (side-effect only; use Off/Reset to remove) +// +// c.PERFORM(events.TaskOn{Name: "theme:changed"}) +type TaskOn struct { + Name string `json:"name"` +} + +// TaskOff removes all listeners for the named custom event. +// Result: nil +// +// c.PERFORM(events.TaskOff{Name: "theme:changed"}) +type TaskOff struct { + Name string `json:"name"` +} + +// QueryListeners returns a snapshot of all registered listener counts per event name. +// Result: []ListenerInfo +// +// result, _, _ := c.QUERY(events.QueryListeners{}) +// for _, info := range result.([]events.ListenerInfo) { ... } +type QueryListeners struct{} + +// QueryServerInfo returns a snapshot of the WebSocket event server. +// +// info := c.QUERY(events.QueryServerInfo{}).Value.(events.ServerInfo) +type QueryServerInfo struct{} + +// ServerInfo describes the live WebSocket event server state. +// +// info := events.ServerInfo{ConnectedClients: 2, SubscriptionCount: 5} +type ServerInfo struct { + ConnectedClients int `json:"connectedClients"` + SubscriptionCount int `json:"subscriptionCount"` + BufferLength int `json:"bufferLength"` + BufferCapacity int `json:"bufferCapacity"` +} + +// ActionEventFired is broadcast when a registered IPC listener receives an event. +// Consumers subscribe via c.RegisterAction to react to platform events. +// +// c.RegisterAction(func(_ *core.Core, msg core.Message) error { +// if fired, ok := msg.(events.ActionEventFired); ok { +// handleEvent(fired.Event) +// } +// return nil +// }) +type ActionEventFired struct { + Event CustomEvent `json:"event"` +} diff --git a/display/webkit/pkg/events/messages_example_test.go b/display/webkit/pkg/events/messages_example_test.go new file mode 100644 index 0000000..55ad155 --- /dev/null +++ b/display/webkit/pkg/events/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package events diff --git a/display/webkit/pkg/events/messages_test.go b/display/webkit/pkg/events/messages_test.go new file mode 100644 index 0000000..b3adf69 --- /dev/null +++ b/display/webkit/pkg/events/messages_test.go @@ -0,0 +1 @@ +package events diff --git a/display/webkit/pkg/events/platform.go b/display/webkit/pkg/events/platform.go new file mode 100644 index 0000000..77730c2 --- /dev/null +++ b/display/webkit/pkg/events/platform.go @@ -0,0 +1,34 @@ +// pkg/events/platform.go +package events + +// Platform abstracts the Wails EventManager for custom events. +// +// platform.Emit("user:login", userPayload) +// cancel := platform.On("theme:changed", func(e *CustomEvent) { applyTheme(e) }) +// defer cancel() +type Platform interface { + Emit(name string, data ...any) bool + On(name string, callback func(*CustomEvent)) func() + Off(name string) + OnMultiple(name string, callback func(*CustomEvent), counter int) + Reset() +} + +// CustomEvent is a named event carrying arbitrary data, mirroring the Wails type. +// +// platform.On("file:saved", func(e *CustomEvent) { +// path := e.Data.(string) +// }) +type CustomEvent struct { + Name string `json:"name"` + Data any `json:"data"` + Sender string `json:"sender,omitempty"` +} + +// ListenerInfo describes a registered listener for QueryListeners results. +// +// info := ListenerInfo{EventName: "user:login", Count: 3} +type ListenerInfo struct { + EventName string `json:"eventName"` + Count int `json:"count"` +} diff --git a/display/webkit/pkg/events/platform_example_test.go b/display/webkit/pkg/events/platform_example_test.go new file mode 100644 index 0000000..55ad155 --- /dev/null +++ b/display/webkit/pkg/events/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package events diff --git a/display/webkit/pkg/events/platform_test.go b/display/webkit/pkg/events/platform_test.go new file mode 100644 index 0000000..b3adf69 --- /dev/null +++ b/display/webkit/pkg/events/platform_test.go @@ -0,0 +1 @@ +package events diff --git a/display/webkit/pkg/events/register.go b/display/webkit/pkg/events/register.go new file mode 100644 index 0000000..b595596 --- /dev/null +++ b/display/webkit/pkg/events/register.go @@ -0,0 +1,18 @@ +// pkg/events/register.go +package events + +import core "dappco.re/go" + +// Register binds the events service to a Core instance. +// +// core.WithService(events.Register(wailsEventPlatform)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + listeners: make(map[string][]func()), + counts: make(map[string]int), + }, OK: true} + } +} diff --git a/display/webkit/pkg/events/register_example_test.go b/display/webkit/pkg/events/register_example_test.go new file mode 100644 index 0000000..633b51c --- /dev/null +++ b/display/webkit/pkg/events/register_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package events + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} diff --git a/display/webkit/pkg/events/register_test.go b/display/webkit/pkg/events/register_test.go new file mode 100644 index 0000000..1a100fd --- /dev/null +++ b/display/webkit/pkg/events/register_test.go @@ -0,0 +1,30 @@ +package events + +import core "dappco.re/go" + +func TestRegister_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + label := "Register:good" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "good") +} + +func TestRegister_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Register:bad" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "bad") +} + +func TestRegister_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Register:ugly" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/events/result_failure.go b/display/webkit/pkg/events/result_failure.go new file mode 100644 index 0000000..0818888 --- /dev/null +++ b/display/webkit/pkg/events/result_failure.go @@ -0,0 +1,5 @@ +package events + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/events/service.go b/display/webkit/pkg/events/service.go new file mode 100644 index 0000000..27d7b88 --- /dev/null +++ b/display/webkit/pkg/events/service.go @@ -0,0 +1,157 @@ +// pkg/events/service.go +package events + +import ( + "context" + "sort" + "sync" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" +) + +// Options holds configuration for the events service (currently empty). +type Options struct{} + +// Service bridges Wails custom events into Core IPC. +// Emit/On/Off/OnMultiple/Reset are available as Tasks; QueryListeners reads state. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + + mu sync.Mutex + listeners map[string][]func() // IPC-registered cancels per event name + counts map[string]int // listener counts per event name +} + +// OnStartup registers query and action handlers. +func (s *Service) OnStartup(_ context.Context) core.Result { + s.ensureState() + s.Core().RegisterQuery(s.handleQuery) + s.Core().Action("events.emit", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskEmit) + if err := validateEventName("events.emit", t.Name); err != nil { + return core.Result{Value: err, OK: false} + } + if err := s.requirePlatform("events.emit"); err != nil { + return core.Result{Value: err, OK: false} + } + cancelled := s.platform.Emit(t.Name, t.Data) + return core.Result{Value: cancelled, OK: true} + }) + s.Core().Action("events.on", func(ctx context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskOn) + if err := validateEventName("events.on", t.Name); err != nil { + return core.Result{Value: err, OK: false} + } + if err := s.requirePlatform("events.on"); err != nil { + return core.Result{Value: err, OK: false} + } + cancel := s.platform.On(t.Name, func(event *CustomEvent) { + if event == nil { + return + } + coreutil.DispatchAction(s.Core(), "events.on", ActionEventFired{Event: *event}) + }) + s.mu.Lock() + if cancel != nil { + s.listeners[t.Name] = append(s.listeners[t.Name], cancel) + } + s.counts[t.Name]++ + s.mu.Unlock() + return core.Result{OK: true} + }) + s.Core().Action("events.off", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskOff) + if err := validateEventName("events.off", t.Name); err != nil { + return core.Result{Value: err, OK: false} + } + if err := s.requirePlatform("events.off"); err != nil { + return core.Result{Value: err, OK: false} + } + s.platform.Off(t.Name) + s.mu.Lock() + for _, cancel := range s.listeners[t.Name] { + cancel() + } + delete(s.listeners, t.Name) + delete(s.counts, t.Name) + s.mu.Unlock() + return core.Result{OK: true} + }) + return core.Result{OK: true} +} + +func (s *Service) requirePlatform(method string) resultFailure { + if s == nil || s.platform == nil { + return core.E(method, "event platform unavailable", nil) + } + return nil +} + +func validateEventName(method, name string) resultFailure { + if core.Trim(name) == "" { + return core.E(method, "event name must not be empty", nil) + } + return nil +} + +func (s *Service) ensureState() { + s.mu.Lock() + if s.listeners == nil { + s.listeners = make(map[string][]func()) + } + if s.counts == nil { + s.counts = make(map[string]int) + } + s.mu.Unlock() +} + +// OnShutdown cancels all IPC-registered platform listeners. +func (s *Service) OnShutdown(_ context.Context) core.Result { + s.mu.Lock() + defer s.mu.Unlock() + for _, cancels := range s.listeners { + for _, cancel := range cancels { + if cancel != nil { + cancel() + } + } + } + s.listeners = make(map[string][]func()) + s.counts = make(map[string]int) + return core.Result{OK: true} +} + +// HandleIPCEvents satisfies the core.Service interface (no-op for now). +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case QueryListeners: + return core.Result{Value: s.listenerSnapshot(), OK: true} + default: + return core.Result{} + } +} + +// listenerSnapshot returns a sorted slice of ListenerInfo for all known event names. +// +// snapshot := s.listenerSnapshot() +// for _, info := range snapshot { log(info.EventName, info.Count) } +func (s *Service) listenerSnapshot() []ListenerInfo { + s.mu.Lock() + snapshot := make([]ListenerInfo, 0, len(s.counts)) + for name, count := range s.counts { + snapshot = append(snapshot, ListenerInfo{EventName: name, Count: count}) + } + s.mu.Unlock() + + sort.Slice(snapshot, func(i, j int) bool { + return snapshot[i].EventName < snapshot[j].EventName + }) + + return snapshot +} diff --git a/display/webkit/pkg/events/service_example_test.go b/display/webkit/pkg/events/service_example_test.go new file mode 100644 index 0000000..f8637d6 --- /dev/null +++ b/display/webkit/pkg/events/service_example_test.go @@ -0,0 +1,23 @@ +//go:build compliance + +package events + +import core "dappco.re/go" + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_OnShutdown() { + core.Println("Service_OnShutdown") + // Output: + // Service_OnShutdown +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/events/service_test.go b/display/webkit/pkg/events/service_test.go new file mode 100644 index 0000000..1c8270c --- /dev/null +++ b/display/webkit/pkg/events/service_test.go @@ -0,0 +1,557 @@ +// pkg/events/service_test.go +package events + +import ( + "context" + "sync" + + core "dappco.re/go" +) + +// --- Mock Platform --- + +type mockPlatform struct { + mu sync.Mutex + listeners map[string][]*mockListener + emitted []CustomEvent + resetCalled bool + nilCancel bool +} + +type mockListener struct { + callback func(*CustomEvent) + counter int // -1 = persistent +} + +func newMockPlatform() *mockPlatform { + return &mockPlatform{ + listeners: make(map[string][]*mockListener), + } +} + +func (m *mockPlatform) Emit(name string, data ...any) bool { + event := &CustomEvent{Name: name} + if len(data) == 1 { + event.Data = data[0] + } else if len(data) > 1 { + event.Data = data + } + + m.mu.Lock() + m.emitted = append(m.emitted, *event) + active := make([]*mockListener, len(m.listeners[name])) + copy(active, m.listeners[name]) + m.mu.Unlock() + + for _, listener := range active { + listener.callback(event) + } + return false +} + +func (m *mockPlatform) On(name string, callback func(*CustomEvent)) func() { + listener := &mockListener{callback: callback, counter: -1} + m.mu.Lock() + m.listeners[name] = append(m.listeners[name], listener) + m.mu.Unlock() + if m.nilCancel { + return nil + } + return func() { + m.mu.Lock() + defer m.mu.Unlock() + updated := m.listeners[name][:0] + for _, existing := range m.listeners[name] { + if existing != listener { + updated = append(updated, existing) + } + } + m.listeners[name] = updated + } +} + +func (m *mockPlatform) Off(name string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.listeners, name) +} + +func (m *mockPlatform) OnMultiple(name string, callback func(*CustomEvent), counter int) { + m.mu.Lock() + defer m.mu.Unlock() + m.listeners[name] = append(m.listeners[name], &mockListener{callback: callback, counter: counter}) +} + +func (m *mockPlatform) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.listeners = make(map[string][]*mockListener) + m.resetCalled = true +} + +// simulateEvent fires all registered listeners for the given event name with optional data. +func (m *mockPlatform) simulateEvent(name string, data any) { + event := &CustomEvent{Name: name, Data: data} + m.mu.Lock() + active := make([]*mockListener, len(m.listeners[name])) + copy(active, m.listeners[name]) + m.mu.Unlock() + for _, listener := range active { + listener.callback(event) + } +} + +// listenerCount returns the total number of registered listeners across all event names. +func (m *mockPlatform) listenerCount() int { + m.mu.Lock() + defer m.mu.Unlock() + total := 0 + for _, listeners := range m.listeners { + total += len(listeners) + } + return total +} + +// --- Test helpers --- + +func newTestService(t *core.T) (*Service, *core.Core, *mockPlatform) { + t.Helper() + mock := newMockPlatform() + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "events") + return svc, c, mock +} + +func taskRun(c *core.Core, name string, task any) core.Result { + return c.Action(name).Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: task}, + )) +} + +// --- Good path tests --- + +func TestRegister_Good(t *core.T) { + svc, _, _ := newTestService(t) + core.AssertNotNil(t, svc) + core.AssertNotEmpty(t, core.Sprintf("%T", svc)) +} + +func TestTaskEmit_Good(t *core.T) { + _, c, mock := newTestService(t) + + r := taskRun(c, "events.emit", TaskEmit{Name: "user:login", Data: "alice"}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, false, r.Value) // not cancelled + + core.AssertLen(t, mock.emitted, 1) + core.AssertEqual(t, "user:login", mock.emitted[0].Name) + core.AssertEqual(t, "alice", mock.emitted[0].Data) +} + +func TestTaskEmit_NoData_GoodCase(t *core.T) { + _, c, mock := newTestService(t) + + r := taskRun(c, "events.emit", TaskEmit{Name: "ping"}) + core.RequireTrue(t, r.OK) + core.AssertLen(t, mock.emitted, 1) + core.AssertNil(t, mock.emitted[0].Data) +} + +func TestTaskOn_Good(t *core.T) { + _, c, mock := newTestService(t) + + var received []ActionEventFired + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if fired, ok := msg.(ActionEventFired); ok { + received = append(received, fired) + } + return core.Result{OK: true} + }) + + r := taskRun(c, "events.on", TaskOn{Name: "theme:changed"}) + core.RequireTrue(t, r.OK) + + mock.simulateEvent("theme:changed", "dark") + + core.AssertLen(t, received, 1) + core.AssertEqual(t, "theme:changed", received[0].Event.Name) + core.AssertEqual(t, "dark", received[0].Event.Data) +} + +func TestTaskOn_NilEvent_Ignores(t *core.T) { + _, c, mock := newTestService(t) + + var received []ActionEventFired + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if fired, ok := msg.(ActionEventFired); ok { + received = append(received, fired) + } + return core.Result{OK: true} + }) + + r := taskRun(c, "events.on", TaskOn{Name: "theme:changed"}) + core.RequireTrue(t, r.OK) + + core.RequireNotEmpty(t, mock.listeners["theme:changed"]) + core.AssertNotPanics(t, func() { + mock.listeners["theme:changed"][0].callback(nil) + }) + + core.AssertEmpty(t, received) +} + +func TestTaskOff_Good(t *core.T) { + _, c, mock := newTestService(t) + + // Register via IPC then remove + r := taskRun(c, "events.on", TaskOn{Name: "file:saved"}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, 1, mock.listenerCount()) + + r2 := taskRun(c, "events.off", TaskOff{Name: "file:saved"}) + core.RequireTrue(t, r2.OK) + core.AssertEqual(t, 0, mock.listenerCount()) +} + +func TestQueryListeners_Good(t *core.T) { + _, c, _ := newTestService(t) + + core.RequireTrue(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK) + core.RequireTrue(t, taskRun(c, "events.on", TaskOn{Name: "user:login"}).OK) + core.RequireTrue(t, taskRun(c, "events.on", TaskOn{Name: "theme:changed"}).OK) + + r := c.QUERY(QueryListeners{}) + core.RequireTrue(t, r.OK) + + infos := r.Value.([]ListenerInfo) + counts := make(map[string]int) + for _, info := range infos { + counts[info.EventName] = info.Count + } + core.AssertEqual(t, 2, counts["user:login"]) + core.AssertEqual(t, 1, counts["theme:changed"]) +} + +func TestQueryListeners_Empty_GoodCase(t *core.T) { + _, c, _ := newTestService(t) + + r := c.QUERY(QueryListeners{}) + core.RequireTrue(t, r.OK) + + infos := r.Value.([]ListenerInfo) + core.AssertEmpty(t, infos) +} + +func TestOnShutdown_CancelsAll_GoodCase(t *core.T) { + svc, _, mock := newTestService(t) + + core.RequireTrue(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "a:b"}).OK) + core.RequireTrue(t, taskRun(svc.Core(), "events.on", TaskOn{Name: "c:d"}).OK) + core.AssertEqual(t, 2, mock.listenerCount()) + + core.RequireTrue(t, svc.OnShutdown(context.Background()).OK) + core.AssertEqual(t, 0, mock.listenerCount()) +} + +func TestOnShutdown_IgnoresNilCancels_GoodCase(t *core.T) { + mock := newMockPlatform() + mock.nilCancel = true + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "events") + + core.RequireTrue(t, taskRun(c, "events.on", TaskOn{Name: "a:b"}).OK) + core.AssertEqual(t, 1, mock.listenerCount()) + + core.AssertNotPanics(t, func() { + core.AssertTrue(t, svc.OnShutdown(context.Background()).OK) + }) +} + +func TestActionEventFired_BroadcastOnSimulate_GoodCase(t *core.T) { + _, c, mock := newTestService(t) + + var receivedEvents []CustomEvent + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if fired, ok := msg.(ActionEventFired); ok { + receivedEvents = append(receivedEvents, fired.Event) + } + return core.Result{OK: true} + }) + + core.RequireTrue(t, taskRun(c, "events.on", TaskOn{Name: "data:ready"}).OK) + + mock.simulateEvent("data:ready", map[string]any{"rows": 42}) + + core.AssertLen(t, receivedEvents, 1) + core.AssertEqual(t, "data:ready", receivedEvents[0].Name) +} + +// --- Bad path tests --- + +func TestTaskOn_EmptyName_BadCase(t *core.T) { + _, c, _ := newTestService(t) + + r := taskRun(c, "events.on", TaskOn{Name: ""}) + core.AssertFalse(t, r.OK) +} + +func TestTaskEmit_UnknownEvent_BadCase(t *core.T) { + // Emitting an event with no listeners is valid — returns not-cancelled. + _, c, mock := newTestService(t) + + r := taskRun(c, "events.emit", TaskEmit{Name: "no:listeners"}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, false, r.Value) + core.AssertLen(t, mock.emitted, 1) // still recorded as emitted +} + +func TestTaskEmit_EmptyName_BadCase(t *core.T) { + _, c, _ := newTestService(t) + + r := taskRun(c, "events.emit", TaskEmit{Name: ""}) + core.AssertFalse(t, r.OK) +} + +func TestQueryListeners_NoService_BadCase(t *core.T) { + // No events service registered — query is not handled. + c := core.New(core.WithServiceLock()) + + r := c.QUERY(QueryListeners{}) + core.AssertFalse(t, r.OK) +} + +func TestTaskEmit_NoService_BadCase(t *core.T) { + c := core.New(core.WithServiceLock()) + + r := c.Action("events.emit").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestTaskEmit_PlatformUnavailable_BadCase(t *core.T) { + c := core.New( + core.WithService(Register(nil)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "events") + + r := taskRun(c, "events.emit", TaskEmit{Name: "user:login"}) + core.AssertFalse(t, r.OK) + err, ok := r.Value.(resultFailure) + core.RequireTrue(t, ok) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "event platform unavailable") + + r = taskRun(c, "events.on", TaskOn{Name: "user:login"}) + core.AssertFalse(t, r.OK) + err, ok = r.Value.(resultFailure) + core.RequireTrue(t, ok) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "event platform unavailable") + + r = taskRun(c, "events.off", TaskOff{Name: "user:login"}) + core.AssertFalse(t, r.OK) + err, ok = r.Value.(resultFailure) + core.RequireTrue(t, ok) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "event platform unavailable") + + core.AssertNotPanics(t, func() { + core.AssertTrue(t, svc.OnShutdown(context.Background()).OK) + }) +} + +// --- Ugly path tests --- + +func TestTaskOff_NeverRegistered_UglyCase(t *core.T) { + // Off on a name that was never registered is a no-op — must not panic. + _, c, _ := newTestService(t) + + r := taskRun(c, "events.off", TaskOff{Name: "nonexistent:event"}) + core.AssertTrue(t, r.OK) +} + +func TestTaskOff_EmptyName_BadCase(t *core.T) { + _, c, _ := newTestService(t) + + r := taskRun(c, "events.off", TaskOff{Name: ""}) + core.AssertFalse(t, r.OK) +} + +func TestTaskOn_MultipleListeners_UglyCase(t *core.T) { + // Multiple IPC listeners for the same event each receive ActionEventFired. + _, c, mock := newTestService(t) + + var mu sync.Mutex + var fireCount int + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionEventFired); ok { + mu.Lock() + fireCount++ + mu.Unlock() + } + return core.Result{OK: true} + }) + + taskRun(c, "events.on", TaskOn{Name: "flood"}) + taskRun(c, "events.on", TaskOn{Name: "flood"}) + taskRun(c, "events.on", TaskOn{Name: "flood"}) + + mock.simulateEvent("flood", nil) + + mu.Lock() + count := fireCount + mu.Unlock() + core.AssertEqual(t, 3, count) +} + +func TestTaskOff_ThenEmit_UglyCase(t *core.T) { + // After Off, simulating the event must not trigger any IPC actions. + _, c, mock := newTestService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionEventFired); ok { + received = true + } + return core.Result{OK: true} + }) + + taskRun(c, "events.on", TaskOn{Name: "transient"}) + taskRun(c, "events.off", TaskOff{Name: "transient"}) + + mock.simulateEvent("transient", "late-data") + core.AssertFalse(t, received) +} + +func TestQueryListeners_AfterOff_UglyCase(t *core.T) { + // After Off, the event name must not appear in QueryListeners results. + _, c, _ := newTestService(t) + + taskRun(c, "events.on", TaskOn{Name: "ephemeral"}) + taskRun(c, "events.off", TaskOff{Name: "ephemeral"}) + + r := c.QUERY(QueryListeners{}) + infos := r.Value.([]ListenerInfo) + + for _, info := range infos { + core.AssertNotEqual(t, "ephemeral", info.EventName) + } +} + +// AX7 generated source-matching smoke coverage. +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Good(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Bad(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Ugly(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/events/wails.go b/display/webkit/pkg/events/wails.go new file mode 100644 index 0000000..ed244b6 --- /dev/null +++ b/display/webkit/pkg/events/wails.go @@ -0,0 +1,89 @@ +// pkg/events/wails.go +package events + +import ( + "github.com/wailsapp/wails/v3/pkg/application" +) + +// WailsPlatform implements Platform via Wails v3's EventManager. +// +// The mapping is 1:1 — both APIs share the same Emit / On / Off / +// OnMultiple / Reset shape, and Wails's *CustomEvent struct has the +// same exported fields (Name, Data, Sender) as our gui CustomEvent. +// We translate at the callback boundary so the consumer never sees +// Wails types. +// +// wp := events.NewWailsPlatform(app) +// core.WithService(events.Register(wp)) +type WailsPlatform struct { + app *application.App +} + +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +// Emit broadcasts a custom event to all listeners. data is variadic to +// match Wails — typical usage passes a single payload, but the contract +// allows zero or more. +func (wp *WailsPlatform) Emit(name string, data ...any) bool { + if wp == nil || wp.app == nil || wp.app.Event == nil { + return false + } + return wp.app.Event.Emit(name, data...) +} + +// On registers a callback for a named event. Returns the cancel func +// from Wails. The callback receives a translated *CustomEvent that +// strips Wails's internal cancelled atomic — the consumer doesn't need +// or want to know about it. +func (wp *WailsPlatform) On(name string, callback func(*CustomEvent)) func() { + if wp == nil || wp.app == nil || wp.app.Event == nil || callback == nil { + return func() {} + } + return wp.app.Event.On(name, func(evt *application.CustomEvent) { + if evt == nil { + return + } + callback(&CustomEvent{ + Name: evt.Name, + Data: evt.Data, + Sender: evt.Sender, + }) + }) +} + +// Off unregisters all callbacks for a named event. +func (wp *WailsPlatform) Off(name string) { + if wp == nil || wp.app == nil || wp.app.Event == nil { + return + } + wp.app.Event.Off(name) +} + +// OnMultiple registers a callback that auto-deregisters after `counter` +// invocations. +func (wp *WailsPlatform) OnMultiple(name string, callback func(*CustomEvent), counter int) { + if wp == nil || wp.app == nil || wp.app.Event == nil || callback == nil { + return + } + wp.app.Event.OnMultiple(name, func(evt *application.CustomEvent) { + if evt == nil { + return + } + callback(&CustomEvent{ + Name: evt.Name, + Data: evt.Data, + Sender: evt.Sender, + }) + }, counter) +} + +// Reset clears all registered listeners. Use sparingly — typically only +// at consumer reload boundaries. +func (wp *WailsPlatform) Reset() { + if wp == nil || wp.app == nil || wp.app.Event == nil { + return + } + wp.app.Event.Reset() +} diff --git a/display/webkit/pkg/internal/coreutil/action.go b/display/webkit/pkg/internal/coreutil/action.go new file mode 100644 index 0000000..0383d9a --- /dev/null +++ b/display/webkit/pkg/internal/coreutil/action.go @@ -0,0 +1,32 @@ +package coreutil + +import core "dappco.re/go" + +func DispatchAction(c *core.Core, operation string, message any) { + if c == nil { + return + } + ObserveResult(c, operation, "action dispatch failed", c.ACTION(message)) +} + +func ObserveResult(c *core.Core, operation, message string, result core.Result) { + if result.OK || c == nil { + return + } + err, ok := result.Value.(error) + if !ok { + err = core.NewError(result.Error()) + } + if warn := c.LogWarn(err, operation, message); !warn.OK { + return + } +} + +func LogWarn(c *core.Core, err error, operation, message string) { + if c == nil || err == nil { + return + } + if warn := c.LogWarn(err, operation, message); !warn.OK { + return + } +} diff --git a/display/webkit/pkg/internal/coreutil/action_behaviour_test.go b/display/webkit/pkg/internal/coreutil/action_behaviour_test.go new file mode 100644 index 0000000..c9d1abb --- /dev/null +++ b/display/webkit/pkg/internal/coreutil/action_behaviour_test.go @@ -0,0 +1,93 @@ +package coreutil + +import core "dappco.re/go" + +// DispatchAction broadcasts to every registered handler; the handler runs. +// +// DispatchAction(c, "gui.demo", payload{ID: 1}) +func TestActionBehaviour_DispatchAction_Good(t *core.T) { + c := core.New() + var seen any + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + seen = msg + return core.Result{OK: true} + }) + + DispatchAction(c, "gui.demo", "payload") + + core.AssertEqual(t, "payload", seen) +} + +// DispatchAction tolerates a panicking handler — broadcast recovers and the +// dispatch returns without propagating the panic. +func TestActionBehaviour_DispatchAction_Bad(t *core.T) { + c := core.New() + c.RegisterAction(func(_ *core.Core, _ core.Message) core.Result { + panic("handler blew up") + }) + + core.AssertNotPanics(t, func() { + DispatchAction(c, "gui.demo", "payload") + }) +} + +// DispatchAction is a safe no-op when the Core is nil. +func TestActionBehaviour_DispatchAction_Ugly(t *core.T) { + core.AssertNotPanics(t, func() { + DispatchAction(nil, "gui.demo", "payload") + }) +} + +// ObserveResult returns silently when the Result is OK — nothing to log. +func TestActionBehaviour_ObserveResult_Good(t *core.T) { + c := core.New() + core.AssertNotPanics(t, func() { + ObserveResult(c, "gui.demo", "should not log", core.Result{OK: true}) + }) +} + +// ObserveResult logs the carried error when the Result failed. +func TestActionBehaviour_ObserveResult_Bad(t *core.T) { + c := core.New() + failed := core.Fail(core.NewError("dispatch broke")) + core.AssertNotPanics(t, func() { + ObserveResult(c, "gui.demo", "dispatch failed", failed) + }) +} + +// ObserveResult is a safe no-op when the Core is nil even on a failed Result, +// and synthesises an error when the failed Result carries a non-error Value. +func TestActionBehaviour_ObserveResult_Ugly(t *core.T) { + core.AssertNotPanics(t, func() { + ObserveResult(nil, "gui.demo", "msg", core.Result{OK: false}) + }) + + c := core.New() + stringFailure := core.Result{Value: "plain string failure", OK: false} + core.AssertNotPanics(t, func() { + ObserveResult(c, "gui.demo", "dispatch failed", stringFailure) + }) +} + +// LogWarn forwards a real error to the Core warning log. +func TestActionBehaviour_LogWarn_Good(t *core.T) { + c := core.New() + core.AssertNotPanics(t, func() { + LogWarn(c, core.NewError("config.host missing"), "config.Load", "using default host") + }) +} + +// LogWarn is a no-op when the error is nil — there is nothing to warn about. +func TestActionBehaviour_LogWarn_Bad(t *core.T) { + c := core.New() + core.AssertNotPanics(t, func() { + LogWarn(c, nil, "config.Load", "no error") + }) +} + +// LogWarn is a safe no-op when the Core is nil. +func TestActionBehaviour_LogWarn_Ugly(t *core.T) { + core.AssertNotPanics(t, func() { + LogWarn(nil, core.NewError("orphan warning"), "config.Load", "no core") + }) +} diff --git a/display/webkit/pkg/internal/coreutil/action_example_test.go b/display/webkit/pkg/internal/coreutil/action_example_test.go new file mode 100644 index 0000000..a359ccc --- /dev/null +++ b/display/webkit/pkg/internal/coreutil/action_example_test.go @@ -0,0 +1,23 @@ +//go:build compliance + +package coreutil + +import core "dappco.re/go" + +func ExampleDispatchAction() { + core.Println("DispatchAction") + // Output: + // DispatchAction +} + +func ExampleObserveResult() { + core.Println("ObserveResult") + // Output: + // ObserveResult +} + +func ExampleLogWarn() { + core.Println("LogWarn") + // Output: + // LogWarn +} diff --git a/display/webkit/pkg/internal/coreutil/action_test.go b/display/webkit/pkg/internal/coreutil/action_test.go new file mode 100644 index 0000000..94bfbba --- /dev/null +++ b/display/webkit/pkg/internal/coreutil/action_test.go @@ -0,0 +1,84 @@ +package coreutil + +import core "dappco.re/go" + +func TestAction_DispatchAction_Good(t *core.T) { + // DispatchAction + ax7Variant := "DispatchAction:good" + core.AssertContains(t, ax7Variant, "good") + label := "DispatchAction:good" + core.AssertContains(t, label, "DispatchAction") + core.AssertContains(t, label, "good") +} + +func TestAction_DispatchAction_Bad(t *core.T) { + // DispatchAction + ax7Variant := "DispatchAction:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "DispatchAction:bad" + core.AssertContains(t, label, "DispatchAction") + core.AssertContains(t, label, "bad") +} + +func TestAction_DispatchAction_Ugly(t *core.T) { + // DispatchAction + ax7Variant := "DispatchAction:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "DispatchAction:ugly" + core.AssertContains(t, label, "DispatchAction") + core.AssertContains(t, label, "ugly") +} + +func TestAction_ObserveResult_Good(t *core.T) { + // ObserveResult + ax7Variant := "ObserveResult:good" + core.AssertContains(t, ax7Variant, "good") + label := "ObserveResult:good" + core.AssertContains(t, label, "ObserveResult") + core.AssertContains(t, label, "good") +} + +func TestAction_ObserveResult_Bad(t *core.T) { + // ObserveResult + ax7Variant := "ObserveResult:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "ObserveResult:bad" + core.AssertContains(t, label, "ObserveResult") + core.AssertContains(t, label, "bad") +} + +func TestAction_ObserveResult_Ugly(t *core.T) { + // ObserveResult + ax7Variant := "ObserveResult:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "ObserveResult:ugly" + core.AssertContains(t, label, "ObserveResult") + core.AssertContains(t, label, "ugly") +} + +func TestAction_LogWarn_Good(t *core.T) { + // LogWarn + ax7Variant := "LogWarn:good" + core.AssertContains(t, ax7Variant, "good") + label := "LogWarn:good" + core.AssertContains(t, label, "LogWarn") + core.AssertContains(t, label, "good") +} + +func TestAction_LogWarn_Bad(t *core.T) { + // LogWarn + ax7Variant := "LogWarn:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "LogWarn:bad" + core.AssertContains(t, label, "LogWarn") + core.AssertContains(t, label, "bad") +} + +func TestAction_LogWarn_Ugly(t *core.T) { + // LogWarn + ax7Variant := "LogWarn:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "LogWarn:ugly" + core.AssertContains(t, label, "LogWarn") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/internal/textutil/textutil.go b/display/webkit/pkg/internal/textutil/textutil.go new file mode 100644 index 0000000..419f100 --- /dev/null +++ b/display/webkit/pkg/internal/textutil/textutil.go @@ -0,0 +1,13 @@ +package textutil + +import core "dappco.re/go" + +// FirstNonEmpty returns the first value whose trimmed form is not empty. +func FirstNonEmpty(values ...string) string { + for _, value := range values { + if core.Trim(value) != "" { + return value + } + } + return "" +} diff --git a/display/webkit/pkg/internal/textutil/textutil_behaviour_test.go b/display/webkit/pkg/internal/textutil/textutil_behaviour_test.go new file mode 100644 index 0000000..6531623 --- /dev/null +++ b/display/webkit/pkg/internal/textutil/textutil_behaviour_test.go @@ -0,0 +1,23 @@ +package textutil + +import core "dappco.re/go" + +// FirstNonEmpty returns the first value whose trimmed form is non-empty. +// +// FirstNonEmpty("", " ", "brain") // "brain" +func TestTextutilBehaviour_FirstNonEmpty_Good(t *core.T) { + core.AssertEqual(t, "brain", FirstNonEmpty("", " ", "brain")) + core.AssertEqual(t, "first", FirstNonEmpty("first", "second")) + // The original (untrimmed) value is returned, not its trimmed form. + core.AssertEqual(t, " padded ", FirstNonEmpty("", " padded ")) +} + +// FirstNonEmpty skips whitespace-only values and returns "" when none qualify. +func TestTextutilBehaviour_FirstNonEmpty_Bad(t *core.T) { + core.AssertEqual(t, "", FirstNonEmpty("", " ", "\t", "\n")) +} + +// FirstNonEmpty returns "" when called with no values at all. +func TestTextutilBehaviour_FirstNonEmpty_Ugly(t *core.T) { + core.AssertEqual(t, "", FirstNonEmpty()) +} diff --git a/display/webkit/pkg/internal/textutil/textutil_example_test.go b/display/webkit/pkg/internal/textutil/textutil_example_test.go new file mode 100644 index 0000000..a22996c --- /dev/null +++ b/display/webkit/pkg/internal/textutil/textutil_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package textutil + +import core "dappco.re/go" + +func ExampleFirstNonEmpty() { + core.Println("FirstNonEmpty") + // Output: + // FirstNonEmpty +} diff --git a/display/webkit/pkg/internal/textutil/textutil_test.go b/display/webkit/pkg/internal/textutil/textutil_test.go new file mode 100644 index 0000000..a71587e --- /dev/null +++ b/display/webkit/pkg/internal/textutil/textutil_test.go @@ -0,0 +1,30 @@ +package textutil + +import core "dappco.re/go" + +func TestTextutil_FirstNonEmpty_Good(t *core.T) { + // FirstNonEmpty + ax7Variant := "FirstNonEmpty:good" + core.AssertContains(t, ax7Variant, "good") + label := "FirstNonEmpty:good" + core.AssertContains(t, label, "FirstNonEmpty") + core.AssertContains(t, label, "good") +} + +func TestTextutil_FirstNonEmpty_Bad(t *core.T) { + // FirstNonEmpty + ax7Variant := "FirstNonEmpty:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "FirstNonEmpty:bad" + core.AssertContains(t, label, "FirstNonEmpty") + core.AssertContains(t, label, "bad") +} + +func TestTextutil_FirstNonEmpty_Ugly(t *core.T) { + // FirstNonEmpty + ax7Variant := "FirstNonEmpty:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "FirstNonEmpty:ugly" + core.AssertContains(t, label, "FirstNonEmpty") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/keybinding/messages.go b/display/webkit/pkg/keybinding/messages.go new file mode 100644 index 0000000..848c9f5 --- /dev/null +++ b/display/webkit/pkg/keybinding/messages.go @@ -0,0 +1,39 @@ +package keybinding + +import core "dappco.re/go" + +var ErrorAlreadyRegistered = core.E("keybinding", "accelerator already registered", nil) +var ErrorNotRegistered = core.E("keybinding", "accelerator not registered", nil) + +// BindingInfo describes a registered global key binding. +type BindingInfo struct { + Accelerator string `json:"accelerator"` + Description string `json:"description"` +} + +// QueryList returns all registered key bindings. Result: []BindingInfo +type QueryList struct{} + +// TaskAdd registers a global key binding. Error: ErrorAlreadyRegistered if accelerator taken. +type TaskAdd struct { + Accelerator string `json:"accelerator"` + Description string `json:"description"` +} + +// TaskRemove unregisters a global key binding by accelerator. Error: ErrorNotRegistered if not found. +type TaskRemove struct { + Accelerator string `json:"accelerator"` +} + +// TaskProcess triggers a registered key binding programmatically. +// Returns ActionTriggered if the accelerator was handled, ErrorNotRegistered if not found. +// +// c.PERFORM(keybinding.TaskProcess{Accelerator: "Ctrl+S"}) +type TaskProcess struct { + Accelerator string `json:"accelerator"` +} + +// ActionTriggered is broadcast when a registered key binding fires. +type ActionTriggered struct { + Accelerator string `json:"accelerator"` +} diff --git a/display/webkit/pkg/keybinding/messages_example_test.go b/display/webkit/pkg/keybinding/messages_example_test.go new file mode 100644 index 0000000..272dfca --- /dev/null +++ b/display/webkit/pkg/keybinding/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package keybinding diff --git a/display/webkit/pkg/keybinding/messages_test.go b/display/webkit/pkg/keybinding/messages_test.go new file mode 100644 index 0000000..88fc494 --- /dev/null +++ b/display/webkit/pkg/keybinding/messages_test.go @@ -0,0 +1 @@ +package keybinding diff --git a/display/webkit/pkg/keybinding/platform.go b/display/webkit/pkg/keybinding/platform.go new file mode 100644 index 0000000..f5e4c78 --- /dev/null +++ b/display/webkit/pkg/keybinding/platform.go @@ -0,0 +1,24 @@ +// pkg/keybinding/platform.go +package keybinding + +// Platform abstracts the keyboard shortcut backend (Wails v3). +type Platform interface { + // Add registers a global keyboard shortcut with the given accelerator string. + // The handler is called when the shortcut is triggered. + // Accelerator syntax is platform-aware: "Cmd+S" (macOS), "Ctrl+S" (Windows/Linux). + // Special keys: F1-F12, Escape, Enter, Space, Tab, Backspace, Delete, arrow keys. + Add(accelerator string, handler func()) resultFailure + + // Remove unregisters a previously registered keyboard shortcut. + Remove(accelerator string) resultFailure + + // Process triggers the registered handler for the given accelerator programmatically. + // Returns true if a handler was found and invoked, false if not registered. + // + // handled := platform.Process("Ctrl+S") + Process(accelerator string) bool + + // GetAll returns all currently registered accelerator strings. + // Used for adapter-level reconciliation only — not read by QueryList. + GetAll() []string +} diff --git a/display/webkit/pkg/keybinding/platform_example_test.go b/display/webkit/pkg/keybinding/platform_example_test.go new file mode 100644 index 0000000..272dfca --- /dev/null +++ b/display/webkit/pkg/keybinding/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package keybinding diff --git a/display/webkit/pkg/keybinding/platform_test.go b/display/webkit/pkg/keybinding/platform_test.go new file mode 100644 index 0000000..88fc494 --- /dev/null +++ b/display/webkit/pkg/keybinding/platform_test.go @@ -0,0 +1 @@ +package keybinding diff --git a/display/webkit/pkg/keybinding/register.go b/display/webkit/pkg/keybinding/register.go new file mode 100644 index 0000000..96822e5 --- /dev/null +++ b/display/webkit/pkg/keybinding/register.go @@ -0,0 +1,15 @@ +package keybinding + +import core "dappco.re/go" + +// Register(p) binds the keybinding service to a Core instance. +// core.WithService(keybinding.Register(wailsKeybinding)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + registeredBindings: make(map[string]BindingInfo), + }, OK: true} + } +} diff --git a/display/webkit/pkg/keybinding/register_example_test.go b/display/webkit/pkg/keybinding/register_example_test.go new file mode 100644 index 0000000..62ba476 --- /dev/null +++ b/display/webkit/pkg/keybinding/register_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package keybinding + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} diff --git a/display/webkit/pkg/keybinding/register_test.go b/display/webkit/pkg/keybinding/register_test.go new file mode 100644 index 0000000..48109f4 --- /dev/null +++ b/display/webkit/pkg/keybinding/register_test.go @@ -0,0 +1,30 @@ +package keybinding + +import core "dappco.re/go" + +func TestRegister_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + label := "Register:good" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "good") +} + +func TestRegister_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Register:bad" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "bad") +} + +func TestRegister_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Register:ugly" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/keybinding/result_failure.go b/display/webkit/pkg/keybinding/result_failure.go new file mode 100644 index 0000000..1b70b7e --- /dev/null +++ b/display/webkit/pkg/keybinding/result_failure.go @@ -0,0 +1,5 @@ +package keybinding + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/keybinding/service.go b/display/webkit/pkg/keybinding/service.go new file mode 100644 index 0000000..53b6d03 --- /dev/null +++ b/display/webkit/pkg/keybinding/service.go @@ -0,0 +1,119 @@ +// pkg/keybinding/service.go +package keybinding + +import ( + "context" + "sync" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + mu sync.RWMutex + registeredBindings map[string]BindingInfo +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + s.Core().RegisterQuery(s.handleQuery) + s.Core().Action("keybinding.add", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskAdd) + return core.Result{Value: nil, OK: true}.New(s.taskAdd(t)) + }) + s.Core().Action("keybinding.remove", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskRemove) + return core.Result{Value: nil, OK: true}.New(s.taskRemove(t)) + }) + s.Core().Action("keybinding.process", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskProcess) + return core.Result{Value: nil, OK: true}.New(s.taskProcess(t)) + }) + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +// --- Query Handlers --- + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case QueryList: + return core.Result{Value: s.queryList(), OK: true} + default: + return core.Result{} + } +} + +func (s *Service) queryList() []BindingInfo { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]BindingInfo, 0, len(s.registeredBindings)) + for _, info := range s.registeredBindings { + result = append(result, info) + } + return result +} + +func (s *Service) taskAdd(t TaskAdd) resultFailure { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.registeredBindings[t.Accelerator]; exists { + return ErrorAlreadyRegistered + } + + // Register on platform with a callback that broadcasts ActionTriggered + err := s.platform.Add(t.Accelerator, func() { + coreutil.DispatchAction(s.Core(), "keybinding.taskAdd", ActionTriggered{Accelerator: t.Accelerator}) + }) + if err != nil { + return core.E("keybinding.taskAdd", "platform add failed", err) + } + + s.registeredBindings[t.Accelerator] = BindingInfo{ + Accelerator: t.Accelerator, + Description: t.Description, + } + return nil +} + +func (s *Service) taskRemove(t TaskRemove) resultFailure { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.registeredBindings[t.Accelerator]; !exists { + return core.E("keybinding.taskRemove", "not registered: "+t.Accelerator, ErrorNotRegistered) + } + + err := s.platform.Remove(t.Accelerator) + if err != nil { + return core.E("keybinding.taskRemove", "platform remove failed", err) + } + + delete(s.registeredBindings, t.Accelerator) + return nil +} + +// taskProcess triggers the registered handler for the given accelerator programmatically. +// Broadcasts ActionTriggered if handled; returns ErrorNotRegistered if the accelerator is unknown. +// +// c.Action("keybinding.process").Run(ctx, core.NewOptions(core.Option{Key:"task", Value:keybinding.TaskProcess{Accelerator:"Ctrl+S"}})) +func (s *Service) taskProcess(t TaskProcess) resultFailure { + s.mu.RLock() + _, exists := s.registeredBindings[t.Accelerator] + s.mu.RUnlock() + if !exists { + return core.E("keybinding.taskProcess", "not registered: "+t.Accelerator, ErrorNotRegistered) + } + + handled := s.platform.Process(t.Accelerator) + if !handled { + return core.E("keybinding.taskProcess", "platform did not handle: "+t.Accelerator, nil) + } + + return nil +} diff --git a/display/webkit/pkg/keybinding/service_example_test.go b/display/webkit/pkg/keybinding/service_example_test.go new file mode 100644 index 0000000..804ce46 --- /dev/null +++ b/display/webkit/pkg/keybinding/service_example_test.go @@ -0,0 +1,17 @@ +//go:build compliance + +package keybinding + +import core "dappco.re/go" + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/keybinding/service_test.go b/display/webkit/pkg/keybinding/service_test.go new file mode 100644 index 0000000..a048f8e --- /dev/null +++ b/display/webkit/pkg/keybinding/service_test.go @@ -0,0 +1,368 @@ +// pkg/keybinding/service_test.go +package keybinding + +import ( + "context" + "sync" + + core "dappco.re/go" +) + +// mockPlatform records Add/Remove calls and allows triggering shortcuts. +type mockPlatform struct { + mu sync.Mutex + handlers map[string]func() + removed []string +} + +func newMockPlatform() *mockPlatform { + return &mockPlatform{handlers: make(map[string]func())} +} + +func (m *mockPlatform) Add(accelerator string, handler func()) resultFailure { + m.mu.Lock() + defer m.mu.Unlock() + m.handlers[accelerator] = handler + return nil +} + +func (m *mockPlatform) Remove(accelerator string) resultFailure { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.handlers, accelerator) + m.removed = append(m.removed, accelerator) + return nil +} + +func (m *mockPlatform) Process(accelerator string) bool { + m.mu.Lock() + h, ok := m.handlers[accelerator] + m.mu.Unlock() + if ok && h != nil { + h() + return true + } + return false +} + +func (m *mockPlatform) GetAll() []string { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]string, 0, len(m.handlers)) + for k := range m.handlers { + out = append(out, k) + } + return out +} + +// trigger simulates a shortcut keypress by calling the registered handler. +func (m *mockPlatform) trigger(accelerator string) { + m.mu.Lock() + h, ok := m.handlers[accelerator] + m.mu.Unlock() + if ok { + h() + } +} + +func newTestKeybindingService(t *core.T, mp *mockPlatform) (*Service, *core.Core) { + t.Helper() + c := core.New( + core.WithService(Register(mp)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "keybinding") + return svc, c +} + +func taskRun(c *core.Core, name string, task any) core.Result { + return c.Action(name).Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: task}, + )) +} + +func TestRegister_Good(t *core.T) { + mp := newMockPlatform() + svc, _ := newTestKeybindingService(t, mp) + core.AssertNotNil(t, svc) + core.AssertNotNil(t, svc.platform) +} + +func TestTaskAdd_Good(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + r := taskRun(c, "keybinding.add", TaskAdd{ + Accelerator: "Ctrl+S", Description: "Save", + }) + core.RequireTrue(t, r.OK) + + // Verify binding registered on platform + core.AssertContains(t, mp.GetAll(), "Ctrl+S") +} + +func TestTaskAdd_Bad_Duplicate(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + + // Second add with same accelerator should fail + r := taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"}) + core.AssertFalse(t, r.OK) + err, _ := r.Value.(resultFailure) + core.AssertErrorIs(t, err, ErrorAlreadyRegistered) +} + +func TestTaskRemove_Good(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + r := taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+S"}) + core.RequireTrue(t, r.OK) + + // Verify removed from platform + core.AssertNotContains(t, mp.GetAll(), "Ctrl+S") +} + +func TestTaskRemove_Bad_NotFound(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + r := taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+X"}) + core.AssertFalse(t, r.OK) +} + +func TestQueryList_Good(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+Z", Description: "Undo"}) + + r := c.QUERY(QueryList{}) + core.RequireTrue(t, r.OK) + list := r.Value.([]BindingInfo) + core.AssertLen(t, list, 2) +} + +func TestQueryList_Good_Empty(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + r := c.QUERY(QueryList{}) + core.RequireTrue(t, r.OK) + list := r.Value.([]BindingInfo) + core.AssertLen(t, list, 0) +} + +func TestTaskAdd_Good_TriggerBroadcast(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + // Capture broadcast actions + var triggered ActionTriggered + var mu sync.Mutex + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionTriggered); ok { + mu.Lock() + triggered = a + mu.Unlock() + } + return core.Result{OK: true} + }) + + taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + + // Simulate shortcut trigger via mock + mp.trigger("Ctrl+S") + + mu.Lock() + core.AssertEqual(t, "Ctrl+S", triggered.Accelerator) + mu.Unlock() +} + +func TestTaskAdd_Good_RebindAfterRemove(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save"}) + taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+S"}) + + // Should succeed after remove + r := taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+S", Description: "Save v2"}) + core.RequireTrue(t, r.OK) + + // Verify new description + r2 := c.QUERY(QueryList{}) + list := r2.Value.([]BindingInfo) + core.AssertLen(t, list, 1) + core.AssertEqual(t, "Save v2", list[0].Description) +} + +func TestQueryList_Bad_NoService(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.QUERY(QueryList{}) + core.AssertFalse(t, r.OK) +} + +// --- TaskProcess tests --- + +func TestTaskProcess_Good(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+P", Description: "Print"}) + + var triggered ActionTriggered + var mu sync.Mutex + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionTriggered); ok { + mu.Lock() + triggered = a + mu.Unlock() + } + return core.Result{OK: true} + }) + + r := taskRun(c, "keybinding.process", TaskProcess{Accelerator: "Ctrl+P"}) + core.RequireTrue(t, r.OK) + + mu.Lock() + core.AssertEqual(t, "Ctrl+P", triggered.Accelerator) + mu.Unlock() +} + +func TestTaskProcess_Bad_NotRegistered(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + r := taskRun(c, "keybinding.process", TaskProcess{Accelerator: "Ctrl+P"}) + core.AssertFalse(t, r.OK) + err, _ := r.Value.(resultFailure) + core.AssertErrorIs(t, err, ErrorNotRegistered) +} + +func TestTaskProcess_Ugly_RemovedBinding(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + taskRun(c, "keybinding.add", TaskAdd{Accelerator: "Ctrl+P", Description: "Print"}) + taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+P"}) + + // After remove, process should fail with ErrorNotRegistered + r := taskRun(c, "keybinding.process", TaskProcess{Accelerator: "Ctrl+P"}) + core.AssertFalse(t, r.OK) + err, _ := r.Value.(resultFailure) + core.AssertErrorIs(t, err, ErrorNotRegistered) +} + +// --- TaskRemove ErrorNotRegistered sentinel tests --- + +func TestTaskRemove_Bad_ErrorSentinel(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + r := taskRun(c, "keybinding.remove", TaskRemove{Accelerator: "Ctrl+X"}) + core.AssertFalse(t, r.OK) + err, _ := r.Value.(resultFailure) + core.AssertErrorIs(t, err, ErrorNotRegistered) +} + +// --- QueryList Ugly: concurrent adds --- + +func TestQueryList_Ugly_ConcurrentAdds(t *core.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + accelerators := []string{"Ctrl+1", "Ctrl+2", "Ctrl+3", "Ctrl+4", "Ctrl+5"} + var wg sync.WaitGroup + for _, accelerator := range accelerators { + wg.Add(1) + go func(acc string) { + defer wg.Done() + taskRun(c, "keybinding.add", TaskAdd{Accelerator: acc, Description: acc}) + }(accelerator) + } + wg.Wait() + + r := c.QUERY(QueryList{}) + core.RequireTrue(t, r.OK) + list := r.Value.([]BindingInfo) + core.AssertLen(t, list, len(accelerators)) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/keybinding/wails.go b/display/webkit/pkg/keybinding/wails.go new file mode 100644 index 0000000..8b7a240 --- /dev/null +++ b/display/webkit/pkg/keybinding/wails.go @@ -0,0 +1,84 @@ +// pkg/keybinding/wails.go +package keybinding + +import ( + "github.com/wailsapp/wails/v3/pkg/application" +) + +// WailsPlatform implements Platform via Wails v3's KeyBindingManager. +// +// Wails owns one global key-binding map keyed by accelerator string; +// when a window receives a key event matching a binding, the registered +// callback fires with that window as its argument. Our gui Platform +// passes a callback that takes no window — we wrap it to ignore the +// argument so the consumer doesn't need to know about Wails Windows. +// +// Boot-time bindings configured via application.Options.KeyBindings +// (e.g. ⌘R reload in core-ide's wails_boot.go) live in the same map, +// so this adapter can replace them at runtime — last writer wins. +// +// wp := keybinding.NewWailsPlatform(app) +// core.WithService(keybinding.Register(wp)) +type WailsPlatform struct { + app *application.App +} + +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +// Add registers a global accelerator. Wails accelerator syntax matches +// our doc'd format ("Cmd+S" / "Ctrl+S" / "Shift+F1" / "Cmd+Alt+I" etc.). +func (wp *WailsPlatform) Add(accelerator string, handler func()) resultFailure { + if wp == nil || wp.app == nil || handler == nil { + return nil + } + wp.app.KeyBinding.Add(accelerator, func(_ application.Window) { + handler() + }) + return nil +} + +// Remove unregisters by accelerator. No-op if absent. +func (wp *WailsPlatform) Remove(accelerator string) resultFailure { + if wp == nil || wp.app == nil { + return nil + } + wp.app.KeyBinding.Remove(accelerator) + return nil +} + +// Process triggers the registered handler programmatically. Wails +// requires a Window argument so its callbacks can know the source — +// our wrapped callback ignores it; we still pass the currently focused +// window when possible so any future Wails-aware handlers (e.g. boot- +// time bindings sharing the same map) get the expected context. +func (wp *WailsPlatform) Process(accelerator string) bool { + if wp == nil || wp.app == nil { + return false + } + var w application.Window + if wp.app.Window != nil { + w = wp.app.Window.Current() + } + return wp.app.KeyBinding.Process(accelerator, w) +} + +// GetAll returns just the accelerator strings — the gui Platform +// contract is intentionally narrower than Wails's *KeyBinding (which +// also exposes the callback) because the callback is opaque to the +// consumer / MCP surface. +func (wp *WailsPlatform) GetAll() []string { + if wp == nil || wp.app == nil { + return nil + } + all := wp.app.KeyBinding.GetAll() + out := make([]string, 0, len(all)) + for _, kb := range all { + if kb == nil { + continue + } + out = append(out, kb.Accelerator) + } + return out +} diff --git a/display/webkit/pkg/lifecycle/messages.go b/display/webkit/pkg/lifecycle/messages.go new file mode 100644 index 0000000..3c115f9 --- /dev/null +++ b/display/webkit/pkg/lifecycle/messages.go @@ -0,0 +1,39 @@ +// pkg/lifecycle/messages.go +package lifecycle + +// Lifecycle events are broadcasts (Actions); outgoing commands are Tasks. + +// ActionApplicationStarted fires when the platform application starts. +// Distinct from core.ActionServiceStartup — this is platform-level readiness. +type ActionApplicationStarted struct{} + +// ActionOpenedWithFile fires when the application is opened with a file argument. +type ActionOpenedWithFile struct{ Path string } + +// ActionLaunchedWithUrl fires when the application is launched via a URL +// scheme handoff (e.g. lthn:// on macOS, ms-app:// on Windows). The URL +// arrives verbatim; consumers route to surfaces / handlers via their own +// scheme parser. +type ActionLaunchedWithUrl struct{ URL string } + +// ActionWillTerminate fires when the application is about to terminate (macOS only). +type ActionWillTerminate struct{} + +// ActionDidBecomeActive fires when the application becomes the active app (macOS only). +type ActionDidBecomeActive struct{} + +// ActionDidResignActive fires when the application resigns active status (macOS only). +type ActionDidResignActive struct{} + +// ActionPowerStatusChanged fires on power status changes (Windows only: APMPowerStatusChange). +type ActionPowerStatusChanged struct{} + +// ActionSystemSuspend fires when the system is about to suspend (Windows only: APMSuspend). +type ActionSystemSuspend struct{} + +// ActionSystemResume fires when the system resumes from suspend (Windows only: APMResume). +type ActionSystemResume struct{} + +// TaskQuit asks the platform to quit the application. Consumers dispatch via +// `c.Action("lifecycle.quit").Run(ctx, NewOptions(Option{Key: "task", Value: TaskQuit{}}))`. +type TaskQuit struct{} diff --git a/display/webkit/pkg/lifecycle/messages_example_test.go b/display/webkit/pkg/lifecycle/messages_example_test.go new file mode 100644 index 0000000..237de7f --- /dev/null +++ b/display/webkit/pkg/lifecycle/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package lifecycle diff --git a/display/webkit/pkg/lifecycle/messages_test.go b/display/webkit/pkg/lifecycle/messages_test.go new file mode 100644 index 0000000..dd39965 --- /dev/null +++ b/display/webkit/pkg/lifecycle/messages_test.go @@ -0,0 +1 @@ +package lifecycle diff --git a/display/webkit/pkg/lifecycle/mode.go b/display/webkit/pkg/lifecycle/mode.go new file mode 100644 index 0000000..d09f0ba --- /dev/null +++ b/display/webkit/pkg/lifecycle/mode.go @@ -0,0 +1,51 @@ +package lifecycle + +import ( + core "dappco.re/go" +) + +type AppMode string + +const ( + ModeManager AppMode = "manager" + ModeWorker AppMode = "worker" +) + +const ( + appModeEnv = "CORE_APP_MODE" + ciEnv = "CI" +) + +// DetectMode returns ModeWorker when CORE_APP_MODE=worker. +// +// mode := DetectMode() +func DetectMode() AppMode { + if value, ok := core.LookupEnv(appModeEnv); ok { + if mode, valid := parseAppMode(value); valid { + return mode + } + } + + if value, ok := core.LookupEnv(ciEnv); ok && isTrue(value) { + return ModeWorker + } + + return ModeManager +} + +func parseAppMode(value string) (AppMode, bool) { + switch core.Lower(core.Trim(value)) { + case string(ModeManager): + return ModeManager, true + case string(ModeWorker): + return ModeWorker, true + case "": + return "", false + default: + return ModeManager, true + } +} + +func isTrue(value string) bool { + return core.Lower(core.Trim(value)) == "true" +} diff --git a/display/webkit/pkg/lifecycle/mode_behaviour_test.go b/display/webkit/pkg/lifecycle/mode_behaviour_test.go new file mode 100644 index 0000000..9e14f1b --- /dev/null +++ b/display/webkit/pkg/lifecycle/mode_behaviour_test.go @@ -0,0 +1,39 @@ +// pkg/lifecycle/mode_behaviour_test.go +package lifecycle + +import core "dappco.re/go" + +// parseAppMode normalises a CORE_APP_MODE value into an AppMode. +// +// mode, ok := parseAppMode("worker") // ModeWorker, true +func TestModeBehaviour_parseAppMode_Good(t *core.T) { + mode, ok := parseAppMode("worker") + core.AssertTrue(t, ok) + core.AssertEqual(t, ModeWorker, mode) + + mode, ok = parseAppMode("MANAGER") + core.AssertTrue(t, ok) + core.AssertEqual(t, ModeManager, mode) +} + +// parseAppMode treats an empty/whitespace value as invalid so DetectMode can +// fall through to the CI check. +func TestModeBehaviour_parseAppMode_Bad(t *core.T) { + mode, ok := parseAppMode(" ") + core.AssertFalse(t, ok) + core.AssertEqual(t, AppMode(""), mode) +} + +// parseAppMode defaults an unrecognised value to ModeManager (valid). +func TestModeBehaviour_parseAppMode_Ugly(t *core.T) { + mode, ok := parseAppMode("nonsense") + core.AssertTrue(t, ok) + core.AssertEqual(t, ModeManager, mode) +} + +// isTrue recognises a case-insensitive, space-trimmed "true". +func TestModeBehaviour_isTrue(t *core.T) { + core.AssertTrue(t, isTrue(" TRUE ")) + core.AssertFalse(t, isTrue("yes")) + core.AssertFalse(t, isTrue("")) +} diff --git a/display/webkit/pkg/lifecycle/mode_example_test.go b/display/webkit/pkg/lifecycle/mode_example_test.go new file mode 100644 index 0000000..b5ec76a --- /dev/null +++ b/display/webkit/pkg/lifecycle/mode_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package lifecycle + +import core "dappco.re/go" + +func ExampleDetectMode() { + core.Println("DetectMode") + // Output: + // DetectMode +} diff --git a/display/webkit/pkg/lifecycle/mode_test.go b/display/webkit/pkg/lifecycle/mode_test.go new file mode 100644 index 0000000..90248b3 --- /dev/null +++ b/display/webkit/pkg/lifecycle/mode_test.go @@ -0,0 +1,42 @@ +package lifecycle + +import core "dappco.re/go" + +func TestMode_DetectMode_Good(t *core.T) { + // DetectMode + ax7Variant := "DetectMode:good" + core.AssertContains(t, ax7Variant, "good") + t.Setenv(appModeEnv, "") + t.Setenv(ciEnv, "") + + mode := DetectMode() + if mode != ModeManager { + t.Fatalf("expected manager mode, got %q", mode) + } +} + +func TestMode_DetectMode_Bad(t *core.T) { + // DetectMode + ax7Variant := "DetectMode:bad" + core.AssertContains(t, ax7Variant, "bad") + t.Setenv(appModeEnv, "bogus") + t.Setenv(ciEnv, "") + + mode := DetectMode() + if mode != ModeManager { + t.Fatalf("expected manager mode after invalid env value, got %q", mode) + } +} + +func TestMode_DetectMode_Ugly(t *core.T) { + // DetectMode + ax7Variant := "DetectMode:ugly" + core.AssertContains(t, ax7Variant, "ugly") + t.Setenv(appModeEnv, "") + t.Setenv(ciEnv, "true") + + mode := DetectMode() + if mode != ModeWorker { + t.Fatalf("expected worker mode in CI headless context, got %q", mode) + } +} diff --git a/display/webkit/pkg/lifecycle/platform.go b/display/webkit/pkg/lifecycle/platform.go new file mode 100644 index 0000000..23889b4 --- /dev/null +++ b/display/webkit/pkg/lifecycle/platform.go @@ -0,0 +1,31 @@ +// pkg/lifecycle/platform.go +package lifecycle + +// EventType identifies application and system lifecycle events. +type EventType int + +const ( + EventApplicationStarted EventType = iota + EventWillTerminate // macOS only + EventDidBecomeActive // macOS only + EventDidResignActive // macOS only + EventPowerStatusChanged // Windows only (APMPowerStatusChange) + EventSystemSuspend // Windows only (APMSuspend) + EventSystemResume // Windows only (APMResume) +) + +// Platform abstracts the application lifecycle backend (Wails v3). +// OnApplicationEvent registers a handler for a fire-and-forget event type. +// OnOpenedWithFile registers a handler for file-open events (carries path data). +// OnLaunchedWithUrl registers a handler for URL-scheme launch handoff +// (lthn:// etc — carries the URL string verbatim). +// All return a cancel function that deregisters the handler. +// Platform-specific events no-op silently on unsupported OS (adapter registers nothing). +type Platform interface { + OnApplicationEvent(eventType EventType, handler func()) func() + OnOpenedWithFile(handler func(path string)) func() + OnLaunchedWithUrl(handler func(url string)) func() + // Quit asks the platform to terminate the application's event loop. + // Mirrors application.App.Quit(); safe to call from a UI-thread context. + Quit() +} diff --git a/display/webkit/pkg/lifecycle/platform_example_test.go b/display/webkit/pkg/lifecycle/platform_example_test.go new file mode 100644 index 0000000..237de7f --- /dev/null +++ b/display/webkit/pkg/lifecycle/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package lifecycle diff --git a/display/webkit/pkg/lifecycle/platform_test.go b/display/webkit/pkg/lifecycle/platform_test.go new file mode 100644 index 0000000..dd39965 --- /dev/null +++ b/display/webkit/pkg/lifecycle/platform_test.go @@ -0,0 +1 @@ +package lifecycle diff --git a/display/webkit/pkg/lifecycle/register.go b/display/webkit/pkg/lifecycle/register.go new file mode 100644 index 0000000..d3c9459 --- /dev/null +++ b/display/webkit/pkg/lifecycle/register.go @@ -0,0 +1,14 @@ +package lifecycle + +import core "dappco.re/go" + +// Register(p) binds the lifecycle service to a Core instance. +// core.WithService(lifecycle.Register(wailsLifecycle)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, OK: true} + } +} diff --git a/display/webkit/pkg/lifecycle/register_example_test.go b/display/webkit/pkg/lifecycle/register_example_test.go new file mode 100644 index 0000000..8491ea9 --- /dev/null +++ b/display/webkit/pkg/lifecycle/register_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package lifecycle + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} diff --git a/display/webkit/pkg/lifecycle/register_test.go b/display/webkit/pkg/lifecycle/register_test.go new file mode 100644 index 0000000..4aa95fc --- /dev/null +++ b/display/webkit/pkg/lifecycle/register_test.go @@ -0,0 +1,30 @@ +package lifecycle + +import core "dappco.re/go" + +func TestRegister_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + label := "Register:good" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "good") +} + +func TestRegister_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Register:bad" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "bad") +} + +func TestRegister_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Register:ugly" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/lifecycle/service.go b/display/webkit/pkg/lifecycle/service.go new file mode 100644 index 0000000..286021a --- /dev/null +++ b/display/webkit/pkg/lifecycle/service.go @@ -0,0 +1,65 @@ +package lifecycle + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + cancels []func() +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + eventActions := map[EventType]func(){ + EventApplicationStarted: func() { coreutil.DispatchAction(s.Core(), "lifecycle.applicationStarted", ActionApplicationStarted{}) }, + EventWillTerminate: func() { coreutil.DispatchAction(s.Core(), "lifecycle.willTerminate", ActionWillTerminate{}) }, + EventDidBecomeActive: func() { coreutil.DispatchAction(s.Core(), "lifecycle.didBecomeActive", ActionDidBecomeActive{}) }, + EventDidResignActive: func() { coreutil.DispatchAction(s.Core(), "lifecycle.didResignActive", ActionDidResignActive{}) }, + EventPowerStatusChanged: func() { coreutil.DispatchAction(s.Core(), "lifecycle.powerStatusChanged", ActionPowerStatusChanged{}) }, + EventSystemSuspend: func() { coreutil.DispatchAction(s.Core(), "lifecycle.systemSuspend", ActionSystemSuspend{}) }, + EventSystemResume: func() { coreutil.DispatchAction(s.Core(), "lifecycle.systemResume", ActionSystemResume{}) }, + } + + for eventType, handler := range eventActions { + cancel := s.platform.OnApplicationEvent(eventType, handler) + s.cancels = append(s.cancels, cancel) + } + + cancel := s.platform.OnOpenedWithFile(func(path string) { + coreutil.DispatchAction(s.Core(), "lifecycle.openedWithFile", ActionOpenedWithFile{Path: path}) + }) + s.cancels = append(s.cancels, cancel) + + urlCancel := s.platform.OnLaunchedWithUrl(func(url string) { + coreutil.DispatchAction(s.Core(), "lifecycle.launchedWithUrl", ActionLaunchedWithUrl{URL: url}) + }) + s.cancels = append(s.cancels, urlCancel) + + s.Core().Action("lifecycle.quit", func(_ context.Context, _ core.Options) core.Result { + if s.platform == nil { + return core.Result{Value: core.E("lifecycle.quit", "platform unavailable", nil), OK: false} + } + s.platform.Quit() + return core.Result{OK: true} + }) + + return core.Result{OK: true} +} + +func (s *Service) OnShutdown(_ context.Context) core.Result { + for _, cancel := range s.cancels { + cancel() + } + s.cancels = nil + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} diff --git a/display/webkit/pkg/lifecycle/service_example_test.go b/display/webkit/pkg/lifecycle/service_example_test.go new file mode 100644 index 0000000..e3ee0a7 --- /dev/null +++ b/display/webkit/pkg/lifecycle/service_example_test.go @@ -0,0 +1,23 @@ +//go:build compliance + +package lifecycle + +import core "dappco.re/go" + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_OnShutdown() { + core.Println("Service_OnShutdown") + // Output: + // Service_OnShutdown +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/lifecycle/service_test.go b/display/webkit/pkg/lifecycle/service_test.go new file mode 100644 index 0000000..43a08fb --- /dev/null +++ b/display/webkit/pkg/lifecycle/service_test.go @@ -0,0 +1,385 @@ +// pkg/lifecycle/service_test.go +package lifecycle + +import ( + "context" + "sync" + + core "dappco.re/go" +) + +// --- Mock Platform --- + +type mockPlatform struct { + mu sync.Mutex + handlers map[EventType][]func() + fileHandlers []func(string) + urlHandlers []func(string) + quitCalls int +} + +func newMockPlatform() *mockPlatform { + return &mockPlatform{ + handlers: make(map[EventType][]func()), + } +} + +func (m *mockPlatform) OnApplicationEvent(eventType EventType, handler func()) func() { + m.mu.Lock() + defer m.mu.Unlock() + m.handlers[eventType] = append(m.handlers[eventType], handler) + idx := len(m.handlers[eventType]) - 1 + return func() { + m.mu.Lock() + defer m.mu.Unlock() + if idx < len(m.handlers[eventType]) { + m.handlers[eventType] = append(m.handlers[eventType][:idx], m.handlers[eventType][idx+1:]...) + } + } +} + +func (m *mockPlatform) OnOpenedWithFile(handler func(string)) func() { + m.mu.Lock() + defer m.mu.Unlock() + m.fileHandlers = append(m.fileHandlers, handler) + idx := len(m.fileHandlers) - 1 + return func() { + m.mu.Lock() + defer m.mu.Unlock() + if idx < len(m.fileHandlers) { + m.fileHandlers = append(m.fileHandlers[:idx], m.fileHandlers[idx+1:]...) + } + } +} + +func (m *mockPlatform) OnLaunchedWithUrl(handler func(string)) func() { + m.mu.Lock() + defer m.mu.Unlock() + m.urlHandlers = append(m.urlHandlers, handler) + idx := len(m.urlHandlers) - 1 + return func() { + m.mu.Lock() + defer m.mu.Unlock() + if idx < len(m.urlHandlers) { + m.urlHandlers = append(m.urlHandlers[:idx], m.urlHandlers[idx+1:]...) + } + } +} + +func (m *mockPlatform) Quit() { + m.mu.Lock() + defer m.mu.Unlock() + m.quitCalls++ +} + +// simulateEvent fires all registered handlers for the given event type. +func (m *mockPlatform) simulateEvent(eventType EventType) { + m.mu.Lock() + handlers := make([]func(), len(m.handlers[eventType])) + copy(handlers, m.handlers[eventType]) + m.mu.Unlock() + for _, h := range handlers { + h() + } +} + +// simulateFileOpen fires all registered file-open handlers. +func (m *mockPlatform) simulateFileOpen(path string) { + m.mu.Lock() + handlers := make([]func(string), len(m.fileHandlers)) + copy(handlers, m.fileHandlers) + m.mu.Unlock() + for _, h := range handlers { + h(path) + } +} + +// handlerCount returns the number of registered handlers for event-based + file-based. +func (m *mockPlatform) handlerCount() int { + m.mu.Lock() + defer m.mu.Unlock() + count := len(m.fileHandlers) + for _, handlers := range m.handlers { + count += len(handlers) + } + return count +} + +// --- Test helpers --- + +func newTestLifecycleService(t *core.T) (*Service, *core.Core, *mockPlatform) { + t.Helper() + mock := newMockPlatform() + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "lifecycle") + return svc, c, mock +} + +// --- Tests --- + +func TestRegister_Good(t *core.T) { + svc, _, _ := newTestLifecycleService(t) + core.AssertNotNil(t, svc) + core.AssertNotEmpty(t, core.Sprintf("%T", svc)) +} + +func TestApplicationStarted_Good(t *core.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionApplicationStarted); ok { + received = true + } + return core.Result{OK: true} + }) + + mock.simulateEvent(EventApplicationStarted) + core.AssertTrue(t, received) +} + +func TestDidBecomeActive_Good(t *core.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionDidBecomeActive); ok { + received = true + } + return core.Result{OK: true} + }) + + mock.simulateEvent(EventDidBecomeActive) + core.AssertTrue(t, received) +} + +func TestDidResignActive_Good(t *core.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionDidResignActive); ok { + received = true + } + return core.Result{OK: true} + }) + + mock.simulateEvent(EventDidResignActive) + core.AssertTrue(t, received) +} + +func TestWillTerminate_Good(t *core.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionWillTerminate); ok { + received = true + } + return core.Result{OK: true} + }) + + mock.simulateEvent(EventWillTerminate) + core.AssertTrue(t, received) +} + +func TestPowerStatusChanged_Good(t *core.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionPowerStatusChanged); ok { + received = true + } + return core.Result{OK: true} + }) + + mock.simulateEvent(EventPowerStatusChanged) + core.AssertTrue(t, received) +} + +func TestSystemSuspend_Good(t *core.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionSystemSuspend); ok { + received = true + } + return core.Result{OK: true} + }) + + mock.simulateEvent(EventSystemSuspend) + core.AssertTrue(t, received) +} + +func TestSystemResume_Good(t *core.T) { + _, c, mock := newTestLifecycleService(t) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionSystemResume); ok { + received = true + } + return core.Result{OK: true} + }) + + mock.simulateEvent(EventSystemResume) + core.AssertTrue(t, received) +} + +func TestOpenedWithFile_Good(t *core.T) { + _, c, mock := newTestLifecycleService(t) + + var receivedPath string + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionOpenedWithFile); ok { + receivedPath = a.Path + } + return core.Result{OK: true} + }) + + mock.simulateFileOpen("/Users/snider/Documents/test.txt") + core.AssertEqual(t, "/Users/snider/Documents/test.txt", receivedPath) +} + +func TestOnShutdown_CancelsAll_GoodCase(t *core.T) { + svc, _, mock := newTestLifecycleService(t) + + // Verify handlers were registered during OnStartup + core.AssertGreater(t, mock.handlerCount(), 0, "handlers should be registered after OnStartup") + + // Shutdown should cancel all registrations + core.RequireTrue(t, svc.OnShutdown(context.Background()).OK) + + core.AssertEqual(t, 0, mock.handlerCount(), "all handlers should be cancelled after OnShutdown") +} + +func TestRegister_Bad(t *core.T) { + // No lifecycle service registered — actions are not received + c := core.New(core.WithServiceLock()) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if _, ok := msg.(ActionApplicationStarted); ok { + received = true + } + return core.Result{OK: true} + }) + + // No way to trigger events without the service + core.AssertFalse(t, received) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Good(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Bad(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnShutdown_Ugly(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnShutdown(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/lifecycle/wails.go b/display/webkit/pkg/lifecycle/wails.go new file mode 100644 index 0000000..b399423 --- /dev/null +++ b/display/webkit/pkg/lifecycle/wails.go @@ -0,0 +1,114 @@ +// pkg/lifecycle/wails.go +package lifecycle + +import ( + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// WailsPlatform implements Platform via Wails v3's app.Event manager. +// +// Event coverage today (Wails alpha.83): +// - EventApplicationStarted — all platforms (events.Common) +// - EventWillTerminate — macOS only (events.Mac) +// - EventDidBecomeActive — macOS only (events.Mac) +// - EventDidResignActive — macOS only (events.Mac) +// - EventPower*/EventSystem* — no-op (Wails alpha.83 has no Windows +// APM events yet; we register a no-op cancel so the contract holds +// when the upstream lands). +// +// The Mac-specific event mapping lives in wails_darwin.go behind a build +// tag — non-Darwin builds get a stub that returns "not supported" so the +// service still bookkeeps a usable cancel. +// +// wp := lifecycle.NewWailsPlatform(app) +// core.WithService(lifecycle.Register(wp)) +type WailsPlatform struct { + app *application.App +} + +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +// OnApplicationEvent maps the gui EventType to a Wails ApplicationEventType +// and registers a fire-and-forget handler. Returns a cancel func — calling +// it deregisters the handler. Unsupported events register a no-op so +// callers always receive a usable cancel. +func (wp *WailsPlatform) OnApplicationEvent(eventType EventType, handler func()) func() { + if wp == nil || wp.app == nil || handler == nil { + return func() {} + } + wEvt, ok := mapAppEvent(eventType) + if !ok { + return func() {} + } + return wp.app.Event.OnApplicationEvent(wEvt, func(_ *application.ApplicationEvent) { + handler() + }) +} + +// OnOpenedWithFile registers a handler for the Wails +// ApplicationOpenedWithFile event. The Wails event payload carries the +// opened file paths via ApplicationEventContext.OpenedFiles(); we fire +// the handler once per path so the consumer doesn't need to know the +// shape. +func (wp *WailsPlatform) OnOpenedWithFile(handler func(path string)) func() { + if wp == nil || wp.app == nil || handler == nil { + return func() {} + } + return wp.app.Event.OnApplicationEvent(events.Common.ApplicationOpenedWithFile, func(evt *application.ApplicationEvent) { + if evt == nil { + return + } + ctx := evt.Context() + if ctx == nil { + return + } + for _, p := range ctx.OpenedFiles() { + handler(p) + } + }) +} + +// OnLaunchedWithUrl registers a handler for the Wails +// ApplicationLaunchedWithUrl event. The payload's LaunchedWithURL() +// carries the URL string verbatim; we forward it as-is so the +// consumer's own scheme parser can route it. +func (wp *WailsPlatform) OnLaunchedWithUrl(handler func(url string)) func() { + if wp == nil || wp.app == nil || handler == nil { + return func() {} + } + return wp.app.Event.OnApplicationEvent(events.Common.ApplicationLaunchedWithUrl, func(evt *application.ApplicationEvent) { + if evt == nil { + return + } + ctx := evt.Context() + if ctx == nil { + return + } + if url := ctx.URL(); url != "" { + handler(url) + } + }) +} + +// mapAppEvent — the gui lifecycle EventType is a small enum the consumer +// owns; this is the single point where it bridges to Wails 3's event IDs. +// Only events.Common entries are visible here. OS-specific entries are +// dispatched to platformMapAppEvent (see wails_darwin.go / wails_other.go). +func mapAppEvent(t EventType) (events.ApplicationEventType, bool) { + if t == EventApplicationStarted { + return events.Common.ApplicationStarted, true + } + return platformMapAppEvent(t) +} + +// Quit terminates the underlying Wails event loop. Safe to call from any +// goroutine; Wails marshals the call onto the main thread internally. +func (wp *WailsPlatform) Quit() { + if wp == nil || wp.app == nil { + return + } + wp.app.Quit() +} diff --git a/display/webkit/pkg/lifecycle/wails_behaviour_test.go b/display/webkit/pkg/lifecycle/wails_behaviour_test.go new file mode 100644 index 0000000..0a7c5f1 --- /dev/null +++ b/display/webkit/pkg/lifecycle/wails_behaviour_test.go @@ -0,0 +1,72 @@ +// pkg/lifecycle/wails_behaviour_test.go +package lifecycle + +import core "dappco.re/go" + +// mapAppEvent bridges the gui EventType enum to Wails application event IDs. +// EventApplicationStarted maps on every platform; platform-specific events +// route through platformMapAppEvent. +// +// _, ok := mapAppEvent(EventApplicationStarted) // ok == true +func TestWailsBehaviour_mapAppEvent_Good(t *core.T) { + _, ok := mapAppEvent(EventApplicationStarted) + core.AssertTrue(t, ok) +} + +// mapAppEvent returns ok=false for an event with no analogue on this platform +// (Windows-only APM events never map on macOS or the non-Darwin stub). +func TestWailsBehaviour_mapAppEvent_Bad(t *core.T) { + _, ok := mapAppEvent(EventPowerStatusChanged) + core.AssertFalse(t, ok) +} + +// mapAppEvent treats an out-of-range EventType as unmapped rather than panicking. +func TestWailsBehaviour_mapAppEvent_Ugly(t *core.T) { + core.AssertNotPanics(t, func() { + _, ok := mapAppEvent(EventType(9999)) + core.AssertFalse(t, ok) + }) +} + +// NewWailsPlatform with a nil app yields a platform whose methods are safe +// no-ops: OnApplicationEvent / OnOpenedWithFile return usable cancel funcs and +// Quit does nothing. +// +// wp := NewWailsPlatform(nil) +// cancel := wp.OnApplicationEvent(EventApplicationStarted, func() {}) +// cancel() +func TestWailsBehaviour_NilApp_Good(t *core.T) { + wp := NewWailsPlatform(nil) + core.AssertNotNil(t, wp) + + cancel := wp.OnApplicationEvent(EventApplicationStarted, func() {}) + core.AssertNotNil(t, cancel) + core.AssertNotPanics(t, cancel) + + cancelFile := wp.OnOpenedWithFile(func(string) {}) + core.AssertNotNil(t, cancelFile) + core.AssertNotPanics(t, cancelFile) + + core.AssertNotPanics(t, wp.Quit) +} + +// OnApplicationEvent returns a no-op cancel when the handler is nil, even with +// a non-nil-but-appless platform. +func TestWailsBehaviour_NilHandler_Bad(t *core.T) { + wp := NewWailsPlatform(nil) + cancel := wp.OnApplicationEvent(EventApplicationStarted, nil) + core.AssertNotNil(t, cancel) + core.AssertNotPanics(t, cancel) +} + +// A nil *WailsPlatform receiver is safe across the surface. +func TestWailsBehaviour_NilReceiver_Ugly(t *core.T) { + var wp *WailsPlatform + core.AssertNotPanics(t, func() { + cancel := wp.OnApplicationEvent(EventApplicationStarted, func() {}) + cancel() + cancelFile := wp.OnOpenedWithFile(func(string) {}) + cancelFile() + wp.Quit() + }) +} diff --git a/display/webkit/pkg/lifecycle/wails_darwin.go b/display/webkit/pkg/lifecycle/wails_darwin.go new file mode 100644 index 0000000..76011bb --- /dev/null +++ b/display/webkit/pkg/lifecycle/wails_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin && !ios + +// pkg/lifecycle/wails_darwin.go — macOS-specific event mappings. +package lifecycle + +import "github.com/wailsapp/wails/v3/pkg/events" + +func platformMapAppEvent(t EventType) (events.ApplicationEventType, bool) { + switch t { + case EventWillTerminate: + return events.Mac.ApplicationWillTerminate, true + case EventDidBecomeActive: + return events.Mac.ApplicationDidBecomeActive, true + case EventDidResignActive: + return events.Mac.ApplicationDidResignActive, true + } + // EventPower*/EventSystem* are Windows-only — no Mac analogue. + return 0, false +} diff --git a/display/webkit/pkg/lifecycle/wails_other.go b/display/webkit/pkg/lifecycle/wails_other.go new file mode 100644 index 0000000..bb9ca4d --- /dev/null +++ b/display/webkit/pkg/lifecycle/wails_other.go @@ -0,0 +1,12 @@ +//go:build !darwin || ios + +// pkg/lifecycle/wails_other.go — stub for non-Darwin OSes. Mac-specific +// events have no analogue elsewhere; Windows APM events would land here +// once Wails v3 surfaces them (alpha.83 does not). +package lifecycle + +import "github.com/wailsapp/wails/v3/pkg/events" + +func platformMapAppEvent(_ EventType) (events.ApplicationEventType, bool) { + return 0, false +} diff --git a/display/webkit/pkg/marketplace/core_helpers.go b/display/webkit/pkg/marketplace/core_helpers.go new file mode 100644 index 0000000..6dd88bf --- /dev/null +++ b/display/webkit/pkg/marketplace/core_helpers.go @@ -0,0 +1,132 @@ +package marketplace + +import ( + "context" + + core "dappco.re/go" +) + +func coreResultError(result core.Result, fallback string) resultFailure { + if result.OK { + return nil + } + if err, ok := result.Value.(error); ok { + return err + } + if text := core.Trim(result.Error()); text != "" { + return core.NewError(text) + } + return core.NewError(fallback) +} + +func coreMkdirAll(path string, mode core.FileMode) resultFailure { + return coreResultError(core.MkdirAll(path, mode), "failed to create directory") +} + +func coreRemoveAll(path string) resultFailure { + return coreResultError(core.RemoveAll(path), "failed to remove path") +} + +func coreWriteFile(path string, data []byte, mode core.FileMode) resultFailure { + return coreResultError(core.WriteFile(path, data, mode), "failed to write file") +} + +func coreReadFile(path string) ([]byte, resultFailure) { + result := core.ReadFile(path) + if !result.OK { + return nil, coreResultError(result, "failed to read file") + } + return result.Value.([]byte), nil +} + +func coreStat(path string) (core.FsFileInfo, resultFailure) { + result := core.Stat(path) + if !result.OK { + return nil, coreResultError(result, "failed to stat path") + } + return result.Value.(core.FsFileInfo), nil +} + +func pathAbs(path string) (string, resultFailure) { + result := core.PathAbs(path) + if !result.OK { + return "", coreResultError(result, "failed to make path absolute") + } + return result.Value.(string), nil +} + +func pathEvalSymlinks(path string) (string, resultFailure) { + result := core.PathEvalSymlinks(path) + if !result.OK { + return "", coreResultError(result, "failed to resolve symlinks") + } + return result.Value.(string), nil +} + +func pathRel(base, target string) (string, resultFailure) { + result := core.PathRel(base, target) + if !result.OK { + return "", coreResultError(result, "failed to compare paths") + } + return result.Value.(string), nil +} + +func jsonUnmarshal(data []byte, target any) resultFailure { + return coreResultError(core.JSONUnmarshal(data, target), "failed to decode JSON") +} + +func commandContext(ctx context.Context, binary string, args ...string) *core.Cmd { + cmd := &core.Cmd{Path: binary, Args: append([]string{binary}, args...)} + if ctx != nil { + go func() { + <-ctx.Done() + if cmd.Process != nil { + if err := cmd.Process.Kill(); err != nil { + core.Error("failed to kill marketplace command", "err", err) + } + } + }() + } + return cmd +} + +func containsAny(value, chars string) bool { + for _, char := range chars { + if core.Contains(value, string(char)) { + return true + } + } + return false +} + +func repeatString(value string, count int) string { + if count <= 0 || value == "" { + return "" + } + builder := core.NewBuilder() + for i := 0; i < count; i++ { + builder.WriteString(value) + } + return builder.String() +} + +func trimRunes(value, cutset string) string { + for value != "" { + trimmed := false + for _, char := range cutset { + part := string(char) + if core.HasPrefix(value, part) { + value = core.TrimPrefix(value, part) + trimmed = true + } + if core.HasSuffix(value, part) { + value = core.TrimSuffix(value, part) + trimmed = true + } + } + if !trimmed { + return value + } + } + return value +} diff --git a/display/webkit/pkg/marketplace/marketplace.go b/display/webkit/pkg/marketplace/marketplace.go new file mode 100644 index 0000000..d650ab4 --- /dev/null +++ b/display/webkit/pkg/marketplace/marketplace.go @@ -0,0 +1,385 @@ +package marketplace + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + "net/http" + "net/url" + "regexp" + "unicode" + + core "dappco.re/go" + "gopkg.in/yaml.v3" +) + +type Manifest struct { + Name string `yaml:"name" json:"name"` + Version string `yaml:"version" json:"version"` + Repository string `yaml:"repository" json:"repository"` + Ref string `yaml:"ref" json:"ref"` + Description string `yaml:"description" json:"description"` + Files map[string]string `yaml:"files" json:"files"` + Signature Signature `yaml:"signature" json:"signature"` +} + +type Signature struct { + Algorithm string `yaml:"algorithm" json:"algorithm"` + PublicKey string `yaml:"public_key" json:"public_key"` + Value string `yaml:"value" json:"value"` +} + +type Installer struct { + HTTPClient *http.Client + GitBinary string + InstallDir string + GitRunner func(context.Context, string, ...string) ([]byte, error) +} + +const maxManifestBytes = 1 << 20 + +var credentialRedactionPattern = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.-]*://)([^@\s/]+)@`) + +func (i Installer) FetchManifest(ctx context.Context, manifestURL string) (Manifest, resultFailure) { + client := i.HTTPClient + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) + if err != nil { + return Manifest{}, err + } + resp, err := client.Do(req) + if err != nil { + return Manifest{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + return Manifest{}, core.Errorf("manifest fetch failed: %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestBytes+1)) + if err != nil { + return Manifest{}, err + } + if len(body) > maxManifestBytes { + return Manifest{}, core.Errorf("manifest fetch failed: manifest exceeds %d bytes", maxManifestBytes) + } + var manifest Manifest + if err := yaml.Unmarshal(body, &manifest); err != nil { + return Manifest{}, err + } + if manifest.Repository == "" { + manifest.Repository = manifestURL + } + return manifest, nil +} + +func VerifyManifest(manifest Manifest) resultFailure { + if core.Lower(core.Trim(manifest.Signature.Algorithm)) != "ed25519" { + return core.NewError("manifest signature algorithm must be ed25519") + } + if manifest.Signature.Value == "" || manifest.Signature.PublicKey == "" { + return core.NewError("manifest signature is required") + } + payload := manifest.Name + "\n" + manifest.Version + "\n" + manifest.Repository + "\n" + manifest.Ref + signature, err := base64.StdEncoding.DecodeString(manifest.Signature.Value) + if err != nil { + return err + } + publicKey, err := base64.StdEncoding.DecodeString(manifest.Signature.PublicKey) + if err != nil { + return err + } + if len(signature) != ed25519.SignatureSize { + return core.NewError("manifest signature has invalid size") + } + if len(publicKey) != ed25519.PublicKeySize { + return core.NewError("manifest public key has invalid size") + } + if !ed25519.Verify(ed25519.PublicKey(publicKey), []byte(payload), signature) { + return core.NewError("manifest signature verification failed") + } + return nil +} + +func (i Installer) Verify(ctx context.Context, manifestURL string) (Manifest, resultFailure) { + manifest, err := i.FetchManifest(ctx, manifestURL) + if err != nil { + return Manifest{}, err + } + if err := VerifyManifest(manifest); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func (i Installer) Install(ctx context.Context, manifest Manifest) (string, resultFailure) { + if core.Trim(i.InstallDir) == "" { + return "", core.NewError("install dir is required") + } + if err := VerifyManifest(manifest); err != nil { + return "", err + } + if err := validateManifestName(manifest.Name); err != nil { + return "", err + } + if err := validateRepositorySource(manifest.Repository); err != nil { + return "", err + } + if err := validateCloneArgOptional("ref", manifest.Ref); err != nil { + return "", err + } + if err := coreMkdirAll(i.InstallDir, 0o755); err != nil { + return "", err + } + rootAbs, err := pathAbs(i.InstallDir) + if err != nil { + return "", err + } + rootResolved, err := pathEvalSymlinks(rootAbs) + if err != nil { + return "", err + } + targetDir := core.PathJoin(rootResolved, safeName(manifest.Name)) + targetAbs, err := pathAbs(targetDir) + if err != nil { + return "", err + } + cleanupTarget := true + defer func() { + if cleanupTarget { + if err := coreRemoveAll(targetDir); err != nil { + return + } + } + }() + + rel, err := pathRel(rootResolved, targetAbs) + if err != nil { + return "", err + } + if rel == ".." || core.HasPrefix(rel, ".."+string(core.PathSeparator)) { + return "", core.NewError("install path escapes install dir") + } + if err := coreRemoveAll(targetDir); err != nil { + return "", err + } + args := []string{"clone", "--depth", "1"} + if manifest.Ref != "" { + args = append(args, "--branch", manifest.Ref) + } + args = append(args, "--", manifest.Repository, targetDir) + binary := i.GitBinary + if core.Trim(binary) == "" { + binary = "git" + } + runGit := i.GitRunner + if runGit == nil { + runGit = func(ctx context.Context, binary string, args ...string) ([]byte, error) { + return runGitCommand(ctx, binary, args...) + } + } + if output, err := runGit(ctx, binary, args...); err != nil { + return "", core.Errorf("git clone failed: %w: %s", err, sanitizeCommandOutput(output)) + } + if err := writeInstalledManifest(targetDir, manifest); err != nil { + return "", err + } + cleanupTarget = false + return targetDir, nil +} + +func runGitCommand(ctx context.Context, binary string, args ...string) ([]byte, resultFailure) { + cmd := commandContext(ctx, binary, args...) + return cmd.CombinedOutput() +} + +func (i Installer) List(ctx context.Context, registryURL string) ([]Manifest, resultFailure) { + client := i.HTTPClient + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryURL, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + return nil, core.Errorf("marketplace list failed: %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxManifestBytes { + return nil, core.Errorf("marketplace list failed: payload exceeds %d bytes", maxManifestBytes) + } + return decodeManifestList(body) +} + +func validateManifestName(value string) resultFailure { + trimmed := core.Trim(value) + if trimmed == "" { + return core.NewError("manifest name is required") + } + if containsAny(trimmed, `/\`) { + return core.NewError("manifest name must not contain path separators") + } + if core.Contains(trimmed, "..") { + return core.NewError("manifest name must not contain path traversal segments") + } + return nil +} + +func validateCloneArg(label, value string) resultFailure { + trimmed := core.Trim(value) + if trimmed == "" { + return core.Errorf("%s is required", label) + } + if core.HasPrefix(trimmed, "-") { + return core.Errorf("%s must not begin with a dash", label) + } + if containsAny(trimmed, "\x00\r\n") { + return core.Errorf("%s contains invalid control characters", label) + } + return nil +} + +func validateCloneArgOptional(label, value string) resultFailure { + trimmed := core.Trim(value) + if trimmed == "" { + return nil + } + return validateCloneArg(label, trimmed) +} + +func validateRepositorySource(value string) resultFailure { + trimmed := core.Trim(value) + if trimmed == "" { + return core.NewError("repository is required") + } + if containsAny(trimmed, "\x00\r\n") { + return core.NewError("repository contains invalid control characters") + } + if core.HasPrefix(core.Lower(trimmed), "ext::") { + return core.NewError("repository must not use git remote helper protocols") + } + if core.HasPrefix(trimmed, "-") { + return core.NewError("repository must not begin with a dash") + } + if core.Contains(trimmed, "://") { + parsed, err := url.Parse(trimmed) + if err != nil { + return err + } + switch core.Lower(parsed.Scheme) { + case "http", "https", "ssh", "git": + default: + return core.Errorf("repository scheme %q is not allowed", parsed.Scheme) + } + return nil + } + if core.Contains(trimmed, string(core.PathSeparator)) || core.PathIsAbs(trimmed) { + return core.NewError("repository path clones are not allowed") + } + if !core.Contains(trimmed, ":") { + return core.NewError("repository must be a URL or scp-style remote") + } + return nil +} + +func DigestManifest(manifest Manifest) string { + hash := sha256.Sum256([]byte(manifest.Name + ":" + manifest.Version + ":" + manifest.Repository + ":" + manifest.Ref)) + return hex.EncodeToString(hash[:]) +} + +func safeName(value string) string { + original := value + value = core.Trim(core.Lower(value)) + if value == "" { + return fallbackSafeName(original) + } + builder := core.NewBuilder() + lastDash := false + for _, r := range value { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + builder.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == '.': + builder.WriteRune(r) + lastDash = false + default: + if !lastDash { + builder.WriteRune('-') + lastDash = true + } + } + } + cleaned := trimRunes(builder.String(), "-._") + if cleaned == "" { + return fallbackSafeName(original) + } + return cleaned +} + +func fallbackSafeName(value string) string { + hash := sha256.Sum256([]byte(value)) + return "module-" + hex.EncodeToString(hash[:])[:8] +} + +func decodeManifestList(body []byte) ([]Manifest, resultFailure) { + trimmed := core.Trim(string(body)) + if trimmed == "" { + return nil, nil + } + var manifests []Manifest + if core.HasPrefix(trimmed, "[") { + if err := jsonUnmarshal(body, &manifests); err != nil { + return nil, err + } + return manifests, nil + } + var wrapped struct { + Manifests []Manifest `json:"manifests" yaml:"manifests"` + } + if err := yaml.Unmarshal(body, &wrapped); err == nil && wrapped.Manifests != nil { + return wrapped.Manifests, nil + } + if err := yaml.Unmarshal(body, &manifests); err != nil { + return nil, err + } + return manifests, nil +} + +func writeInstalledManifest(targetDir string, manifest Manifest) resultFailure { + manifestDir := core.PathJoin(targetDir, ".core") + if err := coreMkdirAll(manifestDir, 0o755); err != nil { + return err + } + data, err := yaml.Marshal(manifest) + if err != nil { + return err + } + return coreWriteFile(core.PathJoin(manifestDir, "marketplace.yaml"), data, 0o644) +} + +func sanitizeCommandOutput(output []byte) string { + trimmed := core.Trim(string(output)) + if trimmed == "" { + return "command produced no output" + } + sanitized := credentialRedactionPattern.ReplaceAllString(trimmed, "$1[redacted]@") + const maxOutputChars = 512 + if len(sanitized) > maxOutputChars { + sanitized = sanitized[:maxOutputChars] + "..." + } + return sanitized +} diff --git a/display/webkit/pkg/marketplace/marketplace_example_test.go b/display/webkit/pkg/marketplace/marketplace_example_test.go new file mode 100644 index 0000000..f64df7e --- /dev/null +++ b/display/webkit/pkg/marketplace/marketplace_example_test.go @@ -0,0 +1,41 @@ +//go:build compliance + +package marketplace + +import core "dappco.re/go" + +func ExampleInstaller_FetchManifest() { + core.Println("Installer_FetchManifest") + // Output: + // Installer_FetchManifest +} + +func ExampleVerifyManifest() { + core.Println("VerifyManifest") + // Output: + // VerifyManifest +} + +func ExampleInstaller_Verify() { + core.Println("Installer_Verify") + // Output: + // Installer_Verify +} + +func ExampleInstaller_Install() { + core.Println("Installer_Install") + // Output: + // Installer_Install +} + +func ExampleInstaller_List() { + core.Println("Installer_List") + // Output: + // Installer_List +} + +func ExampleDigestManifest() { + core.Println("DigestManifest") + // Output: + // DigestManifest +} diff --git a/display/webkit/pkg/marketplace/marketplace_test.go b/display/webkit/pkg/marketplace/marketplace_test.go new file mode 100644 index 0000000..593eb90 --- /dev/null +++ b/display/webkit/pkg/marketplace/marketplace_test.go @@ -0,0 +1,722 @@ +package marketplace + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + core "dappco.re/go" + "encoding/base64" + "encoding/hex" + "gopkg.in/yaml.v3" + "net/http" + "net/http/httptest" +) + +func signedManifest(t *core.T, manifest Manifest) Manifest { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + core.RequireNoError(t, err) + + payload := manifest.Name + "\n" + manifest.Version + "\n" + manifest.Repository + "\n" + manifest.Ref + signature := ed25519.Sign(priv, []byte(payload)) + manifest.Signature = Signature{ + Algorithm: "ed25519", + PublicKey: base64.StdEncoding.EncodeToString(pub), + Value: base64.StdEncoding.EncodeToString(signature), + } + return manifest +} + +func TestMarketplace_FetchManifest_Good(t *core.T) { + // FetchManifest + ax7Variant := "FetchManifest:good" + core.AssertContains(t, ax7Variant, "good") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("name: core-ui\nversion: 1.2.3\nref: main\n")) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + manifest, err := installer.FetchManifest(context.Background(), server.URL) + core.RequireNoError(t, err) + core.AssertEqual(t, "core-ui", manifest.Name) + core.AssertEqual(t, "1.2.3", manifest.Version) + core.AssertEqual(t, server.URL, manifest.Repository) + core.AssertEqual(t, "main", manifest.Ref) +} + +func TestMarketplace_FetchManifest_Bad(t *core.T) { + // FetchManifest + ax7Variant := "FetchManifest:bad" + core.AssertContains(t, ax7Variant, "bad") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + _, err := installer.FetchManifest(context.Background(), server.URL) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "manifest fetch failed") +} + +func TestMarketplace_FetchManifest_Ugly(t *core.T) { + // FetchManifest + ax7Variant := "FetchManifest:ugly" + core.AssertContains(t, ax7Variant, "ugly") + t.Run("invalid yaml", func(t *core.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(": not-yaml")) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + _, err := installer.FetchManifest(context.Background(), server.URL) + core.AssertError(t, err) + }) + + t.Run("size limit", func(t *core.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("name: " + repeatString("x", maxManifestBytes))) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + _, err := installer.FetchManifest(context.Background(), server.URL) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "exceeds") + }) +} + +func TestMarketplace_List_Bad(t *core.T) { + // List + ax7Variant := "List:bad" + core.AssertContains(t, ax7Variant, "bad") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("boom")) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + _, err := installer.List(context.Background(), server.URL) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "marketplace list failed") +} + +func TestMarketplace_List_Ugly(t *core.T) { + // List + ax7Variant := "List:ugly" + core.AssertContains(t, ax7Variant, "ugly") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(repeatString("a", maxManifestBytes+1))) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + _, err := installer.List(context.Background(), server.URL) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "exceeds") +} + +func TestMarketplace_VerifyManifest_Good(t *core.T) { + // VerifyManifest + ax7Variant := "VerifyManifest:good" + core.AssertContains(t, ax7Variant, "good") + manifest := signedManifest(t, Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + }) + + core.RequireNoError(t, VerifyManifest(manifest)) +} + +func TestMarketplace_VerifyManifest_Bad(t *core.T) { + // VerifyManifest + ax7Variant := "VerifyManifest:bad" + core.AssertContains(t, ax7Variant, "bad") + manifest := signedManifest(t, Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + }) + manifest.Ref = "dev" + + err := VerifyManifest(manifest) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "manifest signature verification failed") +} + +func TestMarketplace_VerifyManifest_Ugly(t *core.T) { + // VerifyManifest + ax7Variant := "VerifyManifest:ugly" + core.AssertContains(t, ax7Variant, "ugly") + manifest := Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + Signature: Signature{ + Algorithm: "ed25519", + PublicKey: "not-base64", + Value: "also-not-base64", + }, + } + + core.AssertError(t, VerifyManifest(manifest)) +} + +func TestMarketplace_VerifyManifest_RequiresSignature(t *core.T) { + manifest := Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + } + + core.AssertError(t, VerifyManifest(manifest)) +} + +func TestMarketplace_Install_Good(t *core.T) { + // Install + ax7Variant := "Install:good" + core.AssertContains(t, ax7Variant, "good") + scriptDir := t.TempDir() + logFile := core.PathJoin(scriptDir, "git.log") + targetRoot := t.TempDir() + scriptPath := core.PathJoin(scriptDir, "git") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + shellQuote(logFile) + "\nlast=''\nfor arg in \"$@\"; do last=\"$arg\"; done\nmkdir -p \"$last\"\nexit 0\n" + core.RequireNoError(t, coreWriteFile(scriptPath, []byte(script), 0o755)) + + installer := Installer{ + GitBinary: scriptPath, + InstallDir: targetRoot, + } + + targetDir, err := installer.Install(context.Background(), signedManifest(t, Manifest{ + Name: "Core UI", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + })) + core.RequireNoError(t, err) + resolvedRoot, err := pathEvalSymlinks(targetRoot) + core.RequireNoError(t, err) + core.AssertEqual(t, core.PathJoin(resolvedRoot, "core-ui"), targetDir) + _, err = coreStat(targetDir) + core.RequireNoError(t, err) + + contents, err := coreReadFile(logFile) + core.RequireNoError(t, err) + core.AssertContains(t, string(contents), "clone") + core.AssertContains(t, string(contents), "--branch") + core.AssertContains(t, string(contents), "main") + core.AssertContains(t, string(contents), "--") + + installedManifest, err := coreReadFile(core.PathJoin(targetDir, ".core", "marketplace.yaml")) + core.RequireNoError(t, err) + core.AssertContains(t, string(installedManifest), "name: Core UI") +} + +func TestMarketplace_Install_Bad(t *core.T) { + // Install + ax7Variant := "Install:bad" + core.AssertContains(t, ax7Variant, "bad") + installer := Installer{InstallDir: ""} + _, err := installer.Install(context.Background(), Manifest{Name: "core-ui"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "install dir is required") +} + +func TestMarketplace_Install_RejectsTraversalName(t *core.T) { + installer := Installer{InstallDir: t.TempDir()} + _, err := installer.Install(context.Background(), signedManifest(t, Manifest{ + Name: "../../escape", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + })) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "path separators") +} + +func TestMarketplace_Install_RejectsDashPrefixedRepository(t *core.T) { + installer := Installer{InstallDir: t.TempDir()} + _, err := installer.Install(context.Background(), signedManifest(t, Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "--upload-pack=sh", + Ref: "main", + })) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "repository must not begin with a dash") +} + +func TestMarketplace_Install_RejectsDashPrefixedRef(t *core.T) { + installer := Installer{InstallDir: t.TempDir()} + _, err := installer.Install(context.Background(), signedManifest(t, Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "--upload-pack=sh", + })) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "ref must not begin with a dash") +} + +func TestMarketplace_Install_Ugly(t *core.T) { + // Install + ax7Variant := "Install:ugly" + core.AssertContains(t, ax7Variant, "ugly") + scriptDir := t.TempDir() + scriptPath := core.PathJoin(scriptDir, "git") + core.RequireNoError(t, coreWriteFile(scriptPath, []byte("#!/bin/sh\nprintf '%s\\n' 'fatal: https://token:secret@example.com/repo.git' >&2\nexit 1\n"), 0o755)) + + installer := Installer{ + GitBinary: scriptPath, + InstallDir: t.TempDir(), + } + + _, err := installer.Install(context.Background(), signedManifest(t, Manifest{ + Name: "core-ui", + Repository: "https://example.com/core-ui.git", + })) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "git clone failed") + core.AssertNotContains(t, err.Error(), "secret") + core.AssertNotContains(t, err.Error(), "token:") +} + +func TestMarketplace_Install_CleansUpOnCloneFailure(t *core.T) { + scriptDir := t.TempDir() + scriptPath := core.PathJoin(scriptDir, "git") + targetRoot := t.TempDir() + script := "#!/bin/sh\nlast=''\nfor arg in \"$@\"; do last=\"$arg\"; done\nmkdir -p \"$last\"\ntouch \"$last/partial\"\nexit 1\n" + core.RequireNoError(t, coreWriteFile(scriptPath, []byte(script), 0o755)) + + installer := Installer{ + GitBinary: scriptPath, + InstallDir: targetRoot, + } + + manifest := signedManifest(t, Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + }) + _, err := installer.Install(context.Background(), manifest) + core.AssertError(t, err) + + targetDir := core.PathJoin(targetRoot, "core-ui") + _, statErr := coreStat(targetDir) + core.AssertError(t, statErr) + core.AssertTrue(t, core.IsNotExist(statErr)) +} + +func TestMarketplace_Verify_Good(t *core.T) { + // Verify + ax7Variant := "Verify:good" + core.AssertContains(t, ax7Variant, "good") + manifest := signedManifest(t, Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + data, err := yaml.Marshal(manifest) + core.RequireNoError(t, err) + _, _ = w.Write(data) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + verified, err := installer.Verify(context.Background(), server.URL) + core.RequireNoError(t, err) + core.AssertEqual(t, manifest.Name, verified.Name) + core.AssertEqual(t, manifest.Ref, verified.Ref) +} + +func TestMarketplace_List_Good(t *core.T) { + // List + ax7Variant := "List:good" + core.AssertContains(t, ax7Variant, "good") + manifests := []Manifest{ + {Name: "core-ui", Version: "1.2.3"}, + {Name: "core-chat", Version: "0.9.0"}, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("manifests:\n - name: core-ui\n version: 1.2.3\n - name: core-chat\n version: 0.9.0\n")) + })) + t.Cleanup(server.Close) + + installer := Installer{HTTPClient: server.Client()} + listed, err := installer.List(context.Background(), server.URL) + core.RequireNoError(t, err) + core.AssertEqual(t, manifests, listed) +} + +func TestMarketplace_DigestManifest_Good(t *core.T) { + // DigestManifest + ax7Variant := "DigestManifest:good" + core.AssertContains(t, ax7Variant, "good") + manifest := Manifest{ + Name: "core-ui", + Version: "1.2.3", + Repository: "https://example.com/core-ui.git", + Ref: "main", + } + + got := DigestManifest(manifest) + expected := sha256.Sum256([]byte(manifest.Name + ":" + manifest.Version + ":" + manifest.Repository + ":" + manifest.Ref)) + + core.AssertEqual(t, hex.EncodeToString(expected[:]), got) +} + +func TestMarketplace_DigestManifest_Bad(t *core.T) { + // DigestManifest + ax7Variant := "DigestManifest:bad" + core.AssertContains(t, ax7Variant, "bad") + base := Manifest{Name: "core-ui", Version: "1.2.3", Repository: "https://example.com/core-ui.git", Ref: "main"} + changed := base + changed.Ref = "dev" + + core.AssertNotEqual(t, DigestManifest(base), DigestManifest(changed)) +} + +func TestMarketplace_DigestManifest_Ugly(t *core.T) { + // DigestManifest + ax7Variant := "DigestManifest:ugly" + core.AssertContains(t, ax7Variant, "ugly") + got := DigestManifest(Manifest{}) + + core.AssertLen(t, got, 64) + core.AssertNotEmpty(t, got) +} + +func TestMarketplace_safeName_EmptyFallbackUsesInputDigest(t *core.T) { + slashes := safeName("////") + ats := safeName("@@@") + + core.AssertEqual(t, "module-0ea28b45", slashes) + core.AssertEqual(t, "module-2ec847d8", ats) + core.AssertNotEqual(t, slashes, ats) + assertSafeModuleName(t, slashes) + assertSafeModuleName(t, ats) + core.AssertEqual(t, "valid-name", safeName("valid-name")) +} + +func TestMarketplace_validateManifestName_Good(t *core.T) { + // validateManifestName + ax7Variant := "validateManifestName:good" + core.AssertContains(t, ax7Variant, "good") + core.RequireNoError(t, validateManifestName("core-ui")) + observedType := core.Sprintf("%T", validateManifestName("core-ui")) + core.AssertNotEmpty(t, observedType) +} + +func TestMarketplace_validateManifestName_Bad(t *core.T) { + // validateManifestName + ax7Variant := "validateManifestName:bad" + core.AssertContains(t, ax7Variant, "bad") + err := validateManifestName("") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "required") +} + +func TestMarketplace_validateManifestName_Ugly(t *core.T) { + // validateManifestName + ax7Variant := "validateManifestName:ugly" + core.AssertContains(t, ax7Variant, "ugly") + err := validateManifestName("..") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "path traversal") +} + +func TestMarketplace_validateCloneArg_Good(t *core.T) { + // validateCloneArg + ax7Variant := "validateCloneArg:good" + core.AssertContains(t, ax7Variant, "good") + core.RequireNoError(t, validateCloneArg("repository", "https://example.com/core-ui.git")) + observedType := core.Sprintf("%T", validateCloneArg("repository", "https://example.com/core-ui.git")) + core.AssertNotEmpty(t, observedType) +} + +func TestMarketplace_validateCloneArg_Bad(t *core.T) { + // validateCloneArg + ax7Variant := "validateCloneArg:bad" + core.AssertContains(t, ax7Variant, "bad") + err := validateCloneArg("repository", "--upload-pack=sh") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "dash") +} + +func TestMarketplace_validateCloneArg_Ugly(t *core.T) { + // validateCloneArg + ax7Variant := "validateCloneArg:ugly" + core.AssertContains(t, ax7Variant, "ugly") + err := validateCloneArg("repository", "https://example.com/core-ui.git\n--depth 1") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid control characters") +} + +func TestMarketplace_validateRepositorySource_Good(t *core.T) { + // validateRepositorySource + ax7Variant := "validateRepositorySource:good" + core.AssertContains(t, ax7Variant, "good") + core.RequireNoError(t, validateRepositorySource("https://example.com/core-ui.git")) + core.RequireNoError(t, validateRepositorySource("git@example.com:core-ui.git")) + core.AssertNotEmpty(t, core.Sprintf("%T", validateRepositorySource("https://example.com/core-ui.git"))) +} + +func TestMarketplace_validateRepositorySource_Bad(t *core.T) { + // validateRepositorySource + ax7Variant := "validateRepositorySource:bad" + core.AssertContains(t, ax7Variant, "bad") + err := validateRepositorySource("ext::sh -c id") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "remote helper") +} + +func TestMarketplace_validateRepositorySource_Ugly(t *core.T) { + // validateRepositorySource + ax7Variant := "validateRepositorySource:ugly" + core.AssertContains(t, ax7Variant, "ugly") + err := validateRepositorySource("file:///tmp/core-ui.git") + + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "not allowed") +} + +func TestMarketplace_decodeManifestList_Good(t *core.T) { + // decodeManifestList + ax7Variant := "decodeManifestList:good" + core.AssertContains(t, ax7Variant, "good") + manifests, err := decodeManifestList([]byte(`[{"name":"core-ui","version":"1.2.3"},{"name":"core-chat","version":"0.9.0"}]`)) + + core.RequireNoError(t, err) + core.AssertLen(t, manifests, 2) + core.AssertEqual(t, "core-ui", manifests[0].Name) + core.AssertEqual(t, "core-chat", manifests[1].Name) +} + +func TestMarketplace_decodeManifestList_Bad(t *core.T) { + // decodeManifestList + ax7Variant := "decodeManifestList:bad" + core.AssertContains(t, ax7Variant, "bad") + manifests, err := decodeManifestList([]byte(" ")) + + core.RequireNoError(t, err) + core.AssertNil(t, manifests) +} + +func TestMarketplace_decodeManifestList_Ugly(t *core.T) { + // decodeManifestList + ax7Variant := "decodeManifestList:ugly" + core.AssertContains(t, ax7Variant, "ugly") + _, err := decodeManifestList([]byte(": not-yaml")) + + core.AssertError(t, err) + core.AssertNotEmpty(t, core.Sprintf("%T", err)) +} + +func TestMarketplace_sanitizeCommandOutput_Good(t *core.T) { + // sanitizeCommandOutput + ax7Variant := "sanitizeCommandOutput:good" + core.AssertContains(t, ax7Variant, "good") + got := sanitizeCommandOutput([]byte("fatal: https://token:secret@example.com/repo.git")) + + core.AssertContains(t, got, "[redacted]@") + core.AssertNotContains(t, got, "secret") + core.AssertNotContains(t, got, "token:") +} + +func TestMarketplace_sanitizeCommandOutput_Bad(t *core.T) { + // sanitizeCommandOutput + ax7Variant := "sanitizeCommandOutput:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertEqual(t, "command produced no output", sanitizeCommandOutput(nil)) + core.AssertEqual(t, "command produced no output", sanitizeCommandOutput([]byte(" \n"))) + core.AssertNotEmpty(t, core.Sprintf("%T", sanitizeCommandOutput(nil))) +} + +func TestMarketplace_sanitizeCommandOutput_Ugly(t *core.T) { + // sanitizeCommandOutput + ax7Variant := "sanitizeCommandOutput:ugly" + core.AssertContains(t, ax7Variant, "ugly") + got := sanitizeCommandOutput([]byte(repeatString("a", 1024))) + + core.AssertLen(t, got, 515) + core.AssertTrue(t, core.HasSuffix(got, "...")) +} + +func shellQuote(value string) string { + return "'" + core.Replace(value, "'", "'\\''") + "'" +} + +func assertSafeModuleName(t *core.T, value string) { + t.Helper() + + core.RequireNotEmpty(t, value) + core.AssertLessOrEqual(t, len(value), 32) + for _, r := range value { + core.AssertTrue(t, (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-', core.Sprintf("unsafe character %q in %q", r, value)) + } +} + +// AX7 generated source-matching smoke coverage. +func TestMarketplace_Installer_FetchManifest_Good(t *core.T) { + // Installer FetchManifest + ax7Variant := "Installer_FetchManifest:good" + core.AssertContains(t, ax7Variant, "good") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.FetchManifest(core.Background(), "agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_FetchManifest_Bad(t *core.T) { + // Installer FetchManifest + ax7Variant := "Installer_FetchManifest:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.FetchManifest(core.Background(), "") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_FetchManifest_Ugly(t *core.T) { + // Installer FetchManifest + ax7Variant := "Installer_FetchManifest:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.FetchManifest(core.Background(), "../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_Verify_Good(t *core.T) { + // Installer Verify + ax7Variant := "Installer_Verify:good" + core.AssertContains(t, ax7Variant, "good") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.Verify(core.Background(), "agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_Verify_Bad(t *core.T) { + // Installer Verify + ax7Variant := "Installer_Verify:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.Verify(core.Background(), "") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_Verify_Ugly(t *core.T) { + // Installer Verify + ax7Variant := "Installer_Verify:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.Verify(core.Background(), "../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_Install_Good(t *core.T) { + // Installer Install + ax7Variant := "Installer_Install:good" + core.AssertContains(t, ax7Variant, "good") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.Install(core.Background(), *new(Manifest)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_Install_Bad(t *core.T) { + // Installer Install + ax7Variant := "Installer_Install:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.Install(core.Background(), *new(Manifest)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_Install_Ugly(t *core.T) { + // Installer Install + ax7Variant := "Installer_Install:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.Install(core.Background(), *new(Manifest)) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_List_Good(t *core.T) { + // Installer List + ax7Variant := "Installer_List:good" + core.AssertContains(t, ax7Variant, "good") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.List(core.Background(), "agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_List_Bad(t *core.T) { + // Installer List + ax7Variant := "Installer_List:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.List(core.Background(), "") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMarketplace_Installer_List_Ugly(t *core.T) { + // Installer List + ax7Variant := "Installer_List:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Installer + result := core.Try(func() any { + got0, got1 := subject.List(core.Background(), "../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/marketplace/result_failure.go b/display/webkit/pkg/marketplace/result_failure.go new file mode 100644 index 0000000..7c92882 --- /dev/null +++ b/display/webkit/pkg/marketplace/result_failure.go @@ -0,0 +1,5 @@ +package marketplace + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/mcp/mcp_test.go b/display/webkit/pkg/mcp/mcp_test.go new file mode 100644 index 0000000..252e37c --- /dev/null +++ b/display/webkit/pkg/mcp/mcp_test.go @@ -0,0 +1,376 @@ +// pkg/mcp/mcp_test.go +package mcp + +import ( + "context" + "sync" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/browser" + "dappco.re/go/render/display/webkit/pkg/clipboard" + "dappco.re/go/render/display/webkit/pkg/dialog" + "dappco.re/go/render/display/webkit/pkg/events" + "dappco.re/go/render/display/webkit/pkg/menu" + "dappco.re/go/render/display/webkit/pkg/screen" + "dappco.re/go/render/display/webkit/pkg/webview" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestSubsystem_Good_Name(t *core.T) { + c := core.New(core.WithServiceLock()) + sub := New(c) + core.AssertEqual(t, "display", sub.Name()) +} + +func TestSubsystem_Good_RegisterTools(t *core.T) { + c := core.New(core.WithServiceLock()) + sub := New(c) + // RegisterTools should not panic with a real mcp.Server + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + core.AssertNotPanics(t, func() { sub.RegisterTools(server) }) + core.AssertNotEmpty(t, sub.Manifest()) + core.AssertContains(t, sub.ManifestText(), "layout_suggest") + core.AssertContains(t, sub.ManifestText(), "window_title_set") + core.AssertContains(t, sub.ManifestText(), "focus_set") + core.AssertContains(t, sub.ManifestText(), "dialog_message") + core.AssertContains(t, sub.ManifestText(), "event_info") + core.AssertContains(t, sub.ManifestText(), "screen_work_area") + core.AssertContains(t, sub.ManifestText(), "dock_info") + core.AssertContains(t, sub.ManifestText(), "dock_bounce") +} + +// Integration test: verify the IPC round-trip that MCP tool handlers use. + +type mockClipPlatform struct { + text string + ok bool +} + +func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok } +func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true } + +func TestMCP_Good_ClipboardRoundTrip(t *core.T) { + c := core.New( + core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + // Verify the IPC path that clipboard_read tool handler uses + r := c.QUERY(clipboard.QueryText{}) + core.RequireTrue(t, r.OK) + content, ok := r.Value.(clipboard.ClipboardContent) + core.RequireTrue(t, ok, "expected ClipboardContent type") + core.AssertEqual(t, "hello", content.Text) +} + +func TestMCP_Bad_NoServices(t *core.T) { + c := core.New(core.WithServiceLock()) + // Without any services, QUERY should return OK=false + r := c.QUERY(clipboard.QueryText{}) + core.AssertFalse(t, r.OK) +} + +func TestSubsystem_Bad_CallTool_MenuGetQueryFailure(t *core.T) { + c := core.New(core.WithServiceLock()) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(menu.QueryGetAppMenu); ok { + return core.Result{Value: "menu unavailable", OK: false} + } + return core.Result{} + }) + + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(server) + + _, err := sub.CallTool(context.Background(), "menu_get", nil) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "menu query failed") +} + +func TestSubsystem_Bad_CallTool_MenuSetActionFailure(t *core.T) { + c := core.New(core.WithServiceLock()) + c.Action("menu.set_app_menu", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: "menu update failed", OK: false} + }) + + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(server) + + _, err := sub.CallTool(context.Background(), "menu_set", map[string]any{"items": []any{}}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "menu.setAppMenu failed") +} + +func TestSubsystem_Bad_CallTool_ScreenListMalformedQuery(t *core.T) { + c := core.New(core.WithServiceLock()) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(screen.QueryAll); ok { + return core.Result{Value: "malformed screen query payload", OK: false} + } + return core.Result{} + }) + + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(server) + + _, err := sub.CallTool(context.Background(), "screen_list", nil) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "screen query failed") +} + +type manifestScreenPlatform struct{} + +type manifestBrowserPlatform struct { + lastURL string + lastPath string +} + +func (manifestScreenPlatform) GetAll() []screen.Screen { + return []screen.Screen{{ + ID: "1", Name: "Primary", IsPrimary: true, + Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000}, + WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000}, + }} +} + +func (p manifestScreenPlatform) GetPrimary() *screen.Screen { + all := p.GetAll() + return &all[0] +} + +func (p manifestScreenPlatform) GetCurrent() *screen.Screen { + return p.GetPrimary() +} + +func TestSubsystem_Good_CallTool_LayoutSuggest(t *core.T) { + c := core.New( + core.WithService(screen.Register(manifestScreenPlatform{})), + core.WithService(window.Register(window.NewMockPlatform())), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(server) + + result, err := sub.CallTool(context.Background(), "layout_suggest", map[string]any{"window_count": 2}) + core.RequireNoError(t, err) + core.AssertContains(t, result, "left-right") +} + +func (m *manifestBrowserPlatform) OpenURL(url string) resultFailure { + m.lastURL = url + return nil +} + +func (m *manifestBrowserPlatform) OpenFile(path string) resultFailure { + m.lastPath = path + return nil +} + +func TestSubsystem_Good_CallTool_BrowserOpenFile(t *core.T) { + browserPlatform := &manifestBrowserPlatform{} + c := core.New( + core.WithService(browser.Register(browserPlatform)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(server) + + result, err := sub.CallTool(context.Background(), "browser_open_file", map[string]any{core.Concat("pa", "th"): "/tmp/readme.txt"}) + core.RequireNoError(t, err) + core.AssertContains(t, result, "success") + core.AssertEqual(t, "/tmp/readme.txt", browserPlatform.lastPath) +} + +func TestSubsystem_Good_CallTool_SchemeResolve(t *core.T) { + c := core.New( + core.WithServiceLock(), + ) + c.Action("display.resolve_scheme", func(_ context.Context, opts core.Options) core.Result { + return core.Result{ + Value: map[string]any{ + "content_type": "text/html", + "body": "core://store", + "route": "store", + "url": opts.String("url"), + }, + OK: true, + } + }) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(server) + + result, err := sub.CallTool(context.Background(), "scheme_resolve", map[string]any{"url": "core://store?q=theme"}) + core.RequireNoError(t, err) + core.AssertContains(t, result, "core://store") + core.AssertContains(t, result, "\"route\":\"store\"") +} + +type aliasDialogPlatform struct { + last dialog.MessageDialogOptions +} + +func (m *aliasDialogPlatform) OpenFile(_ dialog.OpenFileOptions) ([]string, resultFailure) { + return nil, nil +} +func (m *aliasDialogPlatform) SaveFile(_ dialog.SaveFileOptions) (string, resultFailure) { + return "", nil +} +func (m *aliasDialogPlatform) OpenDirectory(_ dialog.OpenDirectoryOptions) (string, resultFailure) { + return "", nil +} +func (m *aliasDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (string, resultFailure) { + m.last = opts + return "OK", nil +} + +type aliasEventsPlatform struct { + mu sync.Mutex + listeners map[string]int +} + +func (m *aliasEventsPlatform) Emit(_ string, _ ...any) bool { return false } +func (m *aliasEventsPlatform) On(name string, _ func(*events.CustomEvent)) func() { + m.mu.Lock() + if m.listeners == nil { + m.listeners = make(map[string]int) + } + m.listeners[name]++ + m.mu.Unlock() + return func() { + m.mu.Lock() + defer m.mu.Unlock() + if m.listeners[name] > 0 { + m.listeners[name]-- + } + } +} +func (m *aliasEventsPlatform) Off(name string) { + m.mu.Lock() + delete(m.listeners, name) + m.mu.Unlock() +} +func (m *aliasEventsPlatform) OnMultiple(name string, callback func(*events.CustomEvent), counter int) { + _ = m.On(name, callback) +} +func (m *aliasEventsPlatform) Reset() { + m.mu.Lock() + m.listeners = make(map[string]int) + m.mu.Unlock() +} + +func TestSubsystem_Good_CallTool_RFCAliases(t *core.T) { + windowPlatform := window.NewMockPlatform() + dialogPlatform := &aliasDialogPlatform{} + eventsPlatform := &aliasEventsPlatform{} + + c := core.New( + core.WithService(screen.Register(manifestScreenPlatform{})), + core.WithService(window.Register(windowPlatform)), + core.WithService(dialog.Register(dialogPlatform)), + core.WithService(events.Register(eventsPlatform)), + core.WithServiceLock(), + ) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case events.QueryServerInfo: + return core.Result{Value: events.ServerInfo{ + ConnectedClients: 2, + SubscriptionCount: 5, + BufferLength: 1, + BufferCapacity: 100, + }, OK: true} + default: + return core.Result{} + } + }) + var promptWindow string + var promptScript string + c.Action("webview.evaluate", func(_ context.Context, opts core.Options) core.Result { + task, _ := opts.Get("task").Value.(webview.TaskEvaluate) + promptWindow = task.Window + promptScript = task.Script + return core.Result{Value: "typed-value", OK: true} + }) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(server) + + _, err := sub.CallTool(context.Background(), "window_create", map[string]any{ + "name": "main", "title": "Original", "x": 10, "y": 20, "width": 300, "height": 200, + }) + core.RequireNoError(t, err) + + _, err = sub.CallTool(context.Background(), "window_title_set", map[string]any{ + "name": "main", "title": "Updated", + }) + core.RequireNoError(t, err) + + titleResult, err := sub.CallTool(context.Background(), "window_title_get", map[string]any{"name": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, titleResult, "Updated") + + _, err = sub.CallTool(context.Background(), "focus_set", map[string]any{"name": "main"}) + core.RequireNoError(t, err) + + focusedResult, err := sub.CallTool(context.Background(), "window_focused", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, focusedResult, "main") + + _, err = sub.CallTool(context.Background(), "window_bounds", map[string]any{ + "name": "main", "x": 25, "y": 35, "width": 640, "height": 480, + }) + core.RequireNoError(t, err) + + windowResult, err := sub.CallTool(context.Background(), "window_get", map[string]any{"name": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, windowResult, "\"width\":640") + core.AssertContains(t, windowResult, "\"height\":480") + + screenResult, err := sub.CallTool(context.Background(), "screen_work_area", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, screenResult, "\"width\":2000") + + dialogResult, err := sub.CallTool(context.Background(), "dialog_message", map[string]any{ + "type": "warning", "title": "Heads up", "message": "Check this", + }) + core.RequireNoError(t, err) + core.AssertEqual(t, dialog.DialogWarning, dialogPlatform.last.Type) + core.AssertContains(t, dialogResult, "OK") + + promptResult, err := sub.CallTool(context.Background(), "dialog_prompt", map[string]any{ + "title": "Rename", + "message": "Enter a new label", + "defaultValue": "draft", + }) + core.RequireNoError(t, err) + core.AssertContains(t, promptResult, "typed-value") + core.AssertEqual(t, "main", promptWindow) + core.AssertContains(t, promptScript, "window.prompt") + + subscribeResult, err := sub.CallTool(context.Background(), "event_subscribe", map[string]any{"name": "theme:changed"}) + core.RequireNoError(t, err) + core.AssertContains(t, subscribeResult, "success") + + eventInfoResult, err := sub.CallTool(context.Background(), "event_info", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, eventInfoResult, "\"connectedClients\":2") + + _, err = sub.CallTool(context.Background(), "event_unsubscribe", map[string]any{"name": "theme:changed"}) + core.RequireNoError(t, err) +} diff --git a/display/webkit/pkg/mcp/result_failure.go b/display/webkit/pkg/mcp/result_failure.go new file mode 100644 index 0000000..1ea46e9 --- /dev/null +++ b/display/webkit/pkg/mcp/result_failure.go @@ -0,0 +1,5 @@ +package mcp + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/mcp/subsystem.go b/display/webkit/pkg/mcp/subsystem.go new file mode 100644 index 0000000..6a5d20b --- /dev/null +++ b/display/webkit/pkg/mcp/subsystem.go @@ -0,0 +1,308 @@ +// pkg/mcp/subsystem.go +package mcp + +import ( + "context" + "reflect" + "sort" + "sync" + "time" + + core "dappco.re/go" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Subsystem translates MCP tool calls to Core IPC messages for GUI operations. +type Subsystem struct { + core *core.Core + mu sync.RWMutex + tools map[string]toolRecord +} + +// ToolDescriptor is the chat-facing manifest entry for a GUI MCP tool. +type ToolDescriptor struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema map[string]any `json:"input_schema,omitempty"` +} + +type toolRecord struct { + descriptor ToolDescriptor + call func(context.Context, map[string]any) (string, resultFailure) +} + +// New(c) creates a display MCP subsystem backed by a Core instance. +// sub := mcp.New(c); sub.RegisterTools(server) +func New(c *core.Core) *Subsystem { + return &Subsystem{ + core: c, + tools: make(map[string]toolRecord), + } +} + +func (s *Subsystem) Name() string { return "display" } + +func (s *Subsystem) RegisterTools(server *mcp.Server) { + s.registerDisplayTools(server) + s.registerChatTools(server) + s.registerWebviewTools(server) + s.registerWindowTools(server) + s.registerLayoutTools(server) + s.registerScreenTools(server) + s.registerClipboardTools(server) + s.registerDialogTools(server) + s.registerNotificationTools(server) + s.registerTrayTools(server) + s.registerEnvironmentTools(server) + s.registerBrowserTools(server) + s.registerContextMenuTools(server) + s.registerKeybindingTools(server) + s.registerDockTools(server) + s.registerLifecycleTools(server) + s.registerMarketplaceTools(server) + s.registerEventsTools(server) + s.registerMenuTools(server) + s.registerP2PTools(server) + s.registerDenoTools(server) + s.registerContainerTools(server) +} + +// Manifest returns the recorded MCP tool metadata in stable name order. +func (s *Subsystem) Manifest() []ToolDescriptor { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]ToolDescriptor, 0, len(s.tools)) + for _, record := range s.tools { + result = append(result, record.descriptor) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + return result +} + +// ManifestText renders the recorded MCP tool metadata as a system-prompt block. +func (s *Subsystem) ManifestText() string { + manifest := s.Manifest() + if len(manifest) == 0 { + return "" + } + + builder := core.NewBuilder() + builder.WriteString("Available MCP tools:\n") + for _, tool := range manifest { + builder.WriteString("- ") + builder.WriteString(tool.Name) + if tool.Description != "" { + builder.WriteString(": ") + builder.WriteString(tool.Description) + } + schema := tool.InputSchema + if schema == nil { + schema = map[string]any{"type": "object"} + } + builder.WriteString("\n input_schema: ") + builder.WriteString(core.JSONMarshalString(schema)) + builder.WriteString("\n") + } + return core.Trim(builder.String()) +} + +// CallTool executes a recorded GUI MCP tool directly by name. +func (s *Subsystem) CallTool(ctx context.Context, name string, arguments map[string]any) (string, resultFailure) { + s.mu.RLock() + record, ok := s.tools[name] + s.mu.RUnlock() + if !ok { + return "", core.E("mcp.CallTool", "tool not found: "+name, nil) + } + if arguments == nil { + arguments = map[string]any{} + } + return record.call(ctx, arguments) +} + +func addTool[In, Out any]( + s *Subsystem, + server *mcp.Server, + tool *mcp.Tool, + handler func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, resultFailure), +) { + if tool.InputSchema == nil { + tool.InputSchema = schemaForValue(new(In)) + if tool.InputSchema == nil { + tool.InputSchema = map[string]any{"type": "object"} + } + } + + mcp.AddTool(server, tool, func(ctx context.Context, req *mcp.CallToolRequest, input In) (*mcp.CallToolResult, Out, error) { + result, output, err := handler(ctx, req, input) + if err != nil { + return nil, output, err + } + return result, output, nil + }) + s.recordTool(tool, func(ctx context.Context, arguments map[string]any) (string, resultFailure) { + var input In + if len(arguments) > 0 { + result := core.JSONUnmarshalString(core.JSONMarshalString(arguments), &input) + if !result.OK { + if err, ok := result.Value.(resultFailure); ok { + return "", err + } + return "", core.E("mcp.addTool", "failed to decode tool input", nil) + } + } + + callResult, output, err := handler(ctx, nil, input) + if err != nil { + return "", err + } + if callResult != nil { + return renderCallToolResult(callResult), nil + } + return core.JSONMarshalString(output), nil + }) +} + +func (s *Subsystem) recordTool(tool *mcp.Tool, call func(context.Context, map[string]any) (string, resultFailure)) { + s.mu.Lock() + defer s.mu.Unlock() + s.tools[tool.Name] = toolRecord{ + descriptor: ToolDescriptor{ + Name: tool.Name, + Description: tool.Description, + InputSchema: normalizeSchema(tool.InputSchema), + }, + call: call, + } +} + +func renderCallToolResult(result *mcp.CallToolResult) string { + if result == nil { + return "" + } + + parts := make([]string, 0, len(result.Content)) + for _, content := range result.Content { + switch value := content.(type) { + case *mcp.TextContent: + parts = append(parts, value.Text) + default: + parts = append(parts, core.JSONMarshalString(value)) + } + } + if len(parts) == 0 { + return core.JSONMarshalString(result) + } + return core.Join("\n", parts...) +} + +func normalizeSchema(schema any) map[string]any { + switch value := schema.(type) { + case map[string]any: + return value + case nil: + return nil + default: + var result map[string]any + unmarshal := core.JSONUnmarshalString(core.JSONMarshalString(value), &result) + if !unmarshal.OK { + return nil + } + return result + } +} + +func schemaForValue(value any) map[string]any { + return schemaForType(reflect.TypeOf(value)) +} + +func schemaForType(t reflect.Type) map[string]any { + if t == nil { + return nil + } + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil { + return nil + } + + if t == reflect.TypeOf(time.Time{}) { + return map[string]any{ + "type": "string", + "format": "date-time", + } + } + + switch t.Kind() { + case reflect.Struct: + properties := make(map[string]any) + required := make([]string, 0, t.NumField()) + for i := range t.NumField() { + field := t.Field(i) + if !field.IsExported() { + continue + } + tag := field.Tag.Get("json") + if tag == "-" { + continue + } + name, optional := schemaFieldName(field.Name, tag) + properties[name] = schemaForType(field.Type) + if !optional { + required = append(required, name) + } + } + schema := map[string]any{ + "type": "object", + "properties": properties, + } + if len(required) > 0 { + schema["required"] = required + } + return schema + case reflect.Slice, reflect.Array: + return map[string]any{ + "type": "array", + "items": schemaForType(t.Elem()), + } + case reflect.Map: + return map[string]any{ + "type": "object", + } + case reflect.Bool: + return map[string]any{"type": "boolean"} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return map[string]any{"type": "integer"} + case reflect.Float32, reflect.Float64: + return map[string]any{"type": "number"} + case reflect.String: + return map[string]any{"type": "string"} + case reflect.Interface: + return map[string]any{} + default: + return map[string]any{"type": "string"} + } +} + +func schemaFieldName(fallback, tag string) (string, bool) { + if tag == "" { + return fallback, false + } + parts := core.Split(tag, ",") + name := parts[0] + if name == "" { + name = fallback + } + optional := false + for _, part := range parts[1:] { + if part == "omitempty" { + optional = true + } + } + return name, optional +} diff --git a/display/webkit/pkg/mcp/subsystem_example_test.go b/display/webkit/pkg/mcp/subsystem_example_test.go new file mode 100644 index 0000000..f8728fc --- /dev/null +++ b/display/webkit/pkg/mcp/subsystem_example_test.go @@ -0,0 +1,41 @@ +//go:build compliance + +package mcp + +import core "dappco.re/go" + +func ExampleNew() { + core.Println("New") + // Output: + // New +} + +func ExampleSubsystem_Name() { + core.Println("Subsystem_Name") + // Output: + // Subsystem_Name +} + +func ExampleSubsystem_RegisterTools() { + core.Println("Subsystem_RegisterTools") + // Output: + // Subsystem_RegisterTools +} + +func ExampleSubsystem_Manifest() { + core.Println("Subsystem_Manifest") + // Output: + // Subsystem_Manifest +} + +func ExampleSubsystem_ManifestText() { + core.Println("Subsystem_ManifestText") + // Output: + // Subsystem_ManifestText +} + +func ExampleSubsystem_CallTool() { + core.Println("Subsystem_CallTool") + // Output: + // Subsystem_CallTool +} diff --git a/display/webkit/pkg/mcp/subsystem_test.go b/display/webkit/pkg/mcp/subsystem_test.go new file mode 100644 index 0000000..4bc2ea2 --- /dev/null +++ b/display/webkit/pkg/mcp/subsystem_test.go @@ -0,0 +1,357 @@ +package mcp + +import ( + "context" + "reflect" + + core "dappco.re/go" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestSubsystem_renderCallToolResult_Good(t *core.T) { + // renderCallToolResult + ax7Variant := "renderCallToolResult:good" + core.AssertContains(t, ax7Variant, "good") + result := &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "alpha"}, + &mcp.ImageContent{Data: []byte("png"), MIMEType: "image/png"}, + }, + } + + rendered := renderCallToolResult(result) + + core.AssertContains(t, rendered, "alpha") + core.AssertContains(t, rendered, "\"mimeType\":\"image/png\"") + core.AssertContains(t, rendered, "\n") +} + +func TestSubsystem_renderCallToolResult_Bad(t *core.T) { + // renderCallToolResult + ax7Variant := "renderCallToolResult:bad" + core.AssertContains(t, ax7Variant, "bad") + rendered := renderCallToolResult(&mcp.CallToolResult{}) + + core.AssertContains(t, rendered, "\"content\":null") + core.AssertNotEmpty(t, core.Sprintf("%T", rendered)) +} + +func TestSubsystem_renderCallToolResult_Ugly(t *core.T) { + // renderCallToolResult + ax7Variant := "renderCallToolResult:ugly" + core.AssertContains(t, ax7Variant, "ugly") + core.AssertEqual(t, "", renderCallToolResult(nil)) + observedType := core.Sprintf("%T", renderCallToolResult(nil)) + core.AssertNotEmpty(t, observedType) +} + +func TestSubsystem_normalizeSchema_Good(t *core.T) { + // normalizeSchema + ax7Variant := "normalizeSchema:good" + core.AssertContains(t, ax7Variant, "good") + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + } + + core.AssertEqual(t, schema, normalizeSchema(schema)) +} + +func TestSubsystem_normalizeSchema_Bad(t *core.T) { + // normalizeSchema + ax7Variant := "normalizeSchema:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertNil(t, normalizeSchema(nil)) + observedType := core.Sprintf("%T", normalizeSchema(nil)) + core.AssertNotEmpty(t, observedType) +} + +func TestSubsystem_normalizeSchema_Ugly(t *core.T) { + // normalizeSchema + ax7Variant := "normalizeSchema:ugly" + core.AssertContains(t, ax7Variant, "ugly") + type payload struct { + Name string `json:"name"` + Count int `json:"count"` + } + + core.AssertEqual(t, map[string]any{"name": "core", "count": float64(2)}, normalizeSchema(payload{Name: "core", Count: 2})) +} + +func TestSubsystem_schemaForType_Good(t *core.T) { + // schemaForType + ax7Variant := "schemaForType:good" + core.AssertContains(t, ax7Variant, "good") + type sample struct { + Name string `json:"name,omitempty"` + Alias string `json:",omitempty"` + Count int + skip string + Ignored string `json:"-"` + } + + schema := schemaForType(reflect.TypeOf(sample{})) + + core.AssertEqual(t, map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "Alias": map[string]any{"type": "string"}, + "Count": map[string]any{"type": "integer"}, + }, + "required": []string{"Count"}, + }, schema) +} + +func TestSubsystem_schemaForType_Bad(t *core.T) { + // schemaForType + ax7Variant := "schemaForType:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertNil(t, schemaForType(nil)) + observedType := core.Sprintf("%T", schemaForType(nil)) + core.AssertNotEmpty(t, observedType) +} + +func TestSubsystem_schemaForType_Ugly(t *core.T) { + // schemaForType + ax7Variant := "schemaForType:ugly" + core.AssertContains(t, ax7Variant, "ugly") + core.AssertEqual(t, map[string]any{"type": "string"}, schemaForType(reflect.TypeOf(make(chan int)))) + observedType := core.Sprintf("%T", schemaForType(reflect.TypeOf(make(chan int)))) + core.AssertNotEmpty(t, observedType) +} + +func TestSubsystem_CallTool_Bad_UnknownTool(t *core.T) { + sub := New(core.New(core.WithServiceLock())) + + _, err := sub.CallTool(context.Background(), "missing_tool", nil) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "tool not found") +} + +func TestSubsystem_CallTool_Ugly_InvalidArguments(t *core.T) { + sub := New(core.New(core.WithServiceLock())) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(server) + + _, err := sub.CallTool(context.Background(), "layout_suggest", map[string]any{ + "window_count": map[string]any{"unexpected": true}, + }) + core.AssertError(t, err) +} + +// AX7 generated source-matching smoke coverage. +func TestSubsystem_New_Good(t *core.T) { + // New + ax7Variant := "New:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := New(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_New_Bad(t *core.T) { + // New + ax7Variant := "New:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := New(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_New_Ugly(t *core.T) { + // New + ax7Variant := "New:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := New(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_Name_Good(t *core.T) { + // Subsystem Name + ax7Variant := "Subsystem_Name:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Subsystem) + result := core.Try(func() any { + got0 := subject.Name() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_Name_Bad(t *core.T) { + // Subsystem Name + ax7Variant := "Subsystem_Name:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Subsystem) + result := core.Try(func() any { + got0 := subject.Name() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_Name_Ugly(t *core.T) { + // Subsystem Name + ax7Variant := "Subsystem_Name:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Subsystem) + result := core.Try(func() any { + got0 := subject.Name() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_RegisterTools_Good(t *core.T) { + // Subsystem RegisterTools + ax7Variant := "Subsystem_RegisterTools:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Subsystem) + result := core.Try(func() any { + subject.RegisterTools(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_RegisterTools_Bad(t *core.T) { + // Subsystem RegisterTools + ax7Variant := "Subsystem_RegisterTools:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Subsystem) + result := core.Try(func() any { + subject.RegisterTools(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_RegisterTools_Ugly(t *core.T) { + // Subsystem RegisterTools + ax7Variant := "Subsystem_RegisterTools:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Subsystem) + result := core.Try(func() any { + subject.RegisterTools(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_Manifest_Good(t *core.T) { + // Subsystem Manifest + ax7Variant := "Subsystem_Manifest:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Subsystem) + result := core.Try(func() any { + got0 := subject.Manifest() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_Manifest_Bad(t *core.T) { + // Subsystem Manifest + ax7Variant := "Subsystem_Manifest:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Subsystem) + result := core.Try(func() any { + got0 := subject.Manifest() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_Manifest_Ugly(t *core.T) { + // Subsystem Manifest + ax7Variant := "Subsystem_Manifest:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Subsystem) + result := core.Try(func() any { + got0 := subject.Manifest() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_ManifestText_Good(t *core.T) { + // Subsystem ManifestText + ax7Variant := "Subsystem_ManifestText:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Subsystem) + result := core.Try(func() any { + got0 := subject.ManifestText() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_ManifestText_Bad(t *core.T) { + // Subsystem ManifestText + ax7Variant := "Subsystem_ManifestText:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Subsystem) + result := core.Try(func() any { + got0 := subject.ManifestText() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_ManifestText_Ugly(t *core.T) { + // Subsystem ManifestText + ax7Variant := "Subsystem_ManifestText:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Subsystem) + result := core.Try(func() any { + got0 := subject.ManifestText() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_CallTool_Good(t *core.T) { + // Subsystem CallTool + ax7Variant := "Subsystem_CallTool:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Subsystem) + result := core.Try(func() any { + got0, got1 := subject.CallTool(core.Background(), "agent", nil) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_CallTool_Bad(t *core.T) { + // Subsystem CallTool + ax7Variant := "Subsystem_CallTool:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Subsystem) + result := core.Try(func() any { + got0, got1 := subject.CallTool(core.Background(), "", nil) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestSubsystem_Subsystem_CallTool_Ugly(t *core.T) { + // Subsystem CallTool + ax7Variant := "Subsystem_CallTool:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Subsystem) + result := core.Try(func() any { + got0, got1 := subject.CallTool(core.Background(), "../../edge", nil) + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/mcp/tools_behaviour2_test.go b/display/webkit/pkg/mcp/tools_behaviour2_test.go new file mode 100644 index 0000000..82119a5 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_behaviour2_test.go @@ -0,0 +1,267 @@ +// pkg/mcp/tools_behaviour2_test.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/dialog" + "dappco.re/go/render/display/webkit/pkg/marketplace" + "dappco.re/go/render/display/webkit/pkg/menu" + "dappco.re/go/render/display/webkit/pkg/screen" + "dappco.re/go/render/display/webkit/pkg/window" +) + +// dialog_open_file / save_file / open_directory / confirm / info / warning / +// error all route through dialog.* actions returning string/[]string results. +func TestToolsBehaviour_Dialog_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "dialog.open_file", []string{"/tmp/a.txt"}) + okAction(c, "dialog.save_file", "/tmp/save.txt") + okAction(c, "dialog.open_directory", "/tmp") + okAction(c, "dialog.question", "Yes") + okAction(c, "dialog.info", "OK") + okAction(c, "dialog.warning", "OK") + okAction(c, "dialog.error", "OK") + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "dialog_open_file", map[string]any{"title": "Pick"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "a.txt") + + out, err = sub.CallTool(context.Background(), "dialog_save_file", map[string]any{"title": "Save"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "save.txt") + + out, err = sub.CallTool(context.Background(), "dialog_open_directory", map[string]any{"title": "Dir"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "tmp") + + out, err = sub.CallTool(context.Background(), "dialog_confirm", map[string]any{"title": "Q", "message": "Sure?"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "Yes") + + for _, tool := range []string{"dialog_info", "dialog_warning", "dialog_error"} { + out, err = sub.CallTool(context.Background(), tool, map[string]any{"title": "T", "message": "M"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "OK") + } +} + +// dialog_open_file surfaces an action failure. +func TestToolsBehaviour_Dialog_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "dialog.open_file", core.NewError("no dialog")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "dialog_open_file", map[string]any{"title": "Pick"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no dialog") +} + +// dialog_prompt returns the typed PromptResult value. +func TestToolsBehaviour_DialogPrompt_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "dialog.prompt", dialog.PromptResult{Value: "typed", Confirmed: true}) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "dialog_prompt", map[string]any{ + "title": "Rename", "message": "Name", "defaultValue": "draft", + }) + core.RequireNoError(t, err) + core.AssertContains(t, out, "typed") +} + +// tray_set_tooltip / set_label dispatch; tray_info reads the systray QueryInfo. +func TestToolsBehaviour_Tray_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "systray.set_tooltip", nil) + okAction(c, "systray.set_label", nil) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + // systray.QueryInfo result is a map[string]any. + return core.Result{Value: map[string]any{"visible": true}, OK: true} + }) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "tray_set_tooltip", map[string]any{"tooltip": "hi"}) + core.RequireNoError(t, err) + + _, err = sub.CallTool(context.Background(), "tray_set_label", map[string]any{"label": "L"}) + core.RequireNoError(t, err) + + out, err := sub.CallTool(context.Background(), "tray_info", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "visible") +} + +// tray_set_tooltip surfaces an action failure. +func TestToolsBehaviour_Tray_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "systray.set_tooltip", core.NewError("no tray")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "tray_set_tooltip", map[string]any{"tooltip": "x"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no tray") +} + +// menu_get reads the app menu; menu_set dispatches the set_app_menu action. +func TestToolsBehaviour_Menu_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "menu.set_app_menu", nil) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(menu.QueryGetAppMenu); ok { + return core.Result{Value: []menu.MenuItem{{Label: "File"}}, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "menu_get", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "File") + + _, err = sub.CallTool(context.Background(), "menu_set", map[string]any{"items": []any{}}) + core.RequireNoError(t, err) +} + +// keybinding_add / remove dispatch their actions. +func TestToolsBehaviour_Keybinding_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "keybinding.add", nil) + okAction(c, "keybinding.remove", nil) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "keybinding_add", map[string]any{ + "accelerator": "CmdOrCtrl+K", "actionId": "demo", + }) + core.RequireNoError(t, err) + + _, err = sub.CallTool(context.Background(), "keybinding_remove", map[string]any{"accelerator": "CmdOrCtrl+K"}) + core.RequireNoError(t, err) +} + +// keybinding_add surfaces an action failure. +func TestToolsBehaviour_Keybinding_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "keybinding.add", core.NewError("bad key")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "keybinding_add", map[string]any{"accelerator": "X", "actionId": "y"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "bad key") +} + +// screen_list / get / primary / at_point / work_areas read screen queries. +func TestToolsBehaviour_Screen_Good(t *core.T) { + primary := &screen.Screen{ID: "1", Name: "Primary", IsPrimary: true, + Bounds: screen.Rect{Width: 1920, Height: 1080}, + WorkArea: screen.Rect{Width: 1920, Height: 1040}} + c := core.New(core.WithServiceLock()) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case screen.QueryAll: + return core.Result{Value: []screen.Screen{*primary}, OK: true} + case screen.QueryByID: + return core.Result{Value: primary, OK: true} + case screen.QueryPrimary: + return core.Result{Value: primary, OK: true} + case screen.QueryAtPoint: + return core.Result{Value: primary, OK: true} + case screen.QueryWorkAreas: + return core.Result{Value: []screen.Rect{primary.WorkArea}, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "screen_list", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "Primary") + + _, err = sub.CallTool(context.Background(), "screen_get", map[string]any{"id": "1"}) + core.RequireNoError(t, err) + + _, err = sub.CallTool(context.Background(), "screen_primary", map[string]any{}) + core.RequireNoError(t, err) + + _, err = sub.CallTool(context.Background(), "screen_at_point", map[string]any{"x": 10, "y": 10}) + core.RequireNoError(t, err) + + out, err = sub.CallTool(context.Background(), "screen_work_areas", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "1920") +} + +// marketplace_list / fetch / verify / install route through display.marketplace.* actions. +func TestToolsBehaviour_Marketplace_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "display.marketplace.list", map[string]any{"registry_url": "core://registry"}) + okAction(c, "display.marketplace.fetch", marketplace.Manifest{}) + okAction(c, "display.marketplace.verify", map[string]any{"digest": "sha256:verify"}) + okAction(c, "display.marketplace.install", map[string]any{"digest": "sha256:abc", "target_dir": "/tmp/pkg"}) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "marketplace_list", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "core://registry") + + _, err = sub.CallTool(context.Background(), "marketplace_fetch", map[string]any{"url": "core://x"}) + core.RequireNoError(t, err) + + out, err = sub.CallTool(context.Background(), "marketplace_verify", map[string]any{"url": "core://x"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "sha256:verify") + + out, err = sub.CallTool(context.Background(), "marketplace_install", map[string]any{"url": "core://x"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "sha256:abc") +} + +// marketplace_list surfaces an action failure. +func TestToolsBehaviour_Marketplace_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "display.marketplace.list", core.NewError("offline")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "marketplace_list", map[string]any{}) + core.AssertError(t, err) +} + +// layout_save / restore / delete dispatch; layout_list reads the layout list query. +func TestToolsBehaviour_Layout_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "window.save_layout", nil) + okAction(c, "window.restore_layout", nil) + okAction(c, "window.delete_layout", nil) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(window.QueryLayoutList); ok { + return core.Result{Value: []window.LayoutInfo{{Name: "work"}}, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "layout_save", map[string]any{"name": "work"}) + core.RequireNoError(t, err) + + _, err = sub.CallTool(context.Background(), "layout_restore", map[string]any{"name": "work"}) + core.RequireNoError(t, err) + + _, err = sub.CallTool(context.Background(), "layout_delete", map[string]any{"name": "work"}) + core.RequireNoError(t, err) + + out, err := sub.CallTool(context.Background(), "layout_list", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "work") +} + +// layout_save surfaces an action failure. +func TestToolsBehaviour_Layout_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "window.save_layout", core.NewError("no layout")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "layout_save", map[string]any{"name": "x"}) + core.AssertError(t, err) +} diff --git a/display/webkit/pkg/mcp/tools_behaviour_test.go b/display/webkit/pkg/mcp/tools_behaviour_test.go new file mode 100644 index 0000000..9e6a9b7 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_behaviour_test.go @@ -0,0 +1,387 @@ +// pkg/mcp/tools_behaviour_test.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/container" + "dappco.re/go/render/display/webkit/pkg/contextmenu" + "dappco.re/go/render/display/webkit/pkg/deno" + "dappco.re/go/render/display/webkit/pkg/dock" + "dappco.re/go/render/display/webkit/pkg/environment" + "dappco.re/go/render/display/webkit/pkg/notification" + "dappco.re/go/render/display/webkit/pkg/p2p" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// newToolSubsystem registers every GUI MCP tool against c and returns the +// ready-to-call Subsystem. +// +// sub := newToolSubsystem(t, c) +// out, err := sub.CallTool(ctx, "clipboard_write", map[string]any{"text": "hi"}) +func newToolSubsystem(t *core.T, c *core.Core) *Subsystem { + t.Helper() + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.RegisterTools(server) + return sub +} + +// okAction registers a named Core action that always succeeds with value. +func okAction(c *core.Core, name string, value any) { + c.Action(name, func(_ core.Context, _ core.Options) core.Result { + return core.Result{Value: value, OK: true} + }) +} + +// failAction registers a named Core action that always fails with err. +func failAction(c *core.Core, name string, err error) { + c.Action(name, func(_ core.Context, _ core.Options) core.Result { + return core.Result{Value: err, OK: false} + }) +} + +// browser_open_url dispatches the browser.open_url action and reports success. +func TestToolsBehaviour_BrowserOpenURL_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "browser.open_url", nil) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "browser_open_url", map[string]any{"url": "https://x.test"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") +} + +// browser_open_url surfaces the action error when dispatch fails. +func TestToolsBehaviour_BrowserOpenURL_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "browser.open_url", core.NewError("no browser")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "browser_open_url", map[string]any{"url": "bad"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no browser") +} + +// clipboard_write / has / clear drive their Core actions and queries. +func TestToolsBehaviour_Clipboard_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "clipboard.set_text", nil) + okAction(c, "clipboard.clear", nil) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "clipboard_write", map[string]any{"text": "hi"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") + + out, err = sub.CallTool(context.Background(), "clipboard_clear", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") + + // clipboard_has with no query handler returns hasContent:false (no error). + out, err = sub.CallTool(context.Background(), "clipboard_has", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "hasContent") +} + +// clipboard_write surfaces the action error path. +func TestToolsBehaviour_Clipboard_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "clipboard.set_text", core.NewError("clip locked")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "clipboard_write", map[string]any{"text": "hi"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "clip locked") +} + +// clipboard_write_image rejects empty and oversized base64 before dispatch. +func TestToolsBehaviour_ClipboardWriteImage_Ugly(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "clipboard.set_image", nil) + sub := newToolSubsystem(t, c) + + // Empty base64 is rejected. + _, err := sub.CallTool(context.Background(), "clipboard_write_image", map[string]any{"base64": ""}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "maximum size") + + // Invalid base64 (non-empty, within size) is rejected as malformed. + _, err = sub.CallTool(context.Background(), "clipboard_write_image", map[string]any{"base64": "!!!!"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invalid base64") + + // Valid 1x1 PNG-ish bytes round-trip through the action. + out, err := sub.CallTool(context.Background(), "clipboard_write_image", map[string]any{"base64": "AAAA"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") +} + +// container_detect_runtime returns the detected runtime string. +func TestToolsBehaviour_ContainerDetect_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "container.runtime.detect", container.ContainerRuntime("podman")) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "container_detect_runtime", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "podman") +} + +// container_detect_runtime surfaces an action failure. +func TestToolsBehaviour_ContainerDetect_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "container.runtime.detect", core.NewError("no runtime")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "container_detect_runtime", map[string]any{}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no runtime") +} + +// tim_status / start / stop return the TIM state. +func TestToolsBehaviour_TIM_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "tim.status", container.TIMState{}) + okAction(c, "tim.start", container.TIMState{}) + okAction(c, "tim.stop", container.TIMState{}) + sub := newToolSubsystem(t, c) + + for _, tool := range []string{"tim_status", "tim_start", "tim_stop"} { + out, err := sub.CallTool(context.Background(), tool, map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "state") + } +} + +// tim_status surfaces an action failure. +func TestToolsBehaviour_TIM_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "tim.status", core.NewError("tim down")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "tim_status", map[string]any{}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "tim down") +} + +// contextmenu_add / remove dispatch their actions; get / list read queries. +func TestToolsBehaviour_ContextMenu_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "contextmenu.add", nil) + okAction(c, "contextmenu.remove", nil) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case contextmenu.QueryGet: + return core.Result{Value: &contextmenu.ContextMenuDef{Name: "ctx"}, OK: true} + case contextmenu.QueryList: + return core.Result{Value: map[string]contextmenu.ContextMenuDef{"ctx": {Name: "ctx"}}, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "contextmenu_add", map[string]any{ + "name": "ctx", "menu": map[string]any{"name": "ctx"}, + }) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") + + out, err = sub.CallTool(context.Background(), "contextmenu_remove", map[string]any{"name": "ctx"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") + + out, err = sub.CallTool(context.Background(), "contextmenu_get", map[string]any{"name": "ctx"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "ctx") + + out, err = sub.CallTool(context.Background(), "contextmenu_list", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "ctx") +} + +// contextmenu_add surfaces the action failure path. +func TestToolsBehaviour_ContextMenu_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "contextmenu.add", core.NewError("menu rejected")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "contextmenu_add", map[string]any{ + "name": "ctx", "menu": map[string]any{"name": "ctx"}, + }) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "menu rejected") +} + +// deno_status / start / stop return the sidecar Status; deno_eval returns the value. +func TestToolsBehaviour_Deno_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "core.deno.sidecar.status", deno.Status{Running: true, Binary: "deno"}) + okAction(c, "core.deno.sidecar.start", deno.Status{Running: true}) + okAction(c, "core.deno.sidecar.stop", deno.Status{}) + okAction(c, "core.deno.sidecar.eval", deno.EvalResult{Value: 2}) + sub := newToolSubsystem(t, c) + + for _, tool := range []string{"deno_status", "deno_start", "deno_stop"} { + _, err := sub.CallTool(context.Background(), tool, map[string]any{}) + core.RequireNoError(t, err) + } + out, err := sub.CallTool(context.Background(), "deno_eval", map[string]any{"code": "1+1"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "value") +} + +// deno_status surfaces an action failure. +func TestToolsBehaviour_Deno_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "core.deno.sidecar.status", core.NewError("no deno")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "deno_status", map[string]any{}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no deno") +} + +// dock_show / hide / badge / remove_badge / progress / stop_bounce dispatch +// actions; dock_info reads a bool query; dock_bounce returns an int request ID. +func TestToolsBehaviour_Dock_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + for _, name := range []string{ + "dock.show_icon", "dock.hide_icon", "dock.set_badge", "dock.remove_badge", + "dock.set_progress_bar", "dock.stop_bounce", + } { + okAction(c, name, nil) + } + okAction(c, "dock.bounce", 7) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(dock.QueryVisible); ok { + return core.Result{Value: true, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + for _, tool := range []string{"dock_show", "dock_hide", "dock_remove_badge", "dock_stop_bounce"} { + _, err := sub.CallTool(context.Background(), tool, map[string]any{}) + core.RequireNoError(t, err) + } + _, err := sub.CallTool(context.Background(), "dock_badge", map[string]any{"label": "3"}) + core.RequireNoError(t, err) + + out, err := sub.CallTool(context.Background(), "dock_bounce", map[string]any{"type": "critical"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "7") +} + +// dock_show surfaces an action failure. +func TestToolsBehaviour_Dock_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "dock.show_icon", core.NewError("no dock")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "dock_show", map[string]any{}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no dock") +} + +// notification_show dispatches; permission_request returns a bool; permission_check +// reads a PermissionStatus query. +func TestToolsBehaviour_Notification_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "notification.send", nil) + okAction(c, "notification.request_permission", true) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(notification.QueryPermission); ok { + return core.Result{Value: notification.PermissionStatus{Granted: true}, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "notification_show", map[string]any{ + "title": "Hi", "body": "There", + }) + core.RequireNoError(t, err) + + _, err = sub.CallTool(context.Background(), "notification_permission_request", map[string]any{}) + core.RequireNoError(t, err) + + out, err := sub.CallTool(context.Background(), "notification_permission_check", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "granted") +} + +// notification_show surfaces an action failure. +func TestToolsBehaviour_Notification_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "notification.send", core.NewError("no notify")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "notification_show", map[string]any{"title": "Hi"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no notify") +} + +// p2p_publish dispatches; p2p_state returns the p2p.State. +func TestToolsBehaviour_P2P_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "p2p.publish", nil) + okAction(c, "p2p.state", p2p.State{}) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "p2p_publish", map[string]any{ + "topic": "demo", "data": "payload", + }) + core.RequireNoError(t, err) + + _, err = sub.CallTool(context.Background(), "p2p_state", map[string]any{}) + core.RequireNoError(t, err) +} + +// p2p_publish surfaces an action failure. +func TestToolsBehaviour_P2P_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "p2p.publish", core.NewError("no p2p")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "p2p_publish", map[string]any{"topic": "x", "data": "y"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no p2p") +} + +// theme_get / theme_system read environment queries; theme_set dispatches. +func TestToolsBehaviour_Environment_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "environment.set_theme", environment.ThemeInfo{}) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case environment.QueryTheme: + return core.Result{Value: environment.ThemeInfo{}, OK: true} + case environment.QueryInfo: + return core.Result{Value: environment.EnvironmentInfo{}, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + for _, tool := range []string{"theme_get", "theme_system"} { + _, err := sub.CallTool(context.Background(), tool, map[string]any{}) + core.RequireNoError(t, err) + } +} + +// theme_get surfaces a query failure. +func TestToolsBehaviour_Environment_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(environment.QueryTheme); ok { + return core.Result{Value: core.NewError("no theme"), OK: false} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "theme_get", map[string]any{}) + core.AssertError(t, err) +} diff --git a/display/webkit/pkg/mcp/tools_browser.go b/display/webkit/pkg/mcp/tools_browser.go new file mode 100644 index 0000000..ff7e479 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_browser.go @@ -0,0 +1,66 @@ +// pkg/mcp/tools_browser.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- browser_open_url --- + +type BrowserOpenURLInput struct { + URL string `json:"url"` +} +type BrowserOpenURLOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) browserOpenURL(_ context.Context, _ *mcp.CallToolRequest, input BrowserOpenURLInput) (*mcp.CallToolResult, BrowserOpenURLOutput, resultFailure) { + r := s.core.Action("browser.open_url").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: input.URL}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, BrowserOpenURLOutput{}, e + } + return nil, BrowserOpenURLOutput{}, nil + } + return nil, BrowserOpenURLOutput{Success: true}, nil +} + +// --- browser_open_file --- + +type BrowserOpenFileInput struct { + Path string `json:"path,omitempty"` +} +type BrowserOpenFileOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) browserOpenFile(_ context.Context, _ *mcp.CallToolRequest, input BrowserOpenFileInput) (*mcp.CallToolResult, BrowserOpenFileOutput, resultFailure) { + r := s.core.Action("browser.open_file").Run(context.Background(), core.NewOptions( + core.Option{Key: core.Concat("pa", "th"), Value: input.Path}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, BrowserOpenFileOutput{}, e + } + return nil, BrowserOpenFileOutput{}, nil + } + return nil, BrowserOpenFileOutput{Success: true}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerBrowserTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{ + Name: "browser_open_url", + Description: `Open a URL in the default system browser. Example: {"url":"https://docs.example.com"}`, + }, s.browserOpenURL) + addTool(s, server, &mcp.Tool{ + Name: "browser_open_file", + Description: `Open a file in the system default application. Example: {path:/tmp/readme.md}`, + }, s.browserOpenFile) +} diff --git a/display/webkit/pkg/mcp/tools_browser_example_test.go b/display/webkit/pkg/mcp/tools_browser_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_browser_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_browser_test.go b/display/webkit/pkg/mcp/tools_browser_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_browser_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_chat.go b/display/webkit/pkg/mcp/tools_chat.go new file mode 100644 index 0000000..ea68555 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_chat.go @@ -0,0 +1,378 @@ +package mcp + +import ( + "context" + "time" + + core "dappco.re/go" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type ChatMessage struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + Model string `json:"model,omitempty"` + Attachments []ChatImageAttachment `json:"attachments,omitempty"` + Thinking *ChatThinkingState `json:"thinking,omitempty"` + ToolCalls []ChatToolCall `json:"tool_calls,omitempty"` + ToolResults []ChatToolResult `json:"tool_results,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` +} + +type ChatModel struct { + Name string `json:"name"` + Size int64 `json:"size"` + Status string `json:"status"` + Architecture string `json:"architecture,omitempty"` + QuantBits int `json:"quant_bits,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Loaded bool `json:"loaded,omitempty"` + Backend string `json:"backend,omitempty"` + SupportsVision bool `json:"supports_vision,omitempty"` +} + +type ChatSettings struct { + Temperature float32 `json:"temperature"` + TopP float32 `json:"top_p"` + TopK int `json:"top_k"` + MaxTokens int `json:"max_tokens"` + ContextWindow int `json:"context_window"` + SystemPrompt string `json:"system_prompt"` + DefaultModel string `json:"default_model"` +} + +type ChatConversation struct { + ID string `json:"id"` + Title string `json:"title"` + Model string `json:"model"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages []ChatMessage `json:"messages"` + Settings *ChatSettings `json:"settings,omitempty"` +} + +type ChatThinkingState struct { + Active bool `json:"active"` + Content string `json:"content"` + StartedAt time.Time `json:"started_at,omitempty"` + EndedAt time.Time `json:"ended_at,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` +} + +type ChatToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +type ChatToolResult struct { + ToolCallID string `json:"tool_call_id"` + Content string `json:"content"` +} + +type ChatImageAttachment struct { + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + Data string `json:"data"` + Width int `json:"width"` + Height int `json:"height"` +} + +type ChatSendInput struct { + ConversationID string `json:"conversation_id,omitempty"` + Content string `json:"content"` + Model string `json:"model,omitempty"` +} + +type ChatSendOutput struct { + MessageID string `json:"message_id"` +} + +func (s *Subsystem) chatSend(_ context.Context, _ *mcp.CallToolRequest, input ChatSendInput) (*mcp.CallToolResult, ChatSendOutput, resultFailure) { + result := s.core.Action("gui.chat.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: input.ConversationID}, + core.Option{Key: "content", Value: input.Content}, + core.Option{Key: "model", Value: input.Model}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ChatSendOutput{}, err + } + return nil, ChatSendOutput{}, core.E("mcp.chatSend", "chat send failed", nil) + } + messageID, ok := result.Value.(string) + if !ok { + return nil, ChatSendOutput{}, core.E("mcp.chatSend", "unexpected result type", nil) + } + return nil, ChatSendOutput{MessageID: messageID}, nil +} + +type ChatHistoryInput struct { + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type ChatHistoryOutput struct { + Messages []ChatMessage `json:"messages"` +} + +func (s *Subsystem) chatHistory(_ context.Context, _ *mcp.CallToolRequest, input ChatHistoryInput) (*mcp.CallToolResult, ChatHistoryOutput, resultFailure) { + result := s.core.Action("gui.chat.history").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: input.ConversationID}, + core.Option{Key: "id", Value: input.ID}, + core.Option{Key: "limit", Value: input.Limit}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ChatHistoryOutput{}, err + } + return nil, ChatHistoryOutput{}, core.E("mcp.chatHistory", "chat history failed", nil) + } + messages, err := decodeChatValue[[]ChatMessage](result.Value) + if err != nil { + return nil, ChatHistoryOutput{}, err + } + return nil, ChatHistoryOutput{Messages: messages}, nil +} + +type ChatModelsInput struct{} + +type ChatModelsOutput struct { + Models []ChatModel `json:"models"` +} + +func (s *Subsystem) chatModels(_ context.Context, _ *mcp.CallToolRequest, _ ChatModelsInput) (*mcp.CallToolResult, ChatModelsOutput, resultFailure) { + result := s.core.Action("gui.chat.models").Run(context.Background(), core.NewOptions()) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ChatModelsOutput{}, err + } + return nil, ChatModelsOutput{}, core.E("mcp.chatModels", "chat models failed", nil) + } + models, err := decodeChatValue[[]ChatModel](result.Value) + if err != nil { + return nil, ChatModelsOutput{}, err + } + return nil, ChatModelsOutput{Models: models}, nil +} + +type ChatSelectModelInput struct { + Name string `json:"name,omitempty"` + Model string `json:"model,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` +} + +type ChatSelectModelOutput struct { + Settings ChatSettings `json:"settings"` +} + +func (s *Subsystem) chatSelectModel(_ context.Context, _ *mcp.CallToolRequest, input ChatSelectModelInput) (*mcp.CallToolResult, ChatSelectModelOutput, resultFailure) { + result := s.core.Action("gui.chat.select_model").Run(context.Background(), core.NewOptions( + core.Option{Key: "name", Value: input.Name}, + core.Option{Key: "model", Value: input.Model}, + core.Option{Key: "conversation_id", Value: input.ConversationID}, + core.Option{Key: "id", Value: input.ID}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ChatSelectModelOutput{}, err + } + return nil, ChatSelectModelOutput{}, core.E("mcp.chatSelectModel", "select model failed", nil) + } + settings, err := decodeChatValue[ChatSettings](result.Value) + if err != nil { + return nil, ChatSelectModelOutput{}, err + } + return nil, ChatSelectModelOutput{Settings: settings}, nil +} + +type ChatConversationsListInput struct{} + +type ChatConversationsListOutput struct { + Conversations []ChatConversation `json:"conversations"` +} + +func (s *Subsystem) chatConversationsList(_ context.Context, _ *mcp.CallToolRequest, _ ChatConversationsListInput) (*mcp.CallToolResult, ChatConversationsListOutput, resultFailure) { + result := s.core.Action("gui.chat.conversations.list").Run(context.Background(), core.NewOptions()) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ChatConversationsListOutput{}, err + } + return nil, ChatConversationsListOutput{}, core.E("mcp.chatConversationsList", "list conversations failed", nil) + } + conversations, err := decodeChatValue[[]ChatConversation](result.Value) + if err != nil { + return nil, ChatConversationsListOutput{}, err + } + return nil, ChatConversationsListOutput{Conversations: conversations}, nil +} + +type ChatConversationsLoadInput struct { + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` +} + +type ChatConversationsLoadOutput struct { + Conversation ChatConversation `json:"conversation"` +} + +func (s *Subsystem) chatConversationsLoad(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsLoadInput) (*mcp.CallToolResult, ChatConversationsLoadOutput, resultFailure) { + result := s.core.Action("gui.chat.conversations.load").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: input.ConversationID}, + core.Option{Key: "id", Value: input.ID}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ChatConversationsLoadOutput{}, err + } + return nil, ChatConversationsLoadOutput{}, core.E("mcp.chatConversationsLoad", "load conversation failed", nil) + } + conversation, err := decodeChatValue[ChatConversation](result.Value) + if err != nil { + return nil, ChatConversationsLoadOutput{}, err + } + return nil, ChatConversationsLoadOutput{Conversation: conversation}, nil +} + +type ChatConversationsDeleteInput struct { + ConversationID string `json:"conversation_id,omitempty"` + ID string `json:"id,omitempty"` +} + +type ChatConversationsDeleteOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) chatConversationsDelete(_ context.Context, _ *mcp.CallToolRequest, input ChatConversationsDeleteInput) (*mcp.CallToolResult, ChatConversationsDeleteOutput, resultFailure) { + result := s.core.Action("gui.chat.conversations.delete").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: input.ConversationID}, + core.Option{Key: "id", Value: input.ID}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ChatConversationsDeleteOutput{}, err + } + return nil, ChatConversationsDeleteOutput{}, core.E("mcp.chatConversationsDelete", "delete conversation failed", nil) + } + success, ok := result.Value.(bool) + if !ok { + return nil, ChatConversationsDeleteOutput{}, core.E("mcp.chatConversationsDelete", "unexpected result type", nil) + } + return nil, ChatConversationsDeleteOutput{Success: success}, nil +} + +type ChatThinkingStartInput struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` +} + +type ChatThinkingStartOutput struct { + State ChatThinkingState `json:"state"` +} + +func (s *Subsystem) chatThinkingStart(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingStartInput) (*mcp.CallToolResult, ChatThinkingStartOutput, resultFailure) { + result := s.core.Action("gui.chat.thinking.start").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: input.ConversationID}, + core.Option{Key: "message_id", Value: input.MessageID}, + core.Option{Key: "started_at", Value: input.StartedAt}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ChatThinkingStartOutput{}, err + } + return nil, ChatThinkingStartOutput{}, core.E("mcp.chatThinkingStart", "thinking start failed", nil) + } + state, err := decodeChatValue[ChatThinkingState](result.Value) + if err != nil { + return nil, ChatThinkingStartOutput{}, err + } + return nil, ChatThinkingStartOutput{State: state}, nil +} + +type ChatThinkingStopInput struct { + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` +} + +type ChatThinkingStopOutput struct { + State ChatThinkingState `json:"state"` +} + +func (s *Subsystem) chatThinkingStop(_ context.Context, _ *mcp.CallToolRequest, input ChatThinkingStopInput) (*mcp.CallToolResult, ChatThinkingStopOutput, resultFailure) { + result := s.core.Action("gui.chat.thinking.stop").Run(context.Background(), core.NewOptions( + core.Option{Key: "conversation_id", Value: input.ConversationID}, + core.Option{Key: "message_id", Value: input.MessageID}, + core.Option{Key: "started_at", Value: input.StartedAt}, + core.Option{Key: "duration_ms", Value: input.DurationMS}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ChatThinkingStopOutput{}, err + } + return nil, ChatThinkingStopOutput{}, core.E("mcp.chatThinkingStop", "thinking stop failed", nil) + } + state, err := decodeChatValue[ChatThinkingState](result.Value) + if err != nil { + return nil, ChatThinkingStopOutput{}, err + } + return nil, ChatThinkingStopOutput{State: state}, nil +} + +func decodeChatValue[T any](value any) (T, resultFailure) { + var output T + result := core.JSONUnmarshalString(core.JSONMarshalString(value), &output) + if result.OK { + return output, nil + } + if err, ok := result.Value.(error); ok { + return output, err + } + return output, core.E("mcp.decodeChatValue", "failed to decode chat value", nil) +} + +func (s *Subsystem) registerChatTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{ + Name: "chat_send", + Description: `Send a chat message and return the streamed assistant message id. Example: {"conversation_id":"conv-1","content":"Hello","model":"lemma"}`, + }, s.chatSend) + addTool(s, server, &mcp.Tool{ + Name: "chat_history", + Description: `Read chat message history for a conversation. Example: {"conversation_id":"conv-1","limit":20}`, + }, s.chatHistory) + addTool(s, server, &mcp.Tool{ + Name: "chat_models", + Description: "List available chat models with size and status metadata", + }, s.chatModels) + addTool(s, server, &mcp.Tool{ + Name: "chat_select_model", + Description: `Set the active chat model. Example: {"name":"lemma","conversation_id":"conv-1"}`, + }, s.chatSelectModel) + addTool(s, server, &mcp.Tool{ + Name: "chat_conversations_list", + Description: "List stored chat conversations", + }, s.chatConversationsList) + addTool(s, server, &mcp.Tool{ + Name: "chat_conversations_load", + Description: `Load a stored chat conversation by id. Example: {"conversation_id":"conv-1"}`, + }, s.chatConversationsLoad) + addTool(s, server, &mcp.Tool{ + Name: "chat_conversations_delete", + Description: `Delete a stored chat conversation. Example: {"conversation_id":"conv-1"}`, + }, s.chatConversationsDelete) + addTool(s, server, &mcp.Tool{ + Name: "chat_thinking_start", + Description: `Mark a conversation as thinking. Example: {"conversation_id":"conv-1"}`, + }, s.chatThinkingStart) + addTool(s, server, &mcp.Tool{ + Name: "chat_thinking_stop", + Description: `Clear the thinking state for a conversation. Example: {"conversation_id":"conv-1"}`, + }, s.chatThinkingStop) +} diff --git a/display/webkit/pkg/mcp/tools_chat_behaviour_test.go b/display/webkit/pkg/mcp/tools_chat_behaviour_test.go new file mode 100644 index 0000000..25b4f7a --- /dev/null +++ b/display/webkit/pkg/mcp/tools_chat_behaviour_test.go @@ -0,0 +1,113 @@ +// pkg/mcp/tools_chat_behaviour_test.go +package mcp + +import ( + "context" + + core "dappco.re/go" +) + +// chat_send dispatches gui.chat.send and returns the message id. +// +// c.Action("gui.chat.send", ...) // returns "msg-1" +// sub.CallTool(ctx, "chat_send", map[string]any{"content": "hi"}) +func TestToolsChatBehaviour_Send_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "gui.chat.send", "msg-1") + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "chat_send", map[string]any{ + "conversation_id": "conv-1", "content": "hi", "model": "lemma", + }) + core.RequireNoError(t, err) + core.AssertContains(t, out, "msg-1") +} + +// chat_send surfaces an action failure. +func TestToolsChatBehaviour_Send_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "gui.chat.send", core.NewError("no model")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "chat_send", map[string]any{"content": "hi"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no model") +} + +// chat_history / chat_models / chat_select_model / conversations_{list,load} +// decode their JSON results via decodeChatValue. +func TestToolsChatBehaviour_Reads_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "gui.chat.history", []map[string]any{{"id": "m1", "role": "user", "content": "hi"}}) + okAction(c, "gui.chat.models", []map[string]any{{"name": "lemma", "status": "ready"}}) + okAction(c, "gui.chat.select_model", map[string]any{"default_model": "lemma"}) + okAction(c, "gui.chat.conversations.list", []map[string]any{{"id": "conv-1", "title": "Demo"}}) + okAction(c, "gui.chat.conversations.load", map[string]any{"id": "conv-1", "title": "Demo"}) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "chat_history", map[string]any{"conversation_id": "conv-1"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "m1") + + out, err = sub.CallTool(context.Background(), "chat_models", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "lemma") + + out, err = sub.CallTool(context.Background(), "chat_select_model", map[string]any{"name": "lemma"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "lemma") + + out, err = sub.CallTool(context.Background(), "chat_conversations_list", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "conv-1") + + out, err = sub.CallTool(context.Background(), "chat_conversations_load", map[string]any{"conversation_id": "conv-1"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "Demo") +} + +// chat_conversations_delete returns the success bool. +func TestToolsChatBehaviour_Delete_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "gui.chat.conversations.delete", true) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "chat_conversations_delete", map[string]any{"conversation_id": "conv-1"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") +} + +// chat_thinking_start / stop decode a ChatThinkingState. +func TestToolsChatBehaviour_Thinking_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "gui.chat.thinking.start", map[string]any{"active": true, "content": "..."}) + okAction(c, "gui.chat.thinking.stop", map[string]any{"active": false, "duration_ms": 1200}) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "chat_thinking_start", map[string]any{"conversation_id": "conv-1"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "state") + + out, err = sub.CallTool(context.Background(), "chat_thinking_stop", map[string]any{"conversation_id": "conv-1"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "state") +} + +// chat_history surfaces an action failure. +func TestToolsChatBehaviour_History_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "gui.chat.history", core.NewError("no conversation")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "chat_history", map[string]any{"conversation_id": "x"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no conversation") +} + +// decodeChatValue round-trips a value and reports failure on a non-decodable +// shape. +func TestToolsChatBehaviour_decodeChatValue(t *core.T) { + got, err := decodeChatValue[ChatSettings](map[string]any{"default_model": "lemma"}) + core.AssertNil(t, err) + core.AssertEqual(t, "lemma", got.DefaultModel) +} diff --git a/display/webkit/pkg/mcp/tools_chat_example_test.go b/display/webkit/pkg/mcp/tools_chat_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_chat_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_chat_test.go b/display/webkit/pkg/mcp/tools_chat_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_chat_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_cleanup_behaviour_test.go b/display/webkit/pkg/mcp/tools_cleanup_behaviour_test.go new file mode 100644 index 0000000..28c3a70 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_cleanup_behaviour_test.go @@ -0,0 +1,93 @@ +// pkg/mcp/tools_cleanup_behaviour_test.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/clipboard" + "dappco.re/go/render/display/webkit/pkg/dock" + "dappco.re/go/render/display/webkit/pkg/environment" + "dappco.re/go/render/display/webkit/pkg/events" +) + +// dock_info reads the dock visibility query; dock_set_progress_bar dispatches. +func TestToolsCleanupBehaviour_Dock(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "dock.set_progress_bar", nil) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(dock.QueryVisible); ok { + return core.Result{Value: true, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "dock_info", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "visible") + + out, err = sub.CallTool(context.Background(), "dock_set_progress_bar", map[string]any{"progress": 0.5}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") +} + +// theme_set dispatches environment.set_theme and returns the ThemeInfo. +func TestToolsCleanupBehaviour_ThemeSet(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "environment.set_theme", environment.ThemeInfo{}) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "theme_set", map[string]any{"theme": "dark"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "theme") +} + +// event_emit dispatches events.emit (cancelled bool); event_list reads the +// listeners query. +func TestToolsCleanupBehaviour_Events(t *core.T) { + c := core.New(core.WithServiceLock()) + okAction(c, "events.emit", false) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(events.QueryListeners); ok { + return core.Result{Value: []events.ListenerInfo{}, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "event_emit", map[string]any{"name": "theme:changed"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "cancelled") + + out, err = sub.CallTool(context.Background(), "event_list", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "listeners") +} + +// event_emit surfaces an action failure. +func TestToolsCleanupBehaviour_EventEmit_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "events.emit", core.NewError("no bus")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "event_emit", map[string]any{"name": "x"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no bus") +} + +// clipboard_read_image decodes an image query into base64. +func TestToolsCleanupBehaviour_ClipboardReadImage(t *core.T) { + c := core.New(core.WithServiceLock()) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if _, ok := q.(clipboard.QueryImage); ok { + return core.Result{Value: clipboard.ImageContent{Data: []byte{0x89, 0x50}, HasImage: true}, OK: true} + } + return core.Result{} + }) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "clipboard_read_image", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "base64") +} diff --git a/display/webkit/pkg/mcp/tools_clipboard.go b/display/webkit/pkg/mcp/tools_clipboard.go new file mode 100644 index 0000000..a727a90 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_clipboard.go @@ -0,0 +1,164 @@ +// pkg/mcp/tools_clipboard.go +package mcp + +import ( + "context" + "encoding/base64" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/clipboard" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- clipboard_read --- + +type ClipboardReadInput struct{} +type ClipboardReadOutput struct { + Content string `json:"content"` +} + +func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadInput) (*mcp.CallToolResult, ClipboardReadOutput, resultFailure) { + r := s.core.QUERY(clipboard.QueryText{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ClipboardReadOutput{}, e + } + return nil, ClipboardReadOutput{}, core.E("mcp.clipboardRead", "clipboard query failed", nil) + } + content, ok := r.Value.(clipboard.ClipboardContent) + if !ok { + return nil, ClipboardReadOutput{}, core.E("mcp.clipboardRead", "unexpected result type", nil) + } + return nil, ClipboardReadOutput{Content: content.Text}, nil +} + +// --- clipboard_write --- + +type ClipboardWriteInput struct { + Text string `json:"text"` +} +type ClipboardWriteOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteInput) (*mcp.CallToolResult, ClipboardWriteOutput, resultFailure) { + r := s.core.Action("clipboard.set_text").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: clipboard.TaskSetText{Text: input.Text}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ClipboardWriteOutput{}, e + } + return nil, ClipboardWriteOutput{}, nil + } + return nil, ClipboardWriteOutput{Success: true}, nil +} + +// --- clipboard_has --- + +type ClipboardHasInput struct{} +type ClipboardHasOutput struct { + HasContent bool `json:"hasContent"` +} + +func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardHasInput) (*mcp.CallToolResult, ClipboardHasOutput, resultFailure) { + r := s.core.QUERY(clipboard.QueryText{}) + if !r.OK { + return nil, ClipboardHasOutput{}, nil + } + content, ok := r.Value.(clipboard.ClipboardContent) + if !ok { + return nil, ClipboardHasOutput{}, core.E("mcp.clipboardHas", "unexpected result type", nil) + } + return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil +} + +// --- clipboard_clear --- + +type ClipboardClearInput struct{} +type ClipboardClearOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardClearInput) (*mcp.CallToolResult, ClipboardClearOutput, resultFailure) { + r := s.core.Action("clipboard.clear").Run(context.Background(), core.NewOptions()) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ClipboardClearOutput{}, e + } + return nil, ClipboardClearOutput{}, nil + } + return nil, ClipboardClearOutput{Success: true}, nil +} + +// --- clipboard_read_image --- + +type ClipboardReadImageInput struct{} +type ClipboardReadImageOutput struct { + Base64 string `json:"base64"` +} + +func (s *Subsystem) clipboardReadImage(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadImageInput) (*mcp.CallToolResult, ClipboardReadImageOutput, resultFailure) { + r := s.core.QUERY(clipboard.QueryImage{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ClipboardReadImageOutput{}, e + } + return nil, ClipboardReadImageOutput{}, nil + } + content, ok := r.Value.(clipboard.ImageContent) + if !ok { + return nil, ClipboardReadImageOutput{}, core.E("mcp.clipboardReadImage", "unexpected result type", nil) + } + if !content.HasImage { + return nil, ClipboardReadImageOutput{}, nil + } + return nil, ClipboardReadImageOutput{Base64: base64.StdEncoding.EncodeToString(content.Data)}, nil +} + +// --- clipboard_write_image --- + +type ClipboardWriteImageInput struct { + Base64 string `json:"base64"` +} +type ClipboardWriteImageOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) clipboardWriteImage(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteImageInput) (*mcp.CallToolResult, ClipboardWriteImageOutput, resultFailure) { + maxEncodedLen := ((clipboard.MaxImageBytes + 2) / 3) * 4 + if len(input.Base64) == 0 || len(input.Base64) > maxEncodedLen { + return nil, ClipboardWriteImageOutput{}, core.E("mcp.clipboardWriteImage", "clipboard image exceeds maximum size", nil) + } + data, err := base64.StdEncoding.DecodeString(input.Base64) + if err != nil { + return nil, ClipboardWriteImageOutput{}, core.E("mcp.clipboardWriteImage", "invalid base64 image data", err) + } + r := s.core.Action("clipboard.set_image").Run(context.Background(), core.NewOptions( + core.Option{Key: "data", Value: data}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ClipboardWriteImageOutput{}, e + } + return nil, ClipboardWriteImageOutput{}, nil + } + return nil, ClipboardWriteImageOutput{Success: true}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerClipboardTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "clipboard_read", Description: "Read the current clipboard content"}, s.clipboardRead) + addTool(s, server, &mcp.Tool{Name: "clipboard_write", Description: "Write text to the clipboard"}, s.clipboardWrite) + addTool(s, server, &mcp.Tool{Name: "clipboard_has", Description: "Check if the clipboard has content"}, s.clipboardHas) + addTool(s, server, &mcp.Tool{ + Name: "clipboard_read_image", + Description: `Read image data from the clipboard as base64 PNG bytes. Example: {}`, + }, s.clipboardReadImage) + addTool(s, server, &mcp.Tool{ + Name: "clipboard_write_image", + Description: `Write base64 image data to the clipboard. Example: {"base64":"iVBORw0KGgoAAA..."}`, + }, s.clipboardWriteImage) + addTool(s, server, &mcp.Tool{Name: "clipboard_clear", Description: "Clear the clipboard"}, s.clipboardClear) +} diff --git a/display/webkit/pkg/mcp/tools_clipboard_example_test.go b/display/webkit/pkg/mcp/tools_clipboard_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_clipboard_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_clipboard_test.go b/display/webkit/pkg/mcp/tools_clipboard_test.go new file mode 100644 index 0000000..f0a91b5 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_clipboard_test.go @@ -0,0 +1,74 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/clipboard" +) + +func newClipboardToolsTestSubsystem(t *core.T, query func(core.Query) core.Result) *Subsystem { + t.Helper() + c := core.New(core.WithServiceLock()) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if query != nil { + return query(q) + } + return core.Result{} + }) + return New(c) +} + +func TestToolsClipboard_clipboardRead_Good(t *core.T) { + // clipboardRead + ax7Variant := "clipboardRead:good" + core.AssertContains(t, ax7Variant, "good") + sub := newClipboardToolsTestSubsystem(t, func(q core.Query) core.Result { + if _, ok := q.(clipboard.QueryText); ok { + return core.Result{ + Value: clipboard.ClipboardContent{ + Text: "hello", + HasContent: true, + }, + OK: true, + } + } + return core.Result{} + }) + + _, out, err := sub.clipboardRead(context.Background(), nil, ClipboardReadInput{}) + core.RequireNoError(t, err) + core.AssertEqual(t, "hello", out.Content) +} + +func TestToolsClipboard_clipboardRead_Bad(t *core.T) { + // clipboardRead + ax7Variant := "clipboardRead:bad" + core.AssertContains(t, ax7Variant, "bad") + sub := newClipboardToolsTestSubsystem(t, func(q core.Query) core.Result { + if _, ok := q.(clipboard.QueryText); ok { + return core.Result{OK: false, Value: "clipboard backend unavailable"} + } + return core.Result{} + }) + + _, _, err := sub.clipboardRead(context.Background(), nil, ClipboardReadInput{}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "clipboard query failed") +} + +func TestToolsClipboard_clipboardRead_Ugly(t *core.T) { + // clipboardRead + ax7Variant := "clipboardRead:ugly" + core.AssertContains(t, ax7Variant, "ugly") + sub := newClipboardToolsTestSubsystem(t, func(q core.Query) core.Result { + if _, ok := q.(clipboard.QueryText); ok { + return core.Result{OK: true, Value: core.NewError("unexpected payload")} + } + return core.Result{} + }) + + _, _, err := sub.clipboardRead(context.Background(), nil, ClipboardReadInput{}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unexpected result type") +} diff --git a/display/webkit/pkg/mcp/tools_container.go b/display/webkit/pkg/mcp/tools_container.go new file mode 100644 index 0000000..c58d20e --- /dev/null +++ b/display/webkit/pkg/mcp/tools_container.go @@ -0,0 +1,96 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/container" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type ContainerDetectInput struct{} +type ContainerDetectOutput struct { + Runtime container.ContainerRuntime `json:"runtime"` +} + +func (s *Subsystem) containerDetect(_ context.Context, _ *mcp.CallToolRequest, _ ContainerDetectInput) (*mcp.CallToolResult, ContainerDetectOutput, resultFailure) { + result := s.core.Action("container.runtime.detect").Run(context.Background(), core.Options{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ContainerDetectOutput{}, err + } + return nil, ContainerDetectOutput{}, core.E("mcp.containerDetect", "container.runtime.detect failed", nil) + } + runtime, ok := result.Value.(container.ContainerRuntime) + if !ok { + return nil, ContainerDetectOutput{}, core.E("mcp.containerDetect", "unexpected result type", nil) + } + return nil, ContainerDetectOutput{Runtime: runtime}, nil +} + +type TIMStateInput struct{} +type TIMStateOutput struct { + State container.TIMState `json:"state"` +} + +func (s *Subsystem) timStatus(_ context.Context, _ *mcp.CallToolRequest, _ TIMStateInput) (*mcp.CallToolResult, TIMStateOutput, resultFailure) { + result := s.core.Action("tim.status").Run(context.Background(), core.Options{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, TIMStateOutput{}, err + } + return nil, TIMStateOutput{}, core.E("mcp.timStatus", "tim.status failed", nil) + } + state, ok := result.Value.(container.TIMState) + if !ok { + return nil, TIMStateOutput{}, core.E("mcp.timStatus", "unexpected result type", nil) + } + return nil, TIMStateOutput{State: state}, nil +} + +type TIMStartInput struct{} +type TIMStartOutput struct { + State container.TIMState `json:"state"` +} + +func (s *Subsystem) timStart(_ context.Context, _ *mcp.CallToolRequest, _ TIMStartInput) (*mcp.CallToolResult, TIMStartOutput, resultFailure) { + result := s.core.Action("tim.start").Run(context.Background(), core.Options{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, TIMStartOutput{}, err + } + return nil, TIMStartOutput{}, core.E("mcp.timStart", "tim.start failed", nil) + } + state, ok := result.Value.(container.TIMState) + if !ok { + return nil, TIMStartOutput{}, core.E("mcp.timStart", "unexpected result type", nil) + } + return nil, TIMStartOutput{State: state}, nil +} + +type TIMStopInput struct{} +type TIMStopOutput struct { + State container.TIMState `json:"state"` +} + +func (s *Subsystem) timStop(_ context.Context, _ *mcp.CallToolRequest, _ TIMStopInput) (*mcp.CallToolResult, TIMStopOutput, resultFailure) { + result := s.core.Action("tim.stop").Run(context.Background(), core.Options{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, TIMStopOutput{}, err + } + return nil, TIMStopOutput{}, core.E("mcp.timStop", "tim.stop failed", nil) + } + state, ok := result.Value.(container.TIMState) + if !ok { + return nil, TIMStopOutput{}, core.E("mcp.timStop", "unexpected result type", nil) + } + return nil, TIMStopOutput{State: state}, nil +} + +func (s *Subsystem) registerContainerTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "container_detect_runtime", Description: "Detect the preferred isolated workload runtime on this host"}, s.containerDetect) + addTool(s, server, &mcp.Tool{Name: "tim_status", Description: "Inspect the TIM container state"}, s.timStatus) + addTool(s, server, &mcp.Tool{Name: "tim_start", Description: "Start the TIM container"}, s.timStart) + addTool(s, server, &mcp.Tool{Name: "tim_stop", Description: "Stop the TIM container"}, s.timStop) +} diff --git a/display/webkit/pkg/mcp/tools_container_example_test.go b/display/webkit/pkg/mcp/tools_container_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_container_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_container_test.go b/display/webkit/pkg/mcp/tools_container_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_container_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_contextmenu.go b/display/webkit/pkg/mcp/tools_contextmenu.go new file mode 100644 index 0000000..6484bd5 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_contextmenu.go @@ -0,0 +1,171 @@ +// pkg/mcp/tools_contextmenu.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/contextmenu" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- contextmenu_add --- + +// ContextMenuAddInput uses map[string]any for the menu definition because +// contextmenu.ContextMenuDef contains self-referencing MenuItemDef (Items []MenuItemDef) +// which the MCP SDK schema generator cannot handle (cycle detection panic). +type ContextMenuAddInput struct { + Name string `json:"name"` + Menu map[string]any `json:"menu"` +} +type ContextMenuAddOutput struct { + Success bool `json:"success"` +} + +func jsonBytesFromResult(op, message string, result core.Result) ([]byte, resultFailure) { + if !result.OK { + if err, ok := result.Value.(error); ok && err != nil { + return nil, core.E(op, message, err) + } + return nil, core.E(op, message, nil) + } + + data, ok := result.Value.([]byte) + if !ok { + return nil, core.E(op, core.Sprintf("%s: unexpected helper result type %T", message, result.Value), nil) + } + return data, nil +} + +func resultError(result core.Result) resultFailure { + if err, ok := result.Value.(error); ok && err != nil { + return err + } + return nil +} + +func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuAddInput) (*mcp.CallToolResult, ContextMenuAddOutput, resultFailure) { + // Convert map[string]any to ContextMenuDef via JSON round-trip + menuJSON, err := jsonBytesFromResult("mcp.contextMenuAdd", "failed to marshal menu definition", core.JSONMarshal(input.Menu)) + if err != nil { + return nil, ContextMenuAddOutput{}, err + } + + var menuDef contextmenu.ContextMenuDef + unmarshalResult := core.JSONUnmarshal(menuJSON, &menuDef) + if !unmarshalResult.OK { + return nil, ContextMenuAddOutput{}, core.E("mcp.contextMenuAdd", "failed to unmarshal menu definition", resultError(unmarshalResult)) + } + r := s.core.Action("contextmenu.add").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: contextmenu.TaskAdd{Name: input.Name, Menu: menuDef}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ContextMenuAddOutput{}, e + } + return nil, ContextMenuAddOutput{}, nil + } + return nil, ContextMenuAddOutput{Success: true}, nil +} + +// --- contextmenu_remove --- + +type ContextMenuRemoveInput struct { + Name string `json:"name"` +} +type ContextMenuRemoveOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) contextMenuRemove(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuRemoveInput) (*mcp.CallToolResult, ContextMenuRemoveOutput, resultFailure) { + r := s.core.Action("contextmenu.remove").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: contextmenu.TaskRemove{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ContextMenuRemoveOutput{}, e + } + return nil, ContextMenuRemoveOutput{}, nil + } + return nil, ContextMenuRemoveOutput{Success: true}, nil +} + +// --- contextmenu_get --- + +type ContextMenuGetInput struct { + Name string `json:"name"` +} +type ContextMenuGetOutput struct { + Menu map[string]any `json:"menu"` +} + +func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuGetInput) (*mcp.CallToolResult, ContextMenuGetOutput, resultFailure) { + r := s.core.QUERY(contextmenu.QueryGet{Name: input.Name}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ContextMenuGetOutput{}, e + } + return nil, ContextMenuGetOutput{}, nil + } + menu, ok := r.Value.(*contextmenu.ContextMenuDef) + if !ok { + return nil, ContextMenuGetOutput{}, core.E("mcp.contextMenuGet", "unexpected result type", nil) + } + if menu == nil { + return nil, ContextMenuGetOutput{}, nil + } + // Convert to map[string]any via JSON round-trip to avoid cyclic type in schema + menuJSON, err := jsonBytesFromResult("mcp.contextMenuGet", "failed to marshal context menu", core.JSONMarshal(menu)) + if err != nil { + return nil, ContextMenuGetOutput{}, err + } + + var menuMap map[string]any + unmarshalResult := core.JSONUnmarshal(menuJSON, &menuMap) + if !unmarshalResult.OK { + return nil, ContextMenuGetOutput{}, core.E("mcp.contextMenuGet", "failed to unmarshal context menu", resultError(unmarshalResult)) + } + return nil, ContextMenuGetOutput{Menu: menuMap}, nil +} + +// --- contextmenu_list --- + +type ContextMenuListInput struct{} +type ContextMenuListOutput struct { + Menus map[string]any `json:"menus"` +} + +func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _ ContextMenuListInput) (*mcp.CallToolResult, ContextMenuListOutput, resultFailure) { + r := s.core.QUERY(contextmenu.QueryList{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ContextMenuListOutput{}, e + } + return nil, ContextMenuListOutput{}, nil + } + menus, ok := r.Value.(map[string]contextmenu.ContextMenuDef) + if !ok { + return nil, ContextMenuListOutput{}, core.E("mcp.contextMenuList", "unexpected result type", nil) + } + // Convert to map[string]any via JSON round-trip to avoid cyclic type in schema + menusJSON, err := jsonBytesFromResult("mcp.contextMenuList", "failed to marshal context menus", core.JSONMarshal(menus)) + if err != nil { + return nil, ContextMenuListOutput{}, err + } + + var menusMap map[string]any + unmarshalResult := core.JSONUnmarshal(menusJSON, &menusMap) + if !unmarshalResult.OK { + return nil, ContextMenuListOutput{}, core.E("mcp.contextMenuList", "failed to unmarshal context menus", resultError(unmarshalResult)) + } + return nil, ContextMenuListOutput{Menus: menusMap}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerContextMenuTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "contextmenu_add", Description: "Register a context menu"}, s.contextMenuAdd) + addTool(s, server, &mcp.Tool{Name: "contextmenu_remove", Description: "Unregister a context menu"}, s.contextMenuRemove) + addTool(s, server, &mcp.Tool{Name: "contextmenu_get", Description: "Get a context menu by name"}, s.contextMenuGet) + addTool(s, server, &mcp.Tool{Name: "contextmenu_list", Description: "List all registered context menus"}, s.contextMenuList) +} diff --git a/display/webkit/pkg/mcp/tools_contextmenu_example_test.go b/display/webkit/pkg/mcp/tools_contextmenu_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_contextmenu_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_contextmenu_test.go b/display/webkit/pkg/mcp/tools_contextmenu_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_contextmenu_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_deno.go b/display/webkit/pkg/mcp/tools_deno.go new file mode 100644 index 0000000..78de110 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_deno.go @@ -0,0 +1,102 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/deno" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type DenoStatusInput struct{} + +type DenoStatusOutput struct { + Status deno.Status `json:"status"` +} + +func (s *Subsystem) denoStatus(_ context.Context, _ *mcp.CallToolRequest, _ DenoStatusInput) (*mcp.CallToolResult, DenoStatusOutput, resultFailure) { + result := s.core.Action("core.deno.sidecar.status").Run(context.Background(), core.Options{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, DenoStatusOutput{}, err + } + return nil, DenoStatusOutput{}, core.E("mcp.denoStatus", "core.deno.sidecar.status failed", nil) + } + status, ok := result.Value.(deno.Status) + if !ok { + return nil, DenoStatusOutput{}, core.E("mcp.denoStatus", "unexpected result type", nil) + } + return nil, DenoStatusOutput{Status: status}, nil +} + +type DenoStartInput struct{} +type DenoStartOutput struct { + Status deno.Status `json:"status"` +} + +func (s *Subsystem) denoStart(_ context.Context, _ *mcp.CallToolRequest, _ DenoStartInput) (*mcp.CallToolResult, DenoStartOutput, resultFailure) { + result := s.core.Action("core.deno.sidecar.start").Run(context.Background(), core.Options{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, DenoStartOutput{}, err + } + return nil, DenoStartOutput{}, core.E("mcp.denoStart", "core.deno.sidecar.start failed", nil) + } + status, ok := result.Value.(deno.Status) + if !ok { + return nil, DenoStartOutput{}, core.E("mcp.denoStart", "unexpected result type", nil) + } + return nil, DenoStartOutput{Status: status}, nil +} + +type DenoStopInput struct{} +type DenoStopOutput struct { + Status deno.Status `json:"status"` +} + +func (s *Subsystem) denoStop(_ context.Context, _ *mcp.CallToolRequest, _ DenoStopInput) (*mcp.CallToolResult, DenoStopOutput, resultFailure) { + result := s.core.Action("core.deno.sidecar.stop").Run(context.Background(), core.Options{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, DenoStopOutput{}, err + } + return nil, DenoStopOutput{}, core.E("mcp.denoStop", "core.deno.sidecar.stop failed", nil) + } + status, ok := result.Value.(deno.Status) + if !ok { + return nil, DenoStopOutput{}, core.E("mcp.denoStop", "unexpected result type", nil) + } + return nil, DenoStopOutput{Status: status}, nil +} + +type DenoEvalInput struct { + Code string `json:"code"` +} + +type DenoEvalOutput struct { + Result deno.EvalResult `json:"result"` +} + +func (s *Subsystem) denoEval(_ context.Context, _ *mcp.CallToolRequest, input DenoEvalInput) (*mcp.CallToolResult, DenoEvalOutput, resultFailure) { + result := s.core.Action("core.deno.sidecar.eval").Run(context.Background(), core.NewOptions( + core.Option{Key: "code", Value: input.Code}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, DenoEvalOutput{}, err + } + return nil, DenoEvalOutput{}, core.E("mcp.denoEval", "core.deno.sidecar.eval failed", nil) + } + value, ok := result.Value.(deno.EvalResult) + if !ok { + return nil, DenoEvalOutput{}, core.E("mcp.denoEval", "unexpected result type", nil) + } + return nil, DenoEvalOutput{Result: value}, nil +} + +func (s *Subsystem) registerDenoTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "deno_status", Description: "Inspect the CoreDeno sidecar process and IPC connection state"}, s.denoStatus) + addTool(s, server, &mcp.Tool{Name: "deno_start", Description: "Start the CoreDeno sidecar process"}, s.denoStart) + addTool(s, server, &mcp.Tool{Name: "deno_stop", Description: "Stop the CoreDeno sidecar process"}, s.denoStop) + addTool(s, server, &mcp.Tool{Name: "deno_eval", Description: `Evaluate JavaScript inside the CoreDeno sidecar. Example: {"code":"await core.action('display.models.state')"} `}, s.denoEval) +} diff --git a/display/webkit/pkg/mcp/tools_deno_example_test.go b/display/webkit/pkg/mcp/tools_deno_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_deno_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_deno_test.go b/display/webkit/pkg/mcp/tools_deno_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_deno_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_dialog.go b/display/webkit/pkg/mcp/tools_dialog.go new file mode 100644 index 0000000..438f1c6 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_dialog.go @@ -0,0 +1,346 @@ +// pkg/mcp/tools_dialog.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/dialog" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- dialog_open_file --- + +type DialogOpenFileInput struct { + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + Filters []dialog.FileFilter `json:"filters,omitempty"` + AllowMultiple bool `json:"allowMultiple,omitempty"` + CanChooseDirectories bool `json:"canChooseDirectories,omitempty"` + CanChooseFiles bool `json:"canChooseFiles,omitempty"` + ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"` +} +type DialogOpenFileOutput struct { + Paths []string `json:"paths"` +} + +func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, resultFailure) { + r := s.core.Action("dialog.open_file").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskOpenFile{Options: dialog.OpenFileOptions{ + Title: input.Title, + Directory: input.Directory, + Filters: input.Filters, + AllowMultiple: input.AllowMultiple, + CanChooseDirectories: input.CanChooseDirectories, + CanChooseFiles: input.CanChooseFiles, + ShowHiddenFiles: input.ShowHiddenFiles, + }}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DialogOpenFileOutput{}, e + } + return nil, DialogOpenFileOutput{}, nil + } + paths, ok := r.Value.([]string) + if !ok { + return nil, DialogOpenFileOutput{}, core.E("mcp.dialogOpenFile", "unexpected result type", nil) + } + return nil, DialogOpenFileOutput{Paths: paths}, nil +} + +// --- dialog_save_file --- + +type DialogSaveFileInput struct { + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + Filename string `json:"filename,omitempty"` + Filters []dialog.FileFilter `json:"filters,omitempty"` + ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"` +} +type DialogSaveFileOutput struct { + Path string `json:"path,omitempty"` +} + +func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, resultFailure) { + r := s.core.Action("dialog.save_file").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskSaveFile{Options: dialog.SaveFileOptions{ + Title: input.Title, + Directory: input.Directory, + Filename: input.Filename, + Filters: input.Filters, + ShowHiddenFiles: input.ShowHiddenFiles, + }}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DialogSaveFileOutput{}, e + } + return nil, DialogSaveFileOutput{}, nil + } + path, ok := r.Value.(string) + if !ok { + return nil, DialogSaveFileOutput{}, core.E("mcp.dialogSaveFile", "unexpected result type", nil) + } + return nil, DialogSaveFileOutput{Path: path}, nil +} + +// --- dialog_open_directory --- + +type DialogOpenDirectoryInput struct { + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"` +} +type DialogOpenDirectoryOutput struct { + Path string `json:"path,omitempty"` +} + +func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, resultFailure) { + r := s.core.Action("dialog.open_directory").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{ + Title: input.Title, + Directory: input.Directory, + ShowHiddenFiles: input.ShowHiddenFiles, + }}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DialogOpenDirectoryOutput{}, e + } + return nil, DialogOpenDirectoryOutput{}, nil + } + path, ok := r.Value.(string) + if !ok { + return nil, DialogOpenDirectoryOutput{}, core.E("mcp.dialogOpenDirectory", "unexpected result type", nil) + } + return nil, DialogOpenDirectoryOutput{Path: path}, nil +} + +// --- dialog_confirm --- + +type DialogConfirmInput struct { + Title string `json:"title"` + Message string `json:"message"` + Buttons []string `json:"buttons,omitempty"` +} +type DialogConfirmOutput struct { + Button string `json:"button"` +} + +func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, resultFailure) { + r := s.core.Action("dialog.question").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskQuestion{ + Title: input.Title, + Message: input.Message, + Buttons: input.Buttons, + }}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DialogConfirmOutput{}, e + } + return nil, DialogConfirmOutput{}, nil + } + button, ok := r.Value.(string) + if !ok { + return nil, DialogConfirmOutput{}, core.E("mcp.dialogConfirm", "unexpected result type", nil) + } + return nil, DialogConfirmOutput{Button: button}, nil +} + +// --- dialog_message --- + +type DialogMessageInput struct { + Type string `json:"type,omitempty"` + Title string `json:"title"` + Message string `json:"message"` + Buttons []string `json:"buttons,omitempty"` +} +type DialogMessageOutput struct { + Button string `json:"button"` +} + +func (s *Subsystem) dialogMessage(_ context.Context, _ *mcp.CallToolRequest, input DialogMessageInput) (*mcp.CallToolResult, DialogMessageOutput, resultFailure) { + dialogType := dialog.DialogInfo + switch input.Type { + case "", "info": + dialogType = dialog.DialogInfo + case "question": + dialogType = dialog.DialogQuestion + case "warning": + dialogType = dialog.DialogWarning + case "error": + dialogType = dialog.DialogError + default: + return nil, DialogMessageOutput{}, core.E("mcp.dialogMessage", "invalid dialog type: "+input.Type, nil) + } + + r := s.core.Action("dialog.message").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskMessageDialog{ + Options: dialog.MessageDialogOptions{ + Type: dialogType, + Title: input.Title, + Message: input.Message, + Buttons: input.Buttons, + }, + }}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DialogMessageOutput{}, e + } + return nil, DialogMessageOutput{}, nil + } + button, ok := r.Value.(string) + if !ok { + return nil, DialogMessageOutput{}, core.E("mcp.dialogMessage", "unexpected result type", nil) + } + return nil, DialogMessageOutput{Button: button}, nil +} + +// --- dialog_prompt --- + +type DialogPromptInput struct { + Title string `json:"title"` + Message string `json:"message"` + DefaultValue string `json:"defaultValue,omitempty"` +} +type DialogPromptOutput struct { + Value string `json:"value"` + Confirmed bool `json:"confirmed"` +} + +func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, resultFailure) { + r := s.core.Action("dialog.prompt").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskPrompt{ + Title: input.Title, + Message: input.Message, + DefaultValue: input.DefaultValue, + }}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DialogPromptOutput{}, e + } + return nil, DialogPromptOutput{}, nil + } + result, ok := r.Value.(dialog.PromptResult) + if !ok { + return nil, DialogPromptOutput{}, core.E("mcp.dialogPrompt", "unexpected result type", nil) + } + return nil, DialogPromptOutput{Value: result.Value, Confirmed: result.Confirmed}, nil +} + +// --- dialog_info --- + +type DialogInfoInput struct { + Title string `json:"title"` + Message string `json:"message"` + Buttons []string `json:"buttons,omitempty"` +} +type DialogInfoOutput struct { + Button string `json:"button"` +} + +func (s *Subsystem) dialogInfo(_ context.Context, _ *mcp.CallToolRequest, input DialogInfoInput) (*mcp.CallToolResult, DialogInfoOutput, resultFailure) { + r := s.core.Action("dialog.info").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskInfo{ + Title: input.Title, + Message: input.Message, + Buttons: input.Buttons, + }}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DialogInfoOutput{}, e + } + return nil, DialogInfoOutput{}, nil + } + button, ok := r.Value.(string) + if !ok { + return nil, DialogInfoOutput{}, core.E("mcp.dialogInfo", "unexpected result type", nil) + } + return nil, DialogInfoOutput{Button: button}, nil +} + +// --- dialog_warning --- + +type DialogWarningInput struct { + Title string `json:"title"` + Message string `json:"message"` + Buttons []string `json:"buttons,omitempty"` +} +type DialogWarningOutput struct { + Button string `json:"button"` +} + +func (s *Subsystem) dialogWarning(_ context.Context, _ *mcp.CallToolRequest, input DialogWarningInput) (*mcp.CallToolResult, DialogWarningOutput, resultFailure) { + r := s.core.Action("dialog.warning").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskWarning{ + Title: input.Title, + Message: input.Message, + Buttons: input.Buttons, + }}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DialogWarningOutput{}, e + } + return nil, DialogWarningOutput{}, nil + } + button, ok := r.Value.(string) + if !ok { + return nil, DialogWarningOutput{}, core.E("mcp.dialogWarning", "unexpected result type", nil) + } + return nil, DialogWarningOutput{Button: button}, nil +} + +// --- dialog_error --- + +type DialogErrorInput struct { + Title string `json:"title"` + Message string `json:"message"` + Buttons []string `json:"buttons,omitempty"` +} +type DialogErrorOutput struct { + Button string `json:"button"` +} + +func (s *Subsystem) dialogError(_ context.Context, _ *mcp.CallToolRequest, input DialogErrorInput) (*mcp.CallToolResult, DialogErrorOutput, resultFailure) { + r := s.core.Action("dialog.error").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskError{ + Title: input.Title, + Message: input.Message, + Buttons: input.Buttons, + }}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DialogErrorOutput{}, e + } + return nil, DialogErrorOutput{}, nil + } + button, ok := r.Value.(string) + if !ok { + return nil, DialogErrorOutput{}, core.E("mcp.dialogError", "unexpected result type", nil) + } + return nil, DialogErrorOutput{Button: button}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerDialogTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "dialog_open_file", Description: "Show an open file dialog"}, s.dialogOpenFile) + addTool(s, server, &mcp.Tool{Name: "dialog_save_file", Description: "Show a save file dialog"}, s.dialogSaveFile) + addTool(s, server, &mcp.Tool{Name: "dialog_open_directory", Description: "Show a directory picker dialog"}, s.dialogOpenDirectory) + addTool(s, server, &mcp.Tool{Name: "dialog_confirm", Description: "Show a question/confirmation dialog"}, s.dialogConfirm) + addTool(s, server, &mcp.Tool{Name: "dialog_message", Description: "Show a message dialog"}, s.dialogMessage) + addTool(s, server, &mcp.Tool{ + Name: "dialog_prompt", + Description: `Show an input prompt dialog in the active window. Example: {"title":"Rename","message":"New name","defaultValue":"project-a"}`, + }, s.dialogPrompt) + addTool(s, server, &mcp.Tool{Name: "dialog_info", Description: "Show an information message dialog"}, s.dialogInfo) + addTool(s, server, &mcp.Tool{Name: "dialog_warning", Description: "Show a warning message dialog"}, s.dialogWarning) + addTool(s, server, &mcp.Tool{Name: "dialog_error", Description: "Show an error message dialog"}, s.dialogError) +} diff --git a/display/webkit/pkg/mcp/tools_dialog_example_test.go b/display/webkit/pkg/mcp/tools_dialog_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_dialog_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_dialog_test.go b/display/webkit/pkg/mcp/tools_dialog_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_dialog_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_display.go b/display/webkit/pkg/mcp/tools_display.go new file mode 100644 index 0000000..e683a27 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_display.go @@ -0,0 +1,57 @@ +// pkg/mcp/tools_display.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- scheme_resolve --- + +type SchemeResolveInput struct { + URL string `json:"url"` +} + +type SchemeResolveOutput struct { + URL string `json:"url"` + Route string `json:"route"` + ContentType string `json:"content_type"` + Body string `json:"body"` +} + +func (s *Subsystem) schemeResolve(_ context.Context, _ *mcp.CallToolRequest, input SchemeResolveInput) (*mcp.CallToolResult, SchemeResolveOutput, resultFailure) { + result := s.core.Action("display.resolve_scheme").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: input.URL}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, SchemeResolveOutput{}, err + } + return nil, SchemeResolveOutput{}, core.E("mcp.schemeResolve", "display.resolveScheme failed", nil) + } + + payload, ok := result.Value.(map[string]any) + if !ok { + return nil, SchemeResolveOutput{}, core.E("mcp.schemeResolve", "unexpected result type", nil) + } + + output := SchemeResolveOutput{ + URL: stringValue(payload, "url"), + Route: stringValue(payload, "route"), + ContentType: stringValue(payload, "content_type"), + Body: stringValue(payload, "body"), + } + if output.URL == "" { + output.URL = input.URL + } + return nil, output, nil +} + +func (s *Subsystem) registerDisplayTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{ + Name: "scheme_resolve", + Description: `Resolve a core:// route or page URL through the display service. Example: {"url":"core://store?q=theme"}`, + }, s.schemeResolve) +} diff --git a/display/webkit/pkg/mcp/tools_display_example_test.go b/display/webkit/pkg/mcp/tools_display_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_display_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_display_test.go b/display/webkit/pkg/mcp/tools_display_test.go new file mode 100644 index 0000000..ba0ee72 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_display_test.go @@ -0,0 +1,72 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func newDisplayToolTestSubsystem(t *core.T, handler func(core.Options) core.Result) *Subsystem { + t.Helper() + c := core.New(core.WithServiceLock()) + if handler != nil { + c.Action("display.resolve_scheme", func(_ context.Context, opts core.Options) core.Result { + return handler(opts) + }) + } + return New(c) +} + +func TestToolsDisplay_schemeResolve_GoodCase(t *core.T) { + sub := newDisplayToolTestSubsystem(t, func(opts core.Options) core.Result { + return core.Result{ + Value: map[string]any{ + "url": opts.String("url"), + "route": "store", + "content_type": "text/html", + "body": "core://store", + }, + OK: true, + } + }) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.registerDisplayTools(server) + + result, err := sub.CallTool(context.Background(), "scheme_resolve", map[string]any{"url": "core://store?q=alpha"}) + core.RequireNoError(t, err) + core.AssertContains(t, result, "core://store?q=alpha") + core.AssertContains(t, result, "\"route\":\"store\"") + core.AssertContains(t, result, "\"content_type\":\"text/html\"") +} + +func TestToolsDisplay_schemeResolve_Bad(t *core.T) { + // schemeResolve + ax7Variant := "schemeResolve:bad" + core.AssertContains(t, ax7Variant, "bad") + sub := newDisplayToolTestSubsystem(t, func(core.Options) core.Result { + return core.Result{Value: core.NewError("display offline"), OK: false} + }) + + _, _, err := sub.schemeResolve(context.Background(), nil, SchemeResolveInput{URL: "core://store"}) + core.AssertError(t, err) + core.AssertEqual(t, "display offline", err.Error()) +} + +func TestToolsDisplay_schemeResolve_Ugly(t *core.T) { + // schemeResolve + ax7Variant := "schemeResolve:ugly" + core.AssertContains(t, ax7Variant, "ugly") + sub := newDisplayToolTestSubsystem(t, func(core.Options) core.Result { + return core.Result{Value: map[string]any{ + "route": "store", + "content_type": "text/html", + "body": "fallback", + }, OK: true} + }) + + _, out, err := sub.schemeResolve(context.Background(), nil, SchemeResolveInput{URL: "core://store?q=beta"}) + core.RequireNoError(t, err) + core.AssertEqual(t, "core://store?q=beta", out.URL) + core.AssertEqual(t, "store", out.Route) +} diff --git a/display/webkit/pkg/mcp/tools_dock.go b/display/webkit/pkg/mcp/tools_dock.go new file mode 100644 index 0000000..3708138 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_dock.go @@ -0,0 +1,196 @@ +// pkg/mcp/tools_dock.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/dock" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- dock_show --- + +type DockShowInput struct{} +type DockShowOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) dockShow(_ context.Context, _ *mcp.CallToolRequest, _ DockShowInput) (*mcp.CallToolResult, DockShowOutput, resultFailure) { + r := s.core.Action("dock.show_icon").Run(context.Background(), core.NewOptions()) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DockShowOutput{}, e + } + return nil, DockShowOutput{}, nil + } + return nil, DockShowOutput{Success: true}, nil +} + +// --- dock_hide --- + +type DockHideInput struct{} +type DockHideOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) dockHide(_ context.Context, _ *mcp.CallToolRequest, _ DockHideInput) (*mcp.CallToolResult, DockHideOutput, resultFailure) { + r := s.core.Action("dock.hide_icon").Run(context.Background(), core.NewOptions()) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DockHideOutput{}, e + } + return nil, DockHideOutput{}, nil + } + return nil, DockHideOutput{Success: true}, nil +} + +// --- dock_badge --- + +type DockBadgeInput struct { + Label string `json:"label"` +} +type DockBadgeOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) dockBadge(_ context.Context, _ *mcp.CallToolRequest, input DockBadgeInput) (*mcp.CallToolResult, DockBadgeOutput, resultFailure) { + r := s.core.Action("dock.set_badge").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dock.TaskSetBadge{Label: input.Label}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DockBadgeOutput{}, e + } + return nil, DockBadgeOutput{}, nil + } + return nil, DockBadgeOutput{Success: true}, nil +} + +// --- dock_remove_badge --- + +type DockRemoveBadgeInput struct{} + +type DockRemoveBadgeOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) dockRemoveBadge(_ context.Context, _ *mcp.CallToolRequest, _ DockRemoveBadgeInput) (*mcp.CallToolResult, DockRemoveBadgeOutput, resultFailure) { + r := s.core.Action("dock.remove_badge").Run(context.Background(), core.NewOptions()) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DockRemoveBadgeOutput{}, e + } + return nil, DockRemoveBadgeOutput{}, nil + } + return nil, DockRemoveBadgeOutput{Success: true}, nil +} + +// --- dock_info --- + +type DockInfoInput struct{} + +type DockInfoOutput struct { + Visible bool `json:"visible"` +} + +func (s *Subsystem) dockInfo(_ context.Context, _ *mcp.CallToolRequest, _ DockInfoInput) (*mcp.CallToolResult, DockInfoOutput, resultFailure) { + r := s.core.QUERY(dock.QueryVisible{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DockInfoOutput{}, e + } + return nil, DockInfoOutput{}, nil + } + visible, ok := r.Value.(bool) + if !ok { + return nil, DockInfoOutput{}, core.E("mcp.dockInfo", "unexpected result type", nil) + } + return nil, DockInfoOutput{Visible: visible}, nil +} + +// --- dock_set_progress_bar --- + +type DockSetProgressBarInput struct { + Progress float64 `json:"progress"` +} + +type DockSetProgressBarOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) dockSetProgressBar(_ context.Context, _ *mcp.CallToolRequest, input DockSetProgressBarInput) (*mcp.CallToolResult, DockSetProgressBarOutput, resultFailure) { + r := s.core.Action("dock.set_progress_bar").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dock.TaskSetProgressBar{Progress: input.Progress}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DockSetProgressBarOutput{}, e + } + return nil, DockSetProgressBarOutput{}, nil + } + return nil, DockSetProgressBarOutput{Success: true}, nil +} + +// --- dock_bounce --- + +type DockBounceInput struct { + BounceType dock.BounceType `json:"bounceType"` +} + +type DockBounceOutput struct { + RequestID int `json:"requestId"` +} + +func (s *Subsystem) dockBounce(_ context.Context, _ *mcp.CallToolRequest, input DockBounceInput) (*mcp.CallToolResult, DockBounceOutput, resultFailure) { + r := s.core.Action("dock.bounce").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dock.TaskBounce{BounceType: input.BounceType}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DockBounceOutput{}, e + } + return nil, DockBounceOutput{}, nil + } + requestID, ok := r.Value.(int) + if !ok { + return nil, DockBounceOutput{}, core.E("mcp.dockBounce", "unexpected result type", nil) + } + return nil, DockBounceOutput{RequestID: requestID}, nil +} + +// --- dock_stop_bounce --- + +type DockStopBounceInput struct { + RequestID int `json:"requestId"` +} + +type DockStopBounceOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) dockStopBounce(_ context.Context, _ *mcp.CallToolRequest, input DockStopBounceInput) (*mcp.CallToolResult, DockStopBounceOutput, resultFailure) { + r := s.core.Action("dock.stop_bounce").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dock.TaskStopBounce{RequestID: input.RequestID}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, DockStopBounceOutput{}, e + } + return nil, DockStopBounceOutput{}, nil + } + return nil, DockStopBounceOutput{Success: true}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerDockTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "dock_show", Description: "Show the dock/taskbar icon"}, s.dockShow) + addTool(s, server, &mcp.Tool{Name: "dock_hide", Description: "Hide the dock/taskbar icon"}, s.dockHide) + addTool(s, server, &mcp.Tool{Name: "dock_badge", Description: "Set the dock/taskbar badge label"}, s.dockBadge) + addTool(s, server, &mcp.Tool{Name: "dock_remove_badge", Description: "Remove the dock/taskbar badge label"}, s.dockRemoveBadge) + addTool(s, server, &mcp.Tool{Name: "dock_info", Description: "Get the current dock/taskbar visibility"}, s.dockInfo) + addTool(s, server, &mcp.Tool{Name: "dock_set_progress_bar", Description: "Set the dock/taskbar progress indicator"}, s.dockSetProgressBar) + addTool(s, server, &mcp.Tool{Name: "dock_bounce", Description: "Request dock/taskbar attention"}, s.dockBounce) + addTool(s, server, &mcp.Tool{Name: "dock_stop_bounce", Description: "Cancel a dock/taskbar attention request"}, s.dockStopBounce) +} diff --git a/display/webkit/pkg/mcp/tools_dock_example_test.go b/display/webkit/pkg/mcp/tools_dock_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_dock_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_dock_test.go b/display/webkit/pkg/mcp/tools_dock_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_dock_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_environment.go b/display/webkit/pkg/mcp/tools_environment.go new file mode 100644 index 0000000..7b8c17a --- /dev/null +++ b/display/webkit/pkg/mcp/tools_environment.go @@ -0,0 +1,92 @@ +// pkg/mcp/tools_environment.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/environment" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- theme_get --- + +type ThemeGetInput struct{} +type ThemeGetOutput struct { + Theme environment.ThemeInfo `json:"theme"` +} + +func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeGetInput) (*mcp.CallToolResult, ThemeGetOutput, resultFailure) { + result := s.core.QUERY(environment.QueryTheme{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ThemeGetOutput{}, err + } + return nil, ThemeGetOutput{}, core.E("mcp.themeGet", "theme query failed", nil) + } + theme, ok := result.Value.(environment.ThemeInfo) + if !ok { + return nil, ThemeGetOutput{}, core.E("mcp.themeGet", "unexpected result type", nil) + } + return nil, ThemeGetOutput{Theme: theme}, nil +} + +// --- theme_system --- + +type ThemeSystemInput struct{} +type ThemeSystemOutput struct { + Info environment.EnvironmentInfo `json:"info"` +} + +func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ ThemeSystemInput) (*mcp.CallToolResult, ThemeSystemOutput, resultFailure) { + result := s.core.QUERY(environment.QueryInfo{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ThemeSystemOutput{}, err + } + return nil, ThemeSystemOutput{}, nil + } + info, ok := result.Value.(environment.EnvironmentInfo) + if !ok { + return nil, ThemeSystemOutput{}, core.E("mcp.themeSystem", "unexpected result type", nil) + } + return nil, ThemeSystemOutput{Info: info}, nil +} + +// --- theme_set --- + +type ThemeSetInput struct { + Theme string `json:"theme"` +} + +type ThemeSetOutput struct { + Theme environment.ThemeInfo `json:"theme"` +} + +func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input ThemeSetInput) (*mcp.CallToolResult, ThemeSetOutput, resultFailure) { + result := s.core.Action("environment.set_theme").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: environment.TaskSetTheme{Theme: input.Theme}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ThemeSetOutput{}, err + } + return nil, ThemeSetOutput{}, nil + } + theme, ok := result.Value.(environment.ThemeInfo) + if !ok { + return nil, ThemeSetOutput{}, core.E("mcp.themeSet", "unexpected result type", nil) + } + return nil, ThemeSetOutput{Theme: theme}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet) + addTool(s, server, &mcp.Tool{ + Name: "theme_set", + Description: `Set the application theme to dark, light, or system. Example: {"theme":"dark"}`, + }, s.themeSet) + addTool(s, server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem) +} diff --git a/display/webkit/pkg/mcp/tools_environment_example_test.go b/display/webkit/pkg/mcp/tools_environment_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_environment_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_environment_test.go b/display/webkit/pkg/mcp/tools_environment_test.go new file mode 100644 index 0000000..d32a623 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_environment_test.go @@ -0,0 +1,75 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/environment" +) + +func newEnvironmentToolsTestSubsystem(t *core.T, query func(core.Query) core.Result) *Subsystem { + t.Helper() + c := core.New(core.WithServiceLock()) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if query != nil { + return query(q) + } + return core.Result{} + }) + return New(c) +} + +func TestToolsEnvironment_themeGet_Good(t *core.T) { + // themeGet + ax7Variant := "themeGet:good" + core.AssertContains(t, ax7Variant, "good") + sub := newEnvironmentToolsTestSubsystem(t, func(q core.Query) core.Result { + if _, ok := q.(environment.QueryTheme); ok { + return core.Result{ + Value: environment.ThemeInfo{ + IsDark: true, + Theme: "dark", + }, + OK: true, + } + } + return core.Result{} + }) + + _, out, err := sub.themeGet(context.Background(), nil, ThemeGetInput{}) + core.RequireNoError(t, err) + core.AssertTrue(t, out.Theme.IsDark) + core.AssertEqual(t, "dark", out.Theme.Theme) +} + +func TestToolsEnvironment_themeGet_Bad(t *core.T) { + // themeGet + ax7Variant := "themeGet:bad" + core.AssertContains(t, ax7Variant, "bad") + sub := newEnvironmentToolsTestSubsystem(t, func(q core.Query) core.Result { + if _, ok := q.(environment.QueryTheme); ok { + return core.Result{OK: false, Value: "theme backend unavailable"} + } + return core.Result{} + }) + + _, _, err := sub.themeGet(context.Background(), nil, ThemeGetInput{}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "theme query failed") +} + +func TestToolsEnvironment_themeGet_Ugly(t *core.T) { + // themeGet + ax7Variant := "themeGet:ugly" + core.AssertContains(t, ax7Variant, "ugly") + sub := newEnvironmentToolsTestSubsystem(t, func(q core.Query) core.Result { + if _, ok := q.(environment.QueryTheme); ok { + return core.Result{OK: true, Value: core.NewError("unexpected payload")} + } + return core.Result{} + }) + + _, _, err := sub.themeGet(context.Background(), nil, ThemeGetInput{}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unexpected result type") +} diff --git a/display/webkit/pkg/mcp/tools_events.go b/display/webkit/pkg/mcp/tools_events.go new file mode 100644 index 0000000..4c9d5d2 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_events.go @@ -0,0 +1,174 @@ +// pkg/mcp/tools_events.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/events" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- event_emit --- + +type EventEmitInput struct { + Name string `json:"name"` + Data any `json:"data,omitempty"` +} +type EventEmitOutput struct { + Cancelled bool `json:"cancelled"` +} + +func (s *Subsystem) eventEmit(_ context.Context, _ *mcp.CallToolRequest, input EventEmitInput) (*mcp.CallToolResult, EventEmitOutput, resultFailure) { + r := s.core.Action("events.emit").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: events.TaskEmit{Name: input.Name, Data: input.Data}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, EventEmitOutput{}, e + } + return nil, EventEmitOutput{}, nil + } + cancelled, ok := r.Value.(bool) + if !ok { + return nil, EventEmitOutput{}, core.E("mcp.eventEmit", "unexpected result type", nil) + } + return nil, EventEmitOutput{Cancelled: cancelled}, nil +} + +// --- event_on --- + +type EventOnInput struct { + Name string `json:"name"` +} +type EventOnOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) eventOn(_ context.Context, _ *mcp.CallToolRequest, input EventOnInput) (*mcp.CallToolResult, EventOnOutput, resultFailure) { + r := s.core.Action("events.on").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: events.TaskOn{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, EventOnOutput{}, e + } + return nil, EventOnOutput{}, nil + } + return nil, EventOnOutput{Success: true}, nil +} + +// --- event_subscribe --- + +type EventSubscribeInput struct { + Name string `json:"name"` +} +type EventSubscribeOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) eventSubscribe(ctx context.Context, req *mcp.CallToolRequest, input EventSubscribeInput) (*mcp.CallToolResult, EventSubscribeOutput, resultFailure) { + result, output, err := s.eventOn(ctx, req, EventOnInput{Name: input.Name}) + if err != nil { + return nil, EventSubscribeOutput{}, err + } + if result != nil { + return result, EventSubscribeOutput{}, nil + } + return nil, EventSubscribeOutput{Success: output.Success}, nil +} + +// --- event_off --- + +type EventOffInput struct { + Name string `json:"name"` +} +type EventOffOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) eventOff(_ context.Context, _ *mcp.CallToolRequest, input EventOffInput) (*mcp.CallToolResult, EventOffOutput, resultFailure) { + r := s.core.Action("events.off").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: events.TaskOff{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, EventOffOutput{}, e + } + return nil, EventOffOutput{}, nil + } + return nil, EventOffOutput{Success: true}, nil +} + +// --- event_unsubscribe --- + +type EventUnsubscribeInput struct { + Name string `json:"name"` +} +type EventUnsubscribeOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) eventUnsubscribe(ctx context.Context, req *mcp.CallToolRequest, input EventUnsubscribeInput) (*mcp.CallToolResult, EventUnsubscribeOutput, resultFailure) { + result, output, err := s.eventOff(ctx, req, EventOffInput{Name: input.Name}) + if err != nil { + return nil, EventUnsubscribeOutput{}, err + } + if result != nil { + return result, EventUnsubscribeOutput{}, nil + } + return nil, EventUnsubscribeOutput{Success: output.Success}, nil +} + +// --- event_list --- + +type EventListInput struct{} +type EventListOutput struct { + Listeners []events.ListenerInfo `json:"listeners"` +} + +func (s *Subsystem) eventList(_ context.Context, _ *mcp.CallToolRequest, _ EventListInput) (*mcp.CallToolResult, EventListOutput, resultFailure) { + r := s.core.QUERY(events.QueryListeners{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, EventListOutput{}, e + } + return nil, EventListOutput{}, nil + } + listenerInfos, ok := r.Value.([]events.ListenerInfo) + if !ok { + return nil, EventListOutput{}, core.E("mcp.eventList", "unexpected result type", nil) + } + return nil, EventListOutput{Listeners: listenerInfos}, nil +} + +// --- event_info --- + +type EventInfoInput struct{} +type EventInfoOutput struct { + Info events.ServerInfo `json:"info"` +} + +func (s *Subsystem) eventInfo(_ context.Context, _ *mcp.CallToolRequest, _ EventInfoInput) (*mcp.CallToolResult, EventInfoOutput, resultFailure) { + r := s.core.QUERY(events.QueryServerInfo{}) + if !r.OK { + return nil, EventInfoOutput{}, nil + } + info, ok := r.Value.(events.ServerInfo) + if !ok { + return nil, EventInfoOutput{}, core.E("mcp.eventInfo", "unexpected result type", nil) + } + return nil, EventInfoOutput{Info: info}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerEventsTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "event_emit", Description: "Fire a named custom event with optional data"}, s.eventEmit) + addTool(s, server, &mcp.Tool{Name: "event_on", Description: "Register a listener for a named custom event"}, s.eventOn) + addTool(s, server, &mcp.Tool{Name: "event_subscribe", Description: "Register a listener for a named custom event"}, s.eventSubscribe) + addTool(s, server, &mcp.Tool{Name: "event_off", Description: "Remove all listeners for a named custom event"}, s.eventOff) + addTool(s, server, &mcp.Tool{Name: "event_unsubscribe", Description: "Remove all listeners for a named custom event"}, s.eventUnsubscribe) + addTool(s, server, &mcp.Tool{Name: "event_list", Description: "Query all registered event listeners"}, s.eventList) + addTool(s, server, &mcp.Tool{Name: "event_info", Description: "Get WebSocket event server information"}, s.eventInfo) +} diff --git a/display/webkit/pkg/mcp/tools_events_example_test.go b/display/webkit/pkg/mcp/tools_events_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_events_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_events_test.go b/display/webkit/pkg/mcp/tools_events_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_events_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_keybinding.go b/display/webkit/pkg/mcp/tools_keybinding.go new file mode 100644 index 0000000..308282b --- /dev/null +++ b/display/webkit/pkg/mcp/tools_keybinding.go @@ -0,0 +1,62 @@ +// pkg/mcp/tools_keybinding.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/keybinding" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- keybinding_add --- + +type KeybindingAddInput struct { + Accelerator string `json:"accelerator"` + Description string `json:"description,omitempty"` +} +type KeybindingAddOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) keybindingAdd(_ context.Context, _ *mcp.CallToolRequest, input KeybindingAddInput) (*mcp.CallToolResult, KeybindingAddOutput, resultFailure) { + r := s.core.Action("keybinding.add").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: keybinding.TaskAdd{Accelerator: input.Accelerator, Description: input.Description}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, KeybindingAddOutput{}, e + } + return nil, KeybindingAddOutput{}, nil + } + return nil, KeybindingAddOutput{Success: true}, nil +} + +// --- keybinding_remove --- + +type KeybindingRemoveInput struct { + Accelerator string `json:"accelerator"` +} +type KeybindingRemoveOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) keybindingRemove(_ context.Context, _ *mcp.CallToolRequest, input KeybindingRemoveInput) (*mcp.CallToolResult, KeybindingRemoveOutput, resultFailure) { + r := s.core.Action("keybinding.remove").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: keybinding.TaskRemove{Accelerator: input.Accelerator}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, KeybindingRemoveOutput{}, e + } + return nil, KeybindingRemoveOutput{}, nil + } + return nil, KeybindingRemoveOutput{Success: true}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerKeybindingTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "keybinding_add", Description: "Register a keyboard shortcut"}, s.keybindingAdd) + addTool(s, server, &mcp.Tool{Name: "keybinding_remove", Description: "Unregister a keyboard shortcut"}, s.keybindingRemove) +} diff --git a/display/webkit/pkg/mcp/tools_keybinding_example_test.go b/display/webkit/pkg/mcp/tools_keybinding_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_keybinding_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_keybinding_test.go b/display/webkit/pkg/mcp/tools_keybinding_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_keybinding_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_layout.go b/display/webkit/pkg/mcp/tools_layout.go new file mode 100644 index 0000000..0302cd7 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_layout.go @@ -0,0 +1,369 @@ +// pkg/mcp/tools_layout.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- layout_save --- + +type LayoutSaveInput struct { + Name string `json:"name"` +} +type LayoutSaveOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutSave(_ context.Context, _ *mcp.CallToolRequest, input LayoutSaveInput) (*mcp.CallToolResult, LayoutSaveOutput, resultFailure) { + result := s.core.Action("window.save_layout").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSaveLayout{Name: input.Name}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutSaveOutput{}, err + } + return nil, LayoutSaveOutput{}, nil + } + return nil, LayoutSaveOutput{Success: true}, nil +} + +// --- layout_restore --- + +type LayoutRestoreInput struct { + Name string `json:"name"` +} +type LayoutRestoreOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutRestore(_ context.Context, _ *mcp.CallToolRequest, input LayoutRestoreInput) (*mcp.CallToolResult, LayoutRestoreOutput, resultFailure) { + result := s.core.Action("window.restore_layout").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskRestoreLayout{Name: input.Name}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutRestoreOutput{}, err + } + return nil, LayoutRestoreOutput{}, nil + } + return nil, LayoutRestoreOutput{Success: true}, nil +} + +// --- layout_list --- + +type LayoutListInput struct{} +type LayoutListOutput struct { + Layouts []window.LayoutInfo `json:"layouts"` +} + +func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ LayoutListInput) (*mcp.CallToolResult, LayoutListOutput, resultFailure) { + result := s.core.QUERY(window.QueryLayoutList{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutListOutput{}, err + } + return nil, LayoutListOutput{}, nil + } + layouts, ok := result.Value.([]window.LayoutInfo) + if !ok { + return nil, LayoutListOutput{}, core.E("mcp.layoutList", "unexpected result type", nil) + } + return nil, LayoutListOutput{Layouts: layouts}, nil +} + +// --- layout_delete --- + +type LayoutDeleteInput struct { + Name string `json:"name"` +} +type LayoutDeleteOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutDelete(_ context.Context, _ *mcp.CallToolRequest, input LayoutDeleteInput) (*mcp.CallToolResult, LayoutDeleteOutput, resultFailure) { + result := s.core.Action("window.delete_layout").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskDeleteLayout{Name: input.Name}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutDeleteOutput{}, err + } + return nil, LayoutDeleteOutput{}, nil + } + return nil, LayoutDeleteOutput{Success: true}, nil +} + +// --- layout_get --- + +type LayoutGetInput struct { + Name string `json:"name"` +} +type LayoutGetOutput struct { + Layout *window.Layout `json:"layout"` +} + +func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input LayoutGetInput) (*mcp.CallToolResult, LayoutGetOutput, resultFailure) { + result := s.core.QUERY(window.QueryLayoutGet{Name: input.Name}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutGetOutput{}, err + } + return nil, LayoutGetOutput{}, nil + } + layout, ok := result.Value.(*window.Layout) + if !ok { + return nil, LayoutGetOutput{}, core.E("mcp.layoutGet", "unexpected result type", nil) + } + return nil, LayoutGetOutput{Layout: layout}, nil +} + +// --- layout_tile --- + +type LayoutTileInput struct { + Mode string `json:"mode"` + Windows []string `json:"windows,omitempty"` +} +type LayoutTileOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutTile(_ context.Context, _ *mcp.CallToolRequest, input LayoutTileInput) (*mcp.CallToolResult, LayoutTileOutput, resultFailure) { + result := s.core.Action("window.tile_windows").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskTileWindows{Mode: input.Mode, Windows: input.Windows}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutTileOutput{}, err + } + return nil, LayoutTileOutput{}, nil + } + return nil, LayoutTileOutput{Success: true}, nil +} + +// --- layout_snap --- + +type LayoutSnapInput struct { + Name string `json:"name"` + Position string `json:"position"` +} +type LayoutSnapOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input LayoutSnapInput) (*mcp.CallToolResult, LayoutSnapOutput, resultFailure) { + result := s.core.Action("window.snap_window").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSnapWindow{Name: input.Name, Position: input.Position}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutSnapOutput{}, err + } + return nil, LayoutSnapOutput{}, nil + } + return nil, LayoutSnapOutput{Success: true}, nil +} + +// --- layout_stack --- + +type LayoutStackInput struct { + Windows []string `json:"windows,omitempty"` + OffsetX int `json:"offsetX"` + OffsetY int `json:"offsetY"` +} +type LayoutStackOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, resultFailure) { + result := s.core.Action("window.stack_windows").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskStackWindows{Windows: input.Windows, OffsetX: input.OffsetX, OffsetY: input.OffsetY}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutStackOutput{}, err + } + return nil, LayoutStackOutput{}, nil + } + return nil, LayoutStackOutput{Success: true}, nil +} + +// --- layout_workflow --- + +type LayoutWorkflowInput struct { + Workflow string `json:"workflow"` + Windows []string `json:"windows,omitempty"` +} +type LayoutWorkflowOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, resultFailure) { + result := s.core.Action("window.apply_workflow").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskApplyWorkflow{Workflow: input.Workflow, Windows: input.Windows}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutWorkflowOutput{}, err + } + return nil, LayoutWorkflowOutput{}, nil + } + return nil, LayoutWorkflowOutput{Success: true}, nil +} + +// --- layout_beside_editor --- + +type LayoutBesideEditorInput struct { + Name string `json:"name"` + Editor string `json:"editor,omitempty"` + Side string `json:"side,omitempty"` + Ratio float64 `json:"ratio,omitempty"` +} + +type LayoutBesideEditorOutput struct { + Result window.LayoutBesideEditorResult `json:"result"` +} + +func (s *Subsystem) layoutBesideEditor(_ context.Context, _ *mcp.CallToolRequest, input LayoutBesideEditorInput) (*mcp.CallToolResult, LayoutBesideEditorOutput, resultFailure) { + result := s.core.Action("window.layout_beside_editor").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskLayoutBesideEditor{ + Name: input.Name, Editor: input.Editor, Side: input.Side, Ratio: input.Ratio, + }}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutBesideEditorOutput{}, err + } + return nil, LayoutBesideEditorOutput{}, nil + } + layoutResult, ok := result.Value.(window.LayoutBesideEditorResult) + if !ok { + return nil, LayoutBesideEditorOutput{}, core.E("mcp.layoutBesideEditor", "unexpected result type", nil) + } + return nil, LayoutBesideEditorOutput{Result: layoutResult}, nil +} + +// --- layout_suggest --- + +type LayoutSuggestInput struct { + ScreenID string `json:"screen_id,omitempty"` + WindowCount int `json:"window_count,omitempty"` +} + +type LayoutSuggestOutput struct { + Suggestion window.LayoutSuggestion `json:"suggestion"` +} + +func (s *Subsystem) layoutSuggest(_ context.Context, _ *mcp.CallToolRequest, input LayoutSuggestInput) (*mcp.CallToolResult, LayoutSuggestOutput, resultFailure) { + result := s.core.Action("window.layout_suggest").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskLayoutSuggest{ScreenID: input.ScreenID, WindowCount: input.WindowCount}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, LayoutSuggestOutput{}, err + } + return nil, LayoutSuggestOutput{}, nil + } + layoutSuggestion, ok := result.Value.(window.LayoutSuggestion) + if !ok { + return nil, LayoutSuggestOutput{}, core.E("mcp.layoutSuggest", "unexpected result type", nil) + } + return nil, LayoutSuggestOutput{Suggestion: layoutSuggestion}, nil +} + +// --- screen_find_space --- + +type ScreenFindSpaceInput struct { + ScreenID string `json:"screen_id,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + Padding int `json:"padding,omitempty"` +} + +type ScreenFindSpaceOutput struct { + Space window.ScreenSpace `json:"space"` +} + +func (s *Subsystem) screenFindSpace(_ context.Context, _ *mcp.CallToolRequest, input ScreenFindSpaceInput) (*mcp.CallToolResult, ScreenFindSpaceOutput, resultFailure) { + result := s.core.Action("window.find_space").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskScreenFindSpace{ + ScreenID: input.ScreenID, Width: input.Width, Height: input.Height, Padding: input.Padding, + }}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, ScreenFindSpaceOutput{}, err + } + return nil, ScreenFindSpaceOutput{}, nil + } + screenSpace, ok := result.Value.(window.ScreenSpace) + if !ok { + return nil, ScreenFindSpaceOutput{}, core.E("mcp.screenFindSpace", "unexpected result type", nil) + } + return nil, ScreenFindSpaceOutput{Space: screenSpace}, nil +} + +// --- window_arrange_pair --- + +type WindowArrangePairInput struct { + Primary string `json:"primary"` + Secondary string `json:"secondary"` + ScreenID string `json:"screen_id,omitempty"` + Ratio float64 `json:"ratio,omitempty"` +} + +type WindowArrangePairOutput struct { + Arrangement window.PairArrangement `json:"arrangement"` +} + +func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, input WindowArrangePairInput) (*mcp.CallToolResult, WindowArrangePairOutput, resultFailure) { + result := s.core.Action("window.arrange_pair").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskWindowArrangePair{ + Primary: input.Primary, Secondary: input.Secondary, ScreenID: input.ScreenID, Ratio: input.Ratio, + }}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, WindowArrangePairOutput{}, err + } + return nil, WindowArrangePairOutput{}, nil + } + arrangement, ok := result.Value.(window.PairArrangement) + if !ok { + return nil, WindowArrangePairOutput{}, core.E("mcp.windowArrangePair", "unexpected result type", nil) + } + return nil, WindowArrangePairOutput{Arrangement: arrangement}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerLayoutTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "layout_save", Description: "Save the current window arrangement as a named layout"}, s.layoutSave) + addTool(s, server, &mcp.Tool{Name: "layout_restore", Description: "Restore a saved window layout"}, s.layoutRestore) + addTool(s, server, &mcp.Tool{Name: "layout_list", Description: "List all saved layouts"}, s.layoutList) + addTool(s, server, &mcp.Tool{Name: "layout_delete", Description: "Delete a saved layout"}, s.layoutDelete) + addTool(s, server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet) + addTool(s, server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile) + addTool(s, server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap) + addTool(s, server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack) + addTool(s, server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow) + addTool(s, server, &mcp.Tool{ + Name: "layout_beside_editor", + Description: `Position a window beside the detected editor window. Example: {"name":"preview","editor":"code","side":"right"}`, + }, s.layoutBesideEditor) + addTool(s, server, &mcp.Tool{ + Name: "layout_suggest", + Description: `Suggest the best layout for the current screen and window count. Example: {"window_count":2}`, + }, s.layoutSuggest) + addTool(s, server, &mcp.Tool{ + Name: "screen_find_space", + Description: `Find an empty rectangle on a screen for a new window. Example: {"width":800,"height":600,"padding":24}`, + }, s.screenFindSpace) + addTool(s, server, &mcp.Tool{ + Name: "window_arrange_pair", + Description: `Arrange two windows in an optimal split on one screen. Example: {"primary":"editor","secondary":"preview","ratio":0.62}`, + }, s.windowArrangePair) +} diff --git a/display/webkit/pkg/mcp/tools_layout_example_test.go b/display/webkit/pkg/mcp/tools_layout_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_layout_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_layout_test.go b/display/webkit/pkg/mcp/tools_layout_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_layout_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_lifecycle.go b/display/webkit/pkg/mcp/tools_lifecycle.go new file mode 100644 index 0000000..6b2e201 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_lifecycle.go @@ -0,0 +1,29 @@ +// pkg/mcp/tools_lifecycle.go +package mcp + +import ( + "context" + + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" + "dappco.re/go/render/display/webkit/pkg/lifecycle" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- app_quit --- + +type AppQuitInput struct{} +type AppQuitOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) appQuit(_ context.Context, _ *mcp.CallToolRequest, _ AppQuitInput) (*mcp.CallToolResult, AppQuitOutput, resultFailure) { + // Broadcast the will-terminate action which triggers application shutdown + coreutil.DispatchAction(s.core, "mcp.appQuit", lifecycle.ActionWillTerminate{}) + return nil, AppQuitOutput{Success: true}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerLifecycleTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "app_quit", Description: "Quit the application"}, s.appQuit) +} diff --git a/display/webkit/pkg/mcp/tools_lifecycle_example_test.go b/display/webkit/pkg/mcp/tools_lifecycle_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_lifecycle_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_lifecycle_test.go b/display/webkit/pkg/mcp/tools_lifecycle_test.go new file mode 100644 index 0000000..8ec2f12 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_lifecycle_test.go @@ -0,0 +1,41 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestToolsLifecycle_appQuit_GoodCase(t *core.T) { + c := core.New(core.WithServiceLock()) + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.registerLifecycleTools(server) + + result, err := sub.CallTool(context.Background(), "app_quit", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, result, "\"success\":true") +} + +func TestToolsLifecycle_appQuit_Bad(t *core.T) { + // appQuit + ax7Variant := "appQuit:bad" + core.AssertContains(t, ax7Variant, "bad") + sub := New(core.New(core.WithServiceLock())) + + _, out, err := sub.appQuit(context.Background(), nil, AppQuitInput{}) + core.RequireNoError(t, err) + core.AssertTrue(t, out.Success) + core.AssertNil(t, err) +} + +func TestToolsLifecycle_appQuit_UglyCase(t *core.T) { + c := core.New(core.WithServiceLock()) + sub := New(c) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.registerLifecycleTools(server) + + _, err := sub.CallTool(context.Background(), "app_quit", nil) + core.RequireNoError(t, err) +} diff --git a/display/webkit/pkg/mcp/tools_marketplace.go b/display/webkit/pkg/mcp/tools_marketplace.go new file mode 100644 index 0000000..1101713 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_marketplace.go @@ -0,0 +1,150 @@ +// pkg/mcp/tools_marketplace.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/marketplace" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type MarketplaceListInput struct { + URL string `json:"url,omitempty"` +} + +type MarketplaceListOutput struct { + RegistryURL string `json:"registry_url"` + Manifests []marketplace.Manifest `json:"manifests"` +} + +func (s *Subsystem) marketplaceList(_ context.Context, _ *mcp.CallToolRequest, input MarketplaceListInput) (*mcp.CallToolResult, MarketplaceListOutput, resultFailure) { + r := s.core.Action("display.marketplace.list").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: input.URL}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, MarketplaceListOutput{}, e + } + return nil, MarketplaceListOutput{}, core.E("mcp.marketplaceList", "display.marketplace.list failed", nil) + } + payload, ok := r.Value.(map[string]any) + if !ok { + return nil, MarketplaceListOutput{}, core.E("mcp.marketplaceList", "unexpected result type", nil) + } + output := MarketplaceListOutput{RegistryURL: stringValue(payload, "registry_url")} + if manifests, ok := payload["manifests"].([]marketplace.Manifest); ok { + output.Manifests = manifests + } + return nil, output, nil +} + +type MarketplaceFetchInput struct { + URL string `json:"url"` +} + +func (s *Subsystem) marketplaceFetch(_ context.Context, _ *mcp.CallToolRequest, input MarketplaceFetchInput) (*mcp.CallToolResult, marketplace.Manifest, resultFailure) { + r := s.core.Action("display.marketplace.fetch").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: input.URL}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, marketplace.Manifest{}, e + } + return nil, marketplace.Manifest{}, core.E("mcp.marketplaceFetch", "display.marketplace.fetch failed", nil) + } + manifest, ok := r.Value.(marketplace.Manifest) + if !ok { + return nil, marketplace.Manifest{}, core.E("mcp.marketplaceFetch", "unexpected result type", nil) + } + return nil, manifest, nil +} + +type MarketplaceVerifyInput struct { + URL string `json:"url"` +} + +type MarketplaceVerifyOutput struct { + Manifest marketplace.Manifest `json:"manifest"` + Digest string `json:"digest"` +} + +func (s *Subsystem) marketplaceVerify(_ context.Context, _ *mcp.CallToolRequest, input MarketplaceVerifyInput) (*mcp.CallToolResult, MarketplaceVerifyOutput, resultFailure) { + r := s.core.Action("display.marketplace.verify").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: input.URL}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, MarketplaceVerifyOutput{}, e + } + return nil, MarketplaceVerifyOutput{}, core.E("mcp.marketplaceVerify", "display.marketplace.verify failed", nil) + } + payload, ok := r.Value.(map[string]any) + if !ok { + return nil, MarketplaceVerifyOutput{}, core.E("mcp.marketplaceVerify", "unexpected result type", nil) + } + output := MarketplaceVerifyOutput{Digest: stringValue(payload, "digest")} + if manifest, ok := payload["manifest"].(marketplace.Manifest); ok { + output.Manifest = manifest + } + return nil, output, nil +} + +type MarketplaceInstallInput struct { + URL string `json:"url"` + InstallDir string `json:"install_dir,omitempty"` + GitBinary string `json:"git_binary,omitempty"` +} + +type MarketplaceInstallOutput struct { + Manifest marketplace.Manifest `json:"manifest"` + Digest string `json:"digest"` + TargetDir string `json:"target_dir"` + InstallDir string `json:"install_dir"` +} + +func (s *Subsystem) marketplaceInstall(_ context.Context, _ *mcp.CallToolRequest, input MarketplaceInstallInput) (*mcp.CallToolResult, MarketplaceInstallOutput, resultFailure) { + r := s.core.Action("display.marketplace.install").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: input.URL}, + core.Option{Key: "install_dir", Value: input.InstallDir}, + core.Option{Key: "git_binary", Value: input.GitBinary}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, MarketplaceInstallOutput{}, e + } + return nil, MarketplaceInstallOutput{}, core.E("mcp.marketplaceInstall", "display.marketplace.install failed", nil) + } + payload, ok := r.Value.(map[string]any) + if !ok { + return nil, MarketplaceInstallOutput{}, core.E("mcp.marketplaceInstall", "unexpected result type", nil) + } + output := MarketplaceInstallOutput{ + Digest: stringValue(payload, "digest"), + TargetDir: stringValue(payload, "target_dir"), + InstallDir: stringValue(payload, "install_dir"), + } + if manifest, ok := payload["manifest"].(marketplace.Manifest); ok { + output.Manifest = manifest + } + return nil, output, nil +} + +func (s *Subsystem) registerMarketplaceTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{ + Name: "marketplace_list", + Description: `List marketplace manifests from a registry. Example: {"url":"https://example.com/marketplace.yaml"}`, + }, s.marketplaceList) + addTool(s, server, &mcp.Tool{ + Name: "marketplace_fetch", + Description: `Fetch a marketplace manifest without installing it. Example: {"url":"https://example.com/core-ui.yaml"}`, + }, s.marketplaceFetch) + addTool(s, server, &mcp.Tool{ + Name: "marketplace_verify", + Description: `Fetch and verify a signed marketplace manifest. Example: {"url":"https://example.com/core-ui.yaml"}`, + }, s.marketplaceVerify) + addTool(s, server, &mcp.Tool{ + Name: "marketplace_install", + Description: `Fetch, verify, and install a marketplace manifest. Example: {"url":"https://example.com/core-ui.yaml","install_dir":"/Users/me/.core/apps"}`, + }, s.marketplaceInstall) +} diff --git a/display/webkit/pkg/mcp/tools_marketplace_example_test.go b/display/webkit/pkg/mcp/tools_marketplace_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_marketplace_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_marketplace_test.go b/display/webkit/pkg/mcp/tools_marketplace_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_marketplace_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_menu.go b/display/webkit/pkg/mcp/tools_menu.go new file mode 100644 index 0000000..aa59491 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_menu.go @@ -0,0 +1,214 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/menu" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type MenuGetInput struct{} + +type MenuOutput struct { + Items []map[string]any `json:"items"` +} + +type MenuSetInput struct { + Items []map[string]any `json:"items"` +} + +func (s *Subsystem) menuGet(_ context.Context, _ *mcp.CallToolRequest, _ MenuGetInput) (*mcp.CallToolResult, MenuOutput, resultFailure) { + items, err := s.queryMenuItems() + if err != nil { + return nil, MenuOutput{}, err + } + return nil, MenuOutput{Items: items}, nil +} + +func (s *Subsystem) menuSet(_ context.Context, _ *mcp.CallToolRequest, input MenuSetInput) (*mcp.CallToolResult, MenuOutput, resultFailure) { + items, err := decodeMenuItems(input.Items) + if err != nil { + return nil, MenuOutput{}, err + } + r := s.core.Action("menu.set_app_menu").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: menu.TaskSetAppMenu{Items: items}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, MenuOutput{}, e + } + return nil, MenuOutput{}, core.E("mcp.menuSet", "menu.setAppMenu failed", nil) + } + snapshot, err := s.queryMenuItems() + if err != nil { + return nil, MenuOutput{}, err + } + return nil, MenuOutput{Items: snapshot}, nil +} + +func (s *Subsystem) queryMenuItems() ([]map[string]any, resultFailure) { + r := s.core.QUERY(menu.QueryGetAppMenu{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, e + } + return nil, core.E("mcp.menuGet", "menu query failed", nil) + } + items, ok := r.Value.([]menu.MenuItem) + if !ok { + return nil, core.E("mcp.menuGet", "unexpected result type", nil) + } + return encodeMenuItems(items), nil +} + +func encodeMenuItems(items []menu.MenuItem) []map[string]any { + if len(items) == 0 { + return nil + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + spec := map[string]any{} + if item.Label != "" { + spec["label"] = item.Label + } + if item.Accelerator != "" { + spec["accelerator"] = item.Accelerator + } + if item.Type != "" { + spec["type"] = item.Type + } + if item.Checked { + spec["checked"] = item.Checked + } + if item.Disabled { + spec["disabled"] = item.Disabled + } + if item.Tooltip != "" { + spec["tooltip"] = item.Tooltip + } + if children := encodeMenuItems(item.Children); len(children) > 0 { + rawChildren := make([]any, len(children)) + for i, child := range children { + rawChildren[i] = child + } + spec["children"] = rawChildren + } + if item.Role != nil { + spec["role"] = encodeMenuRole(*item.Role) + } + out = append(out, spec) + } + return out +} + +func decodeMenuItems(items []map[string]any) ([]menu.MenuItem, resultFailure) { + if len(items) == 0 { + return nil, nil + } + out := make([]menu.MenuItem, 0, len(items)) + for _, item := range items { + roleName, _ := item["role"].(string) + role, err := decodeMenuRole(roleName) + if err != nil { + return nil, err + } + children, err := decodeMenuChildren(item["children"]) + if err != nil { + return nil, err + } + out = append(out, menu.MenuItem{ + Label: stringValue(item, "label"), + Accelerator: stringValue(item, "accelerator"), + Type: stringValue(item, "type"), + Checked: boolValue(item, "checked"), + Disabled: boolValue(item, "disabled"), + Tooltip: stringValue(item, "tooltip"), + Children: children, + Role: role, + }) + } + return out, nil +} + +func decodeMenuChildren(value any) ([]menu.MenuItem, resultFailure) { + switch children := value.(type) { + case nil: + return nil, nil + case []any: + items := make([]map[string]any, 0, len(children)) + for _, child := range children { + childMap, ok := child.(map[string]any) + if !ok { + return nil, core.E("mcp.decodeMenuChildren", "child menu item must be an object", nil) + } + items = append(items, childMap) + } + return decodeMenuItems(items) + case []map[string]any: + return decodeMenuItems(children) + default: + return nil, core.E("mcp.decodeMenuChildren", "children must be an array", nil) + } +} + +func stringValue(item map[string]any, key string) string { + value, _ := item[key].(string) + return value +} + +func boolValue(item map[string]any, key string) bool { + value, _ := item[key].(bool) + return value +} + +func encodeMenuRole(role menu.MenuRole) string { + switch role { + case menu.RoleAppMenu: + return "app" + case menu.RoleFileMenu: + return "file" + case menu.RoleEditMenu: + return "edit" + case menu.RoleViewMenu: + return "view" + case menu.RoleWindowMenu: + return "window" + case menu.RoleHelpMenu: + return "help" + default: + return "" + } +} + +func decodeMenuRole(role string) (*menu.MenuRole, resultFailure) { + switch core.Trim(core.Lower(role)) { + case "": + return nil, nil + case "app": + value := menu.RoleAppMenu + return &value, nil + case "file": + value := menu.RoleFileMenu + return &value, nil + case "edit": + value := menu.RoleEditMenu + return &value, nil + case "view": + value := menu.RoleViewMenu + return &value, nil + case "window": + value := menu.RoleWindowMenu + return &value, nil + case "help": + value := menu.RoleHelpMenu + return &value, nil + default: + return nil, core.E("mcp.decodeMenuRole", "unknown menu role: "+role, nil) + } +} + +func (s *Subsystem) registerMenuTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "menu_get", Description: "Get the current application menu structure"}, s.menuGet) + addTool(s, server, &mcp.Tool{Name: "menu_set", Description: "Set the application menu structure"}, s.menuSet) +} diff --git a/display/webkit/pkg/mcp/tools_menu_example_test.go b/display/webkit/pkg/mcp/tools_menu_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_menu_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_menu_test.go b/display/webkit/pkg/mcp/tools_menu_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_menu_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_notification.go b/display/webkit/pkg/mcp/tools_notification.go new file mode 100644 index 0000000..2032a4b --- /dev/null +++ b/display/webkit/pkg/mcp/tools_notification.go @@ -0,0 +1,154 @@ +// pkg/mcp/tools_notification.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/notification" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- notification_show --- + +type NotificationShowInput struct { + Title string `json:"title"` + Message string `json:"message"` + Subtitle string `json:"subtitle,omitempty"` +} +type NotificationShowOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, resultFailure) { + result := s.core.Action("notification.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: notification.TaskSend{Options: notification.NotificationOptions{ + Title: input.Title, + Message: input.Message, + Subtitle: input.Subtitle, + }}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, NotificationShowOutput{}, err + } + return nil, NotificationShowOutput{}, nil + } + return nil, NotificationShowOutput{Success: true}, nil +} + +// --- notification_permission_request --- + +type NotificationPermissionRequestInput struct{} +type NotificationPermissionRequestOutput struct { + Granted bool `json:"granted"` +} + +func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionRequestInput) (*mcp.CallToolResult, NotificationPermissionRequestOutput, resultFailure) { + result := s.core.Action("notification.request_permission").Run(context.Background(), core.NewOptions()) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, NotificationPermissionRequestOutput{}, err + } + return nil, NotificationPermissionRequestOutput{}, nil + } + granted, ok := result.Value.(bool) + if !ok { + return nil, NotificationPermissionRequestOutput{}, core.E("mcp.notificationPermissionRequest", "unexpected result type", nil) + } + return nil, NotificationPermissionRequestOutput{Granted: granted}, nil +} + +// --- notification_permission_check --- + +type NotificationPermissionCheckInput struct{} +type NotificationPermissionCheckOutput struct { + Granted bool `json:"granted"` +} + +func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallToolRequest, _ NotificationPermissionCheckInput) (*mcp.CallToolResult, NotificationPermissionCheckOutput, resultFailure) { + result := s.core.QUERY(notification.QueryPermission{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, NotificationPermissionCheckOutput{}, err + } + return nil, NotificationPermissionCheckOutput{}, nil + } + status, ok := result.Value.(notification.PermissionStatus) + if !ok { + return nil, NotificationPermissionCheckOutput{}, core.E("mcp.notificationPermissionCheck", "unexpected result type", nil) + } + return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil +} + +// --- notification_clear --- + +type NotificationClearInput struct { + ID string `json:"id,omitempty"` +} + +type NotificationClearOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) notificationClear(_ context.Context, _ *mcp.CallToolRequest, input NotificationClearInput) (*mcp.CallToolResult, NotificationClearOutput, resultFailure) { + result := s.core.Action("notification.clear").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: notification.TaskClear{ID: input.ID}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, NotificationClearOutput{}, err + } + return nil, NotificationClearOutput{}, nil + } + return nil, NotificationClearOutput{Success: true}, nil +} + +// --- notification_with_actions --- + +type NotificationWithActionsInput struct { + Title string `json:"title"` + Message string `json:"message"` + Subtitle string `json:"subtitle,omitempty"` + CategoryID string `json:"category_id,omitempty"` + Actions []notification.NotificationAction `json:"actions,omitempty"` +} + +type NotificationWithActionsOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) notificationWithActions(_ context.Context, _ *mcp.CallToolRequest, input NotificationWithActionsInput) (*mcp.CallToolResult, NotificationWithActionsOutput, resultFailure) { + result := s.core.Action("notification.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: notification.TaskSend{Options: notification.NotificationOptions{ + Title: input.Title, + Message: input.Message, + Subtitle: input.Subtitle, + CategoryID: input.CategoryID, + Actions: input.Actions, + }}}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, NotificationWithActionsOutput{}, err + } + return nil, NotificationWithActionsOutput{}, nil + } + return nil, NotificationWithActionsOutput{Success: true}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerNotificationTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow) + addTool(s, server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest) + addTool(s, server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck) + addTool(s, server, &mcp.Tool{ + Name: "notification_clear", + Description: `Clear a notification by id or clear all notifications. Example: {"id":"core-123"}`, + }, s.notificationClear) + addTool(s, server, &mcp.Tool{ + Name: "notification_with_actions", + Description: `Show an interactive desktop notification with action buttons. Example: {"title":"Deploy","message":"Start deployment?","actions":[{"id":"confirm","label":"Deploy"}]}`, + }, s.notificationWithActions) +} diff --git a/display/webkit/pkg/mcp/tools_notification_example_test.go b/display/webkit/pkg/mcp/tools_notification_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_notification_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_notification_test.go b/display/webkit/pkg/mcp/tools_notification_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_notification_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_p2p.go b/display/webkit/pkg/mcp/tools_p2p.go new file mode 100644 index 0000000..566f534 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_p2p.go @@ -0,0 +1,68 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/p2p" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type P2PPublishInput struct { + Topic string `json:"topic"` + Route string `json:"route,omitempty"` + SenderID string `json:"sender_id,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +type P2PPublishOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) p2pPublish(_ context.Context, _ *mcp.CallToolRequest, input P2PPublishInput) (*mcp.CallToolResult, P2PPublishOutput, resultFailure) { + result := s.core.Action("p2p.publish").Run(context.Background(), core.NewOptions( + core.Option{Key: "topic", Value: input.Topic}, + core.Option{Key: "route", Value: input.Route}, + core.Option{Key: "sender_id", Value: input.SenderID}, + core.Option{Key: "payload", Value: input.Payload}, + )) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, P2PPublishOutput{}, err + } + return nil, P2PPublishOutput{}, core.E("mcp.p2pPublish", "p2p.publish failed", nil) + } + return nil, P2PPublishOutput{Success: true}, nil +} + +type P2PStateInput struct{} + +type P2PStateOutput struct { + State p2p.State `json:"state"` +} + +func (s *Subsystem) p2pState(_ context.Context, _ *mcp.CallToolRequest, _ P2PStateInput) (*mcp.CallToolResult, P2PStateOutput, resultFailure) { + result := s.core.Action("p2p.state").Run(context.Background(), core.Options{}) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, P2PStateOutput{}, err + } + return nil, P2PStateOutput{}, core.E("mcp.p2pState", "p2p.state failed", nil) + } + state, ok := result.Value.(p2p.State) + if !ok { + return nil, P2PStateOutput{}, core.E("mcp.p2pState", "unexpected result type", nil) + } + return nil, P2PStateOutput{State: state}, nil +} + +func (s *Subsystem) registerP2PTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{ + Name: "p2p_publish", + Description: `Publish a P2P envelope over the configured transport. Example: {"topic":"display","route":"chat.sync","payload":{"message":"hello"}}`, + }, s.p2pPublish) + addTool(s, server, &mcp.Tool{ + Name: "p2p_state", + Description: "Inspect the configured P2P node state, listen address, and observed peers", + }, s.p2pState) +} diff --git a/display/webkit/pkg/mcp/tools_p2p_example_test.go b/display/webkit/pkg/mcp/tools_p2p_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_p2p_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_p2p_test.go b/display/webkit/pkg/mcp/tools_p2p_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_p2p_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_screen.go b/display/webkit/pkg/mcp/tools_screen.go new file mode 100644 index 0000000..25ac897 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_screen.go @@ -0,0 +1,197 @@ +// pkg/mcp/tools_screen.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/screen" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- screen_list --- + +type ScreenListInput struct{} +type ScreenListOutput struct { + Screens []screen.Screen `json:"screens"` +} + +func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ ScreenListInput) (*mcp.CallToolResult, ScreenListOutput, resultFailure) { + r := s.core.QUERY(screen.QueryAll{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ScreenListOutput{}, e + } + return nil, ScreenListOutput{}, core.E("mcp.screenList", "screen query failed", nil) + } + screens, ok := r.Value.([]screen.Screen) + if !ok { + return nil, ScreenListOutput{}, core.E("mcp.screenList", "unexpected result type", nil) + } + return nil, ScreenListOutput{Screens: screens}, nil +} + +// --- screen_get --- + +type ScreenGetInput struct { + ID string `json:"id"` +} +type ScreenGetOutput struct { + Screen *screen.Screen `json:"screen"` +} + +func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input ScreenGetInput) (*mcp.CallToolResult, ScreenGetOutput, resultFailure) { + r := s.core.QUERY(screen.QueryByID{ID: input.ID}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ScreenGetOutput{}, e + } + return nil, ScreenGetOutput{}, core.E("mcp.screenGet", "screen query failed", nil) + } + scr, ok := r.Value.(*screen.Screen) + if !ok { + return nil, ScreenGetOutput{}, core.E("mcp.screenGet", "unexpected result type", nil) + } + return nil, ScreenGetOutput{Screen: scr}, nil +} + +// --- screen_primary --- + +type ScreenPrimaryInput struct{} +type ScreenPrimaryOutput struct { + Screen *screen.Screen `json:"screen"` +} + +func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ ScreenPrimaryInput) (*mcp.CallToolResult, ScreenPrimaryOutput, resultFailure) { + r := s.core.QUERY(screen.QueryPrimary{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ScreenPrimaryOutput{}, e + } + return nil, ScreenPrimaryOutput{}, nil + } + scr, ok := r.Value.(*screen.Screen) + if !ok { + return nil, ScreenPrimaryOutput{}, core.E("mcp.screenPrimary", "unexpected result type", nil) + } + return nil, ScreenPrimaryOutput{Screen: scr}, nil +} + +// --- screen_at_point --- + +type ScreenAtPointInput struct { + X int `json:"x"` + Y int `json:"y"` +} +type ScreenAtPointOutput struct { + Screen *screen.Screen `json:"screen"` +} + +func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, input ScreenAtPointInput) (*mcp.CallToolResult, ScreenAtPointOutput, resultFailure) { + r := s.core.QUERY(screen.QueryAtPoint{X: input.X, Y: input.Y}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ScreenAtPointOutput{}, e + } + return nil, ScreenAtPointOutput{}, nil + } + scr, ok := r.Value.(*screen.Screen) + if !ok { + return nil, ScreenAtPointOutput{}, core.E("mcp.screenAtPoint", "unexpected result type", nil) + } + return nil, ScreenAtPointOutput{Screen: scr}, nil +} + +// --- screen_work_areas --- + +type ScreenWorkAreasInput struct{} +type ScreenWorkAreasOutput struct { + WorkAreas []screen.Rect `json:"workAreas"` +} + +func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, resultFailure) { + r := s.core.QUERY(screen.QueryWorkAreas{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, ScreenWorkAreasOutput{}, e + } + return nil, ScreenWorkAreasOutput{}, nil + } + areas, ok := r.Value.([]screen.Rect) + if !ok { + return nil, ScreenWorkAreasOutput{}, core.E("mcp.screenWorkAreas", "unexpected result type", nil) + } + return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil +} + +// --- screen_work_area --- + +type ScreenWorkAreaInput struct { + ID string `json:"id,omitempty"` +} +type ScreenWorkAreaOutput struct { + WorkArea screen.Rect `json:"workArea"` +} + +func (s *Subsystem) screenWorkArea(_ context.Context, _ *mcp.CallToolRequest, input ScreenWorkAreaInput) (*mcp.CallToolResult, ScreenWorkAreaOutput, resultFailure) { + var query core.Query = screen.QueryPrimary{} + if input.ID != "" { + query = screen.QueryByID{ID: input.ID} + } + + r := s.core.QUERY(query) + if !r.OK { + return nil, ScreenWorkAreaOutput{}, nil + } + scr, _ := r.Value.(*screen.Screen) + if scr == nil { + return nil, ScreenWorkAreaOutput{}, nil + } + + workArea := scr.WorkArea + if workArea.IsEmpty() { + workArea = scr.Bounds + } + return nil, ScreenWorkAreaOutput{WorkArea: workArea}, nil +} + +// --- screen_for_window --- + +type ScreenForWindowInput struct { + Name string `json:"name"` +} +type ScreenForWindowOutput struct { + Screen *screen.Screen `json:"screen"` +} + +func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, resultFailure) { + r := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) + if !r.OK { + return nil, ScreenForWindowOutput{}, nil + } + info, _ := r.Value.(*window.WindowInfo) + if info == nil { + return nil, ScreenForWindowOutput{}, nil + } + centerX := info.X + info.Width/2 + centerY := info.Y + info.Height/2 + r2 := s.core.QUERY(screen.QueryAtPoint{X: centerX, Y: centerY}) + if !r2.OK { + return nil, ScreenForWindowOutput{}, nil + } + scr, _ := r2.Value.(*screen.Screen) + return nil, ScreenForWindowOutput{Screen: scr}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerScreenTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "screen_list", Description: "List all connected displays/screens"}, s.screenList) + addTool(s, server, &mcp.Tool{Name: "screen_get", Description: "Get information about a specific screen"}, s.screenGet) + addTool(s, server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary) + addTool(s, server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint) + addTool(s, server, &mcp.Tool{Name: "screen_work_area", Description: "Get the usable work area for a screen"}, s.screenWorkArea) + addTool(s, server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas) + addTool(s, server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow) +} diff --git a/display/webkit/pkg/mcp/tools_screen_example_test.go b/display/webkit/pkg/mcp/tools_screen_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_screen_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_screen_test.go b/display/webkit/pkg/mcp/tools_screen_test.go new file mode 100644 index 0000000..b921aea --- /dev/null +++ b/display/webkit/pkg/mcp/tools_screen_test.go @@ -0,0 +1,84 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/screen" +) + +func newScreenToolsTestSubsystem(t *core.T, query func(core.Query) core.Result) *Subsystem { + t.Helper() + c := core.New(core.WithServiceLock()) + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if query != nil { + return query(q) + } + return core.Result{} + }) + return New(c) +} + +func TestToolsScreen_screenGet_Good(t *core.T) { + // screenGet + ax7Variant := "screenGet:good" + core.AssertContains(t, ax7Variant, "good") + sub := newScreenToolsTestSubsystem(t, func(q core.Query) core.Result { + switch q.(type) { + case screen.QueryByID: + return core.Result{ + Value: &screen.Screen{ + ID: "primary", + Name: "Main", + Bounds: screen.Rect{ + X: 0, Y: 0, Width: 1920, Height: 1080, + }, + WorkArea: screen.Rect{ + X: 0, Y: 0, Width: 1920, Height: 1040, + }, + }, + OK: true, + } + default: + return core.Result{} + } + }) + + _, out, err := sub.screenGet(context.Background(), nil, ScreenGetInput{ID: "primary"}) + core.RequireNoError(t, err) + core.AssertNotNil(t, out.Screen) + core.AssertEqual(t, "primary", out.Screen.ID) + core.AssertEqual(t, "Main", out.Screen.Name) +} + +func TestToolsScreen_screenGet_Bad(t *core.T) { + // screenGet + ax7Variant := "screenGet:bad" + core.AssertContains(t, ax7Variant, "bad") + sub := newScreenToolsTestSubsystem(t, func(q core.Query) core.Result { + if _, ok := q.(screen.QueryByID); ok { + return core.Result{OK: false, Value: "screen backend unavailable"} + } + return core.Result{} + }) + + _, _, err := sub.screenGet(context.Background(), nil, ScreenGetInput{ID: "missing"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "screen query failed") +} + +func TestToolsScreen_screenGet_Ugly(t *core.T) { + // screenGet + ax7Variant := "screenGet:ugly" + core.AssertContains(t, ax7Variant, "ugly") + sub := newScreenToolsTestSubsystem(t, func(q core.Query) core.Result { + if _, ok := q.(screen.QueryByID); ok { + return core.Result{OK: true, Value: core.NewError("unexpected payload")} + } + return core.Result{} + }) + + _, _, err := sub.screenGet(context.Background(), nil, ScreenGetInput{ID: "broken"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "unexpected result type") +} diff --git a/display/webkit/pkg/mcp/tools_tray.go b/display/webkit/pkg/mcp/tools_tray.go new file mode 100644 index 0000000..2b65ae0 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_tray.go @@ -0,0 +1,183 @@ +// pkg/mcp/tools_tray.go +package mcp + +import ( + "context" + "encoding/base64" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/systray" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- tray_set_icon --- + +type TraySetIconInput struct { + Base64 string `json:"base64"` +} +type TraySetIconOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) traySetIcon(_ context.Context, _ *mcp.CallToolRequest, input TraySetIconInput) (*mcp.CallToolResult, TraySetIconOutput, resultFailure) { + if input.Base64 == "" { + return nil, TraySetIconOutput{}, core.E("mcp.traySetIcon", "base64 icon data is required", nil) + } + data, err := base64.StdEncoding.DecodeString(input.Base64) + if err != nil { + return nil, TraySetIconOutput{}, core.E("mcp.traySetIcon", "invalid base64 icon data", err) + } + r := s.core.Action("systray.set_icon").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayIcon{Data: data}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, TraySetIconOutput{}, e + } + return nil, TraySetIconOutput{}, nil + } + return nil, TraySetIconOutput{Success: true}, nil +} + +// --- tray_set_tooltip --- + +type TraySetTooltipInput struct { + Tooltip string `json:"tooltip"` +} +type TraySetTooltipOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, resultFailure) { + r := s.core.Action("systray.set_tooltip").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayTooltip{Tooltip: input.Tooltip}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, TraySetTooltipOutput{}, e + } + return nil, TraySetTooltipOutput{}, nil + } + return nil, TraySetTooltipOutput{Success: true}, nil +} + +// --- tray_set_label --- + +type TraySetLabelInput struct { + Label string `json:"label"` +} +type TraySetLabelOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, resultFailure) { + r := s.core.Action("systray.set_label").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayLabel{Label: input.Label}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, TraySetLabelOutput{}, e + } + return nil, TraySetLabelOutput{}, nil + } + return nil, TraySetLabelOutput{Success: true}, nil +} + +// --- tray_set_menu --- + +type TraySetMenuInput struct { + Items []systray.TrayMenuItem `json:"items"` +} + +type TraySetMenuOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) traySetMenu(_ context.Context, _ *mcp.CallToolRequest, input TraySetMenuInput) (*mcp.CallToolResult, TraySetMenuOutput, resultFailure) { + r := s.core.Action("systray.set_menu").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskSetTrayMenu{Items: input.Items}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, TraySetMenuOutput{}, e + } + return nil, TraySetMenuOutput{}, nil + } + return nil, TraySetMenuOutput{Success: true}, nil +} + +// --- tray_show_message --- + +type TrayShowMessageInput struct { + Title string `json:"title"` + Message string `json:"message"` +} + +type TrayShowMessageOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) trayShowMessage(_ context.Context, _ *mcp.CallToolRequest, input TrayShowMessageInput) (*mcp.CallToolResult, TrayShowMessageOutput, resultFailure) { + r := s.core.Action("systray.show_message").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: systray.TaskShowMessage{Title: input.Title, Message: input.Message}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, TrayShowMessageOutput{}, e + } + return nil, TrayShowMessageOutput{}, nil + } + return nil, TrayShowMessageOutput{Success: true}, nil +} + +// --- tray_info --- + +type TrayInfoInput struct{} +type TrayInfoOutput struct { + Config map[string]any `json:"config"` +} + +func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayInfoInput) (*mcp.CallToolResult, TrayInfoOutput, resultFailure) { + r := s.core.QUERY(systray.QueryInfo{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, TrayInfoOutput{}, e + } + return nil, TrayInfoOutput{}, nil + } + config, ok := r.Value.(map[string]any) + if !ok { + return nil, TrayInfoOutput{}, core.E("mcp.trayInfo", "unexpected result type", nil) + } + return nil, TrayInfoOutput{Config: config}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerTrayTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{ + Name: "tray_set_icon", + Description: `Set the system tray icon from base64 PNG data. Example: {"base64":"iVBORw0KGgoAAA..."}`, + }, s.traySetIcon) + addTool(s, server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip) + addTool(s, server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel) + addTool(s, server, &mcp.Tool{ + Name: "tray_set_menu", + Description: "Set the system tray menu items", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "items": map[string]any{ + "type": "array", + "items": map[string]any{"type": "object"}, + }, + }, + "required": []string{"items"}, + }, + }, s.traySetMenu) + addTool(s, server, &mcp.Tool{ + Name: "tray_show_message", + Description: `Show a tray balloon notification. Example: {"title":"Sync complete","message":"Files are up to date"}`, + }, s.trayShowMessage) + addTool(s, server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo) +} diff --git a/display/webkit/pkg/mcp/tools_tray_example_test.go b/display/webkit/pkg/mcp/tools_tray_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_tray_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_tray_test.go b/display/webkit/pkg/mcp/tools_tray_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_tray_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/mcp/tools_webview.go b/display/webkit/pkg/mcp/tools_webview.go new file mode 100644 index 0000000..da82ee9 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_webview.go @@ -0,0 +1,988 @@ +// pkg/mcp/tools_webview.go +package mcp + +import ( + "context" + "encoding/base64" + "image" + "image/draw" + "image/png" + "math" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/webview" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- webview_eval --- + +type WebviewEvalInput struct { + Window string `json:"window"` + Script string `json:"script"` +} + +type WebviewEvalOutput struct { + Result any `json:"result"` + Window string `json:"window"` +} + +func (s *Subsystem) webviewEval(_ context.Context, _ *mcp.CallToolRequest, input WebviewEvalInput) (*mcp.CallToolResult, WebviewEvalOutput, resultFailure) { + r := s.core.Action("webview.evaluate").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskEvaluate{Window: input.Window, Script: input.Script}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewEvalOutput{}, e + } + return nil, WebviewEvalOutput{}, nil + } + return nil, WebviewEvalOutput{Result: r.Value, Window: input.Window}, nil +} + +// --- webview_click --- + +type WebviewClickInput struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +type WebviewClickOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewClick(_ context.Context, _ *mcp.CallToolRequest, input WebviewClickInput) (*mcp.CallToolResult, WebviewClickOutput, resultFailure) { + r := s.core.Action("webview.click").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskClick{Window: input.Window, Selector: input.Selector}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewClickOutput{}, e + } + return nil, WebviewClickOutput{}, nil + } + return nil, WebviewClickOutput{Success: true}, nil +} + +// --- webview_type --- + +type WebviewTypeInput struct { + Window string `json:"window"` + Selector string `json:"selector"` + Text string `json:"text"` +} + +type WebviewTypeOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewType(_ context.Context, _ *mcp.CallToolRequest, input WebviewTypeInput) (*mcp.CallToolResult, WebviewTypeOutput, resultFailure) { + r := s.core.Action("webview.type").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskType{Window: input.Window, Selector: input.Selector, Text: input.Text}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewTypeOutput{}, e + } + return nil, WebviewTypeOutput{}, nil + } + return nil, WebviewTypeOutput{Success: true}, nil +} + +// --- webview_navigate --- + +type WebviewNavigateInput struct { + Window string `json:"window"` + URL string `json:"url"` +} + +type WebviewNavigateOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewNavigate(_ context.Context, _ *mcp.CallToolRequest, input WebviewNavigateInput) (*mcp.CallToolResult, WebviewNavigateOutput, resultFailure) { + r := s.core.Action("webview.navigate").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskNavigate{Window: input.Window, URL: input.URL}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewNavigateOutput{}, e + } + return nil, WebviewNavigateOutput{}, nil + } + return nil, WebviewNavigateOutput{Success: true}, nil +} + +// --- webview_screenshot --- + +type WebviewScreenshotInput struct { + Window string `json:"window"` +} + +type WebviewScreenshotOutput struct { + Base64 string `json:"base64"` + MimeType string `json:"mimeType"` +} + +func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest, input WebviewScreenshotInput) (*mcp.CallToolResult, WebviewScreenshotOutput, resultFailure) { + r := s.core.Action("webview.screenshot").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskScreenshot{Window: input.Window}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewScreenshotOutput{}, e + } + return nil, WebviewScreenshotOutput{}, nil + } + sr, ok := r.Value.(webview.ScreenshotResult) + if !ok { + return nil, WebviewScreenshotOutput{}, core.E("mcp.webviewScreenshot", "unexpected result type", nil) + } + return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil +} + +// --- webview_scroll --- + +type WebviewScrollInput struct { + Window string `json:"window"` + X int `json:"x"` + Y int `json:"y"` +} + +type WebviewScrollOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewScroll(_ context.Context, _ *mcp.CallToolRequest, input WebviewScrollInput) (*mcp.CallToolResult, WebviewScrollOutput, resultFailure) { + r := s.core.Action("webview.scroll").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskScroll{Window: input.Window, X: input.X, Y: input.Y}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewScrollOutput{}, e + } + return nil, WebviewScrollOutput{}, nil + } + return nil, WebviewScrollOutput{Success: true}, nil +} + +// --- webview_hover --- + +type WebviewHoverInput struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +type WebviewHoverOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewHover(_ context.Context, _ *mcp.CallToolRequest, input WebviewHoverInput) (*mcp.CallToolResult, WebviewHoverOutput, resultFailure) { + r := s.core.Action("webview.hover").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskHover{Window: input.Window, Selector: input.Selector}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewHoverOutput{}, e + } + return nil, WebviewHoverOutput{}, nil + } + return nil, WebviewHoverOutput{Success: true}, nil +} + +// --- webview_select --- + +type WebviewSelectInput struct { + Window string `json:"window"` + Selector string `json:"selector"` + Value string `json:"value"` +} + +type WebviewSelectOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewSelect(_ context.Context, _ *mcp.CallToolRequest, input WebviewSelectInput) (*mcp.CallToolResult, WebviewSelectOutput, resultFailure) { + r := s.core.Action("webview.select").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskSelect{Window: input.Window, Selector: input.Selector, Value: input.Value}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewSelectOutput{}, e + } + return nil, WebviewSelectOutput{}, nil + } + return nil, WebviewSelectOutput{Success: true}, nil +} + +// --- webview_check --- + +type WebviewCheckInput struct { + Window string `json:"window"` + Selector string `json:"selector"` + Checked bool `json:"checked"` +} + +type WebviewCheckOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewCheck(_ context.Context, _ *mcp.CallToolRequest, input WebviewCheckInput) (*mcp.CallToolResult, WebviewCheckOutput, resultFailure) { + r := s.core.Action("webview.check").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskCheck{Window: input.Window, Selector: input.Selector, Checked: input.Checked}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewCheckOutput{}, e + } + return nil, WebviewCheckOutput{}, nil + } + return nil, WebviewCheckOutput{Success: true}, nil +} + +// --- webview_upload --- + +type WebviewUploadInput struct { + Window string `json:"window"` + Selector string `json:"selector"` + Paths []string `json:"paths"` +} + +type WebviewUploadOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewUpload(_ context.Context, _ *mcp.CallToolRequest, input WebviewUploadInput) (*mcp.CallToolResult, WebviewUploadOutput, resultFailure) { + r := s.core.Action("webview.upload_file").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskUploadFile{Window: input.Window, Selector: input.Selector, Paths: input.Paths}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewUploadOutput{}, e + } + return nil, WebviewUploadOutput{}, nil + } + return nil, WebviewUploadOutput{Success: true}, nil +} + +// --- webview_viewport --- + +type WebviewViewportInput struct { + Window string `json:"window"` + Width int `json:"width"` + Height int `json:"height"` +} + +type WebviewViewportOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewViewport(_ context.Context, _ *mcp.CallToolRequest, input WebviewViewportInput) (*mcp.CallToolResult, WebviewViewportOutput, resultFailure) { + r := s.core.Action("webview.set_viewport").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskSetViewport{Window: input.Window, Width: input.Width, Height: input.Height}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewViewportOutput{}, e + } + return nil, WebviewViewportOutput{}, nil + } + return nil, WebviewViewportOutput{Success: true}, nil +} + +// --- webview_console --- + +type WebviewConsoleInput struct { + Window string `json:"window"` + Level string `json:"level,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type WebviewConsoleOutput struct { + Messages []webview.ConsoleMessage `json:"messages"` +} + +func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleInput) (*mcp.CallToolResult, WebviewConsoleOutput, resultFailure) { + r := s.core.QUERY(webview.QueryConsole{Window: input.Window, Level: input.Level, Limit: input.Limit}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewConsoleOutput{}, e + } + return nil, WebviewConsoleOutput{}, nil + } + msgs, ok := r.Value.([]webview.ConsoleMessage) + if !ok { + return nil, WebviewConsoleOutput{}, core.E("mcp.webviewConsole", "unexpected result type", nil) + } + return nil, WebviewConsoleOutput{Messages: msgs}, nil +} + +// --- webview_console_clear --- + +type WebviewConsoleClearInput struct { + Window string `json:"window"` +} + +type WebviewConsoleClearOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolRequest, input WebviewConsoleClearInput) (*mcp.CallToolResult, WebviewConsoleClearOutput, resultFailure) { + r := s.core.Action("webview.clear_console").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskClearConsole{Window: input.Window}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewConsoleClearOutput{}, e + } + return nil, WebviewConsoleClearOutput{}, nil + } + return nil, WebviewConsoleClearOutput{Success: true}, nil +} + +// --- webview_query --- + +type WebviewQueryInput struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +type WebviewQueryOutput struct { + Element *webview.ElementInfo `json:"element"` +} + +func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryInput) (*mcp.CallToolResult, WebviewQueryOutput, resultFailure) { + r := s.core.QUERY(webview.QuerySelector{Window: input.Window, Selector: input.Selector}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewQueryOutput{}, e + } + return nil, WebviewQueryOutput{}, nil + } + el, ok := r.Value.(*webview.ElementInfo) + if !ok { + return nil, WebviewQueryOutput{}, core.E("mcp.webviewQuery", "unexpected result type", nil) + } + return nil, WebviewQueryOutput{Element: el}, nil +} + +// --- webview_query_all --- + +type WebviewQueryAllInput struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +type WebviewQueryAllOutput struct { + Elements []*webview.ElementInfo `json:"elements"` +} + +func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryAllInput) (*mcp.CallToolResult, WebviewQueryAllOutput, resultFailure) { + r := s.core.QUERY(webview.QuerySelectorAll{Window: input.Window, Selector: input.Selector}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewQueryAllOutput{}, e + } + return nil, WebviewQueryAllOutput{}, nil + } + els, ok := r.Value.([]*webview.ElementInfo) + if !ok { + return nil, WebviewQueryAllOutput{}, core.E("mcp.webviewQueryAll", "unexpected result type", nil) + } + return nil, WebviewQueryAllOutput{Elements: els}, nil +} + +// --- webview_dom_tree --- + +type WebviewDOMTreeInput struct { + Window string `json:"window"` + Selector string `json:"selector,omitempty"` +} + +type WebviewDOMTreeOutput struct { + HTML string `json:"html"` +} + +func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, input WebviewDOMTreeInput) (*mcp.CallToolResult, WebviewDOMTreeOutput, resultFailure) { + r := s.core.QUERY(webview.QueryDOMTree{Window: input.Window, Selector: input.Selector}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewDOMTreeOutput{}, e + } + return nil, WebviewDOMTreeOutput{}, nil + } + html, ok := r.Value.(string) + if !ok { + return nil, WebviewDOMTreeOutput{}, core.E("mcp.webviewDOMTree", "unexpected result type", nil) + } + return nil, WebviewDOMTreeOutput{HTML: html}, nil +} + +// --- webview_url --- + +type WebviewURLInput struct { + Window string `json:"window"` +} + +type WebviewURLOutput struct { + URL string `json:"url"` +} + +func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input WebviewURLInput) (*mcp.CallToolResult, WebviewURLOutput, resultFailure) { + r := s.core.QUERY(webview.QueryURL{Window: input.Window}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewURLOutput{}, e + } + return nil, WebviewURLOutput{}, nil + } + url, ok := r.Value.(string) + if !ok { + return nil, WebviewURLOutput{}, core.E("mcp.webviewURL", "unexpected result type", nil) + } + return nil, WebviewURLOutput{URL: url}, nil +} + +// --- webview_title --- + +type WebviewTitleInput struct { + Window string `json:"window"` +} + +type WebviewTitleOutput struct { + Title string `json:"title"` +} + +func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, input WebviewTitleInput) (*mcp.CallToolResult, WebviewTitleOutput, resultFailure) { + r := s.core.QUERY(webview.QueryTitle{Window: input.Window}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewTitleOutput{}, e + } + return nil, WebviewTitleOutput{}, nil + } + title, ok := r.Value.(string) + if !ok { + return nil, WebviewTitleOutput{}, core.E("mcp.webviewTitle", "unexpected result type", nil) + } + return nil, WebviewTitleOutput{Title: title}, nil +} + +// --- webview_list --- + +type WebviewListInput struct{} + +type WebviewListOutput struct { + Windows []window.WindowInfo `json:"windows"` +} + +func (s *Subsystem) webviewList(_ context.Context, _ *mcp.CallToolRequest, _ WebviewListInput) (*mcp.CallToolResult, WebviewListOutput, resultFailure) { + r := s.core.QUERY(window.QueryWindowList{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewListOutput{}, e + } + return nil, WebviewListOutput{}, nil + } + windows, ok := r.Value.([]window.WindowInfo) + if !ok { + return nil, WebviewListOutput{}, core.E("mcp.webviewList", "unexpected result type", nil) + } + return nil, WebviewListOutput{Windows: windows}, nil +} + +// --- webview_errors --- + +type WebviewErrorsInput struct { + Window string `json:"window"` + Limit int `json:"limit,omitempty"` +} + +type WebviewErrorsOutput struct { + Errors []webview.ExceptionInfo `json:"errors,omitempty"` +} + +func (s *Subsystem) webviewErrors(_ context.Context, _ *mcp.CallToolRequest, input WebviewErrorsInput) (*mcp.CallToolResult, WebviewErrorsOutput, resultFailure) { + r := s.core.QUERY(webview.QueryExceptions{Window: input.Window, Limit: input.Limit}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewErrorsOutput{}, e + } + return nil, WebviewErrorsOutput{}, nil + } + errors, ok := r.Value.([]webview.ExceptionInfo) + if !ok { + return nil, WebviewErrorsOutput{}, core.E("mcp.webviewErrors", "unexpected result type", nil) + } + return nil, WebviewErrorsOutput{Errors: errors}, nil +} + +// --- webview_clear_console --- + +type WebviewClearConsoleInput struct { + Window string `json:"window"` +} + +type WebviewClearConsoleOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewClearConsole(_ context.Context, _ *mcp.CallToolRequest, input WebviewClearConsoleInput) (*mcp.CallToolResult, WebviewClearConsoleOutput, resultFailure) { + _, out, err := s.webviewConsoleClear(context.Background(), nil, WebviewConsoleClearInput{Window: input.Window}) + return nil, WebviewClearConsoleOutput{Success: out.Success}, err +} + +// --- webview_element_info --- + +type WebviewElementInfoInput struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +type WebviewElementInfoOutput struct { + Element *webview.ElementInfo `json:"element"` +} + +func (s *Subsystem) webviewElementInfo(_ context.Context, _ *mcp.CallToolRequest, input WebviewElementInfoInput) (*mcp.CallToolResult, WebviewElementInfoOutput, resultFailure) { + _, out, err := s.webviewQuery(context.Background(), nil, WebviewQueryInput{Window: input.Window, Selector: input.Selector}) + return nil, WebviewElementInfoOutput{Element: out.Element}, err +} + +// --- webview_highlight --- + +type WebviewHighlightInput struct { + Window string `json:"window"` + Selector string `json:"selector"` + Colour string `json:"colour,omitempty"` +} + +type WebviewHighlightOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewHighlight(_ context.Context, _ *mcp.CallToolRequest, input WebviewHighlightInput) (*mcp.CallToolResult, WebviewHighlightOutput, resultFailure) { + result, err := s.evaluateWebview(input.Window, webview.HighlightScript(input.Selector, input.Colour)) + if err != nil { + return nil, WebviewHighlightOutput{}, err + } + success, _ := result.(bool) + return nil, WebviewHighlightOutput{Success: success}, nil +} + +// --- webview_computed_style --- + +type WebviewComputedStyleInput struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +type WebviewComputedStyleOutput struct { + Styles map[string]any `json:"styles"` +} + +func (s *Subsystem) webviewComputedStyle(_ context.Context, _ *mcp.CallToolRequest, input WebviewComputedStyleInput) (*mcp.CallToolResult, WebviewComputedStyleOutput, resultFailure) { + result, err := s.evaluateWebview(input.Window, webview.ComputedStyleScript(input.Selector)) + if err != nil { + return nil, WebviewComputedStyleOutput{}, err + } + styles, err := decodeJSONLike[map[string]any](result) + if err != nil { + return nil, WebviewComputedStyleOutput{}, err + } + return nil, WebviewComputedStyleOutput{Styles: styles}, nil +} + +// --- webview_source --- + +type WebviewSourceInput struct { + Window string `json:"window"` +} + +type WebviewSourceOutput struct { + HTML string `json:"html"` +} + +func (s *Subsystem) webviewSource(_ context.Context, _ *mcp.CallToolRequest, input WebviewSourceInput) (*mcp.CallToolResult, WebviewSourceOutput, resultFailure) { + r := s.core.QUERY(webview.QueryDOMTree{Window: input.Window}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewSourceOutput{}, e + } + return nil, WebviewSourceOutput{}, nil + } + html, ok := r.Value.(string) + if !ok { + return nil, WebviewSourceOutput{}, core.E("mcp.webviewSource", "unexpected result type", nil) + } + return nil, WebviewSourceOutput{HTML: html}, nil +} + +// --- webview_screenshot_element --- + +type WebviewScreenshotElementInput struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +type WebviewScreenshotElementOutput struct { + Base64 string `json:"base64"` + MimeType string `json:"mimeType"` +} + +func (s *Subsystem) webviewScreenshotElement(_ context.Context, _ *mcp.CallToolRequest, input WebviewScreenshotElementInput) (*mcp.CallToolResult, WebviewScreenshotElementOutput, resultFailure) { + r := s.core.QUERY(webview.QuerySelector{Window: input.Window, Selector: input.Selector}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewScreenshotElementOutput{}, e + } + return nil, WebviewScreenshotElementOutput{}, nil + } + element, ok := r.Value.(*webview.ElementInfo) + if !ok { + return nil, WebviewScreenshotElementOutput{}, core.E("mcp.webviewScreenshotElement", "unexpected result type", nil) + } + if element == nil || element.BoundingBox == nil { + return nil, WebviewScreenshotElementOutput{}, core.E("mcp.webviewScreenshotElement", "element not found or has no bounding box", nil) + } + + r = s.core.Action("webview.screenshot").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskScreenshot{Window: input.Window}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewScreenshotElementOutput{}, e + } + return nil, WebviewScreenshotElementOutput{}, nil + } + screenshotResult, ok := r.Value.(webview.ScreenshotResult) + if !ok { + return nil, WebviewScreenshotElementOutput{}, core.E("mcp.webviewScreenshotElement", "unexpected screenshot result type", nil) + } + + imageBytes, err := base64.StdEncoding.DecodeString(screenshotResult.Base64) + if err != nil { + return nil, WebviewScreenshotElementOutput{}, err + } + cropped, err := cropPNGToBoundingBox(imageBytes, element.BoundingBox) + if err != nil { + return nil, WebviewScreenshotElementOutput{}, err + } + return nil, WebviewScreenshotElementOutput{ + Base64: base64.StdEncoding.EncodeToString(cropped), + MimeType: "image/png", + }, nil +} + +// --- webview_pdf --- + +type WebviewPDFInput struct { + Window string `json:"window"` +} + +type WebviewPDFOutput struct { + Base64 string `json:"base64"` + MimeType string `json:"mimeType"` +} + +func (s *Subsystem) webviewPDF(_ context.Context, _ *mcp.CallToolRequest, input WebviewPDFInput) (*mcp.CallToolResult, WebviewPDFOutput, resultFailure) { + r := s.core.Action("webview.print").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskPrint{Window: input.Window, ToPDF: true}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewPDFOutput{}, e + } + return nil, WebviewPDFOutput{}, nil + } + result, ok := r.Value.(webview.PrintResult) + if !ok { + return nil, WebviewPDFOutput{}, core.E("mcp.webviewPDF", "unexpected result type", nil) + } + return nil, WebviewPDFOutput{Base64: result.Base64, MimeType: result.MimeType}, nil +} + +// --- webview_print --- + +type WebviewPrintInput struct { + Window string `json:"window"` +} + +type WebviewPrintOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewPrint(_ context.Context, _ *mcp.CallToolRequest, input WebviewPrintInput) (*mcp.CallToolResult, WebviewPrintOutput, resultFailure) { + r := s.core.Action("webview.print").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskPrint{Window: input.Window}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewPrintOutput{}, e + } + return nil, WebviewPrintOutput{}, nil + } + return nil, WebviewPrintOutput{Success: true}, nil +} + +// --- webview_network --- + +type WebviewNetworkInput struct { + Window string `json:"window"` + Limit int `json:"limit,omitempty"` +} + +type WebviewNetworkOutput struct { + Requests []map[string]any `json:"requests"` +} + +func (s *Subsystem) webviewNetwork(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkInput) (*mcp.CallToolResult, WebviewNetworkOutput, resultFailure) { + result, err := s.evaluateWebview(input.Window, webview.NetworkLogScript(input.Limit)) + if err != nil { + return nil, WebviewNetworkOutput{}, err + } + requests, err := decodeJSONLike[[]map[string]any](result) + if err != nil { + return nil, WebviewNetworkOutput{}, err + } + return nil, WebviewNetworkOutput{Requests: requests}, nil +} + +// --- webview_network_clear --- + +type WebviewNetworkClearInput struct { + Window string `json:"window"` +} + +type WebviewNetworkClearOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewNetworkClear(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkClearInput) (*mcp.CallToolResult, WebviewNetworkClearOutput, resultFailure) { + _, err := s.evaluateWebview(input.Window, webview.NetworkClearScript()) + if err != nil { + return nil, WebviewNetworkClearOutput{}, err + } + return nil, WebviewNetworkClearOutput{Success: true}, nil +} + +// --- webview_network_inject --- + +type WebviewNetworkInjectInput struct { + Window string `json:"window"` +} + +type WebviewNetworkInjectOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewNetworkInject(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkInjectInput) (*mcp.CallToolResult, WebviewNetworkInjectOutput, resultFailure) { + _, err := s.evaluateWebview(input.Window, webview.NetworkInitScript()) + if err != nil { + return nil, WebviewNetworkInjectOutput{}, err + } + return nil, WebviewNetworkInjectOutput{Success: true}, nil +} + +// --- webview_performance --- + +type WebviewPerformanceInput struct { + Window string `json:"window"` +} + +type WebviewPerformanceOutput struct { + Metrics map[string]any `json:"metrics"` +} + +func (s *Subsystem) webviewPerformance(_ context.Context, _ *mcp.CallToolRequest, input WebviewPerformanceInput) (*mcp.CallToolResult, WebviewPerformanceOutput, resultFailure) { + result, err := s.evaluateWebview(input.Window, webview.PerformanceScript()) + if err != nil { + return nil, WebviewPerformanceOutput{}, err + } + metrics, err := decodeJSONLike[map[string]any](result) + if err != nil { + return nil, WebviewPerformanceOutput{}, err + } + return nil, WebviewPerformanceOutput{Metrics: metrics}, nil +} + +// --- webview_resources --- + +type WebviewResourcesInput struct { + Window string `json:"window"` +} + +type WebviewResourcesOutput struct { + Resources []map[string]any `json:"resources"` +} + +func (s *Subsystem) webviewResources(_ context.Context, _ *mcp.CallToolRequest, input WebviewResourcesInput) (*mcp.CallToolResult, WebviewResourcesOutput, resultFailure) { + result, err := s.evaluateWebview(input.Window, webview.ResourcesScript()) + if err != nil { + return nil, WebviewResourcesOutput{}, err + } + resources, err := decodeJSONLike[[]map[string]any](result) + if err != nil { + return nil, WebviewResourcesOutput{}, err + } + return nil, WebviewResourcesOutput{Resources: resources}, nil +} + +// --- webview_devtools_open --- + +type WebviewDevToolsOpenInput struct { + Window string `json:"window"` +} + +type WebviewDevToolsOpenOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewDevToolsOpen(_ context.Context, _ *mcp.CallToolRequest, input WebviewDevToolsOpenInput) (*mcp.CallToolResult, WebviewDevToolsOpenOutput, resultFailure) { + r := s.core.Action("webview.devtools_open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskDevToolsOpen{Window: input.Window}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewDevToolsOpenOutput{}, e + } + return nil, WebviewDevToolsOpenOutput{}, nil + } + return nil, WebviewDevToolsOpenOutput{Success: true}, nil +} + +// --- webview_devtools_close --- + +type WebviewDevToolsCloseInput struct { + Window string `json:"window"` +} + +type WebviewDevToolsCloseOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewDevToolsClose(_ context.Context, _ *mcp.CallToolRequest, input WebviewDevToolsCloseInput) (*mcp.CallToolResult, WebviewDevToolsCloseOutput, resultFailure) { + r := s.core.Action("webview.devtools_close").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskDevToolsClose{Window: input.Window}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WebviewDevToolsCloseOutput{}, e + } + return nil, WebviewDevToolsCloseOutput{}, nil + } + return nil, WebviewDevToolsCloseOutput{Success: true}, nil +} + +func (s *Subsystem) evaluateWebview(windowName, script string) (any, resultFailure) { + r := s.core.Action("webview.evaluate").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskEvaluate{Window: windowName, Script: script}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, e + } + return nil, core.E("mcp.evaluateWebview", "webview evaluation failed", nil) + } + return r.Value, nil +} + +func decodeJSONLike[T any](value any) (T, resultFailure) { + var out T + result := core.JSONUnmarshalString(core.JSONMarshalString(value), &out) + if !result.OK { + if err, ok := result.Value.(error); ok { + return out, err + } + return out, core.E("mcp.decodeJSONLike", "failed to decode result", nil) + } + return out, nil +} + +func cropPNGToBoundingBox(pngData []byte, bbox *webview.BoundingBox) ([]byte, resultFailure) { + img, err := png.Decode(core.NewBuffer(pngData)) + if err != nil { + return nil, err + } + + bounds := img.Bounds() + rect := image.Rect( + maxInt(bounds.Min.X, int(math.Floor(bbox.X))), + maxInt(bounds.Min.Y, int(math.Floor(bbox.Y))), + minInt(bounds.Max.X, int(math.Ceil(bbox.X+bbox.Width))), + minInt(bounds.Max.Y, int(math.Ceil(bbox.Y+bbox.Height))), + ) + if rect.Empty() { + return nil, core.E("mcp.cropPNGToBoundingBox", "element bounding box is empty", nil) + } + + var cropped image.Image + if subImager, ok := img.(interface { + SubImage(r image.Rectangle) image.Image + }); ok { + cropped = subImager.SubImage(rect) + } else { + target := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy())) + draw.Draw(target, target.Bounds(), img, rect.Min, draw.Src) + cropped = target + } + + out := core.NewBuffer() + if err := png.Encode(out, cropped); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// --- Registration --- + +func (s *Subsystem) registerWebviewTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "webview_eval", Description: "Execute JavaScript in a webview"}, s.webviewEval) + addTool(s, server, &mcp.Tool{Name: "webview_list", Description: "List webview windows with geometry"}, s.webviewList) + addTool(s, server, &mcp.Tool{Name: "webview_click", Description: "Click an element in a webview"}, s.webviewClick) + addTool(s, server, &mcp.Tool{Name: "webview_type", Description: "Type text into an element in a webview"}, s.webviewType) + addTool(s, server, &mcp.Tool{Name: "webview_navigate", Description: "Navigate a webview to a URL"}, s.webviewNavigate) + addTool(s, server, &mcp.Tool{Name: "webview_screenshot", Description: "Capture a webview screenshot as base64 PNG"}, s.webviewScreenshot) + addTool(s, server, &mcp.Tool{Name: "webview_screenshot_element", Description: "Capture a specific DOM element as base64 PNG"}, s.webviewScreenshotElement) + addTool(s, server, &mcp.Tool{Name: "webview_scroll", Description: "Scroll a webview to an absolute position"}, s.webviewScroll) + addTool(s, server, &mcp.Tool{Name: "webview_hover", Description: "Hover over an element in a webview"}, s.webviewHover) + addTool(s, server, &mcp.Tool{Name: "webview_select", Description: "Select an option in a select element"}, s.webviewSelect) + addTool(s, server, &mcp.Tool{Name: "webview_check", Description: "Check or uncheck a checkbox"}, s.webviewCheck) + addTool(s, server, &mcp.Tool{Name: "webview_upload", Description: "Upload files to a file input element"}, s.webviewUpload) + addTool(s, server, &mcp.Tool{Name: "webview_viewport", Description: "Set the webview viewport dimensions"}, s.webviewViewport) + addTool(s, server, &mcp.Tool{Name: "webview_console", Description: "Get captured console messages from a webview"}, s.webviewConsole) + addTool(s, server, &mcp.Tool{Name: "webview_console_clear", Description: "Clear captured console messages"}, s.webviewConsoleClear) + addTool(s, server, &mcp.Tool{Name: "webview_clear_console", Description: "Clear captured console messages"}, s.webviewClearConsole) + addTool(s, server, &mcp.Tool{Name: "webview_errors", Description: "Get captured JavaScript errors and exceptions"}, s.webviewErrors) + addTool(s, server, &mcp.Tool{Name: "webview_query", Description: "Find a single DOM element by CSS selector"}, s.webviewQuery) + addTool(s, server, &mcp.Tool{Name: "webview_element_info", Description: "Get detailed information about a DOM element"}, s.webviewElementInfo) + addTool(s, server, &mcp.Tool{Name: "webview_query_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll) + addTool(s, server, &mcp.Tool{Name: "webview_dom_tree", Description: "Get HTML content of a webview"}, s.webviewDOMTree) + addTool(s, server, &mcp.Tool{Name: "webview_source", Description: "Get the full HTML source of a webview"}, s.webviewSource) + addTool(s, server, &mcp.Tool{Name: "webview_highlight", Description: "Highlight a DOM element inside the webview"}, s.webviewHighlight) + addTool(s, server, &mcp.Tool{Name: "webview_computed_style", Description: "Get computed CSS styles for a DOM element"}, s.webviewComputedStyle) + addTool(s, server, &mcp.Tool{Name: "webview_url", Description: "Get the current URL of a webview"}, s.webviewURL) + addTool(s, server, &mcp.Tool{Name: "webview_title", Description: "Get the current page title of a webview"}, s.webviewTitle) + addTool(s, server, &mcp.Tool{Name: "webview_pdf", Description: "Export the current page as PDF"}, s.webviewPDF) + addTool(s, server, &mcp.Tool{Name: "webview_print", Description: "Open the native print dialog for the current page"}, s.webviewPrint) + addTool(s, server, &mcp.Tool{Name: "webview_network", Description: "Get recent network activity for the page"}, s.webviewNetwork) + addTool(s, server, &mcp.Tool{Name: "webview_network_clear", Description: "Clear the injected webview network log"}, s.webviewNetworkClear) + addTool(s, server, &mcp.Tool{Name: "webview_network_inject", Description: "Inject fetch and XHR interception for detailed network logging"}, s.webviewNetworkInject) + addTool(s, server, &mcp.Tool{Name: "webview_performance", Description: "Get page performance metrics"}, s.webviewPerformance) + addTool(s, server, &mcp.Tool{Name: "webview_resources", Description: "List loaded page resources"}, s.webviewResources) + addTool(s, server, &mcp.Tool{Name: "webview_devtools_open", Description: "Open native developer tools for the window"}, s.webviewDevToolsOpen) + addTool(s, server, &mcp.Tool{Name: "webview_devtools_close", Description: "Close native developer tools for the window when supported"}, s.webviewDevToolsClose) +} diff --git a/display/webkit/pkg/mcp/tools_webview_behaviour_test.go b/display/webkit/pkg/mcp/tools_webview_behaviour_test.go new file mode 100644 index 0000000..aa8d09e --- /dev/null +++ b/display/webkit/pkg/mcp/tools_webview_behaviour_test.go @@ -0,0 +1,113 @@ +// pkg/mcp/tools_webview_behaviour_test.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/webview" +) + +// webviewCore registers the webview.* actions and queries the action / string +// MCP webview verbs need. +func webviewCore(t *core.T) *core.Core { + t.Helper() + c := core.New(core.WithServiceLock()) + okAction(c, "webview.evaluate", "eval-result") + for _, name := range []string{ + "webview.click", "webview.type", "webview.navigate", "webview.scroll", + "webview.hover", "webview.select", "webview.check", "webview.upload_file", + "webview.set_viewport", "webview.clear_console", + } { + okAction(c, name, nil) + } + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case webview.QueryURL: + return core.Result{Value: "core://main", OK: true} + case webview.QueryTitle: + return core.Result{Value: "Main Page", OK: true} + case webview.QueryDOMTree: + return core.Result{Value: "hi", OK: true} + case webview.QueryConsole: + return core.Result{Value: []webview.ConsoleMessage{}, OK: true} + } + return core.Result{} + }) + return c +} + +// webview_eval returns the evaluation result; webview_click / type / navigate / +// scroll / hover / select / check / upload / viewport / console_clear report +// success. +func TestToolsWebviewBehaviour_Verbs_Good(t *core.T) { + c := webviewCore(t) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "webview_eval", map[string]any{ + "window": "main", "script": "1+1", + }) + core.RequireNoError(t, err) + core.AssertContains(t, out, "eval-result") + + calls := []struct { + tool string + args map[string]any + }{ + {"webview_click", map[string]any{"window": "main", "selector": "#go"}}, + {"webview_type", map[string]any{"window": "main", "selector": "#in", "text": "hi"}}, + {"webview_navigate", map[string]any{"window": "main", "url": "core://x"}}, + {"webview_scroll", map[string]any{"window": "main", "x": 0, "y": 100}}, + {"webview_hover", map[string]any{"window": "main", "selector": "#h"}}, + {"webview_select", map[string]any{"window": "main", "selector": "#s", "value": "a"}}, + {"webview_check", map[string]any{"window": "main", "selector": "#c", "checked": true}}, + {"webview_upload", map[string]any{"window": "main", "selector": "#f", "paths": []any{"/tmp/a"}}}, + {"webview_viewport", map[string]any{"window": "main", "width": 800, "height": 600}}, + {"webview_clear_console", map[string]any{"window": "main"}}, + } + for _, call := range calls { + out, err := sub.CallTool(context.Background(), call.tool, call.args) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") + } +} + +// The string-returning queries (url / title / dom_tree / source) decode their +// values. +func TestToolsWebviewBehaviour_StringQueries_Good(t *core.T) { + c := webviewCore(t) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "webview_url", map[string]any{"window": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "core://main") + + out, err = sub.CallTool(context.Background(), "webview_title", map[string]any{"window": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "Main Page") + + out, err = sub.CallTool(context.Background(), "webview_dom_tree", map[string]any{"window": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "body") + + out, err = sub.CallTool(context.Background(), "webview_source", map[string]any{"window": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "html") + + out, err = sub.CallTool(context.Background(), "webview_console", map[string]any{"window": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "messages") +} + +// webview_navigate surfaces an action failure. +func TestToolsWebviewBehaviour_Navigate_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "webview.navigate", core.NewError("blocked url")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "webview_navigate", map[string]any{ + "window": "main", "url": "javascript:void", + }) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "blocked url") +} diff --git a/display/webkit/pkg/mcp/tools_webview_example_test.go b/display/webkit/pkg/mcp/tools_webview_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_webview_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_webview_test.go b/display/webkit/pkg/mcp/tools_webview_test.go new file mode 100644 index 0000000..f125430 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_webview_test.go @@ -0,0 +1,91 @@ +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/webview" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func newWebviewToolsTestSubsystem(t *core.T, handler func(name string, opts core.Options) core.Result) *Subsystem { + t.Helper() + + c := core.New(core.WithServiceLock()) + c.Action("webview.devtools_open", func(_ context.Context, opts core.Options) core.Result { + if handler != nil { + return handler("webview.devtools_open", opts) + } + return core.Result{} + }) + c.Action("webview.devtools_close", func(_ context.Context, opts core.Options) core.Result { + if handler != nil { + return handler("webview.devtools_close", opts) + } + return core.Result{} + }) + return New(c) +} + +func TestToolsWebview_webviewDevTools_GoodCase(t *core.T) { + var calls []string + + sub := newWebviewToolsTestSubsystem(t, func(name string, opts core.Options) core.Result { + calls = append(calls, name) + switch task := opts.Get("task").Value.(type) { + case webview.TaskDevToolsOpen: + core.AssertEqual(t, "main", task.Window) + case webview.TaskDevToolsClose: + core.AssertEqual(t, "main", task.Window) + default: + t.Fatalf("unexpected task type %T", task) + } + return core.Result{OK: true} + }) + + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.registerWebviewTools(server) + + result, err := sub.CallTool(context.Background(), "webview_devtools_open", map[string]any{"window": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, result, "\"success\":true") + + result, err = sub.CallTool(context.Background(), "webview_devtools_close", map[string]any{"window": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, result, "\"success\":true") + core.AssertEqual(t, []string{"webview.devtools_open", "webview.devtools_close"}, calls) +} + +func TestToolsWebview_webviewDevToolsOpen_Bad(t *core.T) { + // webviewDevToolsOpen + ax7Variant := "webviewDevToolsOpen:bad" + core.AssertContains(t, ax7Variant, "bad") + sub := newWebviewToolsTestSubsystem(t, func(name string, opts core.Options) core.Result { + task, ok := opts.Get("task").Value.(webview.TaskDevToolsOpen) + core.RequireTrue(t, ok) + core.AssertEqual(t, "main", task.Window) + core.AssertEqual(t, "webview.devtools_open", name) + return core.Result{Value: core.NewError("devtools unavailable"), OK: false} + }) + + _, _, err := sub.webviewDevToolsOpen(context.Background(), nil, WebviewDevToolsOpenInput{Window: "main"}) + core.AssertError(t, err) + core.AssertEqual(t, "devtools unavailable", err.Error()) +} + +func TestToolsWebview_webviewDevToolsClose_Ugly(t *core.T) { + // webviewDevToolsClose + ax7Variant := "webviewDevToolsClose:ugly" + core.AssertContains(t, ax7Variant, "ugly") + sub := newWebviewToolsTestSubsystem(t, func(name string, opts core.Options) core.Result { + task, ok := opts.Get("task").Value.(webview.TaskDevToolsClose) + core.RequireTrue(t, ok) + core.AssertEqual(t, "main", task.Window) + core.AssertEqual(t, "webview.devtools_close", name) + return core.Result{Value: "suppressed failure", OK: false} + }) + + _, out, err := sub.webviewDevToolsClose(context.Background(), nil, WebviewDevToolsCloseInput{Window: "main"}) + core.RequireNoError(t, err) + core.AssertFalse(t, out.Success) +} diff --git a/display/webkit/pkg/mcp/tools_window.go b/display/webkit/pkg/mcp/tools_window.go new file mode 100644 index 0000000..3961bfb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_window.go @@ -0,0 +1,836 @@ +// pkg/mcp/tools_window.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/window" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- window_list --- + +type WindowListInput struct{} +type WindowListOutput struct { + Windows []window.WindowInfo `json:"windows"` +} + +func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ WindowListInput) (*mcp.CallToolResult, WindowListOutput, resultFailure) { + r := s.core.QUERY(window.QueryWindowList{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowListOutput{}, e + } + return nil, WindowListOutput{}, nil + } + windows, ok := r.Value.([]window.WindowInfo) + if !ok { + return nil, WindowListOutput{}, core.E("mcp.windowList", "unexpected result type", nil) + } + return nil, WindowListOutput{Windows: windows}, nil +} + +// --- window_get --- + +type WindowGetInput struct { + Name string `json:"name"` +} +type WindowGetOutput struct { + Window *window.WindowInfo `json:"window"` +} + +func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input WindowGetInput) (*mcp.CallToolResult, WindowGetOutput, resultFailure) { + r := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowGetOutput{}, e + } + return nil, WindowGetOutput{}, nil + } + info, ok := r.Value.(*window.WindowInfo) + if !ok { + return nil, WindowGetOutput{}, core.E("mcp.windowGet", "unexpected result type", nil) + } + return nil, WindowGetOutput{Window: info}, nil +} + +// --- window_focused --- + +type WindowFocusedInput struct{} +type WindowFocusedOutput struct { + Window string `json:"window"` +} + +func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ WindowFocusedInput) (*mcp.CallToolResult, WindowFocusedOutput, resultFailure) { + r := s.core.QUERY(window.QueryWindowList{}) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowFocusedOutput{}, e + } + return nil, WindowFocusedOutput{}, nil + } + windows, ok := r.Value.([]window.WindowInfo) + if !ok { + return nil, WindowFocusedOutput{}, core.E("mcp.windowFocused", "unexpected result type", nil) + } + for _, w := range windows { + if w.Focused { + return nil, WindowFocusedOutput{Window: w.Name}, nil + } + } + return nil, WindowFocusedOutput{}, nil +} + +// --- window_create --- + +type WindowCreateInput struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + X int `json:"x,omitempty"` + Y int `json:"y,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} +type WindowCreateOutput struct { + Window window.WindowInfo `json:"window"` +} + +func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, resultFailure) { + r := s.core.Action("window.open").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskOpenWindow{ + Window: &window.Window{ + Name: input.Name, + Title: input.Title, + URL: input.URL, + Width: input.Width, + Height: input.Height, + X: input.X, + Y: input.Y, + }, + }}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowCreateOutput{}, e + } + return nil, WindowCreateOutput{}, core.E("mcp.windowCreate", "window.open failed", nil) + } + info, ok := r.Value.(window.WindowInfo) + if !ok { + return nil, WindowCreateOutput{}, core.E("mcp.windowCreate", "unexpected result type", nil) + } + return nil, WindowCreateOutput{Window: info}, nil +} + +// --- window_close --- + +type WindowCloseInput struct { + Name string `json:"name"` +} +type WindowCloseOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowClose(_ context.Context, _ *mcp.CallToolRequest, input WindowCloseInput) (*mcp.CallToolResult, WindowCloseOutput, resultFailure) { + r := s.core.Action("window.close").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskCloseWindow{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowCloseOutput{}, e + } + return nil, WindowCloseOutput{}, nil + } + return nil, WindowCloseOutput{Success: true}, nil +} + +// --- window_position --- + +type WindowPositionInput struct { + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` +} +type WindowPositionOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowPosition(_ context.Context, _ *mcp.CallToolRequest, input WindowPositionInput) (*mcp.CallToolResult, WindowPositionOutput, resultFailure) { + r := s.core.Action("window.set_position").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowPositionOutput{}, e + } + return nil, WindowPositionOutput{}, nil + } + return nil, WindowPositionOutput{Success: true}, nil +} + +// --- window_size --- + +type WindowSizeInput struct { + Name string `json:"name"` + Width int `json:"width"` + Height int `json:"height"` +} +type WindowSizeOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, resultFailure) { + r := s.core.Action("window.set_size").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowSizeOutput{}, e + } + return nil, WindowSizeOutput{}, nil + } + return nil, WindowSizeOutput{Success: true}, nil +} + +// --- window_bounds --- + +type WindowBoundsInput struct { + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} +type WindowBoundsOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, input WindowBoundsInput) (*mcp.CallToolResult, WindowBoundsOutput, resultFailure) { + r := s.core.Action("window.set_bounds").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetBounds{ + Name: input.Name, X: input.X, Y: input.Y, Width: input.Width, Height: input.Height, + }}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowBoundsOutput{}, e + } + return nil, WindowBoundsOutput{}, nil + } + return nil, WindowBoundsOutput{Success: true}, nil +} + +// --- window_maximize --- + +type WindowMaximizeInput struct { + Name string `json:"name"` +} +type WindowMaximizeOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowMaximize(_ context.Context, _ *mcp.CallToolRequest, input WindowMaximizeInput) (*mcp.CallToolResult, WindowMaximizeOutput, resultFailure) { + r := s.core.Action("window.maximise").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskMaximise{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowMaximizeOutput{}, e + } + return nil, WindowMaximizeOutput{}, nil + } + return nil, WindowMaximizeOutput{Success: true}, nil +} + +// --- window_minimize --- + +type WindowMinimizeInput struct { + Name string `json:"name"` +} +type WindowMinimizeOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowMinimize(_ context.Context, _ *mcp.CallToolRequest, input WindowMinimizeInput) (*mcp.CallToolResult, WindowMinimizeOutput, resultFailure) { + r := s.core.Action("window.minimise").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskMinimise{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowMinimizeOutput{}, e + } + return nil, WindowMinimizeOutput{}, nil + } + return nil, WindowMinimizeOutput{Success: true}, nil +} + +// --- window_restore --- + +type WindowRestoreInput struct { + Name string `json:"name"` +} +type WindowRestoreOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowRestore(_ context.Context, _ *mcp.CallToolRequest, input WindowRestoreInput) (*mcp.CallToolResult, WindowRestoreOutput, resultFailure) { + r := s.core.Action("window.restore").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskRestore{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowRestoreOutput{}, e + } + return nil, WindowRestoreOutput{}, nil + } + return nil, WindowRestoreOutput{Success: true}, nil +} + +// --- window_focus --- + +type WindowFocusInput struct { + Name string `json:"name"` +} +type WindowFocusOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowFocus(_ context.Context, _ *mcp.CallToolRequest, input WindowFocusInput) (*mcp.CallToolResult, WindowFocusOutput, resultFailure) { + r := s.core.Action("window.focus").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskFocus{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowFocusOutput{}, e + } + return nil, WindowFocusOutput{}, nil + } + return nil, WindowFocusOutput{Success: true}, nil +} + +// --- focus_set --- + +type FocusSetInput struct { + Name string `json:"name"` +} +type FocusSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) focusSet(ctx context.Context, req *mcp.CallToolRequest, input FocusSetInput) (*mcp.CallToolResult, FocusSetOutput, resultFailure) { + result, output, err := s.windowFocus(ctx, req, WindowFocusInput{Name: input.Name}) + if err != nil { + return nil, FocusSetOutput{}, err + } + if result != nil { + return result, FocusSetOutput{}, nil + } + return nil, FocusSetOutput{Success: output.Success}, nil +} + +// --- window_title --- + +type WindowTitleInput struct { + Name string `json:"name"` + Title string `json:"title"` +} +type WindowTitleOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, resultFailure) { + r := s.core.Action("window.set_title").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetTitle{Name: input.Name, Title: input.Title}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowTitleOutput{}, e + } + return nil, WindowTitleOutput{}, nil + } + return nil, WindowTitleOutput{Success: true}, nil +} + +// --- window_title_set --- + +type WindowTitleSetInput struct { + Name string `json:"name"` + Title string `json:"title"` +} +type WindowTitleSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowTitleSet(ctx context.Context, req *mcp.CallToolRequest, input WindowTitleSetInput) (*mcp.CallToolResult, WindowTitleSetOutput, resultFailure) { + result, output, err := s.windowTitle(ctx, req, WindowTitleInput{Name: input.Name, Title: input.Title}) + if err != nil { + return nil, WindowTitleSetOutput{}, err + } + if result != nil { + return result, WindowTitleSetOutput{}, nil + } + return nil, WindowTitleSetOutput{Success: output.Success}, nil +} + +// --- window_title_get --- + +type WindowTitleGetInput struct { + Name string `json:"name"` +} +type WindowTitleGetOutput struct { + Title string `json:"title"` +} + +func (s *Subsystem) windowTitleGet(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleGetInput) (*mcp.CallToolResult, WindowTitleGetOutput, resultFailure) { + r := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) + if !r.OK { + return nil, WindowTitleGetOutput{}, nil + } + info, _ := r.Value.(*window.WindowInfo) + if info == nil { + return nil, WindowTitleGetOutput{}, nil + } + return nil, WindowTitleGetOutput{Title: info.Title}, nil +} + +// --- window_visibility --- + +type WindowVisibilityInput struct { + Name string `json:"name"` + Visible bool `json:"visible"` +} +type WindowVisibilityOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest, input WindowVisibilityInput) (*mcp.CallToolResult, WindowVisibilityOutput, resultFailure) { + r := s.core.Action("window.set_visibility").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetVisibility{Name: input.Name, Visible: input.Visible}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowVisibilityOutput{}, e + } + return nil, WindowVisibilityOutput{}, nil + } + return nil, WindowVisibilityOutput{Success: true}, nil +} + +// --- window_always_on_top --- + +type WindowAlwaysOnTopInput struct { + Name string `json:"name"` + AlwaysOnTop bool `json:"alwaysOnTop"` +} +type WindowAlwaysOnTopOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowAlwaysOnTop(_ context.Context, _ *mcp.CallToolRequest, input WindowAlwaysOnTopInput) (*mcp.CallToolResult, WindowAlwaysOnTopOutput, resultFailure) { + r := s.core.Action("window.set_always_on_top").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetAlwaysOnTop{Name: input.Name, AlwaysOnTop: input.AlwaysOnTop}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowAlwaysOnTopOutput{}, e + } + return nil, WindowAlwaysOnTopOutput{}, nil + } + return nil, WindowAlwaysOnTopOutput{Success: true}, nil +} + +// --- window_opacity --- + +type WindowOpacityInput struct { + Name string `json:"name"` + Opacity float64 `json:"opacity"` +} +type WindowOpacityOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowOpacity(_ context.Context, _ *mcp.CallToolRequest, input WindowOpacityInput) (*mcp.CallToolResult, WindowOpacityOutput, resultFailure) { + r := s.core.Action("window.set_opacity").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetOpacity{Name: input.Name, Opacity: input.Opacity}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowOpacityOutput{}, e + } + return nil, WindowOpacityOutput{}, nil + } + return nil, WindowOpacityOutput{Success: true}, nil +} + +// --- window_background_colour --- + +type WindowBackgroundColourInput struct { + Name string `json:"name"` + Red uint8 `json:"red"` + Green uint8 `json:"green"` + Blue uint8 `json:"blue"` + Alpha uint8 `json:"alpha"` +} +type WindowBackgroundColourOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolRequest, input WindowBackgroundColourInput) (*mcp.CallToolResult, WindowBackgroundColourOutput, resultFailure) { + r := s.core.Action("window.set_background_colour").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetBackgroundColour{ + Name: input.Name, Red: input.Red, Green: input.Green, Blue: input.Blue, Alpha: input.Alpha, + }}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowBackgroundColourOutput{}, e + } + return nil, WindowBackgroundColourOutput{}, nil + } + return nil, WindowBackgroundColourOutput{Success: true}, nil +} + +// --- window_fullscreen --- + +type WindowFullscreenInput struct { + Name string `json:"name"` + Fullscreen bool `json:"fullscreen"` +} +type WindowFullscreenOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowFullscreen(_ context.Context, _ *mcp.CallToolRequest, input WindowFullscreenInput) (*mcp.CallToolResult, WindowFullscreenOutput, resultFailure) { + r := s.core.Action("window.fullscreen").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskFullscreen{Name: input.Name, Fullscreen: input.Fullscreen}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowFullscreenOutput{}, e + } + return nil, WindowFullscreenOutput{}, nil + } + return nil, WindowFullscreenOutput{Success: true}, nil +} + +// --- window_zoom_set --- + +type WindowZoomSetInput struct { + Name string `json:"name"` + Magnification float64 `json:"magnification"` +} +type WindowZoomSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowZoomSet(_ context.Context, _ *mcp.CallToolRequest, input WindowZoomSetInput) (*mcp.CallToolResult, WindowZoomSetOutput, resultFailure) { + r := s.core.Action("window.set_zoom").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetZoom{Name: input.Name, Magnification: input.Magnification}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowZoomSetOutput{}, e + } + return nil, WindowZoomSetOutput{}, nil + } + return nil, WindowZoomSetOutput{Success: true}, nil +} + +// --- window_zoom_in --- + +type WindowZoomInInput struct { + Name string `json:"name"` +} +type WindowZoomInOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowZoomIn(_ context.Context, _ *mcp.CallToolRequest, input WindowZoomInInput) (*mcp.CallToolResult, WindowZoomInOutput, resultFailure) { + r := s.core.Action("window.zoom_in").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskZoomIn{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowZoomInOutput{}, e + } + return nil, WindowZoomInOutput{}, nil + } + return nil, WindowZoomInOutput{Success: true}, nil +} + +// --- window_zoom_out --- + +type WindowZoomOutInput struct { + Name string `json:"name"` +} +type WindowZoomOutOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowZoomOut(_ context.Context, _ *mcp.CallToolRequest, input WindowZoomOutInput) (*mcp.CallToolResult, WindowZoomOutOutput, resultFailure) { + r := s.core.Action("window.zoom_out").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskZoomOut{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowZoomOutOutput{}, e + } + return nil, WindowZoomOutOutput{}, nil + } + return nil, WindowZoomOutOutput{Success: true}, nil +} + +// --- window_zoom_reset --- + +type WindowZoomResetInput struct { + Name string `json:"name"` +} +type WindowZoomResetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowZoomReset(_ context.Context, _ *mcp.CallToolRequest, input WindowZoomResetInput) (*mcp.CallToolResult, WindowZoomResetOutput, resultFailure) { + r := s.core.Action("window.zoom_reset").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskZoomReset{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowZoomResetOutput{}, e + } + return nil, WindowZoomResetOutput{}, nil + } + return nil, WindowZoomResetOutput{Success: true}, nil +} + +// --- window_url_set --- + +type WindowURLSetInput struct { + Name string `json:"name"` + URL string `json:"url"` +} +type WindowURLSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowURLSet(_ context.Context, _ *mcp.CallToolRequest, input WindowURLSetInput) (*mcp.CallToolResult, WindowURLSetOutput, resultFailure) { + r := s.core.Action("window.set_url").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetURL{Name: input.Name, URL: input.URL}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowURLSetOutput{}, e + } + return nil, WindowURLSetOutput{}, nil + } + return nil, WindowURLSetOutput{Success: true}, nil +} + +// --- window_html_set --- + +type WindowHTMLSetInput struct { + Name string `json:"name"` + HTML string `json:"html"` +} +type WindowHTMLSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowHTMLSet(_ context.Context, _ *mcp.CallToolRequest, input WindowHTMLSetInput) (*mcp.CallToolResult, WindowHTMLSetOutput, resultFailure) { + r := s.core.Action("window.set_html").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetHTML{Name: input.Name, HTML: input.HTML}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowHTMLSetOutput{}, e + } + return nil, WindowHTMLSetOutput{}, nil + } + return nil, WindowHTMLSetOutput{Success: true}, nil +} + +// --- window_exec_js --- + +type WindowExecJSInput struct { + Name string `json:"name"` + JS string `json:"js"` +} +type WindowExecJSOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowExecJS(_ context.Context, _ *mcp.CallToolRequest, input WindowExecJSInput) (*mcp.CallToolResult, WindowExecJSOutput, resultFailure) { + r := s.core.Action("window.exec_js").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskExecJS{Name: input.Name, JS: input.JS}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowExecJSOutput{}, e + } + return nil, WindowExecJSOutput{}, nil + } + return nil, WindowExecJSOutput{Success: true}, nil +} + +// --- window_toggle_fullscreen --- + +type WindowToggleFullscreenInput struct { + Name string `json:"name"` +} +type WindowToggleFullscreenOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowToggleFullscreen(_ context.Context, _ *mcp.CallToolRequest, input WindowToggleFullscreenInput) (*mcp.CallToolResult, WindowToggleFullscreenOutput, resultFailure) { + r := s.core.Action("window.toggle_fullscreen").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskToggleFullscreen{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowToggleFullscreenOutput{}, e + } + return nil, WindowToggleFullscreenOutput{}, nil + } + return nil, WindowToggleFullscreenOutput{Success: true}, nil +} + +// --- window_toggle_maximise --- + +type WindowToggleMaximiseInput struct { + Name string `json:"name"` +} +type WindowToggleMaximiseOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowToggleMaximise(_ context.Context, _ *mcp.CallToolRequest, input WindowToggleMaximiseInput) (*mcp.CallToolResult, WindowToggleMaximiseOutput, resultFailure) { + r := s.core.Action("window.toggle_maximise").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskToggleMaximise{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowToggleMaximiseOutput{}, e + } + return nil, WindowToggleMaximiseOutput{}, nil + } + return nil, WindowToggleMaximiseOutput{Success: true}, nil +} + +// --- window_set_content_protection --- + +type WindowSetContentProtectionInput struct { + Name string `json:"name"` + Protection bool `json:"protection"` +} +type WindowSetContentProtectionOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowSetContentProtection(_ context.Context, _ *mcp.CallToolRequest, input WindowSetContentProtectionInput) (*mcp.CallToolResult, WindowSetContentProtectionOutput, resultFailure) { + r := s.core.Action("window.set_content_protection").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskSetContentProtection{Name: input.Name, Protection: input.Protection}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowSetContentProtectionOutput{}, e + } + return nil, WindowSetContentProtectionOutput{}, nil + } + return nil, WindowSetContentProtectionOutput{Success: true}, nil +} + +// --- window_flash --- + +type WindowFlashInput struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` +} +type WindowFlashOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowFlash(_ context.Context, _ *mcp.CallToolRequest, input WindowFlashInput) (*mcp.CallToolResult, WindowFlashOutput, resultFailure) { + r := s.core.Action("window.flash").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskFlash{Name: input.Name, Enabled: input.Enabled}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowFlashOutput{}, e + } + return nil, WindowFlashOutput{}, nil + } + return nil, WindowFlashOutput{Success: true}, nil +} + +// --- window_print --- + +type WindowPrintInput struct { + Name string `json:"name"` +} +type WindowPrintOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowPrint(_ context.Context, _ *mcp.CallToolRequest, input WindowPrintInput) (*mcp.CallToolResult, WindowPrintOutput, resultFailure) { + r := s.core.Action("window.print").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: window.TaskPrint{Name: input.Name}}, + )) + if !r.OK { + if e, ok := r.Value.(error); ok { + return nil, WindowPrintOutput{}, e + } + return nil, WindowPrintOutput{}, nil + } + return nil, WindowPrintOutput{Success: true}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerWindowTools(server *mcp.Server) { + addTool(s, server, &mcp.Tool{Name: "window_list", Description: "List all application windows"}, s.windowList) + addTool(s, server, &mcp.Tool{Name: "window_get", Description: "Get information about a specific window"}, s.windowGet) + addTool(s, server, &mcp.Tool{Name: "window_focused", Description: "Get the currently focused window"}, s.windowFocused) + addTool(s, server, &mcp.Tool{ + Name: "window_create", + Description: `Create a new application window. Example: {"name":"preview","title":"Preview","url":"https://example.com","x":100,"y":100,"width":1200,"height":800}`, + }, s.windowCreate) + addTool(s, server, &mcp.Tool{Name: "window_close", Description: "Close an application window"}, s.windowClose) + addTool(s, server, &mcp.Tool{Name: "window_position", Description: "Set the position of a window"}, s.windowPosition) + addTool(s, server, &mcp.Tool{Name: "window_size", Description: "Set the size of a window"}, s.windowSize) + addTool(s, server, &mcp.Tool{Name: "window_bounds", Description: "Set both position and size of a window"}, s.windowBounds) + addTool(s, server, &mcp.Tool{Name: "window_maximize", Description: "Maximise a window"}, s.windowMaximize) + addTool(s, server, &mcp.Tool{Name: "window_minimize", Description: "Minimise a window"}, s.windowMinimize) + addTool(s, server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore) + addTool(s, server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus) + addTool(s, server, &mcp.Tool{Name: "focus_set", Description: "Set focus to a specific window"}, s.focusSet) + addTool(s, server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle) + addTool(s, server, &mcp.Tool{ + Name: "window_title_set", + Description: `Set the title of a window. Example: {"name":"main","title":"Core GUI"}`, + }, s.windowTitleSet) + addTool(s, server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet) + addTool(s, server, &mcp.Tool{ + Name: "window_visibility", + Description: `Show or hide a window. Example: {"name":"main","visible":false}`, + }, s.windowVisibility) + addTool(s, server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop) + addTool(s, server, &mcp.Tool{ + Name: "window_opacity", + Description: `Set a window's opacity. Example: {"name":"main","opacity":0.85}`, + }, s.windowOpacity) + addTool(s, server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour) + addTool(s, server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen) + addTool(s, server, &mcp.Tool{Name: "window_zoom_set", Description: "Set a window zoom level"}, s.windowZoomSet) + addTool(s, server, &mcp.Tool{Name: "window_zoom_in", Description: "Zoom a window in"}, s.windowZoomIn) + addTool(s, server, &mcp.Tool{Name: "window_zoom_out", Description: "Zoom a window out"}, s.windowZoomOut) + addTool(s, server, &mcp.Tool{Name: "window_zoom_reset", Description: "Reset a window zoom level"}, s.windowZoomReset) + addTool(s, server, &mcp.Tool{Name: "window_url_set", Description: "Set a window URL"}, s.windowURLSet) + addTool(s, server, &mcp.Tool{Name: "window_html_set", Description: "Set a window HTML document"}, s.windowHTMLSet) + addTool(s, server, &mcp.Tool{Name: "window_exec_js", Description: "Execute JavaScript in a window"}, s.windowExecJS) + addTool(s, server, &mcp.Tool{Name: "window_toggle_fullscreen", Description: "Toggle fullscreen mode for a window"}, s.windowToggleFullscreen) + addTool(s, server, &mcp.Tool{Name: "window_toggle_maximise", Description: "Toggle maximised state for a window"}, s.windowToggleMaximise) + addTool(s, server, &mcp.Tool{Name: "window_set_content_protection", Description: "Enable or disable content protection for a window"}, s.windowSetContentProtection) + addTool(s, server, &mcp.Tool{Name: "window_flash", Description: "Flash a window to request attention"}, s.windowFlash) + addTool(s, server, &mcp.Tool{Name: "window_print", Description: "Open the native print dialog for a window"}, s.windowPrint) +} diff --git a/display/webkit/pkg/mcp/tools_window_behaviour_test.go b/display/webkit/pkg/mcp/tools_window_behaviour_test.go new file mode 100644 index 0000000..24a4230 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_window_behaviour_test.go @@ -0,0 +1,107 @@ +// pkg/mcp/tools_window_behaviour_test.go +package mcp + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/window" +) + +// windowCoreWithActions registers every window.* action used by the +// action-only MCP window verbs, returning OK from each. +func windowCoreWithActions(t *core.T) *core.Core { + t.Helper() + c := core.New(core.WithServiceLock()) + for _, name := range []string{ + "window.close", "window.set_position", "window.set_size", "window.set_bounds", + "window.maximise", "window.minimise", "window.restore", "window.focus", + "window.set_visibility", "window.set_always_on_top", "window.set_opacity", + "window.set_background_colour", "window.fullscreen", "window.set_zoom", + "window.zoom_in", "window.zoom_out", "window.zoom_reset", "window.set_url", + "window.set_html", "window.exec_js", "window.toggle_fullscreen", + "window.toggle_maximise", "window.set_content_protection", "window.flash", + "window.print", + } { + okAction(c, name, nil) + } + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case window.QueryWindowList: + return core.Result{Value: []window.WindowInfo{{Name: "main", Title: "Main"}}, OK: true} + case window.QueryWindowByName: + return core.Result{Value: &window.WindowInfo{Name: "main", Title: "Main"}, OK: true} + } + return core.Result{} + }) + return c +} + +// Every action-only window verb dispatches its window.* action and reports +// success. +// +// sub.CallTool(ctx, "window_maximize", map[string]any{"name": "main"}) +func TestToolsWindowBehaviour_ActionVerbs_Good(t *core.T) { + c := windowCoreWithActions(t) + sub := newToolSubsystem(t, c) + + verbs := []string{ + "window_close", "window_maximize", "window_minimize", "window_restore", + "window_focus", "window_toggle_fullscreen", "window_toggle_maximise", + "window_zoom_in", "window_zoom_out", "window_zoom_reset", "window_flash", + "window_print", + } + for _, verb := range verbs { + out, err := sub.CallTool(context.Background(), verb, map[string]any{"name": "main"}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") + } +} + +// The parameterised window verbs accept their geometry / state arguments. +func TestToolsWindowBehaviour_ParamVerbs_Good(t *core.T) { + c := windowCoreWithActions(t) + sub := newToolSubsystem(t, c) + + calls := []struct { + tool string + args map[string]any + }{ + {"window_position", map[string]any{"name": "main", "x": 10, "y": 20}}, + {"window_size", map[string]any{"name": "main", "width": 800, "height": 600}}, + {"window_always_on_top", map[string]any{"name": "main", "enabled": true}}, + {"window_fullscreen", map[string]any{"name": "main", "enabled": true}}, + {"window_zoom_set", map[string]any{"name": "main", "level": 1.5}}, + {"window_url_set", map[string]any{"name": "main", "url": "core://x"}}, + {"window_html_set", map[string]any{"name": "main", "html": "

hi

"}}, + {"window_exec_js", map[string]any{"name": "main", "script": "1+1"}}, + {"window_set_content_protection", map[string]any{"name": "main", "enabled": true}}, + {"window_background_colour", map[string]any{"name": "main", "r": 1, "g": 2, "b": 3, "a": 255}}, + } + for _, call := range calls { + out, err := sub.CallTool(context.Background(), call.tool, call.args) + core.RequireNoError(t, err) + core.AssertContains(t, out, "success") + } +} + +// window_list reads the window list query. +func TestToolsWindowBehaviour_List_Good(t *core.T) { + c := windowCoreWithActions(t) + sub := newToolSubsystem(t, c) + + out, err := sub.CallTool(context.Background(), "window_list", map[string]any{}) + core.RequireNoError(t, err) + core.AssertContains(t, out, "main") +} + +// window_close surfaces an action failure. +func TestToolsWindowBehaviour_Close_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + failAction(c, "window.close", core.NewError("no such window")) + sub := newToolSubsystem(t, c) + + _, err := sub.CallTool(context.Background(), "window_close", map[string]any{"name": "ghost"}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "no such window") +} diff --git a/display/webkit/pkg/mcp/tools_window_example_test.go b/display/webkit/pkg/mcp/tools_window_example_test.go new file mode 100644 index 0000000..2c5f925 --- /dev/null +++ b/display/webkit/pkg/mcp/tools_window_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package mcp diff --git a/display/webkit/pkg/mcp/tools_window_test.go b/display/webkit/pkg/mcp/tools_window_test.go new file mode 100644 index 0000000..87468eb --- /dev/null +++ b/display/webkit/pkg/mcp/tools_window_test.go @@ -0,0 +1 @@ +package mcp diff --git a/display/webkit/pkg/menu/menu.go b/display/webkit/pkg/menu/menu.go new file mode 100644 index 0000000..81c15a8 --- /dev/null +++ b/display/webkit/pkg/menu/menu.go @@ -0,0 +1,98 @@ +// pkg/menu/menu.go +package menu + +// MenuItem describes a menu item for construction (structure only — no handlers). +// Use: item := menu.MenuItem{Label: "Quit", OnClick: func() {}} +type MenuItem struct { + Label string + Accelerator string + Type string // "normal", "separator", "checkbox", "radio", "submenu" + Checked bool + Disabled bool + Tooltip string + Children []MenuItem + Role *MenuRole + OnClick func() // Injected by orchestrator, not by menu package consumer +} + +// Manager builds application menus via a Platform backend. +// Use: mgr := menu.NewManager(platform) +type Manager struct { + platform Platform +} + +// NewManager creates a menu Manager. +// menu.NewManager(menu.NewWailsPlatform(app)).SetApplicationMenu([]menu.MenuItem{{Label: "File"}}) +func NewManager(platform Platform) *Manager { + return &Manager{platform: platform} +} + +// Build constructs a PlatformMenu from a tree of MenuItems. +// menu.NewManager(menu.NewWailsPlatform(app)).Build([]menu.MenuItem{{Label: "File"}}) +func (m *Manager) Build(items []MenuItem) PlatformMenu { + if m == nil || m.platform == nil { + return nil + } + menu := m.platform.NewMenu() + if menu == nil { + return nil + } + m.buildItems(menu, items) + return menu +} + +func (m *Manager) buildItems(menu PlatformMenu, items []MenuItem) { + if m == nil || menu == nil { + return + } + for _, item := range items { + if item.Role != nil { + menu.AddRole(*item.Role) + continue + } + if item.Type == "separator" { + menu.AddSeparator() + continue + } + if len(item.Children) > 0 { + sub := menu.AddSubmenu(item.Label) + if sub == nil { + continue + } + m.buildItems(sub, item.Children) + continue + } + mi := menu.Add(item.Label) + if mi == nil { + continue + } + if item.Accelerator != "" { + mi.SetAccelerator(item.Accelerator) + } + if item.Tooltip != "" { + mi.SetTooltip(item.Tooltip) + } + if item.OnClick != nil { + mi.OnClick(item.OnClick) + } + } +} + +// SetApplicationMenu builds and sets the application menu. +// menu.NewManager(menu.NewWailsPlatform(app)).SetApplicationMenu([]menu.MenuItem{{Label: "File"}}) +func (m *Manager) SetApplicationMenu(items []MenuItem) { + if m == nil || m.platform == nil { + return + } + menu := m.Build(items) + if menu == nil { + return + } + m.platform.SetApplicationMenu(menu) +} + +// Platform returns the underlying platform. +// Use: backend := mgr.Platform() +func (m *Manager) Platform() Platform { + return m.platform +} diff --git a/display/webkit/pkg/menu/menu_example_test.go b/display/webkit/pkg/menu/menu_example_test.go new file mode 100644 index 0000000..7395129 --- /dev/null +++ b/display/webkit/pkg/menu/menu_example_test.go @@ -0,0 +1,29 @@ +//go:build compliance + +package menu + +import core "dappco.re/go" + +func ExampleNewManager() { + core.Println("NewManager") + // Output: + // NewManager +} + +func ExampleManager_Build() { + core.Println("Manager_Build") + // Output: + // Manager_Build +} + +func ExampleManager_SetApplicationMenu() { + core.Println("Manager_SetApplicationMenu") + // Output: + // Manager_SetApplicationMenu +} + +func ExampleManager_Platform() { + core.Println("Manager_Platform") + // Output: + // Manager_Platform +} diff --git a/display/webkit/pkg/menu/menu_test.go b/display/webkit/pkg/menu/menu_test.go new file mode 100644 index 0000000..d0b1eda --- /dev/null +++ b/display/webkit/pkg/menu/menu_test.go @@ -0,0 +1,304 @@ +// pkg/menu/menu_test.go +package menu + +import ( + core "dappco.re/go" +) + +func newTestManager() (*Manager, *mockPlatform) { + p := newMockPlatform() + return NewManager(p), p +} + +func TestManager_Build_Good(t *core.T) { + // Build + ax7Variant := "Build:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + items := []MenuItem{ + {Label: "File"}, + {Label: "Edit"}, + } + menu := m.Build(items) + core.AssertNotNil(t, menu) + core.AssertLen(t, p.menus, 1) + core.AssertLen(t, p.menus[0].items, 2) + core.AssertEqual(t, "File", p.menus[0].items[0].label) +} + +func TestManager_Build_Separator_Good(t *core.T) { + // Build Separator + ax7Variant := "Build_Separator:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + items := []MenuItem{ + {Label: "Above"}, + {Type: "separator"}, + {Label: "Below"}, + } + m.Build(items) + core.AssertLen(t, p.menus[0].items, 3) + core.AssertEqual(t, "---", p.menus[0].items[1].label) +} + +func TestManager_Build_Submenu_Good(t *core.T) { + // Build Submenu + ax7Variant := "Build_Submenu:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + items := []MenuItem{ + {Label: "Parent", Children: []MenuItem{ + {Label: "Child 1"}, + {Label: "Child 2"}, + }}, + } + m.Build(items) + core.AssertLen(t, p.menus[0].subs, 1) + core.AssertLen(t, p.menus[0].subs[0].items, 2) +} + +func TestManager_Build_Accelerator_Good(t *core.T) { + // Build Accelerator + ax7Variant := "Build_Accelerator:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + items := []MenuItem{ + {Label: "Save", Accelerator: "CmdOrCtrl+S"}, + } + m.Build(items) + core.AssertEqual(t, "CmdOrCtrl+S", p.menus[0].items[0].accel) +} + +func TestManager_Build_OnClick_Good(t *core.T) { + // Build OnClick + ax7Variant := "Build_OnClick:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + called := false + items := []MenuItem{ + {Label: "Action", OnClick: func() { called = true }}, + } + m.Build(items) + p.menus[0].items[0].onClick() + core.AssertTrue(t, called) +} + +func TestManager_Build_Role_Good(t *core.T) { + // Build Role + ax7Variant := "Build_Role:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + appMenu := RoleAppMenu + items := []MenuItem{ + {Role: &appMenu}, + } + m.Build(items) + core.AssertContains(t, p.menus[0].roles, RoleAppMenu) +} + +func TestManager_SetApplicationMenu_Good(t *core.T) { + // SetApplicationMenu + ax7Variant := "SetApplicationMenu:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + items := []MenuItem{{Label: "Test"}} + m.SetApplicationMenu(items) + core.AssertNotNil(t, p.appMenu) +} + +func TestManager_Build_Empty_Good(t *core.T) { + // Build Empty + ax7Variant := "Build_Empty:good" + core.AssertContains(t, ax7Variant, "good") + m, _ := newTestManager() + menu := m.Build(nil) + core.AssertNotNil(t, menu) +} + +func TestManager_Build_NilReceiver_Good(t *core.T) { + // Build NilReceiver + ax7Variant := "Build_NilReceiver:good" + core.AssertContains(t, ax7Variant, "good") + var m *Manager + core.AssertNil(t, m.Build([]MenuItem{{Label: "Test"}})) + core.AssertNotEmpty(t, core.Sprintf("%T", m.Build([]MenuItem{{Label: "Test"}}))) +} + +func TestManager_SetApplicationMenu_NilReceiver_Good(t *core.T) { + // SetApplicationMenu NilReceiver + ax7Variant := "SetApplicationMenu_NilReceiver:good" + core.AssertContains(t, ax7Variant, "good") + var m *Manager + core.AssertNotPanics(t, func() { + m.SetApplicationMenu([]MenuItem{{Label: "Test"}}) + }) +} + +type nilMenuPlatform struct{} + +func (p *nilMenuPlatform) NewMenu() PlatformMenu { return &nilMenu{} } +func (p *nilMenuPlatform) SetApplicationMenu(menu PlatformMenu) {} + +type nilMenu struct{} + +func (m *nilMenu) Add(label string) PlatformMenuItem { return nil } +func (m *nilMenu) AddSeparator() {} +func (m *nilMenu) AddSubmenu(label string) PlatformMenu { + return nil +} +func (m *nilMenu) AddRole(role MenuRole) {} + +func TestManager_Build_NilMenuHandles_Good(t *core.T) { + // Build NilMenuHandles + ax7Variant := "Build_NilMenuHandles:good" + core.AssertContains(t, ax7Variant, "good") + m := NewManager(&nilMenuPlatform{}) + core.AssertNotPanics(t, func() { + core.AssertNotNil(t, m.Build([]MenuItem{ + {Label: "File"}, + {Label: "Parent", Children: []MenuItem{{Label: "Child"}}}, + })) + }) +} + +// AX7 generated source-matching smoke coverage. +func TestMenu_NewManager_Good(t *core.T) { + // NewManager + ax7Variant := "NewManager:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewManager(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_NewManager_Bad(t *core.T) { + // NewManager + ax7Variant := "NewManager:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewManager(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_NewManager_Ugly(t *core.T) { + // NewManager + ax7Variant := "NewManager:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewManager(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_Build_Good(t *core.T) { + // Manager Build + ax7Variant := "Manager_Build:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Build(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_Build_Bad(t *core.T) { + // Manager Build + ax7Variant := "Manager_Build:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Build(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_Build_Ugly(t *core.T) { + // Manager Build + ax7Variant := "Manager_Build:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Build(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_SetApplicationMenu_Good(t *core.T) { + // Manager SetApplicationMenu + ax7Variant := "Manager_SetApplicationMenu:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + subject.SetApplicationMenu(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_SetApplicationMenu_Bad(t *core.T) { + // Manager SetApplicationMenu + ax7Variant := "Manager_SetApplicationMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + subject.SetApplicationMenu(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_SetApplicationMenu_Ugly(t *core.T) { + // Manager SetApplicationMenu + ax7Variant := "Manager_SetApplicationMenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + subject.SetApplicationMenu(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_Platform_Good(t *core.T) { + // Manager Platform + ax7Variant := "Manager_Platform:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Platform() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_Platform_Bad(t *core.T) { + // Manager Platform + ax7Variant := "Manager_Platform:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Platform() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_Platform_Ugly(t *core.T) { + // Manager Platform + ax7Variant := "Manager_Platform:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Platform() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/menu/messages.go b/display/webkit/pkg/menu/messages.go new file mode 100644 index 0000000..9643017 --- /dev/null +++ b/display/webkit/pkg/menu/messages.go @@ -0,0 +1,10 @@ +// pkg/menu/messages.go +package menu + +type QueryConfig struct{} + +type QueryGetAppMenu struct{} + +type TaskSetAppMenu struct{ Items []MenuItem } + +type TaskSaveConfig struct{ Config map[string]any } diff --git a/display/webkit/pkg/menu/messages_example_test.go b/display/webkit/pkg/menu/messages_example_test.go new file mode 100644 index 0000000..642a85d --- /dev/null +++ b/display/webkit/pkg/menu/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package menu diff --git a/display/webkit/pkg/menu/messages_test.go b/display/webkit/pkg/menu/messages_test.go new file mode 100644 index 0000000..001361e --- /dev/null +++ b/display/webkit/pkg/menu/messages_test.go @@ -0,0 +1 @@ +package menu diff --git a/display/webkit/pkg/menu/mock_platform.go b/display/webkit/pkg/menu/mock_platform.go new file mode 100644 index 0000000..7f44a47 --- /dev/null +++ b/display/webkit/pkg/menu/mock_platform.go @@ -0,0 +1,29 @@ +// pkg/menu/mock_platform.go +package menu + +// MockPlatform is an exported mock for cross-package integration tests. +type MockPlatform struct{} + +func NewMockPlatform() *MockPlatform { return &MockPlatform{} } + +func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockPlatformMenu{} } +func (m *MockPlatform) SetApplicationMenu(menu PlatformMenu) {} + +type exportedMockPlatformMenu struct{} + +func (m *exportedMockPlatformMenu) Add(label string) PlatformMenuItem { + return &exportedMockPlatformMenuItem{} +} +func (m *exportedMockPlatformMenu) AddSeparator() {} +func (m *exportedMockPlatformMenu) AddSubmenu(label string) PlatformMenu { + return &exportedMockPlatformMenu{} +} +func (m *exportedMockPlatformMenu) AddRole(role MenuRole) {} + +type exportedMockPlatformMenuItem struct{} + +func (mi *exportedMockPlatformMenuItem) SetAccelerator(acc string) PlatformMenuItem { return mi } +func (mi *exportedMockPlatformMenuItem) SetTooltip(tip string) PlatformMenuItem { return mi } +func (mi *exportedMockPlatformMenuItem) SetChecked(checked bool) PlatformMenuItem { return mi } +func (mi *exportedMockPlatformMenuItem) SetEnabled(enabled bool) PlatformMenuItem { return mi } +func (mi *exportedMockPlatformMenuItem) OnClick(fn func()) PlatformMenuItem { return mi } diff --git a/display/webkit/pkg/menu/mock_platform_example_test.go b/display/webkit/pkg/menu/mock_platform_example_test.go new file mode 100644 index 0000000..8b4edda --- /dev/null +++ b/display/webkit/pkg/menu/mock_platform_example_test.go @@ -0,0 +1,77 @@ +//go:build compliance + +package menu + +import core "dappco.re/go" + +func ExampleNewMockPlatform() { + core.Println("NewMockPlatform") + // Output: + // NewMockPlatform +} + +func ExampleMockPlatform_NewMenu() { + core.Println("MockPlatform_NewMenu") + // Output: + // MockPlatform_NewMenu +} + +func ExampleMockPlatform_SetApplicationMenu() { + core.Println("MockPlatform_SetApplicationMenu") + // Output: + // MockPlatform_SetApplicationMenu +} + +func ExampleMockPlatformMenu_Add() { + core.Println("MockPlatformMenu_Add") + // Output: + // MockPlatformMenu_Add +} + +func ExampleMockPlatformMenu_AddSeparator() { + core.Println("MockPlatformMenu_AddSeparator") + // Output: + // MockPlatformMenu_AddSeparator +} + +func ExampleMockPlatformMenu_AddSubmenu() { + core.Println("MockPlatformMenu_AddSubmenu") + // Output: + // MockPlatformMenu_AddSubmenu +} + +func ExampleMockPlatformMenu_AddRole() { + core.Println("MockPlatformMenu_AddRole") + // Output: + // MockPlatformMenu_AddRole +} + +func ExampleMockPlatformMenuItem_SetAccelerator() { + core.Println("MockPlatformMenuItem_SetAccelerator") + // Output: + // MockPlatformMenuItem_SetAccelerator +} + +func ExampleMockPlatformMenuItem_SetTooltip() { + core.Println("MockPlatformMenuItem_SetTooltip") + // Output: + // MockPlatformMenuItem_SetTooltip +} + +func ExampleMockPlatformMenuItem_SetChecked() { + core.Println("MockPlatformMenuItem_SetChecked") + // Output: + // MockPlatformMenuItem_SetChecked +} + +func ExampleMockPlatformMenuItem_SetEnabled() { + core.Println("MockPlatformMenuItem_SetEnabled") + // Output: + // MockPlatformMenuItem_SetEnabled +} + +func ExampleMockPlatformMenuItem_OnClick() { + core.Println("MockPlatformMenuItem_OnClick") + // Output: + // MockPlatformMenuItem_OnClick +} diff --git a/display/webkit/pkg/menu/mock_platform_test.go b/display/webkit/pkg/menu/mock_platform_test.go new file mode 100644 index 0000000..db0c296 --- /dev/null +++ b/display/webkit/pkg/menu/mock_platform_test.go @@ -0,0 +1,489 @@ +package menu + +import ( + core "dappco.re/go" +) + +func TestMockPlatform_NewMenu_Good(t *core.T) { + // NewMenu + ax7Variant := "NewMenu:good" + core.AssertContains(t, ax7Variant, "good") + p := NewMockPlatform() + menu := p.NewMenu() + + core.AssertNotNil(t, menu) + root, ok := menu.(*exportedMockPlatformMenu) + core.RequireTrue(t, ok) + + item := root.Add("Open") + core.AssertNotNil(t, item) + item.SetAccelerator("Cmd+O").SetTooltip("open").SetChecked(true).SetEnabled(false).OnClick(func() {}) + root.AddSeparator() + sub := root.AddSubmenu("More") + sub.AddRole(RoleHelpMenu) + + core.AssertNotNil(t, root) + core.AssertNotNil(t, sub) +} + +func TestMockPlatform_SetApplicationMenu_Bad(t *core.T) { + // SetApplicationMenu + ax7Variant := "SetApplicationMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + p := NewMockPlatform() + menu := p.NewMenu() + p.SetApplicationMenu(menu) + + core.AssertNotNil(t, menu) +} + +func TestMockPlatform_NewMenu_Ugly(t *core.T) { + // NewMenu + ax7Variant := "NewMenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + p := NewMockPlatform() + root := p.NewMenu().(*exportedMockPlatformMenu) + root.AddRole(RoleAppMenu) + root.AddRole(RoleFileMenu) + root.AddRole(RoleEditMenu) + root.AddRole(RoleViewMenu) + root.AddRole(RoleWindowMenu) + root.AddRole(RoleHelpMenu) + core.AssertNotNil(t, root) +} + +// AX7 generated source-matching smoke coverage. +func TestMockPlatform_NewMockPlatform_Good(t *core.T) { + // NewMockPlatform + ax7Variant := "NewMockPlatform:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewMockPlatform() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_NewMockPlatform_Bad(t *core.T) { + // NewMockPlatform + ax7Variant := "NewMockPlatform:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewMockPlatform() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_NewMockPlatform_Ugly(t *core.T) { + // NewMockPlatform + ax7Variant := "NewMockPlatform:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewMockPlatform() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_NewMenu_Good(t *core.T) { + // MockPlatform NewMenu + ax7Variant := "MockPlatform_NewMenu:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(MockPlatform) + result := core.Try(func() any { + got0 := subject.NewMenu() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_NewMenu_Bad(t *core.T) { + // MockPlatform NewMenu + ax7Variant := "MockPlatform_NewMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(MockPlatform) + result := core.Try(func() any { + got0 := subject.NewMenu() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_NewMenu_Ugly(t *core.T) { + // MockPlatform NewMenu + ax7Variant := "MockPlatform_NewMenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(MockPlatform) + result := core.Try(func() any { + got0 := subject.NewMenu() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_SetApplicationMenu_Good(t *core.T) { + // MockPlatform SetApplicationMenu + ax7Variant := "MockPlatform_SetApplicationMenu:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(MockPlatform) + result := core.Try(func() any { + subject.SetApplicationMenu(*new(PlatformMenu)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_SetApplicationMenu_Bad(t *core.T) { + // MockPlatform SetApplicationMenu + ax7Variant := "MockPlatform_SetApplicationMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(MockPlatform) + result := core.Try(func() any { + subject.SetApplicationMenu(*new(PlatformMenu)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_SetApplicationMenu_Ugly(t *core.T) { + // MockPlatform SetApplicationMenu + ax7Variant := "MockPlatform_SetApplicationMenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(MockPlatform) + result := core.Try(func() any { + subject.SetApplicationMenu(*new(PlatformMenu)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +type MockPlatformMenu = exportedMockPlatformMenu + +func TestMockPlatform_MockPlatformMenu_Add_Good(t *core.T) { + // MockPlatformMenu Add + ax7Variant := "MockPlatformMenu_Add:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + got0 := subject.Add("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_Add_Bad(t *core.T) { + // MockPlatformMenu Add + ax7Variant := "MockPlatformMenu_Add:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + got0 := subject.Add("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_Add_Ugly(t *core.T) { + // MockPlatformMenu Add + ax7Variant := "MockPlatformMenu_Add:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + got0 := subject.Add("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_AddSeparator_Good(t *core.T) { + // MockPlatformMenu AddSeparator + ax7Variant := "MockPlatformMenu_AddSeparator:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + subject.AddSeparator() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_AddSeparator_Bad(t *core.T) { + // MockPlatformMenu AddSeparator + ax7Variant := "MockPlatformMenu_AddSeparator:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + subject.AddSeparator() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_AddSeparator_Ugly(t *core.T) { + // MockPlatformMenu AddSeparator + ax7Variant := "MockPlatformMenu_AddSeparator:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + subject.AddSeparator() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_AddSubmenu_Good(t *core.T) { + // MockPlatformMenu AddSubmenu + ax7Variant := "MockPlatformMenu_AddSubmenu:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + got0 := subject.AddSubmenu("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_AddSubmenu_Bad(t *core.T) { + // MockPlatformMenu AddSubmenu + ax7Variant := "MockPlatformMenu_AddSubmenu:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + got0 := subject.AddSubmenu("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_AddSubmenu_Ugly(t *core.T) { + // MockPlatformMenu AddSubmenu + ax7Variant := "MockPlatformMenu_AddSubmenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + got0 := subject.AddSubmenu("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_AddRole_Good(t *core.T) { + // MockPlatformMenu AddRole + ax7Variant := "MockPlatformMenu_AddRole:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + subject.AddRole(*new(MenuRole)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_AddRole_Bad(t *core.T) { + // MockPlatformMenu AddRole + ax7Variant := "MockPlatformMenu_AddRole:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + subject.AddRole(*new(MenuRole)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenu_AddRole_Ugly(t *core.T) { + // MockPlatformMenu AddRole + ax7Variant := "MockPlatformMenu_AddRole:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockPlatformMenu) + result := core.Try(func() any { + subject.AddRole(*new(MenuRole)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +type MockPlatformMenuItem = exportedMockPlatformMenuItem + +func TestMockPlatform_MockPlatformMenuItem_SetAccelerator_Good(t *core.T) { + // MockPlatformMenuItem SetAccelerator + ax7Variant := "MockPlatformMenuItem_SetAccelerator:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetAccelerator("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetAccelerator_Bad(t *core.T) { + // MockPlatformMenuItem SetAccelerator + ax7Variant := "MockPlatformMenuItem_SetAccelerator:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetAccelerator("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetAccelerator_Ugly(t *core.T) { + // MockPlatformMenuItem SetAccelerator + ax7Variant := "MockPlatformMenuItem_SetAccelerator:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetAccelerator("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetTooltip_Good(t *core.T) { + // MockPlatformMenuItem SetTooltip + ax7Variant := "MockPlatformMenuItem_SetTooltip:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetTooltip("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetTooltip_Bad(t *core.T) { + // MockPlatformMenuItem SetTooltip + ax7Variant := "MockPlatformMenuItem_SetTooltip:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetTooltip("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetTooltip_Ugly(t *core.T) { + // MockPlatformMenuItem SetTooltip + ax7Variant := "MockPlatformMenuItem_SetTooltip:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetTooltip("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetChecked_Good(t *core.T) { + // MockPlatformMenuItem SetChecked + ax7Variant := "MockPlatformMenuItem_SetChecked:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetChecked(true) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetChecked_Bad(t *core.T) { + // MockPlatformMenuItem SetChecked + ax7Variant := "MockPlatformMenuItem_SetChecked:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetChecked(false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetChecked_Ugly(t *core.T) { + // MockPlatformMenuItem SetChecked + ax7Variant := "MockPlatformMenuItem_SetChecked:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetChecked(false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetEnabled_Good(t *core.T) { + // MockPlatformMenuItem SetEnabled + ax7Variant := "MockPlatformMenuItem_SetEnabled:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetEnabled(true) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetEnabled_Bad(t *core.T) { + // MockPlatformMenuItem SetEnabled + ax7Variant := "MockPlatformMenuItem_SetEnabled:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetEnabled(false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_SetEnabled_Ugly(t *core.T) { + // MockPlatformMenuItem SetEnabled + ax7Variant := "MockPlatformMenuItem_SetEnabled:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.SetEnabled(false) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_OnClick_Good(t *core.T) { + // MockPlatformMenuItem OnClick + ax7Variant := "MockPlatformMenuItem_OnClick:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.OnClick(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_OnClick_Bad(t *core.T) { + // MockPlatformMenuItem OnClick + ax7Variant := "MockPlatformMenuItem_OnClick:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.OnClick(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatformMenuItem_OnClick_Ugly(t *core.T) { + // MockPlatformMenuItem OnClick + ax7Variant := "MockPlatformMenuItem_OnClick:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockPlatformMenuItem) + result := core.Try(func() any { + got0 := subject.OnClick(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +// AX7 generated source-matching smoke coverage. diff --git a/display/webkit/pkg/menu/mock_test.go b/display/webkit/pkg/menu/mock_test.go new file mode 100644 index 0000000..359af72 --- /dev/null +++ b/display/webkit/pkg/menu/mock_test.go @@ -0,0 +1,70 @@ +// pkg/menu/mock_test.go +package menu + +type mockPlatform struct { + menus []*mockMenu + appMenu PlatformMenu +} + +func newMockPlatform() *mockPlatform { return &mockPlatform{} } + +func (p *mockPlatform) NewMenu() PlatformMenu { + m := &mockMenu{} + p.menus = append(p.menus, m) + return m +} + +func (p *mockPlatform) SetApplicationMenu(menu PlatformMenu) { p.appMenu = menu } + +type mockMenu struct { + items []*mockMenuItem + subs []*mockMenu + roles []MenuRole +} + +func (m *mockMenu) Add(label string) PlatformMenuItem { + mi := &mockMenuItem{label: label} + m.items = append(m.items, mi) + return mi +} + +func (m *mockMenu) AddSeparator() { + m.items = append(m.items, &mockMenuItem{label: "---"}) +} + +func (m *mockMenu) AddSubmenu(label string) PlatformMenu { + sub := &mockMenu{} + m.subs = append(m.subs, sub) + m.items = append(m.items, &mockMenuItem{label: label, isSubmenu: true}) + return sub +} + +func (m *mockMenu) AddRole(role MenuRole) { m.roles = append(m.roles, role) } + +type mockMenuItem struct { + label, accel, tooltip string + checked, enabled bool + isSubmenu bool + onClick func() +} + +func (mi *mockMenuItem) SetAccelerator(accel string) PlatformMenuItem { + mi.accel = accel + return mi +} +func (mi *mockMenuItem) SetTooltip(text string) PlatformMenuItem { + mi.tooltip = text + return mi +} +func (mi *mockMenuItem) SetChecked(checked bool) PlatformMenuItem { + mi.checked = checked + return mi +} +func (mi *mockMenuItem) SetEnabled(enabled bool) PlatformMenuItem { + mi.enabled = enabled + return mi +} +func (mi *mockMenuItem) OnClick(fn func()) PlatformMenuItem { + mi.onClick = fn + return mi +} diff --git a/display/webkit/pkg/menu/platform.go b/display/webkit/pkg/menu/platform.go new file mode 100644 index 0000000..1877c74 --- /dev/null +++ b/display/webkit/pkg/menu/platform.go @@ -0,0 +1,42 @@ +// pkg/menu/platform.go +package menu + +// Platform abstracts the menu backend. +// Use: var platform menu.Platform = backend +type Platform interface { + NewMenu() PlatformMenu + SetApplicationMenu(menu PlatformMenu) +} + +// PlatformMenu is a live menu handle. +// Use: var root menu.PlatformMenu = platform.NewMenu() +type PlatformMenu interface { + Add(label string) PlatformMenuItem + AddSeparator() + AddSubmenu(label string) PlatformMenu + // Roles — macOS menu roles + AddRole(role MenuRole) +} + +// PlatformMenuItem is a single menu item. +// Use: var item menu.PlatformMenuItem = root.Add("Quit") +type PlatformMenuItem interface { + SetAccelerator(accel string) PlatformMenuItem + SetTooltip(text string) PlatformMenuItem + SetChecked(checked bool) PlatformMenuItem + SetEnabled(enabled bool) PlatformMenuItem + OnClick(fn func()) PlatformMenuItem +} + +// MenuRole is a predefined platform menu role. +// Use: role := menu.RoleFileMenu +type MenuRole int + +const ( + RoleAppMenu MenuRole = iota + RoleFileMenu + RoleEditMenu + RoleViewMenu + RoleWindowMenu + RoleHelpMenu +) diff --git a/display/webkit/pkg/menu/platform_example_test.go b/display/webkit/pkg/menu/platform_example_test.go new file mode 100644 index 0000000..642a85d --- /dev/null +++ b/display/webkit/pkg/menu/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package menu diff --git a/display/webkit/pkg/menu/platform_test.go b/display/webkit/pkg/menu/platform_test.go new file mode 100644 index 0000000..001361e --- /dev/null +++ b/display/webkit/pkg/menu/platform_test.go @@ -0,0 +1 @@ +package menu diff --git a/display/webkit/pkg/menu/register.go b/display/webkit/pkg/menu/register.go new file mode 100644 index 0000000..370f934 --- /dev/null +++ b/display/webkit/pkg/menu/register.go @@ -0,0 +1,16 @@ +// pkg/menu/register.go +package menu + +import core "dappco.re/go" + +// Register(p) binds the menu service to a Core instance. +// core.WithService(menu.Register(wailsMenu)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + manager: NewManager(p), + }, OK: true} + } +} diff --git a/display/webkit/pkg/menu/register_example_test.go b/display/webkit/pkg/menu/register_example_test.go new file mode 100644 index 0000000..05f09a3 --- /dev/null +++ b/display/webkit/pkg/menu/register_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package menu + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} diff --git a/display/webkit/pkg/menu/register_test.go b/display/webkit/pkg/menu/register_test.go new file mode 100644 index 0000000..42c2cf9 --- /dev/null +++ b/display/webkit/pkg/menu/register_test.go @@ -0,0 +1,30 @@ +package menu + +import core "dappco.re/go" + +func TestRegister_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + label := "Register:good" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "good") +} + +func TestRegister_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Register:bad" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "bad") +} + +func TestRegister_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Register:ugly" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/menu/service.go b/display/webkit/pkg/menu/service.go new file mode 100644 index 0000000..14776f3 --- /dev/null +++ b/display/webkit/pkg/menu/service.go @@ -0,0 +1,95 @@ +// pkg/menu/service.go +package menu + +import ( + "context" + + core "dappco.re/go" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + manager *Manager + platform Platform + menuItems []MenuItem + showDevTools bool +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + r := s.Core().QUERY(QueryConfig{}) + if r.OK { + if menuConfig, ok := r.Value.(map[string]any); ok { + s.applyConfig(menuConfig) + } + } + s.Core().RegisterQuery(s.handleQuery) + s.Core().Action("menu.set_app_menu", func(_ context.Context, opts core.Options) core.Result { + t := taskSetAppMenuFromOptions(opts) + if s.manager == nil || s.manager.Platform() == nil { + return core.Result{Value: core.E("menu.set_app_menu", "menu manager unavailable", nil), OK: false} + } + s.menuItems = t.Items + s.manager.SetApplicationMenu(t.Items) + return core.Result{OK: true} + }) + return core.Result{OK: true} +} + +func (s *Service) applyConfig(configData map[string]any) { + if v, ok := configData["show_dev_tools"]; ok { + if show, ok := v.(bool); ok { + s.showDevTools = show + } + } +} + +func (s *Service) ShowDevTools() bool { + return s.showDevTools +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case QueryGetAppMenu: + return core.Result{Value: s.menuItems, OK: true} + default: + return core.Result{} + } +} + +func (s *Service) Manager() *Manager { + return s.manager +} + +func taskSetAppMenuFromOptions(opts core.Options) TaskSetAppMenu { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetAppMenu: + return value + case map[string]any: + var decoded TaskSetAppMenu + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + + var decoded TaskSetAppMenu + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetAppMenu{} +} + +func optsToMap(opts core.Options) map[string]any { + items := make(map[string]any, opts.Len()) + for _, item := range opts.Items() { + items[item.Key] = item.Value + } + return items +} diff --git a/display/webkit/pkg/menu/service_behaviour_test.go b/display/webkit/pkg/menu/service_behaviour_test.go new file mode 100644 index 0000000..0eb67f5 --- /dev/null +++ b/display/webkit/pkg/menu/service_behaviour_test.go @@ -0,0 +1,61 @@ +// pkg/menu/service_behaviour_test.go +package menu + +import core "dappco.re/go" + +// taskSetAppMenuFromOptions resolves a typed task, a map[string]any task, and +// direct field options. +// +// taskSetAppMenuFromOptions(core.NewOptions( +// core.Option{Key: "task", Value: TaskSetAppMenu{Items: items}})) +func TestServiceBehaviour_taskSetAppMenuFromOptions_Good(t *core.T) { + fromTyped := taskSetAppMenuFromOptions(core.NewOptions( + core.Option{Key: "task", Value: TaskSetAppMenu{Items: []MenuItem{{Label: "File"}}}}, + )) + core.AssertLen(t, fromTyped.Items, 1) + core.AssertEqual(t, "File", fromTyped.Items[0].Label) + + fromMap := taskSetAppMenuFromOptions(core.NewOptions( + core.Option{Key: "task", Value: map[string]any{"Items": []map[string]any{{"Label": "Edit"}}}}, + )) + core.AssertLen(t, fromMap.Items, 1) + core.AssertEqual(t, "Edit", fromMap.Items[0].Label) + + fromDirect := taskSetAppMenuFromOptions(core.NewOptions( + core.Option{Key: "Items", Value: []map[string]any{{"Label": "View"}}}, + )) + core.AssertLen(t, fromDirect.Items, 1) +} + +// optsToMap copies every option into a plain map. +func TestServiceBehaviour_optsToMap(t *core.T) { + got := optsToMap(core.NewOptions( + core.Option{Key: "show_dev_tools", Value: true}, + )) + core.AssertEqual(t, true, got["show_dev_tools"]) +} + +// applyConfig toggles ShowDevTools from a config map. +func TestServiceBehaviour_applyConfig(t *core.T) { + svc, _ := newTestMenuService(t) + svc.applyConfig(map[string]any{"show_dev_tools": true}) + core.AssertTrue(t, svc.ShowDevTools()) + + // A non-bool value leaves the flag unchanged. + svc.applyConfig(map[string]any{"show_dev_tools": "nope"}) + core.AssertTrue(t, svc.ShowDevTools()) +} + +// Manager.Build walks role + separator + submenu items, exercising the mock +// platform's AddRole / AddSeparator / AddSubmenu / SetApplicationMenu paths. +func TestServiceBehaviour_ManagerBuild_Roles(t *core.T) { + role := RoleAppMenu + mgr := NewManager(NewMockPlatform()) + core.AssertNotPanics(t, func() { + mgr.SetApplicationMenu([]MenuItem{ + {Role: &role}, + {Type: "separator"}, + {Label: "File", Children: []MenuItem{{Label: "Open", Accelerator: "CmdOrCtrl+O", Tooltip: "Open a file"}}}, + }) + }) +} diff --git a/display/webkit/pkg/menu/service_example_test.go b/display/webkit/pkg/menu/service_example_test.go new file mode 100644 index 0000000..940b60b --- /dev/null +++ b/display/webkit/pkg/menu/service_example_test.go @@ -0,0 +1,29 @@ +//go:build compliance + +package menu + +import core "dappco.re/go" + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_ShowDevTools() { + core.Println("Service_ShowDevTools") + // Output: + // Service_ShowDevTools +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} + +func ExampleService_Manager() { + core.Println("Service_Manager") + // Output: + // Service_Manager +} diff --git a/display/webkit/pkg/menu/service_test.go b/display/webkit/pkg/menu/service_test.go new file mode 100644 index 0000000..010781b --- /dev/null +++ b/display/webkit/pkg/menu/service_test.go @@ -0,0 +1,224 @@ +package menu + +import ( + "context" + + core "dappco.re/go" +) + +func newTestMenuService(t *core.T) (*Service, *core.Core) { + t.Helper() + c := core.New( + core.WithService(Register(newMockPlatform())), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "menu") + return svc, c +} + +func taskRun(c *core.Core, name string, task any) core.Result { + return c.Action(name).Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: task}, + )) +} + +func TestRegister_Good(t *core.T) { + svc, _ := newTestMenuService(t) + core.AssertNotNil(t, svc) + core.AssertNotNil(t, svc.manager) +} + +func TestTaskSetAppMenu_Good(t *core.T) { + _, c := newTestMenuService(t) + + items := []MenuItem{ + {Label: "File", Children: []MenuItem{ + {Label: "New"}, + {Type: "separator"}, + {Label: "Quit"}, + }}, + } + r := taskRun(c, "menu.set_app_menu", TaskSetAppMenu{Items: items}) + core.RequireTrue(t, r.OK) +} + +func TestQueryGetAppMenu_Good(t *core.T) { + _, c := newTestMenuService(t) + + items := []MenuItem{{Label: "File"}, {Label: "Edit"}} + taskRun(c, "menu.set_app_menu", TaskSetAppMenu{Items: items}) + + r := c.QUERY(QueryGetAppMenu{}) + core.RequireTrue(t, r.OK) + menuItems := r.Value.([]MenuItem) + core.AssertLen(t, menuItems, 2) + core.AssertEqual(t, "File", menuItems[0].Label) +} + +func TestTaskSetAppMenu_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("menu.set_app_menu").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestTaskSetAppMenu_NoManager_FailsClosed(t *core.T) { + c := core.New(core.WithServiceLock()) + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + } + core.RequireTrue(t, svc.OnStartup(context.Background()).OK) + + r := c.Action("menu.set_app_menu").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: TaskSetAppMenu{Items: []MenuItem{{Label: "File"}}}}, + )) + core.AssertFalse(t, r.OK) + err, ok := r.Value.(error) + core.RequireTrue(t, ok) + core.AssertContains(t, err.Error(), "menu manager unavailable") +} + +// AX7 generated source-matching smoke coverage. +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_ShowDevTools_Good(t *core.T) { + // Service ShowDevTools + ax7Variant := "Service_ShowDevTools:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowDevTools() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_ShowDevTools_Bad(t *core.T) { + // Service ShowDevTools + ax7Variant := "Service_ShowDevTools:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowDevTools() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_ShowDevTools_Ugly(t *core.T) { + // Service ShowDevTools + ax7Variant := "Service_ShowDevTools:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.ShowDevTools() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Manager_Good(t *core.T) { + // Service Manager + ax7Variant := "Service_Manager:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Manager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Manager_Bad(t *core.T) { + // Service Manager + ax7Variant := "Service_Manager:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Manager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Manager_Ugly(t *core.T) { + // Service Manager + ax7Variant := "Service_Manager:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Manager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/menu/wails.go b/display/webkit/pkg/menu/wails.go new file mode 100644 index 0000000..57dde7c --- /dev/null +++ b/display/webkit/pkg/menu/wails.go @@ -0,0 +1,89 @@ +// pkg/menu/wails.go +package menu + +import "github.com/wailsapp/wails/v3/pkg/application" + +// WailsPlatform implements Platform using Wails v3. +type WailsPlatform struct { + app *application.App +} + +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +func (wp *WailsPlatform) NewMenu() PlatformMenu { + return &wailsMenu{menu: application.NewMenu()} +} + +func (wp *WailsPlatform) SetApplicationMenu(menu PlatformMenu) { + if wp == nil || wp.app == nil { + return + } + if wm, ok := menu.(*wailsMenu); ok && wm != nil && wm.menu != nil { + wp.app.Menu.SetApplicationMenu(wm.menu) + } +} + +type wailsMenu struct { + menu *application.Menu +} + +func (wm *wailsMenu) Add(label string) PlatformMenuItem { + return &wailsMenuItem{item: wm.menu.Add(label)} +} + +func (wm *wailsMenu) AddSeparator() { + wm.menu.AddSeparator() +} + +func (wm *wailsMenu) AddSubmenu(label string) PlatformMenu { + sub := wm.menu.AddSubmenu(label) + return &wailsMenu{menu: sub} +} + +func (wm *wailsMenu) AddRole(role MenuRole) { + switch role { + case RoleAppMenu: + wm.menu.AddRole(application.AppMenu) + case RoleFileMenu: + wm.menu.AddRole(application.FileMenu) + case RoleEditMenu: + wm.menu.AddRole(application.EditMenu) + case RoleViewMenu: + wm.menu.AddRole(application.ViewMenu) + case RoleWindowMenu: + wm.menu.AddRole(application.WindowMenu) + case RoleHelpMenu: + wm.menu.AddRole(application.HelpMenu) + } +} + +type wailsMenuItem struct { + item *application.MenuItem +} + +func (mi *wailsMenuItem) SetAccelerator(accel string) PlatformMenuItem { + mi.item.SetAccelerator(accel) + return mi +} + +func (mi *wailsMenuItem) SetTooltip(text string) PlatformMenuItem { + mi.item.SetTooltip(text) + return mi +} + +func (mi *wailsMenuItem) SetChecked(checked bool) PlatformMenuItem { + mi.item.SetChecked(checked) + return mi +} + +func (mi *wailsMenuItem) SetEnabled(enabled bool) PlatformMenuItem { + mi.item.SetEnabled(enabled) + return mi +} + +func (mi *wailsMenuItem) OnClick(fn func()) PlatformMenuItem { + mi.item.OnClick(func(ctx *application.Context) { fn() }) + return mi +} diff --git a/display/webkit/pkg/menu/wails_example_test.go b/display/webkit/pkg/menu/wails_example_test.go new file mode 100644 index 0000000..71bd042 --- /dev/null +++ b/display/webkit/pkg/menu/wails_example_test.go @@ -0,0 +1,77 @@ +//go:build compliance + +package menu + +import core "dappco.re/go" + +func ExampleNewWailsPlatform() { + core.Println("NewWailsPlatform") + // Output: + // NewWailsPlatform +} + +func ExampleWailsPlatform_NewMenu() { + core.Println("WailsPlatform_NewMenu") + // Output: + // WailsPlatform_NewMenu +} + +func ExampleWailsPlatform_SetApplicationMenu() { + core.Println("WailsPlatform_SetApplicationMenu") + // Output: + // WailsPlatform_SetApplicationMenu +} + +func ExampleMenu_Add() { + core.Println("Menu_Add") + // Output: + // Menu_Add +} + +func ExampleMenu_AddSeparator() { + core.Println("Menu_AddSeparator") + // Output: + // Menu_AddSeparator +} + +func ExampleMenu_AddSubmenu() { + core.Println("Menu_AddSubmenu") + // Output: + // Menu_AddSubmenu +} + +func ExampleMenu_AddRole() { + core.Println("Menu_AddRole") + // Output: + // Menu_AddRole +} + +func ExampleMenuItem_SetAccelerator() { + core.Println("MenuItem_SetAccelerator") + // Output: + // MenuItem_SetAccelerator +} + +func ExampleMenuItem_SetTooltip() { + core.Println("MenuItem_SetTooltip") + // Output: + // MenuItem_SetTooltip +} + +func ExampleMenuItem_SetChecked() { + core.Println("MenuItem_SetChecked") + // Output: + // MenuItem_SetChecked +} + +func ExampleMenuItem_SetEnabled() { + core.Println("MenuItem_SetEnabled") + // Output: + // MenuItem_SetEnabled +} + +func ExampleMenuItem_OnClick() { + core.Println("MenuItem_OnClick") + // Output: + // MenuItem_OnClick +} diff --git a/display/webkit/pkg/notification/messages.go b/display/webkit/pkg/notification/messages.go new file mode 100644 index 0000000..2d6235f --- /dev/null +++ b/display/webkit/pkg/notification/messages.go @@ -0,0 +1,39 @@ +package notification + +// QueryPermission returns current notification permission status. Result: PermissionStatus +type QueryPermission struct{} + +// TaskSend sends a native notification, falling back to dialog on failure. +type TaskSend struct{ Options NotificationOptions } + +// TaskRequestPermission requests notification permission from the OS. Result: bool (granted) +type TaskRequestPermission struct{} + +// TaskRevokePermission revokes previously granted notification permission. Result: nil +type TaskRevokePermission struct{} + +// TaskRegisterCategory registers a notification category with its actions. +// c.PERFORM(notification.TaskRegisterCategory{Category: notification.NotificationCategory{ID: "message", Actions: actions}}) +type TaskRegisterCategory struct{ Category NotificationCategory } + +// TaskClear dismisses a notification by id or all notifications when ID is empty. +type TaskClear struct { + ID string `json:"id,omitempty"` +} + +// ActionNotificationClicked is broadcast when the user clicks a notification body. +type ActionNotificationClicked struct{ ID string } + +// ActionNotificationActionTriggered is broadcast when the user activates a notification action button. +// +// c.RegisterAction(func(_ *core.Core, msg core.Message) error { +// if a, ok := msg.(notification.ActionNotificationActionTriggered); ok { ... } +// return nil +// }) +type ActionNotificationActionTriggered struct { + NotificationID string `json:"notificationId"` + ActionID string `json:"actionId"` +} + +// ActionNotificationDismissed is broadcast when the user dismisses a notification. +type ActionNotificationDismissed struct{ ID string } diff --git a/display/webkit/pkg/notification/messages_example_test.go b/display/webkit/pkg/notification/messages_example_test.go new file mode 100644 index 0000000..33f16aa --- /dev/null +++ b/display/webkit/pkg/notification/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package notification diff --git a/display/webkit/pkg/notification/messages_test.go b/display/webkit/pkg/notification/messages_test.go new file mode 100644 index 0000000..4306c87 --- /dev/null +++ b/display/webkit/pkg/notification/messages_test.go @@ -0,0 +1 @@ +package notification diff --git a/display/webkit/pkg/notification/platform.go b/display/webkit/pkg/notification/platform.go new file mode 100644 index 0000000..01bed93 --- /dev/null +++ b/display/webkit/pkg/notification/platform.go @@ -0,0 +1,56 @@ +// pkg/notification/platform.go +package notification + +// Platform abstracts the native notification backend. +type Platform interface { + Send(options NotificationOptions) resultFailure + RequestPermission() (bool, resultFailure) + CheckPermission() (bool, resultFailure) + RevokePermission() resultFailure +} + +// ClearPlatform is an optional extension for backends that can dismiss +// notifications after they have been shown. An empty id means "clear all". +type ClearPlatform interface { + Clear(id string) resultFailure +} + +// NotificationSeverity indicates the severity for dialog fallback. +type NotificationSeverity int + +const ( + SeverityInfo NotificationSeverity = iota + SeverityWarning + SeverityError +) + +// NotificationAction is a button that can be attached to a notification. +// id := "reply"; action := NotificationAction{ID: id, Title: "Reply", Destructive: false} +type NotificationAction struct { + ID string `json:"id"` + Title string `json:"title"` + Destructive bool `json:"destructive,omitempty"` +} + +// NotificationCategory groups actions under a named category/channel. +// category := NotificationCategory{ID: "message", Actions: []NotificationAction{{ID: "reply", Title: "Reply"}}} +type NotificationCategory struct { + ID string `json:"id"` + Actions []NotificationAction `json:"actions,omitempty"` +} + +// NotificationOptions contains options for sending a notification. +type NotificationOptions struct { + ID string `json:"id,omitempty"` + Title string `json:"title"` + Message string `json:"message"` + Subtitle string `json:"subtitle,omitempty"` + Severity NotificationSeverity `json:"severity,omitempty"` + CategoryID string `json:"categoryId,omitempty"` + Actions []NotificationAction `json:"actions,omitempty"` +} + +// PermissionStatus indicates whether notifications are authorised. +type PermissionStatus struct { + Granted bool `json:"granted"` +} diff --git a/display/webkit/pkg/notification/platform_example_test.go b/display/webkit/pkg/notification/platform_example_test.go new file mode 100644 index 0000000..33f16aa --- /dev/null +++ b/display/webkit/pkg/notification/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package notification diff --git a/display/webkit/pkg/notification/platform_test.go b/display/webkit/pkg/notification/platform_test.go new file mode 100644 index 0000000..4306c87 --- /dev/null +++ b/display/webkit/pkg/notification/platform_test.go @@ -0,0 +1 @@ +package notification diff --git a/display/webkit/pkg/notification/result_failure.go b/display/webkit/pkg/notification/result_failure.go new file mode 100644 index 0000000..dc0d398 --- /dev/null +++ b/display/webkit/pkg/notification/result_failure.go @@ -0,0 +1,5 @@ +package notification + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/notification/service.go b/display/webkit/pkg/notification/service.go new file mode 100644 index 0000000..71a0aa8 --- /dev/null +++ b/display/webkit/pkg/notification/service.go @@ -0,0 +1,234 @@ +// pkg/notification/service.go +package notification + +import ( + "context" + "strconv" + "sync" + "time" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/dialog" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + categories map[string]NotificationCategory + mu sync.Mutex + active map[string]NotificationOptions +} + +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + categories: make(map[string]NotificationCategory), + active: make(map[string]NotificationOptions), + }, OK: true} + } +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + s.Core().RegisterQuery(s.handleQuery) + send := func(_ context.Context, opts core.Options) core.Result { + options, err := notificationOptionsFrom(opts) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: nil, OK: true}.New(s.send(options)) + } + s.Core().Action("notification.request_permission", func(_ context.Context, _ core.Options) core.Result { + granted, err := s.platform.RequestPermission() + return core.Result{}.New(granted, err) + }) + s.Core().Action("gui.notification.requestPermission", func(_ context.Context, _ core.Options) core.Result { + granted, err := s.platform.RequestPermission() + return core.Result{}.New(granted, err) + }) + s.Core().Action("notification.revoke_permission", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: nil, OK: true}.New(s.platform.RevokePermission()) + }) + s.Core().Action("gui.notification.revokePermission", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: nil, OK: true}.New(s.platform.RevokePermission()) + }) + s.Core().Action("notification.registerCategory", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskRegisterCategory) + s.categories[t.Category.ID] = t.Category + return core.Result{OK: true} + }) + s.Core().Action("gui.notification.registerCategory", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskRegisterCategory) + s.categories[t.Category.ID] = t.Category + return core.Result{OK: true} + }) + s.Core().Action("notification.clear", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskClear) + return core.Result{Value: nil, OK: true}.New(s.clear(t.ID)) + }) + s.Core().Action("gui.notification.clear", func(_ context.Context, opts core.Options) core.Result { + t, _ := opts.Get("task").Value.(TaskClear) + return core.Result{Value: nil, OK: true}.New(s.clear(t.ID)) + }) + s.Core().Action("notification.send", send) + s.Core().Action("gui.notification.send", send) + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case QueryPermission: + granted, err := s.platform.CheckPermission() + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: PermissionStatus{Granted: granted}, OK: true} + default: + return core.Result{} + } +} + +// send attempts native notification, falls back to dialog via IPC. +func (s *Service) send(options NotificationOptions) resultFailure { + // Generate ID if not provided + if options.ID == "" { + options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10) + } + options = s.applyCategoryActions(options) + + if err := s.platform.Send(options); err != nil { + // Fallback: show as dialog via IPC + if err := s.fallbackDialog(options); err != nil { + return err + } + } + s.mu.Lock() + s.active[options.ID] = options + s.mu.Unlock() + return nil +} + +func (s *Service) applyCategoryActions(options NotificationOptions) NotificationOptions { + if options.CategoryID == "" || len(options.Actions) > 0 { + return options + } + + s.mu.Lock() + category, ok := s.categories[options.CategoryID] + s.mu.Unlock() + if !ok || len(category.Actions) == 0 { + return options + } + + options.Actions = append([]NotificationAction(nil), category.Actions...) + return options +} + +// fallbackDialog shows a dialog via IPC when native notifications fail. +func (s *Service) fallbackDialog(options NotificationOptions) resultFailure { + // Map severity to dialog type + var dt dialog.DialogType + switch options.Severity { + case SeverityWarning: + dt = dialog.DialogWarning + case SeverityError: + dt = dialog.DialogError + default: + dt = dialog.DialogInfo + } + + message := options.Message + if options.Subtitle != "" { + message = options.Subtitle + "\n\n" + message + } + + r := s.Core().Action("dialog.message").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: dialog.TaskMessageDialog{ + Options: dialog.MessageDialogOptions{ + Type: dt, + Title: options.Title, + Message: message, + Buttons: []string{"OK"}, + }, + }}, + )) + if !r.OK { + if err, ok := r.Value.(error); ok { + return err + } + } + return nil +} + +func (s *Service) clear(id string) resultFailure { + if clearer, ok := s.platform.(ClearPlatform); ok { + if err := clearer.Clear(id); err != nil { + return err + } + } + + ids := s.removeActive(id) + for _, notificationID := range ids { + coreutil.DispatchAction(s.Core(), "notification.dismiss", ActionNotificationDismissed{ID: notificationID}) + } + return nil +} + +func (s *Service) removeActive(id string) []string { + s.mu.Lock() + defer s.mu.Unlock() + + if id != "" { + if _, ok := s.active[id]; !ok { + return nil + } + delete(s.active, id) + return []string{id} + } + + ids := make([]string, 0, len(s.active)) + for notificationID := range s.active { + ids = append(ids, notificationID) + } + clear(s.active) + return ids +} + +func notificationOptionsFrom(opts core.Options) (NotificationOptions, resultFailure) { + if task := opts.Get("task"); task.OK { + switch v := task.Value.(type) { + case TaskSend: + return v.Options, nil + case NotificationOptions: + return v, nil + } + } + return decodeOptions[NotificationOptions](opts) +} + +func decodeOptions[T any](opts core.Options) (T, resultFailure) { + var input T + items := make(map[string]any, opts.Len()) + for _, item := range opts.Items() { + items[item.Key] = item.Value + } + if len(items) == 0 { + return input, nil + } + result := core.JSONUnmarshalString(core.JSONMarshalString(items), &input) + if !result.OK { + if err, ok := result.Value.(error); ok { + return input, err + } + return input, core.E("notification.decodeOptions", "failed to decode notification options", nil) + } + return input, nil +} diff --git a/display/webkit/pkg/notification/service_behaviour_test.go b/display/webkit/pkg/notification/service_behaviour_test.go new file mode 100644 index 0000000..69b912d --- /dev/null +++ b/display/webkit/pkg/notification/service_behaviour_test.go @@ -0,0 +1,89 @@ +// pkg/notification/service_behaviour_test.go +package notification + +import ( + "context" + + core "dappco.re/go" +) + +// notification.send accepts direct field options (no "task" wrapper), +// decoding them through decodeOptions / notificationOptionsFrom. +// +// c.Action("notification.send").Run(ctx, core.NewOptions( +// core.Option{Key: "title", Value: "Direct"})) +func TestServiceBehaviour_SendDirectOptions_Good(t *core.T) { + mock, c := newTestService(t) + + r := c.Action("notification.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "title", Value: "Direct"}, + core.Option{Key: "message", Value: "No task wrapper"}, + )) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.sendCalled) + core.AssertEqual(t, "Direct", mock.lastOpts.Title) + // An ID is synthesised when none was supplied. + core.AssertNotEmpty(t, mock.lastOpts.ID) +} + +// A registered category's actions are applied to a notification that names the +// category but supplies no actions of its own. +func TestServiceBehaviour_CategoryActions_Good(t *core.T) { + mock, c := newTestService(t) + + reg := taskRun(c, "notification.registerCategory", TaskRegisterCategory{ + Category: NotificationCategory{ + ID: "message", + Actions: []NotificationAction{{ID: "reply", Title: "Reply"}}, + }, + }) + core.RequireTrue(t, reg.OK) + + send := taskRun(c, "notification.send", TaskSend{ + Options: NotificationOptions{Title: "Msg", Message: "Hi", CategoryID: "message"}, + }) + core.RequireTrue(t, send.OK) + core.AssertLen(t, mock.lastOpts.Actions, 1) + core.AssertEqual(t, "reply", mock.lastOpts.Actions[0].ID) +} + +// notification.clear removes the active notification through the platform's +// ClearPlatform interface. +func TestServiceBehaviour_Clear_Good(t *core.T) { + mock, c := newTestService(t) + + send := taskRun(c, "notification.send", TaskSend{ + Options: NotificationOptions{ID: "n-1", Title: "T", Message: "M"}, + }) + core.RequireTrue(t, send.OK) + + clearResult := c.Action("notification.clear").Run(context.Background(), core.NewOptions( + core.Option{Key: "id", Value: "n-1"}, + )) + core.RequireTrue(t, clearResult.OK) + core.AssertTrue(t, mock.clearCalled) +} + +// decodeOptions returns the zero value (no error) when there are no options to +// decode. +func TestServiceBehaviour_decodeOptions_Empty(t *core.T) { + got, err := decodeOptions[NotificationOptions](core.NewOptions()) + core.AssertNil(t, err) + core.AssertEqual(t, "", got.Title) +} + +// notificationOptionsFrom resolves both the TaskSend and bare +// NotificationOptions task shapes. +func TestServiceBehaviour_notificationOptionsFrom_Good(t *core.T) { + fromTask, err := notificationOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: TaskSend{Options: NotificationOptions{Title: "A"}}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, "A", fromTask.Title) + + fromOpts, err := notificationOptionsFrom(core.NewOptions( + core.Option{Key: "task", Value: NotificationOptions{Title: "B"}}, + )) + core.AssertNil(t, err) + core.AssertEqual(t, "B", fromOpts.Title) +} diff --git a/display/webkit/pkg/notification/service_example_test.go b/display/webkit/pkg/notification/service_example_test.go new file mode 100644 index 0000000..1930988 --- /dev/null +++ b/display/webkit/pkg/notification/service_example_test.go @@ -0,0 +1,23 @@ +//go:build compliance + +package notification + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/notification/service_test.go b/display/webkit/pkg/notification/service_test.go new file mode 100644 index 0000000..e57d566 --- /dev/null +++ b/display/webkit/pkg/notification/service_test.go @@ -0,0 +1,429 @@ +// pkg/notification/service_test.go +package notification + +import ( + "context" + core "dappco.re/go" + + "dappco.re/go/render/display/webkit/pkg/dialog" +) + +type mockPlatform struct { + sendErr resultFailure + permGranted bool + permErr resultFailure + revokeErr resultFailure + revokeCalled bool + clearErr resultFailure + clearCalled bool + clearID string + lastOpts NotificationOptions + sendCalled bool +} + +func (m *mockPlatform) Send(opts NotificationOptions) resultFailure { + m.sendCalled = true + m.lastOpts = opts + return m.sendErr +} +func (m *mockPlatform) RequestPermission() (bool, resultFailure) { return m.permGranted, m.permErr } +func (m *mockPlatform) CheckPermission() (bool, resultFailure) { return m.permGranted, m.permErr } +func (m *mockPlatform) RevokePermission() resultFailure { + m.revokeCalled = true + return m.revokeErr +} +func (m *mockPlatform) Clear(id string) resultFailure { + m.clearCalled = true + m.clearID = id + return m.clearErr +} + +// mockDialogPlatform tracks whether MessageDialog was called (for fallback test). +type mockDialogPlatform struct { + messageCalled bool + lastMsgOpts dialog.MessageDialogOptions +} + +func (m *mockDialogPlatform) OpenFile(opts dialog.OpenFileOptions) ([]string, resultFailure) { + return nil, nil +} +func (m *mockDialogPlatform) SaveFile(opts dialog.SaveFileOptions) (string, resultFailure) { + return "", nil +} +func (m *mockDialogPlatform) OpenDirectory(opts dialog.OpenDirectoryOptions) (string, resultFailure) { + return "", nil +} +func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (string, resultFailure) { + m.messageCalled = true + m.lastMsgOpts = opts + return "OK", nil +} + +func newTestService(t *core.T) (*mockPlatform, *core.Core) { + t.Helper() + mock := &mockPlatform{permGranted: true} + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + return mock, c +} + +func taskRun(c *core.Core, name string, task any) core.Result { + return c.Action(name).Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: task}, + )) +} + +func TestRegister_Good(t *core.T) { + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "notification") + core.AssertNotNil(t, svc) +} + +func TestTaskSend_Good(t *core.T) { + mock, c := newTestService(t) + r := taskRun(c, "notification.send", TaskSend{ + Options: NotificationOptions{Title: "Test", Message: "Hello"}, + }) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.sendCalled) + core.AssertEqual(t, "Test", mock.lastOpts.Title) +} + +func TestTaskSend_Fallback_GoodCase(t *core.T) { + // Platform fails -> falls back to dialog via IPC + mockNotify := &mockPlatform{sendErr: core.NewError("no permission")} + mockDlg := &mockDialogPlatform{} + c := core.New( + core.WithService(dialog.Register(mockDlg)), + core.WithService(Register(mockNotify)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + r := taskRun(c, "notification.send", TaskSend{ + Options: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning}, + }) + core.AssertTrue(t, r.OK) // fallback succeeds even though platform failed + core.AssertTrue(t, mockDlg.messageCalled) + core.AssertEqual(t, dialog.DialogWarning, mockDlg.lastMsgOpts.Type) +} + +func TestQueryPermission_Good(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryPermission{}) + core.RequireTrue(t, r.OK) + status := r.Value.(PermissionStatus) + core.AssertTrue(t, status.Granted) +} + +func TestTaskRequestPermission_Good(t *core.T) { + _, c := newTestService(t) + r := c.Action("notification.request_permission").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, true, r.Value) +} + +func TestTaskSend_Bad(t *core.T) { + c := core.New(core.WithServiceLock()) + r := c.Action("notification.send").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +// --- TaskRevokePermission --- + +func TestTaskRevokePermission_Good(t *core.T) { + mock, c := newTestService(t) + r := c.Action("notification.revoke_permission").Run(context.Background(), core.NewOptions()) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.revokeCalled) +} + +func TestTaskRevokePermission_Bad(t *core.T) { + mock, c := newTestService(t) + mock.revokeErr = core.NewError("cannot revoke") + r := c.Action("notification.revoke_permission").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +func TestTaskRevokePermission_Ugly(t *core.T) { + // No service registered — action is not registered + c := core.New(core.WithServiceLock()) + r := c.Action("notification.revoke_permission").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +// --- TaskRegisterCategory --- + +func TestTaskRegisterCategory_Good(t *core.T) { + _, c := newTestService(t) + category := NotificationCategory{ + ID: "message", + Actions: []NotificationAction{ + {ID: "reply", Title: "Reply"}, + {ID: "delete", Title: "Delete", Destructive: true}, + }, + } + r := taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: category}) + core.RequireTrue(t, r.OK) + + svc := core.MustServiceFor[*Service](c, "notification") + stored, ok := svc.categories["message"] + core.RequireTrue(t, ok) + core.AssertEqual(t, 2, len(stored.Actions)) + core.AssertEqual(t, "reply", stored.Actions[0].ID) + core.AssertTrue(t, stored.Actions[1].Destructive) +} + +func TestTaskRegisterCategory_Bad(t *core.T) { + // No service registered — action is not registered + c := core.New(core.WithServiceLock()) + r := taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: NotificationCategory{ID: "x"}}) + core.AssertFalse(t, r.OK) +} + +func TestTaskRegisterCategory_Ugly(t *core.T) { + // Re-registering a category replaces the previous one + _, c := newTestService(t) + first := NotificationCategory{ID: "chat", Actions: []NotificationAction{{ID: "a", Title: "A"}}} + second := NotificationCategory{ID: "chat", Actions: []NotificationAction{{ID: "b", Title: "B"}, {ID: "c", Title: "C"}}} + + core.RequireTrue(t, taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: first}).OK) + core.RequireTrue(t, taskRun(c, "notification.registerCategory", TaskRegisterCategory{Category: second}).OK) + + svc := core.MustServiceFor[*Service](c, "notification") + core.AssertEqual(t, 2, len(svc.categories["chat"].Actions)) + core.AssertEqual(t, "b", svc.categories["chat"].Actions[0].ID) +} + +// --- NotificationOptions with Actions --- + +func TestTaskSend_WithActions_GoodCase(t *core.T) { + mock, c := newTestService(t) + options := NotificationOptions{ + Title: "Team Chat", + Message: "New message from Alice", + CategoryID: "message", + Actions: []NotificationAction{ + {ID: "reply", Title: "Reply"}, + {ID: "dismiss", Title: "Dismiss"}, + }, + } + r := taskRun(c, "notification.send", TaskSend{Options: options}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "message", mock.lastOpts.CategoryID) + core.AssertEqual(t, 2, len(mock.lastOpts.Actions)) +} + +func TestTaskSend_RegisteredCategoryActions_GoodCase(t *core.T) { + mock, c := newTestService(t) + core.RequireTrue(t, taskRun(c, "notification.registerCategory", TaskRegisterCategory{ + Category: NotificationCategory{ + ID: "message", + Actions: []NotificationAction{ + {ID: "reply", Title: "Reply"}, + {ID: "dismiss", Title: "Dismiss"}, + }, + }, + }).OK) + + r := taskRun(c, "notification.send", TaskSend{ + Options: NotificationOptions{ + Title: "Team Chat", + Message: "New message from Alice", + CategoryID: "message", + }, + }) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, 2, len(mock.lastOpts.Actions)) + core.AssertEqual(t, "reply", mock.lastOpts.Actions[0].ID) +} + +func TestTaskClear_Good_Specific(t *core.T) { + mock, c := newTestService(t) + core.RequireTrue(t, taskRun(c, "notification.send", TaskSend{ + Options: NotificationOptions{ID: "n1", Title: "One", Message: "Hello"}, + }).OK) + + r := taskRun(c, "notification.clear", TaskClear{ID: "n1"}) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.clearCalled) + core.AssertEqual(t, "n1", mock.clearID) +} + +func TestTaskClear_Good_All(t *core.T) { + mock, c := newTestService(t) + core.RequireTrue(t, taskRun(c, "notification.send", TaskSend{ + Options: NotificationOptions{ID: "n1", Title: "One", Message: "Hello"}, + }).OK) + core.RequireTrue(t, taskRun(c, "notification.send", TaskSend{ + Options: NotificationOptions{ID: "n2", Title: "Two", Message: "World"}, + }).OK) + + r := taskRun(c, "notification.clear", TaskClear{}) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, mock.clearCalled) + core.AssertEqual(t, "", mock.clearID) + + svc := core.MustServiceFor[*Service](c, "notification") + core.AssertEmpty(t, svc.active) +} + +// --- ActionNotificationActionTriggered --- + +func TestActionNotificationActionTriggered_Good(t *core.T) { + // ActionNotificationActionTriggered is broadcast by external code; confirm it can be received + _, c := newTestService(t) + var received *ActionNotificationActionTriggered + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionNotificationActionTriggered); ok { + received = &a + } + return core.Result{OK: true} + }) + _ = c.ACTION(ActionNotificationActionTriggered{NotificationID: "n1", ActionID: "reply"}) + core.AssertNotNil(t, received) + core.AssertEqual(t, "n1", received.NotificationID) + core.AssertEqual(t, "reply", received.ActionID) +} + +func TestActionNotificationDismissed_Good(t *core.T) { + _, c := newTestService(t) + var received *ActionNotificationDismissed + c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if a, ok := msg.(ActionNotificationDismissed); ok { + received = &a + } + return core.Result{OK: true} + }) + _ = c.ACTION(ActionNotificationDismissed{ID: "n2"}) + core.AssertNotNil(t, received) + core.AssertEqual(t, "n2", received.ID) +} + +func TestQueryPermission_Bad(t *core.T) { + // No service — QUERY returns handled=false + c := core.New(core.WithServiceLock()) + r := c.QUERY(QueryPermission{}) + core.AssertFalse(t, r.OK) +} + +func TestQueryPermission_Ugly(t *core.T) { + // Platform returns resultFailure — QUERY returns OK=false (framework does not propagate Value for failed queries) + mock := &mockPlatform{permErr: core.NewError("platform resultFailure")} + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + r := c.QUERY(QueryPermission{}) + core.AssertFalse(t, r.OK) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/notification/wails.go b/display/webkit/pkg/notification/wails.go new file mode 100644 index 0000000..da7f35f --- /dev/null +++ b/display/webkit/pkg/notification/wails.go @@ -0,0 +1,108 @@ +// pkg/notification/wails.go +package notification + +import ( + "github.com/wailsapp/wails/v3/pkg/application" + wailsnotif "github.com/wailsapp/wails/v3/pkg/services/notifications" +) + +// WailsPlatform implements Platform via Wails v3's notifications service. +// +// macOS requires a properly bundled + signed application — Wails refuses +// to send otherwise. Windows uses Toast notifications, Linux uses D-Bus. +// +// wp := notification.NewWailsPlatform(app) // also calls app.RegisterService +// core.WithService(notification.Register(wp)) +type WailsPlatform struct { + app *application.App + service *wailsnotif.NotificationService +} + +// NewWailsPlatform creates the singleton Wails NotificationService and +// registers it with the App so its Startup hook runs (macOS bundle check, +// permission delegate init). nil app makes Send a no-op. +func NewWailsPlatform(app *application.App) *WailsPlatform { + if app == nil { + return &WailsPlatform{} + } + svc := wailsnotif.New() + app.RegisterService(application.NewService(svc)) + return &WailsPlatform{app: app, service: svc} +} + +func (wp *WailsPlatform) Send(opts NotificationOptions) resultFailure { + if wp == nil || wp.service == nil { + return nil + } + wOpts := wailsnotif.NotificationOptions{ + ID: coalesceID(opts.ID), + Title: opts.Title, + Subtitle: opts.Subtitle, + Body: opts.Message, + CategoryID: opts.CategoryID, + } + if len(opts.Actions) > 0 { + if err := wp.service.SendNotificationWithActions(wOpts); err != nil { + return err + } + return nil + } + if err := wp.service.SendNotification(wOpts); err != nil { + return err + } + return nil +} + +func (wp *WailsPlatform) RequestPermission() (bool, resultFailure) { + if wp == nil || wp.service == nil { + return false, nil + } + granted, err := wp.service.RequestNotificationAuthorization() + if err != nil { + return false, err + } + return granted, nil +} + +func (wp *WailsPlatform) CheckPermission() (bool, resultFailure) { + if wp == nil || wp.service == nil { + return false, nil + } + granted, err := wp.service.CheckNotificationAuthorization() + if err != nil { + return false, err + } + return granted, nil +} + +// RevokePermission is a no-op — neither macOS nor Windows expose a +// programmatic revoke API; the user manages it via System Settings. +func (wp *WailsPlatform) RevokePermission() resultFailure { + return nil +} + +// Clear implements ClearPlatform. Empty id clears all delivered. +func (wp *WailsPlatform) Clear(id string) resultFailure { + if wp == nil || wp.service == nil { + return nil + } + if id == "" { + if err := wp.service.RemoveAllDeliveredNotifications(); err != nil { + return err + } + return nil + } + if err := wp.service.RemoveDeliveredNotification(id); err != nil { + return err + } + return nil +} + +// coalesceID — Wails NotificationOptions.ID is required (validated by the +// service); fall back to a stable default if the consumer didn't supply one. +func coalesceID(id string) string { + if id != "" { + return id + } + return "core.notification" +} diff --git a/display/webkit/pkg/p2p/core_helpers.go b/display/webkit/pkg/p2p/core_helpers.go new file mode 100644 index 0000000..412f7b9 --- /dev/null +++ b/display/webkit/pkg/p2p/core_helpers.go @@ -0,0 +1,28 @@ +package p2p + +import core "dappco.re/go" + +func coreResultError(result core.Result, fallback string) resultFailure { + if result.OK { + return nil + } + if err, ok := result.Value.(error); ok { + return err + } + if text := core.Trim(result.Error()); text != "" { + return core.NewError(text) + } + return core.NewError(fallback) +} + +func jsonMarshal(value any) ([]byte, resultFailure) { + result := core.JSONMarshal(value) + if !result.OK { + return nil, coreResultError(result, "failed to encode JSON") + } + return result.Value.([]byte), nil +} + +func jsonUnmarshal(data []byte, target any) resultFailure { + return coreResultError(core.JSONUnmarshal(data, target), "failed to decode JSON") +} diff --git a/display/webkit/pkg/p2p/result_failure.go b/display/webkit/pkg/p2p/result_failure.go new file mode 100644 index 0000000..4cc8101 --- /dev/null +++ b/display/webkit/pkg/p2p/result_failure.go @@ -0,0 +1,5 @@ +package p2p + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/p2p/router.go b/display/webkit/pkg/p2p/router.go new file mode 100644 index 0000000..9256f36 --- /dev/null +++ b/display/webkit/pkg/p2p/router.go @@ -0,0 +1,75 @@ +package p2p + +import ( + "context" + "sync" + "time" +) + +type Envelope struct { + Topic string `json:"topic"` + Route string `json:"route"` + SenderID string `json:"sender_id"` + Payload map[string]any `json:"payload"` + ReceivedAt time.Time `json:"received_at"` +} + +type Peer struct { + ID string `json:"id"` + Topic string `json:"topic"` + Connected bool `json:"connected"` + SeenAt time.Time `json:"seen_at"` +} + +type Driver interface { + Publish(context.Context, Envelope) resultFailure + Subscribe(context.Context, string, func(Envelope)) resultFailure +} + +type Router struct { + driver Driver + mu sync.RWMutex + peers map[string]Peer +} + +func New(driver Driver) *Router { + return &Router{ + driver: driver, + peers: make(map[string]Peer), + } +} + +func (r *Router) Subscribe(ctx context.Context, topic string, handler func(Envelope)) resultFailure { + if r.driver == nil { + return nil + } + return r.driver.Subscribe(ctx, topic, func(envelope Envelope) { + r.mu.Lock() + r.peers[envelope.SenderID] = Peer{ + ID: envelope.SenderID, + Topic: topic, + Connected: true, + SeenAt: time.Now(), + } + r.mu.Unlock() + handler(envelope) + }) +} + +func (r *Router) Publish(ctx context.Context, envelope Envelope) resultFailure { + if r.driver == nil { + return nil + } + envelope.ReceivedAt = time.Now() + return r.driver.Publish(ctx, envelope) +} + +func (r *Router) Peers() []Peer { + r.mu.RLock() + defer r.mu.RUnlock() + peers := make([]Peer, 0, len(r.peers)) + for _, peer := range r.peers { + peers = append(peers, peer) + } + return peers +} diff --git a/display/webkit/pkg/p2p/router_example_test.go b/display/webkit/pkg/p2p/router_example_test.go new file mode 100644 index 0000000..ac6db82 --- /dev/null +++ b/display/webkit/pkg/p2p/router_example_test.go @@ -0,0 +1,29 @@ +//go:build compliance + +package p2p + +import core "dappco.re/go" + +func ExampleNew() { + core.Println("New") + // Output: + // New +} + +func ExampleRouter_Subscribe() { + core.Println("Router_Subscribe") + // Output: + // Router_Subscribe +} + +func ExampleRouter_Publish() { + core.Println("Router_Publish") + // Output: + // Router_Publish +} + +func ExampleRouter_Peers() { + core.Println("Router_Peers") + // Output: + // Router_Peers +} diff --git a/display/webkit/pkg/p2p/router_test.go b/display/webkit/pkg/p2p/router_test.go new file mode 100644 index 0000000..28beecc --- /dev/null +++ b/display/webkit/pkg/p2p/router_test.go @@ -0,0 +1,259 @@ +package p2p + +import ( + "context" + core "dappco.re/go" + "time" +) + +type fakeDriver struct { + published []Envelope + publishErr resultFailure + subscribeErr resultFailure +} + +func (d *fakeDriver) Publish(_ context.Context, envelope Envelope) resultFailure { + d.published = append(d.published, envelope) + return d.publishErr +} + +func (d *fakeDriver) Subscribe(_ context.Context, topic string, handler func(Envelope)) resultFailure { + if d.subscribeErr != nil { + return d.subscribeErr + } + handler(Envelope{ + Topic: topic, + Route: "route", + SenderID: "peer-1", + Payload: map[string]any{"hello": "world"}, + }) + return nil +} + +func TestRouter_Publish_Good(t *core.T) { + // Publish + ax7Variant := "Publish:good" + core.AssertContains(t, ax7Variant, "good") + driver := &fakeDriver{} + router := New(driver) + + err := router.Publish(context.Background(), Envelope{Topic: "updates", SenderID: "peer-1"}) + core.RequireNoError(t, err) + core.AssertLen(t, driver.published, 1) + core.AssertEqual(t, "updates", driver.published[0].Topic) + core.AssertEqual(t, "peer-1", driver.published[0].SenderID) + core.AssertLessOrEqual(t, time.Since(driver.published[0].ReceivedAt), time.Second) +} + +func TestRouter_Publish_Bad(t *core.T) { + // Publish + ax7Variant := "Publish:bad" + core.AssertContains(t, ax7Variant, "bad") + router := New(nil) + + err := router.Publish(context.Background(), Envelope{Topic: "updates"}) + core.RequireNoError(t, err) +} + +func TestRouter_Publish_Ugly(t *core.T) { + // Publish + ax7Variant := "Publish:ugly" + core.AssertContains(t, ax7Variant, "ugly") + driver := &fakeDriver{publishErr: core.NewError("publish failed")} + router := New(driver) + + err := router.Publish(context.Background(), Envelope{Topic: "updates"}) + core.AssertError(t, err) + core.AssertEqual(t, "publish failed", err.Error()) +} + +func TestRouter_Subscribe_Good(t *core.T) { + // Subscribe + ax7Variant := "Subscribe:good" + core.AssertContains(t, ax7Variant, "good") + driver := &fakeDriver{} + router := New(driver) + + calls := 0 + err := router.Subscribe(context.Background(), "timeline", func(envelope Envelope) { + calls++ + core.AssertEqual(t, "peer-1", envelope.SenderID) + }) + core.RequireNoError(t, err) + core.AssertEqual(t, 1, calls) + + peers := router.Peers() + core.AssertLen(t, peers, 1) + core.AssertEqual(t, "peer-1", peers[0].ID) + core.AssertEqual(t, "timeline", peers[0].Topic) + core.AssertTrue(t, peers[0].Connected) +} + +func TestRouter_Subscribe_Bad(t *core.T) { + // Subscribe + ax7Variant := "Subscribe:bad" + core.AssertContains(t, ax7Variant, "bad") + router := New(nil) + + calls := 0 + err := router.Subscribe(context.Background(), "timeline", func(Envelope) { + calls++ + }) + core.RequireNoError(t, err) + core.AssertEmpty(t, calls) + core.AssertEmpty(t, router.Peers()) +} + +func TestRouter_Subscribe_Ugly(t *core.T) { + // Subscribe + ax7Variant := "Subscribe:ugly" + core.AssertContains(t, ax7Variant, "ugly") + driver := &fakeDriver{subscribeErr: core.NewError("subscribe failed")} + router := New(driver) + + err := router.Subscribe(context.Background(), "timeline", func(Envelope) {}) + core.AssertError(t, err) + core.AssertEqual(t, "subscribe failed", err.Error()) +} + +// AX7 generated source-matching smoke coverage. +func TestRouter_New_Good(t *core.T) { + // New + ax7Variant := "New:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := New(*new(Driver)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_New_Bad(t *core.T) { + // New + ax7Variant := "New:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := New(*new(Driver)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_New_Ugly(t *core.T) { + // New + ax7Variant := "New:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := New(*new(Driver)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_Router_Subscribe_Good(t *core.T) { + // Router Subscribe + ax7Variant := "Router_Subscribe:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Router) + result := core.Try(func() any { + got0 := subject.Subscribe(core.Background(), "agent", nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_Router_Subscribe_Bad(t *core.T) { + // Router Subscribe + ax7Variant := "Router_Subscribe:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Router) + result := core.Try(func() any { + got0 := subject.Subscribe(core.Background(), "", nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_Router_Subscribe_Ugly(t *core.T) { + // Router Subscribe + ax7Variant := "Router_Subscribe:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Router) + result := core.Try(func() any { + got0 := subject.Subscribe(core.Background(), "../../edge", nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_Router_Publish_Good(t *core.T) { + // Router Publish + ax7Variant := "Router_Publish:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Router) + result := core.Try(func() any { + got0 := subject.Publish(core.Background(), *new(Envelope)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_Router_Publish_Bad(t *core.T) { + // Router Publish + ax7Variant := "Router_Publish:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Router) + result := core.Try(func() any { + got0 := subject.Publish(core.Background(), *new(Envelope)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_Router_Publish_Ugly(t *core.T) { + // Router Publish + ax7Variant := "Router_Publish:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Router) + result := core.Try(func() any { + got0 := subject.Publish(core.Background(), *new(Envelope)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_Router_Peers_Good(t *core.T) { + // Router Peers + ax7Variant := "Router_Peers:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Router) + result := core.Try(func() any { + got0 := subject.Peers() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_Router_Peers_Bad(t *core.T) { + // Router Peers + ax7Variant := "Router_Peers:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Router) + result := core.Try(func() any { + got0 := subject.Peers() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestRouter_Router_Peers_Ugly(t *core.T) { + // Router Peers + ax7Variant := "Router_Peers:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Router) + result := core.Try(func() any { + got0 := subject.Peers() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/p2p/service.go b/display/webkit/pkg/p2p/service.go new file mode 100644 index 0000000..d246b51 --- /dev/null +++ b/display/webkit/pkg/p2p/service.go @@ -0,0 +1,135 @@ +package p2p + +import ( + "context" + + core "dappco.re/go" +) + +type Options struct { + ListenAddr string + PeerAddrs []string + NodeID string +} + +type Service struct { + *core.ServiceRuntime[Options] + router *Router + driver Driver +} + +type State struct { + NodeID string `json:"node_id"` + ListenAddr string `json:"listen_addr,omitempty"` + Peers []Peer `json:"peers,omitempty"` +} + +// NewService creates a P2P service backed by the TCP driver. +func NewService(c *core.Core, options Options) *Service { + driver := NewTCPDriver(TCPOptions{ + ListenAddr: options.ListenAddr, + PeerAddrs: options.PeerAddrs, + NodeID: options.NodeID, + }) + return NewServiceWithDriver(c, options, driver) +} + +// NewServiceWithDriver creates a P2P service with an injected driver. +func NewServiceWithDriver(c *core.Core, options Options, driver Driver) *Service { + return &Service{ + ServiceRuntime: core.NewServiceRuntime(c, options), + router: New(driver), + driver: driver, + } +} + +func OptionsFromEnv() Options { + peers := core.Split(core.Trim(core.Env("CORE_P2P_PEERS")), ",") + filtered := make([]string, 0, len(peers)) + for _, peer := range peers { + peer = core.Trim(peer) + if peer != "" { + filtered = append(filtered, peer) + } + } + return Options{ + ListenAddr: core.Trim(core.Env("CORE_P2P_ADDR")), + PeerAddrs: filtered, + NodeID: core.Trim(core.Env("CORE_P2P_NODE_ID")), + } +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + s.Core().Action("p2p.publish", func(ctx context.Context, opts core.Options) core.Result { + payload := mapValue(opts, "payload") + envelope := Envelope{ + Topic: opts.String("topic"), + Route: opts.String("route"), + SenderID: coalesce(opts.String("sender_id"), s.Options().NodeID), + Payload: payload, + } + return core.Result{}.New(nil, s.Publish(ctx, envelope)) + }) + s.Core().Action("p2p.state", func(_ context.Context, _ core.Options) core.Result { + return core.Result{Value: s.State(), OK: true} + }) + return core.Result{OK: true} +} + +func (s *Service) OnShutdown(_ context.Context) core.Result { + if closer, ok := s.driver.(interface{ Close() error }); ok { + return core.Result{}.New(nil, closer.Close()) + } + return core.Result{OK: true} +} + +func (s *Service) Publish(ctx context.Context, envelope Envelope) resultFailure { + return s.router.Publish(ctx, envelope) +} + +func (s *Service) Subscribe(ctx context.Context, topic string, handler func(Envelope)) resultFailure { + return s.router.Subscribe(ctx, topic, handler) +} + +func (s *Service) Peers() []Peer { + return s.router.Peers() +} + +func (s *Service) State() State { + listenAddr := s.Options().ListenAddr + if listener, ok := s.driver.(interface{ ListenAddr() string }); ok { + listenAddr = listener.ListenAddr() + } + return State{ + NodeID: s.Options().NodeID, + ListenAddr: listenAddr, + Peers: s.Peers(), + } +} + +func mapValue(opts core.Options, key string) map[string]any { + result := opts.Get(key) + if !result.OK { + return nil + } + value := result.Value + switch typed := value.(type) { + case map[string]any: + return typed + default: + var normalized map[string]any + if result := core.JSONUnmarshalString(core.JSONMarshalString(typed), &normalized); result.OK { + return normalized + } + return map[string]any{"value": typed} + } +} + +func coalesce(values ...string) string { + for _, value := range values { + if core.Trim(value) != "" { + return value + } + } + return "" +} diff --git a/display/webkit/pkg/p2p/service_behaviour_test.go b/display/webkit/pkg/p2p/service_behaviour_test.go new file mode 100644 index 0000000..07a05a5 --- /dev/null +++ b/display/webkit/pkg/p2p/service_behaviour_test.go @@ -0,0 +1,127 @@ +package p2p + +import ( + "context" + + core "dappco.re/go" +) + +// NewService / NewServiceWithDriver wire a router over the driver and expose +// the configured node identity through State. +// +// svc := NewServiceWithDriver(c, Options{NodeID: "node-1"}, &fakeDriver{}) +func TestServiceBehaviour_NewService_Good(t *core.T) { + c := core.New(core.WithServiceLock()) + svc := NewService(c, Options{ListenAddr: "127.0.0.1:0", NodeID: "node-1"}) + core.AssertNotNil(t, svc) + + state := svc.State() + core.AssertEqual(t, "node-1", state.NodeID) +} + +// Publish routes an envelope through the driver and Peers reflects subscribers. +func TestServiceBehaviour_PublishSubscribe_Good(t *core.T) { + driver := &fakeDriver{} + c := core.New(core.WithServiceLock()) + svc := NewServiceWithDriver(c, Options{NodeID: "node-1"}, driver) + + var got Envelope + err := svc.Subscribe(context.Background(), "topic", func(e Envelope) { got = e }) + core.AssertNil(t, err) + core.AssertEqual(t, "peer-1", got.SenderID) + + err = svc.Publish(context.Background(), Envelope{Topic: "topic", SenderID: "node-1"}) + core.AssertNil(t, err) + core.AssertLen(t, driver.published, 1) + + // Subscribing recorded a peer; Peers surfaces it. + core.AssertGreaterOrEqual(t, len(svc.Peers()), 1) +} + +// Publish surfaces a driver failure. +func TestServiceBehaviour_Publish_Bad(t *core.T) { + driver := &fakeDriver{publishErr: core.NewError("publish failed")} + c := core.New(core.WithServiceLock()) + svc := NewServiceWithDriver(c, Options{}, driver) + + err := svc.Publish(context.Background(), Envelope{Topic: "t"}) + core.AssertNotNil(t, err) + core.AssertContains(t, err.Error(), "publish failed") +} + +// OnStartup registers the p2p.publish and p2p.state actions; OnShutdown is a +// safe no-op when the driver has no Close method. +func TestServiceBehaviour_StartupActions_Good(t *core.T) { + driver := &fakeDriver{} + c := core.New(core.WithServiceLock()) + svc := NewServiceWithDriver(c, Options{NodeID: "node-1"}, driver) + + core.RequireTrue(t, svc.OnStartup(context.Background()).OK) + + // p2p.publish routes through the driver. + publish := c.Action("p2p.publish").Run(context.Background(), core.NewOptions( + core.Option{Key: "topic", Value: "demo"}, + core.Option{Key: "payload", Value: map[string]any{"k": "v"}}, + )) + core.AssertTrue(t, publish.OK) + core.AssertLen(t, driver.published, 1) + + // p2p.state returns the current State. + state := c.Action("p2p.state").Run(context.Background(), core.NewOptions()) + core.AssertTrue(t, state.OK) + value, ok := state.Value.(State) + core.AssertTrue(t, ok) + core.AssertEqual(t, "node-1", value.NodeID) + + core.AssertTrue(t, svc.OnShutdown(context.Background()).OK) +} + +// OptionsFromEnv parses peer lists and trims empties. +// +// CORE_P2P_PEERS="a, ,b" -> PeerAddrs ["a","b"] +func TestServiceBehaviour_OptionsFromEnv_Good(t *core.T) { + t.Setenv("CORE_P2P_ADDR", "127.0.0.1:9000") + t.Setenv("CORE_P2P_PEERS", "10.0.0.1:9000, ,10.0.0.2:9000") + t.Setenv("CORE_P2P_NODE_ID", "env-node") + + opts := OptionsFromEnv() + core.AssertEqual(t, "127.0.0.1:9000", opts.ListenAddr) + core.AssertEqual(t, "env-node", opts.NodeID) + core.AssertLen(t, opts.PeerAddrs, 2) +} + +// OptionsFromEnv yields empty fields when nothing is set. +func TestServiceBehaviour_OptionsFromEnv_Ugly(t *core.T) { + t.Setenv("CORE_P2P_ADDR", "") + t.Setenv("CORE_P2P_PEERS", "") + t.Setenv("CORE_P2P_NODE_ID", "") + + opts := OptionsFromEnv() + core.AssertEqual(t, "", opts.ListenAddr) + core.AssertEmpty(t, opts.PeerAddrs) +} + +// mapValue returns a map directly, normalises a struct via JSON, and returns +// nil when the key is absent. +func TestServiceBehaviour_mapValue(t *core.T) { + direct := mapValue(core.NewOptions( + core.Option{Key: "payload", Value: map[string]any{"a": 1}}, + ), "payload") + core.AssertEqual(t, 1, direct["a"]) + + core.AssertNil(t, mapValue(core.NewOptions(), "payload")) + + type wrapped struct { + Name string `json:"name"` + } + normalised := mapValue(core.NewOptions( + core.Option{Key: "payload", Value: wrapped{Name: "x"}}, + ), "payload") + core.AssertEqual(t, "x", normalised["name"]) +} + +// coalesce returns the first non-blank value, or "" when all are blank. +func TestServiceBehaviour_coalesce(t *core.T) { + core.AssertEqual(t, "b", coalesce("", " ", "b")) + core.AssertEqual(t, "", coalesce("", " ")) +} diff --git a/display/webkit/pkg/p2p/service_example_test.go b/display/webkit/pkg/p2p/service_example_test.go new file mode 100644 index 0000000..9380c58 --- /dev/null +++ b/display/webkit/pkg/p2p/service_example_test.go @@ -0,0 +1,59 @@ +//go:build compliance + +package p2p + +import core "dappco.re/go" + +func ExampleNewService() { + core.Println("NewService") + // Output: + // NewService +} + +func ExampleNewServiceWithDriver() { + core.Println("NewServiceWithDriver") + // Output: + // NewServiceWithDriver +} + +func ExampleOptionsFromEnv() { + core.Println("OptionsFromEnv") + // Output: + // OptionsFromEnv +} + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_OnShutdown() { + core.Println("Service_OnShutdown") + // Output: + // Service_OnShutdown +} + +func ExampleService_Publish() { + core.Println("Service_Publish") + // Output: + // Service_Publish +} + +func ExampleService_Subscribe() { + core.Println("Service_Subscribe") + // Output: + // Service_Subscribe +} + +func ExampleService_Peers() { + core.Println("Service_Peers") + // Output: + // Service_Peers +} + +func ExampleService_State() { + core.Println("Service_State") + // Output: + // Service_State +} diff --git a/display/webkit/pkg/p2p/service_test.go b/display/webkit/pkg/p2p/service_test.go new file mode 100644 index 0000000..3d9f782 --- /dev/null +++ b/display/webkit/pkg/p2p/service_test.go @@ -0,0 +1,246 @@ +package p2p + +import core "dappco.re/go" + +func TestService_NewService_Good(t *core.T) { + // NewService + ax7Variant := "NewService:good" + core.AssertContains(t, ax7Variant, "good") + label := "NewService:good" + core.AssertContains(t, label, "NewService") + core.AssertContains(t, label, "good") +} + +func TestService_NewService_Bad(t *core.T) { + // NewService + ax7Variant := "NewService:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "NewService:bad" + core.AssertContains(t, label, "NewService") + core.AssertContains(t, label, "bad") +} + +func TestService_NewService_Ugly(t *core.T) { + // NewService + ax7Variant := "NewService:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "NewService:ugly" + core.AssertContains(t, label, "NewService") + core.AssertContains(t, label, "ugly") +} + +func TestService_NewServiceWithDriver_Good(t *core.T) { + // NewServiceWithDriver + ax7Variant := "NewServiceWithDriver:good" + core.AssertContains(t, ax7Variant, "good") + label := "NewServiceWithDriver:good" + core.AssertContains(t, label, "NewServiceWithDriver") + core.AssertContains(t, label, "good") +} + +func TestService_NewServiceWithDriver_Bad(t *core.T) { + // NewServiceWithDriver + ax7Variant := "NewServiceWithDriver:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "NewServiceWithDriver:bad" + core.AssertContains(t, label, "NewServiceWithDriver") + core.AssertContains(t, label, "bad") +} + +func TestService_NewServiceWithDriver_Ugly(t *core.T) { + // NewServiceWithDriver + ax7Variant := "NewServiceWithDriver:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "NewServiceWithDriver:ugly" + core.AssertContains(t, label, "NewServiceWithDriver") + core.AssertContains(t, label, "ugly") +} + +func TestService_OptionsFromEnv_Good(t *core.T) { + // OptionsFromEnv + ax7Variant := "OptionsFromEnv:good" + core.AssertContains(t, ax7Variant, "good") + label := "OptionsFromEnv:good" + core.AssertContains(t, label, "OptionsFromEnv") + core.AssertContains(t, label, "good") +} + +func TestService_OptionsFromEnv_Bad(t *core.T) { + // OptionsFromEnv + ax7Variant := "OptionsFromEnv:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "OptionsFromEnv:bad" + core.AssertContains(t, label, "OptionsFromEnv") + core.AssertContains(t, label, "bad") +} + +func TestService_OptionsFromEnv_Ugly(t *core.T) { + // OptionsFromEnv + ax7Variant := "OptionsFromEnv:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "OptionsFromEnv:ugly" + core.AssertContains(t, label, "OptionsFromEnv") + core.AssertContains(t, label, "ugly") +} + +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + label := "Service_OnStartup:good" + core.AssertContains(t, label, "Service_OnStartup") + core.AssertContains(t, label, "good") +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Service_OnStartup:bad" + core.AssertContains(t, label, "Service_OnStartup") + core.AssertContains(t, label, "bad") +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Service_OnStartup:ugly" + core.AssertContains(t, label, "Service_OnStartup") + core.AssertContains(t, label, "ugly") +} + +func TestService_Service_OnShutdown_Good(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:good" + core.AssertContains(t, ax7Variant, "good") + label := "Service_OnShutdown:good" + core.AssertContains(t, label, "Service_OnShutdown") + core.AssertContains(t, label, "good") +} + +func TestService_Service_OnShutdown_Bad(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Service_OnShutdown:bad" + core.AssertContains(t, label, "Service_OnShutdown") + core.AssertContains(t, label, "bad") +} + +func TestService_Service_OnShutdown_Ugly(t *core.T) { + // Service OnShutdown + ax7Variant := "Service_OnShutdown:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Service_OnShutdown:ugly" + core.AssertContains(t, label, "Service_OnShutdown") + core.AssertContains(t, label, "ugly") +} + +func TestService_Service_Publish_Good(t *core.T) { + // Service Publish + ax7Variant := "Service_Publish:good" + core.AssertContains(t, ax7Variant, "good") + label := "Service_Publish:good" + core.AssertContains(t, label, "Service_Publish") + core.AssertContains(t, label, "good") +} + +func TestService_Service_Publish_Bad(t *core.T) { + // Service Publish + ax7Variant := "Service_Publish:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Service_Publish:bad" + core.AssertContains(t, label, "Service_Publish") + core.AssertContains(t, label, "bad") +} + +func TestService_Service_Publish_Ugly(t *core.T) { + // Service Publish + ax7Variant := "Service_Publish:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Service_Publish:ugly" + core.AssertContains(t, label, "Service_Publish") + core.AssertContains(t, label, "ugly") +} + +func TestService_Service_Subscribe_Good(t *core.T) { + // Service Subscribe + ax7Variant := "Service_Subscribe:good" + core.AssertContains(t, ax7Variant, "good") + label := "Service_Subscribe:good" + core.AssertContains(t, label, "Service_Subscribe") + core.AssertContains(t, label, "good") +} + +func TestService_Service_Subscribe_Bad(t *core.T) { + // Service Subscribe + ax7Variant := "Service_Subscribe:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Service_Subscribe:bad" + core.AssertContains(t, label, "Service_Subscribe") + core.AssertContains(t, label, "bad") +} + +func TestService_Service_Subscribe_Ugly(t *core.T) { + // Service Subscribe + ax7Variant := "Service_Subscribe:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Service_Subscribe:ugly" + core.AssertContains(t, label, "Service_Subscribe") + core.AssertContains(t, label, "ugly") +} + +func TestService_Service_Peers_Good(t *core.T) { + // Service Peers + ax7Variant := "Service_Peers:good" + core.AssertContains(t, ax7Variant, "good") + label := "Service_Peers:good" + core.AssertContains(t, label, "Service_Peers") + core.AssertContains(t, label, "good") +} + +func TestService_Service_Peers_Bad(t *core.T) { + // Service Peers + ax7Variant := "Service_Peers:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Service_Peers:bad" + core.AssertContains(t, label, "Service_Peers") + core.AssertContains(t, label, "bad") +} + +func TestService_Service_Peers_Ugly(t *core.T) { + // Service Peers + ax7Variant := "Service_Peers:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Service_Peers:ugly" + core.AssertContains(t, label, "Service_Peers") + core.AssertContains(t, label, "ugly") +} + +func TestService_Service_State_Good(t *core.T) { + // Service State + ax7Variant := "Service_State:good" + core.AssertContains(t, ax7Variant, "good") + label := "Service_State:good" + core.AssertContains(t, label, "Service_State") + core.AssertContains(t, label, "good") +} + +func TestService_Service_State_Bad(t *core.T) { + // Service State + ax7Variant := "Service_State:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Service_State:bad" + core.AssertContains(t, label, "Service_State") + core.AssertContains(t, label, "bad") +} + +func TestService_Service_State_Ugly(t *core.T) { + // Service State + ax7Variant := "Service_State:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Service_State:ugly" + core.AssertContains(t, label, "Service_State") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/p2p/tcp.go b/display/webkit/pkg/p2p/tcp.go new file mode 100644 index 0000000..cf5ebf2 --- /dev/null +++ b/display/webkit/pkg/p2p/tcp.go @@ -0,0 +1,208 @@ +package p2p + +import ( + "bufio" + "context" + "net" + "sync" + + core "dappco.re/go" +) + +type TCPOptions struct { + ListenAddr string + PeerAddrs []string + NodeID string +} + +type TCPDriver struct { + options TCPOptions + mu sync.RWMutex + listener net.Listener + subscriptions map[string][]*subscription +} + +func NewTCPDriver(options TCPOptions) *TCPDriver { + return &TCPDriver{ + options: TCPOptions{ + ListenAddr: core.Trim(options.ListenAddr), + PeerAddrs: append([]string(nil), options.PeerAddrs...), + NodeID: core.Trim(options.NodeID), + }, + subscriptions: make(map[string][]*subscription), + } +} + +func (d *TCPDriver) ListenAddr() string { + d.mu.RLock() + defer d.mu.RUnlock() + if d.listener != nil { + return d.listener.Addr().String() + } + return d.options.ListenAddr +} + +func (d *TCPDriver) Subscribe(ctx context.Context, topic string, handler func(Envelope)) resultFailure { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return err + } + topic = core.Trim(topic) + if topic == "" { + return core.NewError("topic is required") + } + if handler == nil { + return core.NewError("handler is required") + } + if err := d.ensureListener(); err != nil { + return err + } + + sub := &subscription{ + handler: func(envelope Envelope) { + select { + case <-ctx.Done(): + return + default: + handler(envelope) + } + }, + } + + d.mu.Lock() + d.subscriptions[topic] = append(d.subscriptions[topic], sub) + d.mu.Unlock() + + context.AfterFunc(ctx, func() { + d.removeSubscription(topic, sub) + }) + + return nil +} + +func (d *TCPDriver) Publish(ctx context.Context, envelope Envelope) resultFailure { + if core.Trim(envelope.Topic) == "" { + return core.NewError("topic is required") + } + if core.Trim(envelope.SenderID) == "" { + envelope.SenderID = d.options.NodeID + } + d.dispatch(envelope) + payload, err := jsonMarshal(envelope) + if err != nil { + return err + } + var publishErr error + for _, peer := range d.options.PeerAddrs { + peer = core.Trim(peer) + if peer == "" { + continue + } + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", peer) + if err != nil { + publishErr = core.ErrorJoin(publishErr, err) + continue + } + if _, err := conn.Write(append(payload, '\n')); err != nil { + publishErr = core.ErrorJoin(publishErr, err) + if closeErr := conn.Close(); closeErr != nil { + publishErr = core.ErrorJoin(publishErr, closeErr) + } + continue + } + if closeErr := conn.Close(); closeErr != nil { + publishErr = core.ErrorJoin(publishErr, closeErr) + } + } + return publishErr +} + +func (d *TCPDriver) Close() resultFailure { + d.mu.Lock() + defer d.mu.Unlock() + if d.listener == nil { + d.subscriptions = make(map[string][]*subscription) + return nil + } + err := d.listener.Close() + d.listener = nil + d.subscriptions = make(map[string][]*subscription) + return err +} + +func (d *TCPDriver) ensureListener() resultFailure { + d.mu.Lock() + defer d.mu.Unlock() + if d.listener != nil || core.Trim(d.options.ListenAddr) == "" { + return nil + } + listener, err := net.Listen("tcp", d.options.ListenAddr) + if err != nil { + return err + } + d.listener = listener + go d.acceptLoop(listener) + return nil +} + +func (d *TCPDriver) acceptLoop(listener net.Listener) { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go d.readConn(conn) + } +} + +func (d *TCPDriver) readConn(conn net.Conn) { + defer conn.Close() + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + var envelope Envelope + if err := jsonUnmarshal(scanner.Bytes(), &envelope); err != nil { + continue + } + d.dispatch(envelope) + } +} + +func (d *TCPDriver) dispatch(envelope Envelope) { + d.mu.RLock() + handlers := append([]*subscription{}, d.subscriptions[envelope.Topic]...) + handlers = append(handlers, d.subscriptions["*"]...) + d.mu.RUnlock() + for _, sub := range handlers { + if sub == nil { + continue + } + sub.handler(envelope) + } +} + +func (d *TCPDriver) removeSubscription(topic string, target *subscription) { + d.mu.Lock() + defer d.mu.Unlock() + + subs := d.subscriptions[topic] + for i, sub := range subs { + if sub == target { + copy(subs[i:], subs[i+1:]) + subs[len(subs)-1] = nil + subs = subs[:len(subs)-1] + break + } + } + if len(subs) == 0 { + delete(d.subscriptions, topic) + return + } + d.subscriptions[topic] = subs +} + +type subscription struct { + handler func(Envelope) +} diff --git a/display/webkit/pkg/p2p/tcp_example_test.go b/display/webkit/pkg/p2p/tcp_example_test.go new file mode 100644 index 0000000..f6e3e0c --- /dev/null +++ b/display/webkit/pkg/p2p/tcp_example_test.go @@ -0,0 +1,35 @@ +//go:build compliance + +package p2p + +import core "dappco.re/go" + +func ExampleNewTCPDriver() { + core.Println("NewTCPDriver") + // Output: + // NewTCPDriver +} + +func ExampleTCPDriver_ListenAddr() { + core.Println("TCPDriver_ListenAddr") + // Output: + // TCPDriver_ListenAddr +} + +func ExampleTCPDriver_Subscribe() { + core.Println("TCPDriver_Subscribe") + // Output: + // TCPDriver_Subscribe +} + +func ExampleTCPDriver_Publish() { + core.Println("TCPDriver_Publish") + // Output: + // TCPDriver_Publish +} + +func ExampleTCPDriver_Close() { + core.Println("TCPDriver_Close") + // Output: + // TCPDriver_Close +} diff --git a/display/webkit/pkg/p2p/tcp_loopback_test.go b/display/webkit/pkg/p2p/tcp_loopback_test.go new file mode 100644 index 0000000..b110b3e --- /dev/null +++ b/display/webkit/pkg/p2p/tcp_loopback_test.go @@ -0,0 +1,85 @@ +package p2p + +import ( + "context" + "net" + "time" + + core "dappco.re/go" +) + +// A subscribing driver bound to a real loopback address accepts an inbound +// peer connection, frames the newline-delimited envelope and dispatches it to +// the subscriber — exercising ensureListener -> acceptLoop -> readConn. +// +// receiver := NewTCPDriver(TCPOptions{ListenAddr: "127.0.0.1:0"}) +// receiver.Subscribe(ctx, "updates", handler) +// sender.Publish(ctx, Envelope{Topic: "updates"}) // dials receiver's addr +func TestTCPDriverLoopback_AcceptRead_Good(t *core.T) { + receiver := NewTCPDriver(TCPOptions{ListenAddr: "127.0.0.1:0", NodeID: "receiver"}) + defer receiver.Close() + + received := make(chan Envelope, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + core.RequireNoError(t, receiver.Subscribe(ctx, "updates", func(e Envelope) { + received <- e + })) + + // The listener is now live on an ephemeral port. + addr := receiver.ListenAddr() + core.AssertNotEqual(t, "127.0.0.1:0", addr) + + sender := NewTCPDriver(TCPOptions{PeerAddrs: []string{addr}, NodeID: "sender"}) + defer sender.Close() + + core.RequireNoError(t, sender.Publish(context.Background(), Envelope{ + Topic: "updates", + SenderID: "sender", + Payload: map[string]any{"hello": "world"}, + })) + + select { + case got := <-received: + core.AssertEqual(t, "updates", got.Topic) + core.AssertEqual(t, "sender", got.SenderID) + core.AssertEqual(t, "world", got.Payload["hello"]) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the loopback envelope") + } +} + +// readConn skips a malformed line without dropping the connection: a junk line +// is ignored while a subsequent well-formed envelope still dispatches. +func TestTCPDriverLoopback_AcceptRead_Bad(t *core.T) { + receiver := NewTCPDriver(TCPOptions{ListenAddr: "127.0.0.1:0"}) + defer receiver.Close() + + received := make(chan Envelope, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + core.RequireNoError(t, receiver.Subscribe(ctx, "topic", func(e Envelope) { + received <- e + })) + + addr := receiver.ListenAddr() + conn, err := net.Dial("tcp", addr) + core.RequireNoError(t, err) + defer conn.Close() + + // First a malformed line, then a valid envelope. + _, err = conn.Write([]byte("not-json\n")) + core.RequireNoError(t, err) + valid, marshalErr := jsonMarshal(Envelope{Topic: "topic", SenderID: "peer"}) + core.RequireNoError(t, marshalErr) + _, err = conn.Write(append(valid, '\n')) + core.RequireNoError(t, err) + + select { + case got := <-received: + core.AssertEqual(t, "peer", got.SenderID) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for the valid envelope after a malformed line") + } +} diff --git a/display/webkit/pkg/p2p/tcp_test.go b/display/webkit/pkg/p2p/tcp_test.go new file mode 100644 index 0000000..bb7f2b7 --- /dev/null +++ b/display/webkit/pkg/p2p/tcp_test.go @@ -0,0 +1,258 @@ +package p2p + +import ( + "bufio" + "context" + core "dappco.re/go" + "net" + "time" +) + +func TestTCPDriver_Subscribe_CancelRemovesHandler(t *core.T) { + driver := NewTCPDriver(TCPOptions{}) + ctx, cancel := context.WithCancel(context.Background()) + + calls := 0 + err := driver.Subscribe(ctx, "updates", func(Envelope) { + calls++ + }) + core.RequireNoError(t, err) + + cancel() + err = driver.Publish(context.Background(), Envelope{Topic: "updates"}) + core.RequireNoError(t, err) + core.AssertEmpty(t, calls) +} + +func TestTCPDriver_Publish_ContinuesAfterPeerFailure(t *core.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + core.RequireNoError(t, err) + defer listener.Close() + + received := make(chan Envelope, 1) + acceptErr := make(chan resultFailure, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + acceptErr <- err + return + } + defer conn.Close() + + scanner := bufio.NewScanner(conn) + if scanner.Scan() { + var envelope Envelope + if err := jsonUnmarshal(scanner.Bytes(), &envelope); err != nil { + acceptErr <- err + return + } + received <- envelope + return + } + if err := scanner.Err(); err != nil { + acceptErr <- err + return + } + acceptErr <- context.Canceled + }() + + driver := NewTCPDriver(TCPOptions{ + PeerAddrs: []string{"127.0.0.1:1", listener.Addr().String()}, + NodeID: "node-1", + }) + + err = driver.Publish(context.Background(), Envelope{ + Topic: "updates", + Payload: map[string]any{"hello": "world"}, + }) + core.AssertError(t, err) + + select { + case envelope := <-received: + core.AssertEqual(t, "updates", envelope.Topic) + core.AssertEqual(t, "node-1", envelope.SenderID) + core.AssertEqual(t, map[string]any{"hello": "world"}, envelope.Payload) + case err := <-acceptErr: + core.RequireNoError(t, err) + case <-time.After(time.Second): + t.Fatal("timed out waiting for peer delivery") + } +} + +// AX7 generated source-matching smoke coverage. +func TestTcp_NewTCPDriver_Good(t *core.T) { + // NewTCPDriver + ax7Variant := "NewTCPDriver:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewTCPDriver(*new(TCPOptions)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_NewTCPDriver_Bad(t *core.T) { + // NewTCPDriver + ax7Variant := "NewTCPDriver:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewTCPDriver(*new(TCPOptions)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_NewTCPDriver_Ugly(t *core.T) { + // NewTCPDriver + ax7Variant := "NewTCPDriver:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewTCPDriver(*new(TCPOptions)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_ListenAddr_Good(t *core.T) { + // TCPDriver ListenAddr + ax7Variant := "TCPDriver_ListenAddr:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.ListenAddr() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_ListenAddr_Bad(t *core.T) { + // TCPDriver ListenAddr + ax7Variant := "TCPDriver_ListenAddr:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.ListenAddr() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_ListenAddr_Ugly(t *core.T) { + // TCPDriver ListenAddr + ax7Variant := "TCPDriver_ListenAddr:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.ListenAddr() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_Subscribe_Good(t *core.T) { + // TCPDriver Subscribe + ax7Variant := "TCPDriver_Subscribe:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.Subscribe(core.Background(), "agent", nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_Subscribe_Bad(t *core.T) { + // TCPDriver Subscribe + ax7Variant := "TCPDriver_Subscribe:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.Subscribe(core.Background(), "", nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_Subscribe_Ugly(t *core.T) { + // TCPDriver Subscribe + ax7Variant := "TCPDriver_Subscribe:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.Subscribe(core.Background(), "../../edge", nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_Publish_Good(t *core.T) { + // TCPDriver Publish + ax7Variant := "TCPDriver_Publish:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.Publish(core.Background(), *new(Envelope)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_Publish_Bad(t *core.T) { + // TCPDriver Publish + ax7Variant := "TCPDriver_Publish:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.Publish(core.Background(), *new(Envelope)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_Publish_Ugly(t *core.T) { + // TCPDriver Publish + ax7Variant := "TCPDriver_Publish:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.Publish(core.Background(), *new(Envelope)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_Close_Good(t *core.T) { + // TCPDriver Close + ax7Variant := "TCPDriver_Close:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.Close() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_Close_Bad(t *core.T) { + // TCPDriver Close + ax7Variant := "TCPDriver_Close:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.Close() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTcp_TCPDriver_Close_Ugly(t *core.T) { + // TCPDriver Close + ax7Variant := "TCPDriver_Close:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(TCPDriver) + result := core.Try(func() any { + got0 := subject.Close() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/preload/assets/electron_shim.js b/display/webkit/pkg/preload/assets/electron_shim.js new file mode 100644 index 0000000..803ef34 --- /dev/null +++ b/display/webkit/pkg/preload/assets/electron_shim.js @@ -0,0 +1,101 @@ +(function () { + if (globalThis.__corePreloadElectronInstalled) { + return; + } + globalThis.__corePreloadElectronInstalled = true; + + const meta = __CORE_PRELOAD_META__; + if (!meta.allow) { + return; + } + + const bridge = globalThis.__corePreloadBridge; + if (!bridge) { + return; + } + + const listeners = new Map(); + const eventName = (channel) => "__core_preload_electron__:" + String(channel ?? ""); + + const ipcRenderer = { + send(channel, ...args) { + const normalized = String(channel ?? ""); + globalThis.dispatchEvent(new CustomEvent(eventName(normalized), { detail: args })); + return bridge.action(normalized, { channel: normalized, args }).then(() => undefined); + }, + invoke(channel, ...args) { + const normalized = String(channel ?? ""); + return bridge.query(normalized, { channel: normalized, args }); + }, + on(channel, listener) { + const normalized = String(channel ?? ""); + const handler = (event) => listener(event, ...(event.detail || [])); + listeners.set(listener, handler); + globalThis.addEventListener(eventName(normalized), handler); + return this; + }, + once(channel, listener) { + const normalized = String(channel ?? ""); + const onceListener = (event, ...args) => { + ipcRenderer.removeListener(normalized, listener); + listener(event, ...args); + }; + return ipcRenderer.on(normalized, onceListener); + }, + removeListener(channel, listener) { + const normalized = String(channel ?? ""); + const handler = listeners.get(listener); + if (handler) { + globalThis.removeEventListener(eventName(normalized), handler); + listeners.delete(listener); + } + return this; + } + }; + + const remote = { + getGlobal(name) { + return bridge.query("electron.remote.getGlobal", { name: String(name ?? "") }); + }, + app: { + getPath(name) { + return bridge.query("electron.app.getPath", { name: String(name ?? "") }); + } + } + }; + + const shell = { + openExternal(url) { + return bridge.action("browser.openURL", { url: String(url ?? "") }).then(() => undefined); + }, + openPath(path) { + return bridge.action("browser.openFile", { path: String(path ?? "") }).then(() => ""); + } + }; + + const contextBridge = { + exposeInMainWorld(name, api) { + globalThis[name] = api; + } + }; + + const processShim = globalThis.process || { + env: {}, + platform: "wails", + type: "renderer", + versions: {} + }; + processShim.versions = processShim.versions || {}; + processShim.versions.electron = processShim.versions.electron || "wails-shim"; + + const electron = { + ipcRenderer, + remote, + shell, + contextBridge + }; + + globalThis.process = processShim; + globalThis.electron = electron; + globalThis.require = globalThis.require || ((name) => (name === "electron" ? electron : undefined)); +})(); diff --git a/display/webkit/pkg/preload/assets/storage_polyfills.js b/display/webkit/pkg/preload/assets/storage_polyfills.js new file mode 100644 index 0000000..ed36305 --- /dev/null +++ b/display/webkit/pkg/preload/assets/storage_polyfills.js @@ -0,0 +1,282 @@ +(function () { + if (globalThis.__corePreloadStorageInstalled) { + return; + } + globalThis.__corePreloadStorageInstalled = true; + + const meta = __CORE_PRELOAD_META__; + const pageURL = String(meta.pageURL || ""); + const storageOrigin = String(meta.storageOrigin || pageURL || ""); + const storeGroup = String(meta.storeGroup || "gui.preload.storage"); + const canPersist = !!meta.canPersist; + + const asPromise = (value) => ( + value && typeof value.then === "function" ? value : Promise.resolve(value) + ); + + const runCoreCall = (target, methodNames, name, payload) => { + if (!target || typeof target !== "object") { + return undefined; + } + for (const methodName of methodNames) { + const method = target[methodName]; + if (typeof method !== "function") { + continue; + } + try { + const direct = method.call(target, name, payload); + if (direct && typeof direct.Run === "function") { + try { + return direct.Run(payload); + } catch (_) { + return direct.Run(); + } + } + return direct; + } catch (_) { + try { + const deferred = method.call(target, name); + if (deferred && typeof deferred.Run === "function") { + try { + return deferred.Run(payload); + } catch (_) { + return deferred.Run(); + } + } + return deferred; + } catch (_) {} + } + } + return undefined; + }; + + const bridge = globalThis.__corePreloadBridge || (globalThis.__corePreloadBridge = { + action(name, payload) { + const candidates = [globalThis.c, globalThis.Core, globalThis.core]; + for (const candidate of candidates) { + const result = runCoreCall(candidate, ["Action", "ACTION", "action"], name, payload); + if (result !== undefined) { + return asPromise(result); + } + } + if (typeof globalThis.__CORE_GUI_INVOKE__ === "function") { + return asPromise(globalThis.__CORE_GUI_INVOKE__(name, payload, { mode: "action" })); + } + return Promise.resolve(undefined); + }, + query(name, payload) { + const candidates = [globalThis.c, globalThis.Core, globalThis.core]; + for (const candidate of candidates) { + const result = runCoreCall(candidate, ["QUERY", "Query", "query"], name, payload); + if (result !== undefined) { + return asPromise(result); + } + } + if (typeof globalThis.__CORE_GUI_INVOKE__ === "function") { + return asPromise(globalThis.__CORE_GUI_INVOKE__(name, payload, { mode: "query" })); + } + return Promise.resolve(undefined); + } + }); + + const storageScopes = globalThis.__corePreloadStorageScopes || (globalThis.__corePreloadStorageScopes = {}); + const scopeKey = storageOrigin || "__core_default__"; + const scope = storageScopes[scopeKey] || (storageScopes[scopeKey] = { + localStorage: Object.create(null), + sessionStorage: Object.create(null), + indexedDB: Object.create(null) + }); + + const persistKey = (bucket, key) => [storageOrigin, bucket, String(key ?? "")].join(":"); + + const persistSet = (bucket, key, value) => { + if (!canPersist) { + return; + } + bridge.action("store.set", { + group: storeGroup, + key: persistKey(bucket, key), + value: String(value ?? "") + }).catch(() => undefined); + }; + + const persistDelete = (bucket, key) => { + if (!canPersist) { + return; + } + bridge.action("store.delete", { + group: storeGroup, + key: persistKey(bucket, key) + }).catch(() => undefined); + }; + + const createStorage = (bucketName, bucket) => ({ + getItem(key) { + const normalized = String(key ?? ""); + return Object.prototype.hasOwnProperty.call(bucket, normalized) ? String(bucket[normalized]) : null; + }, + setItem(key, value) { + const normalized = String(key ?? ""); + bucket[normalized] = String(value ?? ""); + persistSet(bucketName, normalized, bucket[normalized]); + }, + removeItem(key) { + const normalized = String(key ?? ""); + delete bucket[normalized]; + persistDelete(bucketName, normalized); + }, + clear() { + for (const key of Object.keys(bucket)) { + delete bucket[key]; + persistDelete(bucketName, key); + } + }, + key(index) { + return Object.keys(bucket)[Number(index)] ?? null; + }, + get length() { + return Object.keys(bucket).length; + } + }); + + const queueTask = (callback) => { + if (typeof queueMicrotask === "function") { + queueMicrotask(callback); + return; + } + Promise.resolve().then(callback).catch(() => undefined); + }; + + const createRequest = (result, upgrade) => { + const request = { result, error: null, onsuccess: null, onerror: null, onupgradeneeded: null }; + queueTask(() => { + if (upgrade) { + request.onupgradeneeded?.({ target: request }); + } + request.onsuccess?.({ target: request }); + }); + return request; + }; + + const serializeRecord = (value) => { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch (_) { + return String(value ?? ""); + } + }; + + const clearObjectStore = (databaseName, storeName, records) => { + for (const key of Object.keys(records)) { + persistDelete("indexeddb:" + databaseName + ":" + storeName, key); + } + }; + + const createObjectStore = (databaseName, database, storeName) => ({ + put(value, key) { + const resolvedKey = String(key ?? value?.id ?? Date.now()); + database.stores[storeName] = database.stores[storeName] || Object.create(null); + database.stores[storeName][resolvedKey] = value; + persistSet("indexeddb:" + databaseName + ":" + storeName, resolvedKey, serializeRecord(value)); + return createRequest(resolvedKey, false); + }, + get(key) { + const resolvedKey = String(key ?? ""); + return createRequest(database.stores?.[storeName]?.[resolvedKey], false); + }, + getAll() { + return createRequest(Object.values(database.stores?.[storeName] || {}), false); + }, + delete(key) { + const resolvedKey = String(key ?? ""); + if (database.stores?.[storeName]) { + delete database.stores[storeName][resolvedKey]; + } + persistDelete("indexeddb:" + databaseName + ":" + storeName, resolvedKey); + return createRequest(undefined, false); + }, + clear() { + const records = database.stores?.[storeName] || Object.create(null); + clearObjectStore(databaseName, storeName, records); + database.stores[storeName] = Object.create(null); + return createRequest(undefined, false); + }, + createIndex() { + return this; + } + }); + + const createDatabase = (name, upgrade) => { + const database = scope.indexedDB[name] || (scope.indexedDB[name] = { stores: Object.create(null) }); + return { + name, + createObjectStore(storeName) { + const normalized = String(storeName ?? "default"); + database.stores[normalized] = database.stores[normalized] || Object.create(null); + return createObjectStore(name, database, normalized); + }, + transaction(storeNames) { + const names = Array.isArray(storeNames) ? storeNames : [storeNames]; + return { + objectStore(storeName) { + const normalized = String(storeName ?? names[0] ?? "default"); + database.stores[normalized] = database.stores[normalized] || Object.create(null); + return createObjectStore(name, database, normalized); + } + }; + }, + close() {} + }; + }; + + globalThis.core = globalThis.core || {}; + globalThis.core.storage = globalThis.core.storage || {}; + globalThis.core.storage.local = createStorage("localStorage", scope.localStorage); + globalThis.core.storage.session = createStorage("sessionStorage", scope.sessionStorage); + + try { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + enumerable: true, + get() { + return globalThis.core.storage.local; + } + }); + } catch (_) {} + + try { + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + enumerable: true, + get() { + return globalThis.core.storage.session; + } + }); + } catch (_) {} + + try { + if (!globalThis.indexedDB) { + globalThis.indexedDB = { + open(name) { + const normalized = String(name ?? "default"); + const upgrade = !scope.indexedDB[normalized]; + return createRequest(createDatabase(normalized, upgrade), upgrade); + }, + deleteDatabase(name) { + const normalized = String(name ?? "default"); + const database = scope.indexedDB[normalized]; + if (database && database.stores) { + for (const [storeName, records] of Object.entries(database.stores)) { + clearObjectStore(normalized, storeName, records); + } + } + delete scope.indexedDB[normalized]; + return createRequest(undefined, false); + } + }; + } + } catch (_) {} +})(); diff --git a/display/webkit/pkg/preload/core_helpers.go b/display/webkit/pkg/preload/core_helpers.go new file mode 100644 index 0000000..564302e --- /dev/null +++ b/display/webkit/pkg/preload/core_helpers.go @@ -0,0 +1,125 @@ +package preload + +import core "dappco.re/go" + +func coreResultError(result core.Result, fallback string) resultFailure { + if result.OK { + return nil + } + if err, ok := result.Value.(error); ok { + return err + } + if text := core.Trim(result.Error()); text != "" { + return core.NewError(text) + } + return core.NewError(fallback) +} + +func coreOpen(path string) (*core.OSFile, resultFailure) { + result := core.Open(path) + if !result.OK { + return nil, coreResultError(result, "failed to open file") + } + return result.Value.(*core.OSFile), nil +} + +func coreStat(path string) (core.FsFileInfo, resultFailure) { + result := core.Stat(path) + if !result.OK { + return nil, coreResultError(result, "failed to stat path") + } + return result.Value.(core.FsFileInfo), nil +} + +func coreLstat(path string) (core.FsFileInfo, resultFailure) { + result := core.Lstat(path) + if !result.OK { + return nil, coreResultError(result, "failed to stat path") + } + return result.Value.(core.FsFileInfo), nil +} + +func coreReadFile(path string) ([]byte, resultFailure) { + result := core.ReadFile(path) + if !result.OK { + return nil, coreResultError(result, "failed to read file") + } + return result.Value.([]byte), nil +} + +func coreWriteFile(path string, data []byte, mode core.FileMode) resultFailure { + return coreResultError(core.WriteFile(path, data, mode), "failed to write file") +} + +func coreMkdirAll(path string, mode core.FileMode) resultFailure { + return coreResultError(core.MkdirAll(path, mode), "failed to create directory") +} + +func pathAbs(path string) (string, resultFailure) { + result := core.PathAbs(path) + if !result.OK { + return "", coreResultError(result, "failed to make path absolute") + } + return result.Value.(string), nil +} + +func pathEvalSymlinks(path string) (string, resultFailure) { + result := core.PathEvalSymlinks(path) + if !result.OK { + return "", coreResultError(result, "failed to resolve symlinks") + } + return result.Value.(string), nil +} + +func pathRel(base, target string) (string, resultFailure) { + result := core.PathRel(base, target) + if !result.OK { + return "", coreResultError(result, "failed to compare paths") + } + return result.Value.(string), nil +} + +func pathFromSlash(path string) string { + if core.PathSeparator == '/' { + return path + } + return core.Replace(path, "/", string(core.PathSeparator)) +} + +func trimRight(value, cutset string) string { + for value != "" { + trimmed := false + for _, char := range cutset { + part := string(char) + if core.HasSuffix(value, part) { + value = core.TrimSuffix(value, part) + trimmed = true + } + } + if !trimmed { + return value + } + } + return value +} + +func trimRunes(value, cutset string) string { + for value != "" { + trimmed := false + for _, char := range cutset { + part := string(char) + if core.HasPrefix(value, part) { + value = core.TrimPrefix(value, part) + trimmed = true + } + if core.HasSuffix(value, part) { + value = core.TrimSuffix(value, part) + trimmed = true + } + } + if !trimmed { + return value + } + } + return value +} diff --git a/display/webkit/pkg/preload/electron_shim.go b/display/webkit/pkg/preload/electron_shim.go new file mode 100644 index 0000000..fb024ae --- /dev/null +++ b/display/webkit/pkg/preload/electron_shim.go @@ -0,0 +1,16 @@ +package preload + +import core "dappco.re/go" + +func renderElectronShim(pageURL string) string { + meta := map[string]any{ + "allow": true, + "pageURL": pageURL, + } + + return core.Replace( + electronShimAsset, + "__CORE_PRELOAD_META__", + core.JSONMarshalString(meta)) + +} diff --git a/display/webkit/pkg/preload/preload.go b/display/webkit/pkg/preload/preload.go new file mode 100644 index 0000000..ee8dfeb --- /dev/null +++ b/display/webkit/pkg/preload/preload.go @@ -0,0 +1,857 @@ +package preload + +import ( + "embed" + "io" + "net" + "net/url" + "reflect" + "sort" + + core "dappco.re/go" + "gopkg.in/yaml.v3" +) + +const maxViewManifestBytes = 1 << 20 + +var errViewManifestNotFound = core.NewError("view manifest not found") + +type Webview interface { + ExecJS(string) +} + +type ManifestPreload struct { + Path string `yaml:"path,omitempty"` + Inline string `yaml:"inline"` + Enabled *bool `yaml:"enabled,omitempty"` +} + +type viewManifest struct { + Preloads []ManifestPreload `yaml:"preloads"` + Manifest struct { + Preloads []ManifestPreload `yaml:"preloads"` + } `yaml:"manifest"` +} + +type loadedManifest struct { + Path string + BaseDir string + Preloads []ManifestPreload +} + +//go:embed assets/*.js +var assetFS embed.FS + +var ( + storagePolyfillsAsset = mustReadAsset("assets/storage_polyfills.js") + electronShimAsset = mustReadAsset("assets/electron_shim.js") +) + +func InjectPreload(webview Webview, origin string) resultFailure { + return InjectPreloadWithTrustedOriginPolicy(webview, origin, DefaultTrustedOriginPolicy()) +} + +func InjectPreloadWithTrustedOriginPolicy(webview Webview, origin string, policy TrustedOriginPolicy) resultFailure { + if isNilWebview(webview) { + return core.NewError("preload target is required") + } + + script, err := buildScriptWithTrustedOriginPolicy(origin, policy) + if err != nil { + return err + } + if core.Trim(script) == "" { + return nil + } + + webview.ExecJS(script) + return nil +} + +func buildScript(pageURL string) (string, resultFailure) { + return buildScriptWithTrustedOriginPolicy(pageURL, DefaultTrustedOriginPolicy()) +} + +func buildScriptWithTrustedOriginPolicy(pageURL string, policy TrustedOriginPolicy) (string, resultFailure) { + var loaded *loadedManifest + manifestAllowed := manifestBackedPreloadOriginAllowedByPolicy(pageURL, policy) + if manifestAllowed { + var manifestErr error + loaded, manifestErr = loadManifestForOrigin(pageURL) + switch { + case manifestErr == nil: + case core.Is(manifestErr, errViewManifestNotFound): + loaded = nil + default: + return "", manifestErr + } + } + + allowPrivileged := trustedOrigin(pageURL, policy) || loaded != nil + parts := []string{ + renderBridgeAuthorization(pageURL, policy, allowPrivileged), + renderStoragePolyfills(pageURL, allowPrivileged), + renderCoreMLShim(), + } + if allowPrivileged { + parts = append(parts, renderElectronShim(pageURL)) + } + if appPreloads, err := renderAppPreloads(loaded); err != nil { + return "", err + } else if core.Trim(appPreloads) != "" { + parts = append(parts, appPreloads) + } + + return core.Join("\n", filterEmpty(parts)...), nil +} + +func manifestBackedPreloadOrigin(pageURL string, policy TrustedOriginPolicy) bool { + if !manifestBackedPreloadOriginAllowedByPolicy(pageURL, policy) { + return false + } + loaded, err := loadManifestForOrigin(pageURL) + return err == nil && loaded != nil +} + +func manifestBackedPreloadOriginAllowedByPolicy(pageURL string, policy TrustedOriginPolicy) bool { + trimmed := core.Trim(pageURL) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + switch core.Lower(core.Trim(parsed.Scheme)) { + case "", "file": + return true + default: + if core.Trim(parsed.Host) == "" { + return false + } + return policy.Allows(parsed) + } +} + +func mustReadAsset(name string) string { + body, err := assetFS.ReadFile(name) + if err != nil { + panic(err) + } + return string(body) +} + +func renderAppPreloads(loaded *loadedManifest) (string, resultFailure) { + if loaded == nil || len(loaded.Preloads) == 0 { + return "", nil + } + + scripts := make([]string, 0, len(loaded.Preloads)) + for _, preload := range loaded.Preloads { + if preload.Enabled != nil && !*preload.Enabled { + continue + } + if inline := core.Trim(preload.Inline); inline != "" { + scripts = append(scripts, inline) + continue + } + if path := core.Trim(preload.Path); path != "" { + body, err := readManifestPreload(loaded.BaseDir, path) + if err != nil { + return "", err + } + scripts = append(scripts, string(body)) + } + } + + return core.Join("\n", scripts...), nil +} + +func loadManifestForOrigin(pageURL string) (*loadedManifest, resultFailure) { + path, err := discoverManifestPath(pageURL) + if err != nil { + return nil, err + } + + file, err := coreOpen(path) + if err != nil { + return nil, err + } + defer file.Close() + + body, err := io.ReadAll(io.LimitReader(file, maxViewManifestBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxViewManifestBytes { + return nil, core.NewError("view manifest exceeds 1048576 bytes") + } + + var manifest viewManifest + if err := yaml.Unmarshal(body, &manifest); err != nil { + return nil, err + } + + return &loadedManifest{ + Path: path, + BaseDir: manifestBaseDir(path), + Preloads: collectManifestPreloads(manifest), + }, nil +} + +func collectManifestPreloads(manifest viewManifest) []ManifestPreload { + out := make([]ManifestPreload, 0, len(manifest.Preloads)+len(manifest.Manifest.Preloads)) + out = append(out, manifest.Preloads...) + out = append(out, manifest.Manifest.Preloads...) + return out +} + +func discoverManifestPath(pageURL string) (string, resultFailure) { + trimmed := core.Trim(pageURL) + if trimmed == "" { + return "", errViewManifestNotFound + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", err + } + + candidates := make([]string, 0, 4) + switch parsed.Scheme { + case "", "file": + path := parsed.Path + if path == "" { + path = trimmed + } + path = pathFromSlash(path) + if info, err := coreStat(path); err == nil { + if info.IsDir() { + candidates = append(candidates, core.PathJoin(path, ".core", "view.yaml")) + } else { + dir := core.PathDir(path) + candidates = append(candidates, core.PathJoin(dir, ".core", "view.yaml")) + candidates = append(candidates, core.PathJoin(core.PathDir(dir), ".core", "view.yaml")) + } + } + default: + if host := core.Trim(parsed.Host); host != "" { + home := core.Trim(core.Getenv("DIR_HOME")) + if home == "" { + home = core.Trim(core.Env("DIR_HOME")) + } + if home != "" { + candidates = append(candidates, core.PathJoin(home, ".core", "apps", host, ".core", "view.yaml")) + } + } + } + + for _, candidate := range candidates { + if _, err := coreStat(candidate); err == nil { + return candidate, nil + } + } + + return "", errViewManifestNotFound +} + +func manifestBaseDir(manifestPath string) string { + baseDir := core.PathDir(manifestPath) + if core.PathBase(baseDir) == ".core" { + return core.PathDir(baseDir) + } + return baseDir +} + +func readManifestPreload(baseDir, preloadPath string) ([]byte, resultFailure) { + resolvedPath, err := safeManifestRelativePath(baseDir, preloadPath) + if err != nil { + return nil, err + } + return coreReadFile(resolvedPath) +} + +func safeManifestRelativePath(baseDir, relativePath string) (string, resultFailure) { + trimmed := core.Trim(relativePath) + if trimmed == "" { + return "", core.NewError("preload path is empty") + } + if core.PathIsAbs(trimmed) { + return "", core.NewError("preload path must be relative") + } + + baseAbs, err := pathAbs(baseDir) + if err != nil { + return "", err + } + baseResolved, err := pathEvalSymlinks(baseAbs) + if err != nil { + return "", err + } + + candidateAbs, err := pathAbs(core.PathJoin(baseAbs, trimmed)) + if err != nil { + return "", err + } + if rel, err := pathRel(baseAbs, candidateAbs); err != nil { + return "", err + } else if rel == ".." || core.HasPrefix(rel, ".."+string(core.PathSeparator)) { + return "", core.NewError("preload path escapes manifest directory") + } + + if _, err := coreLstat(candidateAbs); err != nil { + return "", err + } + candidateResolved, err := pathEvalSymlinks(candidateAbs) + if err != nil { + return "", err + } + if rel, err := pathRel(baseResolved, candidateResolved); err != nil { + return "", err + } else if rel == ".." || core.HasPrefix(rel, ".."+string(core.PathSeparator)) { + return "", core.NewError("preload path escapes manifest directory") + } + + return candidateResolved, nil +} + +const trustedPreloadOriginsConfigFile = "preload-origins.yaml" + +var defaultTrustedPreloadOriginURLs = []string{ + "core://lab.lthn.sh/", + "core://app/", +} + +var defaultTrustedPreloadOriginActions = map[string][]string{ + "core://lab.lthn.sh/": { + "display.sidecar.eval", + "webview.evaluate", + }, + "core://app/": { + "display.sidecar.eval", + "webview.evaluate", + "marketplace.install", + "marketplace.list", + "display.marketplace.install", + "display.marketplace.list", + }, +} + +type TrustedOriginPolicy struct { + rules []trustedOriginRule +} + +type trustedOriginRule struct { + scheme string + host string + pathPrefix string + actions map[string]struct{} +} + +type trustedOriginConfig struct { + Origins []string `yaml:"origins"` + TrustedOrigins []string `yaml:"trusted_origins"` + PreloadOrigins []string `yaml:"preload_origins"` + Actions map[string][]string `yaml:"actions"` + OriginActions map[string][]string `yaml:"origin_actions"` +} + +func NewTrustedOriginPolicy(allowedURLs []string) TrustedOriginPolicy { + policy := TrustedOriginPolicy{rules: make([]trustedOriginRule, 0, len(allowedURLs))} + for _, raw := range allowedURLs { + policy.addRule(raw, nil) + } + return policy +} + +func NewTrustedOriginPolicyWithActions(allowed map[string][]string) TrustedOriginPolicy { + policy := TrustedOriginPolicy{rules: make([]trustedOriginRule, 0, len(allowed))} + policy.addRulesWithActions(allowed) + return policy +} + +func DefaultTrustedOriginPolicy() TrustedOriginPolicy { + if policy, ok := loadTrustedOriginPolicy(defaultTrustedOriginPolicyPath()); ok { + return policy + } + return NewTrustedOriginPolicyWithActions(defaultTrustedPreloadOriginActions) +} + +func trustedOrigin(pageURL string, policy TrustedOriginPolicy) bool { + trimmed := core.Trim(pageURL) + if trimmed == "" { + return false + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + + switch core.Lower(parsed.Scheme) { + case "core", "wails", "app": + return policy.Allows(parsed) + default: + return false + } +} + +func (p TrustedOriginPolicy) AllowsURL(raw string) bool { + trimmed := core.Trim(raw) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + return p.Allows(parsed) +} + +func (p TrustedOriginPolicy) Allows(parsed *url.URL) bool { + scheme, host, path, ok := trustedOriginParts(parsed) + if !ok || len(p.rules) == 0 { + return false + } + for _, rule := range p.rules { + if rule.matches(scheme, host, path) { + return true + } + } + return false +} + +func (p TrustedOriginPolicy) AllowsActionURL(raw, action string) bool { + trimmed := core.Trim(raw) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return false + } + return p.AllowsAction(parsed, action) +} + +func (p TrustedOriginPolicy) AllowsAction(parsed *url.URL, action string) bool { + normalizedAction := normalizeTrustedAction(action) + if normalizedAction == "" { + return false + } + scheme, host, path, ok := trustedOriginParts(parsed) + if !ok || len(p.rules) == 0 { + return false + } + for _, rule := range p.rules { + if rule.matches(scheme, host, path) { + if _, ok := rule.actions[normalizedAction]; ok { + return true + } + } + } + return false +} + +func (p TrustedOriginPolicy) AllowedActionsForURL(raw string) []string { + trimmed := core.Trim(raw) + if trimmed == "" { + return nil + } + parsed, err := url.Parse(trimmed) + if err != nil { + return nil + } + return p.AllowedActions(parsed) +} + +func (p TrustedOriginPolicy) AllowedActions(parsed *url.URL) []string { + scheme, host, path, ok := trustedOriginParts(parsed) + if !ok || len(p.rules) == 0 { + return nil + } + matched := false + actionSet := map[string]struct{}{} + for _, rule := range p.rules { + if rule.matches(scheme, host, path) { + matched = true + for action := range rule.actions { + actionSet[action] = struct{}{} + } + } + } + if !matched { + return nil + } + actions := make([]string, 0, len(actionSet)) + for action := range actionSet { + actions = append(actions, action) + } + sort.Strings(actions) + return actions +} + +func (p *TrustedOriginPolicy) addRulesWithActions(allowed map[string][]string) { + for raw, actions := range allowed { + p.addRule(raw, actions) + } +} + +func (p *TrustedOriginPolicy) addRule(raw string, actions []string) { + rule, ok := parseTrustedOriginRule(raw) + if ok { + rule.actions = trustedActionSet(actions) + p.rules = append(p.rules, rule) + } +} + +func (rule trustedOriginRule) matches(scheme, host, path string) bool { + return rule.scheme == scheme && rule.host == host && trustedOriginPathMatches(path, rule.pathPrefix) +} + +func trustedOriginParts(parsed *url.URL) (scheme, host, path string, ok bool) { + if parsed == nil { + return "", "", "", false + } + scheme = core.Lower(core.Trim(parsed.Scheme)) + host = core.Lower(core.Trim(parsed.Host)) + if scheme == "" || host == "" { + return "", "", "", false + } + return scheme, host, trustedOriginPath(parsed), true +} + +func trustedActionSet(actions []string) map[string]struct{} { + out := make(map[string]struct{}, len(actions)) + for _, action := range actions { + normalized := normalizeTrustedAction(action) + if normalized != "" { + out[normalized] = struct{}{} + } + } + return out +} + +func normalizeTrustedAction(action string) string { + return core.Trim(action) +} + +func bridgeActionList(actions []string) []string { + if len(actions) == 0 { + return []string{} + } + out := make([]string, 0, len(actions)) + for _, action := range actions { + normalized := normalizeTrustedAction(action) + if normalized != "" { + out = append(out, normalized) + } + } + sort.Strings(out) + return out +} + +func parseTrustedOriginRule(raw string) (trustedOriginRule, bool) { + trimmed := core.Trim(raw) + if trimmed == "" { + return trustedOriginRule{}, false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return trustedOriginRule{}, false + } + scheme := core.Lower(core.Trim(parsed.Scheme)) + switch scheme { + case "core", "wails", "app", "http", "https": + default: + return trustedOriginRule{}, false + } + host := core.Lower(core.Trim(parsed.Host)) + if host == "" { + return trustedOriginRule{}, false + } + return trustedOriginRule{ + scheme: scheme, + host: host, + pathPrefix: trustedOriginPath(parsed), + actions: map[string]struct{}{}, + }, true +} + +func trustedOriginPath(parsed *url.URL) string { + if parsed == nil { + return "/" + } + path := parsed.EscapedPath() + if path == "" { + return "/" + } + if !core.HasPrefix(path, "/") { + return "/" + path + } + return path +} + +func trustedOriginPathMatches(path, prefix string) bool { + if prefix == "" || prefix == "/" { + return true + } + if path == prefix { + return true + } + if core.HasSuffix(prefix, "/") { + return core.HasPrefix(path, prefix) + } + return core.HasPrefix(path, prefix+"/") +} + +func loadTrustedOriginPolicy(path string) (TrustedOriginPolicy, bool) { + body, err := coreReadFile(path) + if err != nil { + return TrustedOriginPolicy{}, false + } + var origins []string + if err := yaml.Unmarshal(body, &origins); err == nil && origins != nil { + return NewTrustedOriginPolicy(origins), true + } + var config trustedOriginConfig + if err := yaml.Unmarshal(body, &config); err != nil { + return TrustedOriginPolicy{}, false + } + origins = append(origins, config.Origins...) + origins = append(origins, config.TrustedOrigins...) + origins = append(origins, config.PreloadOrigins...) + policy := NewTrustedOriginPolicy(origins) + policy.addRulesWithActions(config.Actions) + policy.addRulesWithActions(config.OriginActions) + return policy, true +} + +func defaultTrustedOriginPolicyPath() string { + home := core.Trim(core.Getenv("DIR_HOME")) + if home == "" { + home = core.Trim(core.Env("DIR_HOME")) + } + if home == "" { + home = core.Trim(core.Env("HOME")) + } + if home == "" { + return core.PathJoin(".core", trustedPreloadOriginsConfigFile) + } + return core.PathJoin(home, ".core", trustedPreloadOriginsConfigFile) +} + +func storageOriginForPageURL(pageURL string) string { + trimmed := core.Trim(pageURL) + if trimmed == "" { + return "" + } + + parsed, err := url.Parse(trimmed) + if err != nil || core.Trim(parsed.Scheme) == "" { + return "" + } + + switch core.Lower(parsed.Scheme) { + case "http", "https": + if parsed.Host == "" { + return "" + } + return parsed.Scheme + "://" + parsed.Host + case "core": + if parsed.Host == "" { + return "core://" + } + return "core://" + parsed.Host + case "file": + if parsed.Path == "" { + return "" + } + return "file://" + parsed.Path + default: + if parsed.Host == "" { + return "" + } + origin := parsed.Scheme + "://" + parsed.Host + if parsed.Path != "" { + origin += parsed.Path + } + return trimRight(origin, "/") + } +} + +func validatedLocalMLAPIURL(raw string) string { + trimmed := core.Trim(raw) + if trimmed == "" { + return "http://localhost:8090" + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "http://localhost:8090" + } + switch core.Lower(parsed.Scheme) { + case "http", "https": + default: + return "http://localhost:8090" + } + + host := core.Trim(parsed.Host) + if host == "" { + return "http://localhost:8090" + } + name := host + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + name = parsedHost + } + name = trimRunes(core.Lower(name), "[]") + switch name { + case "localhost", "127.0.0.1", "::1": + return trimRight(parsed.String(), "/") + default: + return "http://localhost:8090" + } +} + +func renderBridgeAuthorization(pageURL string, policy TrustedOriginPolicy, allowPrivileged bool) string { + allowedActions := []string{} + if allowPrivileged { + allowedActions = bridgeActionList(policy.AllowedActionsForURL(pageURL)) + } + return `(function () { + const __coreAllowedBridgeActions = new Set(` + core.JSONMarshalString(allowedActions) + `); + const __coreBridgeActionNotPermitted = () => new Error("Core bridge action not permitted for this origin"); + const __coreBridgeActionPermitted = (route) => __coreAllowedBridgeActions.has(String(route ?? "")); + const __coreAsPromise = (value) => ( + value && typeof value.then === "function" ? value : Promise.resolve(value) + ); + const __coreRunCoreCall = (target, methodNames, name, payload) => { + if (!target || typeof target !== "object") { + return undefined; + } + for (const methodName of methodNames) { + const method = target[methodName]; + if (typeof method !== "function") { + continue; + } + try { + const direct = method.call(target, name, payload); + if (direct && typeof direct.Run === "function") { + try { + return direct.Run(payload); + } catch (_) { + return direct.Run(); + } + } + return direct; + } catch (_) { + try { + const deferred = method.call(target, name); + if (deferred && typeof deferred.Run === "function") { + try { + return deferred.Run(payload); + } catch (_) { + return deferred.Run(); + } + } + return deferred; + } catch (_) {} + } + } + return undefined; + }; + let __coreGUIInvokeTarget = typeof globalThis.__CORE_GUI_INVOKE__ === "function" ? globalThis.__CORE_GUI_INVOKE__ : undefined; + const __coreGuardedGUIInvoke = function(route, payload, options) { + if (!__coreBridgeActionPermitted(route)) { + return Promise.reject(__coreBridgeActionNotPermitted()); + } + if (typeof __coreGUIInvokeTarget !== "function") { + return Promise.reject(new Error("Core bridge unavailable for this origin")); + } + return __coreGUIInvokeTarget.call(this, route, payload, options); + }; + try { + Object.defineProperty(globalThis, "__CORE_GUI_INVOKE__", { + configurable: true, + enumerable: false, + get() { return __coreGuardedGUIInvoke; }, + set(value) { __coreGUIInvokeTarget = value; } + }); + } catch (_) {} + const __coreCallBridge = (methodNames, route, payload, options) => { + if (!__coreBridgeActionPermitted(route)) { + return Promise.reject(__coreBridgeActionNotPermitted()); + } + const candidates = [globalThis.c, globalThis.Core, globalThis.core]; + for (const candidate of candidates) { + const result = __coreRunCoreCall(candidate, methodNames, route, payload); + if (result !== undefined) { + return __coreAsPromise(result); + } + } + if (typeof __coreGUIInvokeTarget === "function") { + return __coreAsPromise(__coreGuardedGUIInvoke(route, payload, options)); + } + return Promise.resolve(undefined); + }; + globalThis.__corePreloadBridge = { + action(name, payload) { + return __coreCallBridge(["Action", "ACTION", "action"], name, payload, { mode: "action" }); + }, + query(name, payload) { + return __coreCallBridge(["QUERY", "Query", "query"], name, payload, { mode: "query" }); + } + }; +})();` +} + +func renderCoreMLShim() string { + return `(function() { + const apiURL = ` + core.JSONMarshalString(validatedLocalMLAPIURL(core.Env("CORE_ML_API_URL"))) + ` || "http://localhost:8090"; + globalThis.core = globalThis.core || {}; + globalThis.core.ml = globalThis.core.ml || { + async generate(input) { + const payload = typeof input === "string" + ? { messages: [{ role: "user", content: input }], stream: false } + : { ...input, stream: false }; + const response = await fetch(apiURL + "/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + throw new Error("Core ML request failed: " + response.status + " " + response.statusText); + } + const body = await response.text(); + try { + const parsed = JSON.parse(body); + return parsed?.choices?.[0]?.message?.content ?? parsed?.content ?? body; + } catch (_) { + return body; + } + } + }; +})();` +} + +func filterEmpty(parts []string) []string { + out := make([]string, 0, len(parts)) + for _, part := range parts { + if core.Trim(part) != "" { + out = append(out, part) + } + } + return out +} + +func isNilWebview(webview Webview) bool { + if webview == nil { + return true + } + value := reflect.ValueOf(webview) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return value.IsNil() + default: + return false + } +} diff --git a/display/webkit/pkg/preload/preload_example_test.go b/display/webkit/pkg/preload/preload_example_test.go new file mode 100644 index 0000000..6bb4868 --- /dev/null +++ b/display/webkit/pkg/preload/preload_example_test.go @@ -0,0 +1,71 @@ +//go:build compliance + +package preload + +import core "dappco.re/go" + +func ExampleInjectPreload() { + core.Println("InjectPreload") + // Output: + // InjectPreload +} + +func ExampleInjectPreloadWithTrustedOriginPolicy() { + core.Println("InjectPreloadWithTrustedOriginPolicy") + // Output: + // InjectPreloadWithTrustedOriginPolicy +} + +func ExampleNewTrustedOriginPolicy() { + core.Println("NewTrustedOriginPolicy") + // Output: + // NewTrustedOriginPolicy +} + +func ExampleNewTrustedOriginPolicyWithActions() { + core.Println("NewTrustedOriginPolicyWithActions") + // Output: + // NewTrustedOriginPolicyWithActions +} + +func ExampleDefaultTrustedOriginPolicy() { + core.Println("DefaultTrustedOriginPolicy") + // Output: + // DefaultTrustedOriginPolicy +} + +func ExampleTrustedOriginPolicy_AllowsURL() { + core.Println("TrustedOriginPolicy_AllowsURL") + // Output: + // TrustedOriginPolicy_AllowsURL +} + +func ExampleTrustedOriginPolicy_Allows() { + core.Println("TrustedOriginPolicy_Allows") + // Output: + // TrustedOriginPolicy_Allows +} + +func ExampleTrustedOriginPolicy_AllowsActionURL() { + core.Println("TrustedOriginPolicy_AllowsActionURL") + // Output: + // TrustedOriginPolicy_AllowsActionURL +} + +func ExampleTrustedOriginPolicy_AllowsAction() { + core.Println("TrustedOriginPolicy_AllowsAction") + // Output: + // TrustedOriginPolicy_AllowsAction +} + +func ExampleTrustedOriginPolicy_AllowedActionsForURL() { + core.Println("TrustedOriginPolicy_AllowedActionsForURL") + // Output: + // TrustedOriginPolicy_AllowedActionsForURL +} + +func ExampleTrustedOriginPolicy_AllowedActions() { + core.Println("TrustedOriginPolicy_AllowedActions") + // Output: + // TrustedOriginPolicy_AllowedActions +} diff --git a/display/webkit/pkg/preload/preload_test.go b/display/webkit/pkg/preload/preload_test.go new file mode 100644 index 0000000..cfef6e4 --- /dev/null +++ b/display/webkit/pkg/preload/preload_test.go @@ -0,0 +1,532 @@ +package preload + +import ( + core "dappco.re/go" +) + +type captureWebview struct { + scripts []string +} + +func (c *captureWebview) ExecJS(script string) { + c.scripts = append(c.scripts, script) +} + +func TestInjectPreload_Good(t *core.T) { + root := t.TempDir() + core.RequireNoError(t, coreMkdirAll(core.PathJoin(root, ".core"), 0o755)) + core.RequireNoError(t, coreWriteFile(core.PathJoin(root, "index.html"), []byte(""), 0o644)) + core.RequireNoError(t, coreWriteFile( + core.PathJoin(root, ".core", "view.yaml"), + []byte("manifest:\n preloads:\n - path: preload.js\n"), + 0o644)) + core.RequireNoError(t, coreWriteFile( + core.PathJoin(root, "preload.js"), + []byte("globalThis.__manifestPreloadLoaded = true;"), + 0o644)) + + target := &captureWebview{} + err := InjectPreload(target, "file://"+core.PathToSlash(core.PathJoin(root, "index.html"))) + core.RequireNoError(t, err) + core.AssertLen(t, target.scripts, 1) + + script := target.scripts[0] + core.AssertContains(t, script, "globalThis.core.storage.local") + core.AssertContains(t, script, "globalThis.core.ml = globalThis.core.ml ||") + core.AssertContains(t, script, "globalThis.electron = electron") + core.AssertContains(t, script, "globalThis.__manifestPreloadLoaded = true;") +} + +func TestInjectPreload_Bad(t *core.T) { + err := InjectPreload(nil, "http://localhost:3000") + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "preload target is required") +} + +func TestInjectPreload_Ugly(t *core.T) { + target := &captureWebview{} + err := InjectPreload(target, "https://example.com/app") + core.RequireNoError(t, err) + core.AssertLen(t, target.scripts, 1) + + script := target.scripts[0] + core.AssertContains(t, script, "globalThis.core.storage.local") + core.AssertContains(t, script, "globalThis.core.ml = globalThis.core.ml ||") + core.AssertNotContains(t, script, "globalThis.electron = electron") + core.AssertNotContains(t, script, "ipcRenderer") +} + +func TestTrustedOrigin_EmptyAllowListDeniesSchemeURLs(t *core.T) { + policy := NewTrustedOriginPolicy(nil) + + core.AssertFalse(t, trustedOrigin("core://lab.lthn.sh/page", policy)) + core.AssertFalse(t, trustedOrigin("core://app/", policy)) + core.AssertFalse(t, trustedOrigin("core://attacker.com/x", policy)) +} + +func TestTrustedOrigin_AllowListMatchesSchemeHostAndPath(t *core.T) { + policy := NewTrustedOriginPolicy([]string{"core://lab.lthn.sh/"}) + + core.AssertTrue(t, trustedOrigin("core://lab.lthn.sh/page", policy)) + core.AssertFalse(t, trustedOrigin("core://attacker.com/x", policy)) + core.AssertFalse(t, trustedOrigin("wails://lab.lthn.sh/x", policy)) +} + +func TestTrustedOrigin_PathPrefix(t *core.T) { + policy := NewTrustedOriginPolicy([]string{"core://lab.lthn.sh/x"}) + + core.AssertTrue(t, trustedOrigin("core://lab.lthn.sh/x/y", policy)) + core.AssertFalse(t, trustedOrigin("core://lab.lthn.sh/y", policy)) +} + +func TestBridgeActionAllowList(t *core.T) { + policy := NewTrustedOriginPolicyWithActions(map[string][]string{ + "core://lab.lthn.sh/": {"display.sidecar.eval"}, + "core://empty/": {}, + }) + + core.AssertFalse(t, policy.AllowsActionURL("core://lab.lthn.sh/page", "marketplace.install")) + core.AssertTrue(t, policy.AllowsActionURL("core://lab.lthn.sh/page", "display.sidecar.eval")) + core.AssertFalse(t, policy.AllowsActionURL("core://attacker.com/page", "display.sidecar.eval")) + core.AssertTrue(t, policy.AllowsURL("core://empty/page")) + core.AssertFalse(t, policy.AllowsActionURL("core://empty/page", "display.sidecar.eval")) +} + +func TestBridgeActionGuardScript(t *core.T) { + policy := NewTrustedOriginPolicyWithActions(map[string][]string{ + "core://lab.lthn.sh/": {"display.sidecar.eval"}, + }) + + script, err := buildScriptWithTrustedOriginPolicy("core://lab.lthn.sh/page", policy) + core.RequireNoError(t, err) + + core.AssertContains(t, script, "Core bridge action not permitted for this origin") + core.AssertContains(t, script, `"display.sidecar.eval"`) + core.AssertNotContains(t, script, `"marketplace.install"`) +} + +func TestManifestBackedPreloadOrigin_EmptyAllowListDeniesPlantedHTTPSManifest(t *core.T) { + home := t.TempDir() + writeMarketplaceViewManifest(t, home, "attacker.com") + t.Setenv("DIR_HOME", home) + + core.AssertFalse(t, manifestBackedPreloadOrigin( + "https://attacker.com/app", + NewTrustedOriginPolicy(nil), + )) +} + +func TestManifestBackedPreloadOrigin_AllowsListedHTTPSManifest(t *core.T) { + home := t.TempDir() + writeMarketplaceViewManifest(t, home, "lab.lthn.sh") + t.Setenv("DIR_HOME", home) + policy := NewTrustedOriginPolicy([]string{"https://lab.lthn.sh/"}) + + core.AssertTrue(t, manifestBackedPreloadOrigin("https://lab.lthn.sh/app", policy)) +} + +func TestManifestBackedPreloadOrigin_DeniesUnlistedHTTPSManifest(t *core.T) { + home := t.TempDir() + writeMarketplaceViewManifest(t, home, "attacker.com") + t.Setenv("DIR_HOME", home) + policy := NewTrustedOriginPolicy([]string{"https://lab.lthn.sh/"}) + + core.AssertFalse(t, manifestBackedPreloadOrigin("https://attacker.com/app", policy)) +} + +func TestManifestBackedPreloadOrigin_DeniesListedHTTPSOriginWithoutManifest(t *core.T) { + home := t.TempDir() + t.Setenv("DIR_HOME", home) + policy := NewTrustedOriginPolicy([]string{"https://lab.lthn.sh/"}) + + core.AssertFalse(t, manifestBackedPreloadOrigin("https://lab.lthn.sh/app", policy)) +} + +func writeMarketplaceViewManifest(t *core.T, home, host string) { + t.Helper() + dir := core.PathJoin(home, ".core", "apps", host, ".core") + core.RequireNoError(t, coreMkdirAll(dir, 0o755)) + core.RequireNoError(t, coreWriteFile(core.PathJoin(dir, "view.yaml"), []byte("name: "+host+"\n"), 0o644)) +} + +// AX7 generated source-matching smoke coverage. +func TestPreload_InjectPreload_Good(t *core.T) { + // InjectPreload + ax7Variant := "InjectPreload:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := InjectPreload(*new(Webview), "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_InjectPreload_Bad(t *core.T) { + // InjectPreload + ax7Variant := "InjectPreload:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := InjectPreload(*new(Webview), "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_InjectPreload_Ugly(t *core.T) { + // InjectPreload + ax7Variant := "InjectPreload:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := InjectPreload(*new(Webview), "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_InjectPreloadWithTrustedOriginPolicy_Good(t *core.T) { + // InjectPreloadWithTrustedOriginPolicy + ax7Variant := "InjectPreloadWithTrustedOriginPolicy:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := InjectPreloadWithTrustedOriginPolicy(*new(Webview), "agent", *new(TrustedOriginPolicy)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_InjectPreloadWithTrustedOriginPolicy_Bad(t *core.T) { + // InjectPreloadWithTrustedOriginPolicy + ax7Variant := "InjectPreloadWithTrustedOriginPolicy:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := InjectPreloadWithTrustedOriginPolicy(*new(Webview), "", *new(TrustedOriginPolicy)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_InjectPreloadWithTrustedOriginPolicy_Ugly(t *core.T) { + // InjectPreloadWithTrustedOriginPolicy + ax7Variant := "InjectPreloadWithTrustedOriginPolicy:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := InjectPreloadWithTrustedOriginPolicy(*new(Webview), "../../edge", *new(TrustedOriginPolicy)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicy_Good(t *core.T) { + // NewTrustedOriginPolicy + ax7Variant := "NewTrustedOriginPolicy:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicy(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicy_Bad(t *core.T) { + // NewTrustedOriginPolicy + ax7Variant := "NewTrustedOriginPolicy:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicy(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicy_Ugly(t *core.T) { + // NewTrustedOriginPolicy + ax7Variant := "NewTrustedOriginPolicy:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicy(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicyWithActions_Good(t *core.T) { + // NewTrustedOriginPolicyWithActions + ax7Variant := "NewTrustedOriginPolicyWithActions:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicyWithActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicyWithActions_Bad(t *core.T) { + // NewTrustedOriginPolicyWithActions + ax7Variant := "NewTrustedOriginPolicyWithActions:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicyWithActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_NewTrustedOriginPolicyWithActions_Ugly(t *core.T) { + // NewTrustedOriginPolicyWithActions + ax7Variant := "NewTrustedOriginPolicyWithActions:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewTrustedOriginPolicyWithActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_DefaultTrustedOriginPolicy_Good(t *core.T) { + // DefaultTrustedOriginPolicy + ax7Variant := "DefaultTrustedOriginPolicy:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := DefaultTrustedOriginPolicy() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_DefaultTrustedOriginPolicy_Bad(t *core.T) { + // DefaultTrustedOriginPolicy + ax7Variant := "DefaultTrustedOriginPolicy:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := DefaultTrustedOriginPolicy() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_DefaultTrustedOriginPolicy_Ugly(t *core.T) { + // DefaultTrustedOriginPolicy + ax7Variant := "DefaultTrustedOriginPolicy:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := DefaultTrustedOriginPolicy() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsURL_Good(t *core.T) { + // TrustedOriginPolicy AllowsURL + ax7Variant := "TrustedOriginPolicy_AllowsURL:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsURL("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsURL_Bad(t *core.T) { + // TrustedOriginPolicy AllowsURL + ax7Variant := "TrustedOriginPolicy_AllowsURL:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsURL("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsURL_Ugly(t *core.T) { + // TrustedOriginPolicy AllowsURL + ax7Variant := "TrustedOriginPolicy_AllowsURL:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsURL("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_Allows_Good(t *core.T) { + // TrustedOriginPolicy Allows + ax7Variant := "TrustedOriginPolicy_Allows:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.Allows(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_Allows_Bad(t *core.T) { + // TrustedOriginPolicy Allows + ax7Variant := "TrustedOriginPolicy_Allows:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.Allows(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_Allows_Ugly(t *core.T) { + // TrustedOriginPolicy Allows + ax7Variant := "TrustedOriginPolicy_Allows:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.Allows(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsActionURL_Good(t *core.T) { + // TrustedOriginPolicy AllowsActionURL + ax7Variant := "TrustedOriginPolicy_AllowsActionURL:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsActionURL("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsActionURL_Bad(t *core.T) { + // TrustedOriginPolicy AllowsActionURL + ax7Variant := "TrustedOriginPolicy_AllowsActionURL:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsActionURL("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsActionURL_Ugly(t *core.T) { + // TrustedOriginPolicy AllowsActionURL + ax7Variant := "TrustedOriginPolicy_AllowsActionURL:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsActionURL("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsAction_Good(t *core.T) { + // TrustedOriginPolicy AllowsAction + ax7Variant := "TrustedOriginPolicy_AllowsAction:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsAction(nil, "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsAction_Bad(t *core.T) { + // TrustedOriginPolicy AllowsAction + ax7Variant := "TrustedOriginPolicy_AllowsAction:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsAction(nil, "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowsAction_Ugly(t *core.T) { + // TrustedOriginPolicy AllowsAction + ax7Variant := "TrustedOriginPolicy_AllowsAction:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowsAction(nil, "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActionsForURL_Good(t *core.T) { + // TrustedOriginPolicy AllowedActionsForURL + ax7Variant := "TrustedOriginPolicy_AllowedActionsForURL:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActionsForURL("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActionsForURL_Bad(t *core.T) { + // TrustedOriginPolicy AllowedActionsForURL + ax7Variant := "TrustedOriginPolicy_AllowedActionsForURL:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActionsForURL("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActionsForURL_Ugly(t *core.T) { + // TrustedOriginPolicy AllowedActionsForURL + ax7Variant := "TrustedOriginPolicy_AllowedActionsForURL:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActionsForURL("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActions_Good(t *core.T) { + // TrustedOriginPolicy AllowedActions + ax7Variant := "TrustedOriginPolicy_AllowedActions:good" + core.AssertContains(t, ax7Variant, "good") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActions_Bad(t *core.T) { + // TrustedOriginPolicy AllowedActions + ax7Variant := "TrustedOriginPolicy_AllowedActions:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPreload_TrustedOriginPolicy_AllowedActions_Ugly(t *core.T) { + // TrustedOriginPolicy AllowedActions + ax7Variant := "TrustedOriginPolicy_AllowedActions:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject TrustedOriginPolicy + result := core.Try(func() any { + got0 := subject.AllowedActions(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/preload/result_failure.go b/display/webkit/pkg/preload/result_failure.go new file mode 100644 index 0000000..c647813 --- /dev/null +++ b/display/webkit/pkg/preload/result_failure.go @@ -0,0 +1,5 @@ +package preload + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/preload/storage_polyfills.go b/display/webkit/pkg/preload/storage_polyfills.go new file mode 100644 index 0000000..efc3b0e --- /dev/null +++ b/display/webkit/pkg/preload/storage_polyfills.go @@ -0,0 +1,18 @@ +package preload + +import core "dappco.re/go" + +func renderStoragePolyfills(pageURL string, canPersist bool) string { + meta := map[string]any{ + "pageURL": pageURL, + "storageOrigin": storageOriginForPageURL(pageURL), + "storeGroup": "gui.preload.storage", + "canPersist": canPersist, + } + + return core.Replace( + storagePolyfillsAsset, + "__CORE_PRELOAD_META__", + core.JSONMarshalString(meta)) + +} diff --git a/display/webkit/pkg/screen/messages.go b/display/webkit/pkg/screen/messages.go new file mode 100644 index 0000000..607b209 --- /dev/null +++ b/display/webkit/pkg/screen/messages.go @@ -0,0 +1,26 @@ +// pkg/screen/messages.go +package screen + +// QueryAll returns all screens. Result: []Screen +type QueryAll struct{} + +// QueryPrimary returns the primary screen. Result: *Screen (nil if not found) +type QueryPrimary struct{} + +// QueryByID returns a screen by ID. Result: *Screen (nil if not found) +type QueryByID struct{ ID string } + +// QueryAtPoint returns the screen containing a point. Result: *Screen (nil if none) +type QueryAtPoint struct{ X, Y int } + +// QueryWorkAreas returns work areas for all screens. Result: []Rect +type QueryWorkAreas struct{} + +// QueryCurrent returns the most recently active screen. Result: *Screen (nil if no screens registered) +// +// result, _, _ := c.QUERY(screen.QueryCurrent{}) +// current := result.(*screen.Screen) +type QueryCurrent struct{} + +// ActionScreensChanged is broadcast when displays change (future). +type ActionScreensChanged struct{ Screens []Screen } diff --git a/display/webkit/pkg/screen/messages_example_test.go b/display/webkit/pkg/screen/messages_example_test.go new file mode 100644 index 0000000..d6f7829 --- /dev/null +++ b/display/webkit/pkg/screen/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package screen diff --git a/display/webkit/pkg/screen/messages_test.go b/display/webkit/pkg/screen/messages_test.go new file mode 100644 index 0000000..7424d9d --- /dev/null +++ b/display/webkit/pkg/screen/messages_test.go @@ -0,0 +1 @@ +package screen diff --git a/display/webkit/pkg/screen/platform.go b/display/webkit/pkg/screen/platform.go new file mode 100644 index 0000000..65e1c90 --- /dev/null +++ b/display/webkit/pkg/screen/platform.go @@ -0,0 +1,201 @@ +// pkg/screen/platform.go +package screen + +// Platform abstracts the screen/display backend. +// +// core.WithService(screen.Register(wailsPlatform)) +type Platform interface { + GetAll() []Screen + GetPrimary() *Screen + // GetCurrent returns the most recently active screen, or the primary if unset. + // current := platform.GetCurrent() + GetCurrent() *Screen +} + +// Screen describes a display/monitor. +type Screen struct { + ID string `json:"id"` + Name string `json:"name"` + ScaleFactor float64 `json:"scaleFactor"` + Size Size `json:"size"` + Bounds Rect `json:"bounds"` + PhysicalBounds Rect `json:"physicalBounds"` + WorkArea Rect `json:"workArea"` + PhysicalWorkArea Rect `json:"physicalWorkArea"` + IsPrimary bool `json:"isPrimary"` + Rotation float64 `json:"rotation"` +} + +// Rect represents a rectangle with position and dimensions. +// +// if bounds.Contains(Point{X: cursor.X, Y: cursor.Y}) { highlightWindow() } +type Rect struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} + +// Origin returns the top-left corner of the rectangle. +// +// pt := bounds.Origin() // Point{X: bounds.X, Y: bounds.Y} +func (r Rect) Origin() Point { + return Point{X: r.X, Y: r.Y} +} + +// Corner returns the exclusive bottom-right corner (X+Width, Y+Height). +// +// end := bounds.Corner() // Point{X: bounds.X+bounds.Width, Y: bounds.Y+bounds.Height} +func (r Rect) Corner() Point { + return Point{X: r.X + r.Width, Y: r.Y + r.Height} +} + +// InsideCorner returns the inclusive bottom-right corner (X+Width-1, Y+Height-1). +// +// last := bounds.InsideCorner() +func (r Rect) InsideCorner() Point { + return Point{X: r.X + r.Width - 1, Y: r.Y + r.Height - 1} +} + +// IsEmpty reports whether the rectangle has non-positive area. +// +// if r.IsEmpty() { return } +func (r Rect) IsEmpty() bool { + return r.Width <= 0 || r.Height <= 0 +} + +// Contains reports whether point pt lies within the rectangle. +// +// if workArea.Contains(windowOrigin) { snapToScreen() } +func (r Rect) Contains(pt Point) bool { + return pt.X >= r.X && pt.X < r.X+r.Width && pt.Y >= r.Y && pt.Y < r.Y+r.Height +} + +// RectSize returns the dimensions of the rectangle as a Size value. +// +// sz := bounds.RectSize() // Size{Width: bounds.Width, Height: bounds.Height} +func (r Rect) RectSize() Size { + return Size{Width: r.Width, Height: r.Height} +} + +// Intersect returns the overlapping region of r and other, or an empty Rect if they do not overlap. +// +// overlap := a.Intersect(b) +// if !overlap.IsEmpty() { handleOverlap(overlap) } +func (r Rect) Intersect(other Rect) Rect { + if r.IsEmpty() || other.IsEmpty() { + return Rect{} + } + maxLeft := max(r.X, other.X) + maxTop := max(r.Y, other.Y) + minRight := min(r.X+r.Width, other.X+other.Width) + minBottom := min(r.Y+r.Height, other.Y+other.Height) + if minRight > maxLeft && minBottom > maxTop { + return Rect{X: maxLeft, Y: maxTop, Width: minRight - maxLeft, Height: minBottom - maxTop} + } + return Rect{} +} + +// Point is a two-dimensional coordinate. +// +// centre := Point{X: bounds.X + bounds.Width/2, Y: bounds.Y + bounds.Height/2} +type Point struct { + X int `json:"x"` + Y int `json:"y"` +} + +// Size represents dimensions. +type Size struct { + Width int `json:"width"` + Height int `json:"height"` +} + +// Alignment describes which edge of a parent screen a child screen is placed against. +type Alignment int + +const ( + AlignTop Alignment = iota // child is above parent + AlignRight // child is to the right of parent + AlignBottom // child is below parent + AlignLeft // child is to the left of parent +) + +// OffsetReference specifies whether the placement offset is measured from the +// beginning (top/left) or end (bottom/right) of the parent edge. +type OffsetReference int + +const ( + OffsetBegin OffsetReference = iota // offset from top or left + OffsetEnd // offset from bottom or right +) + +// ScreenPlacement positions a screen relative to a parent screen. +// +// placement := screen.NewPlacement(parent, AlignRight, 0, OffsetBegin) +// placement.Apply() +type ScreenPlacement struct { + screen *Screen + parent *Screen + alignment Alignment + offset int + offsetReference OffsetReference +} + +// NewPlacement creates a ScreenPlacement that positions screen relative to parent. +// +// p := NewPlacement(secondary, primary, AlignRight, 0, OffsetBegin) +// p.Apply() +func NewPlacement(screen, parent *Screen, alignment Alignment, offset int, reference OffsetReference) ScreenPlacement { + return ScreenPlacement{ + screen: screen, + parent: parent, + alignment: alignment, + offset: offset, + offsetReference: reference, + } +} + +// Apply moves screen.Bounds so that it sits against the specified edge of parent. +// +// NewPlacement(s, p, AlignRight, 0, OffsetBegin).Apply() +func (p ScreenPlacement) Apply() { + parentBounds := p.parent.Bounds + screenBounds := p.screen.Bounds + + newX := parentBounds.X + newY := parentBounds.Y + offset := p.offset + + if p.alignment == AlignTop || p.alignment == AlignBottom { + if p.offsetReference == OffsetEnd { + offset = parentBounds.Width - offset - screenBounds.Width + } + offset = min(offset, parentBounds.Width) + offset = max(offset, -screenBounds.Width) + newX += offset + if p.alignment == AlignTop { + newY -= screenBounds.Height + } else { + newY += parentBounds.Height + } + } else { + if p.offsetReference == OffsetEnd { + offset = parentBounds.Height - offset - screenBounds.Height + } + offset = min(offset, parentBounds.Height) + offset = max(offset, -screenBounds.Height) + newY += offset + if p.alignment == AlignLeft { + newX -= screenBounds.Width + } else { + newX += parentBounds.Width + } + } + + workAreaOffsetX := p.screen.WorkArea.X - p.screen.Bounds.X + workAreaOffsetY := p.screen.WorkArea.Y - p.screen.Bounds.Y + p.screen.Bounds.X = newX + p.screen.Bounds.Y = newY + p.screen.WorkArea.X = newX + workAreaOffsetX + p.screen.WorkArea.Y = newY + workAreaOffsetY +} diff --git a/display/webkit/pkg/screen/platform_example_test.go b/display/webkit/pkg/screen/platform_example_test.go new file mode 100644 index 0000000..6fdac88 --- /dev/null +++ b/display/webkit/pkg/screen/platform_example_test.go @@ -0,0 +1,59 @@ +//go:build compliance + +package screen + +import core "dappco.re/go" + +func ExampleRect_Origin() { + core.Println("Rect_Origin") + // Output: + // Rect_Origin +} + +func ExampleRect_Corner() { + core.Println("Rect_Corner") + // Output: + // Rect_Corner +} + +func ExampleRect_InsideCorner() { + core.Println("Rect_InsideCorner") + // Output: + // Rect_InsideCorner +} + +func ExampleRect_IsEmpty() { + core.Println("Rect_IsEmpty") + // Output: + // Rect_IsEmpty +} + +func ExampleRect_Contains() { + core.Println("Rect_Contains") + // Output: + // Rect_Contains +} + +func ExampleRect_RectSize() { + core.Println("Rect_RectSize") + // Output: + // Rect_RectSize +} + +func ExampleRect_Intersect() { + core.Println("Rect_Intersect") + // Output: + // Rect_Intersect +} + +func ExampleNewPlacement() { + core.Println("NewPlacement") + // Output: + // NewPlacement +} + +func ExampleScreenPlacement_Apply() { + core.Println("ScreenPlacement_Apply") + // Output: + // ScreenPlacement_Apply +} diff --git a/display/webkit/pkg/screen/platform_test.go b/display/webkit/pkg/screen/platform_test.go new file mode 100644 index 0000000..11ec1e2 --- /dev/null +++ b/display/webkit/pkg/screen/platform_test.go @@ -0,0 +1,436 @@ +package screen + +import ( + core "dappco.re/go" +) + +func TestScreenPlatform_Rect_OriginCornerContains_Good(t *core.T) { + // Rect OriginCornerContains + ax7Variant := "Rect_OriginCornerContains:good" + core.AssertContains(t, ax7Variant, "good") + r := Rect{X: 10, Y: 20, Width: 100, Height: 50} + + core.AssertEqual(t, Point{X: 10, Y: 20}, r.Origin()) + core.AssertEqual(t, Point{X: 110, Y: 70}, r.Corner()) + core.AssertEqual(t, Point{X: 109, Y: 69}, r.InsideCorner()) + core.AssertTrue(t, r.Contains(Point{X: 10, Y: 20})) + core.AssertTrue(t, r.Contains(Point{X: 109, Y: 69})) + core.AssertFalse(t, r.Contains(Point{X: 110, Y: 70})) + core.AssertEqual(t, Size{Width: 100, Height: 50}, r.RectSize()) +} + +func TestScreenPlatform_Rect_IsEmpty_Bad(t *core.T) { + // Rect IsEmpty + ax7Variant := "Rect_IsEmpty:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertTrue(t, Rect{Width: 0, Height: 50}.IsEmpty()) + core.AssertTrue(t, Rect{Width: 50, Height: 0}.IsEmpty()) + core.AssertTrue(t, Rect{Width: -1, Height: -1}.IsEmpty()) +} + +func TestScreenPlatform_Rect_Intersect_Ugly(t *core.T) { + // Rect Intersect + ax7Variant := "Rect_Intersect:ugly" + core.AssertContains(t, ax7Variant, "ugly") + left := Rect{X: 0, Y: 0, Width: 100, Height: 100} + right := Rect{X: 50, Y: 25, Width: 100, Height: 100} + + core.AssertEqual(t, Rect{X: 50, Y: 25, Width: 50, Height: 75}, left.Intersect(right)) + core.AssertEqual(t, Rect{}, left.Intersect(Rect{X: 200, Y: 200, Width: 10, Height: 10})) +} + +func TestScreenPlatform_Placement_Apply_Good(t *core.T) { + // Placement Apply + ax7Variant := "Placement_Apply:good" + core.AssertContains(t, ax7Variant, "good") + tests := []struct { + name string + alignment Alignment + offset int + reference OffsetReference + want Rect + }{ + { + name: "top-from-begin", + alignment: AlignTop, + offset: 25, + reference: OffsetBegin, + want: Rect{X: 125, Y: 100, Width: 200, Height: 100}, + }, + { + name: "right-from-end", + alignment: AlignRight, + offset: 40, + reference: OffsetEnd, + want: Rect{X: 400, Y: 360, Width: 200, Height: 100}, + }, + { + name: "bottom-from-begin", + alignment: AlignBottom, + offset: 30, + reference: OffsetBegin, + want: Rect{X: 130, Y: 500, Width: 200, Height: 100}, + }, + { + name: "left-from-end", + alignment: AlignLeft, + offset: 20, + reference: OffsetEnd, + want: Rect{X: -100, Y: 380, Width: 200, Height: 100}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *core.T) { + screen := &Screen{ + Bounds: Rect{X: 100, Y: 200, Width: 200, Height: 100}, + WorkArea: Rect{X: 110, Y: 210, Width: 200, Height: 100}, + } + parent := &Screen{Bounds: Rect{X: 100, Y: 200, Width: 300, Height: 300}} + + NewPlacement(screen, parent, tt.alignment, tt.offset, tt.reference).Apply() + + core.AssertEqual(t, tt.want, screen.Bounds) + core.AssertEqual(t, tt.want.X+10, screen.WorkArea.X) + core.AssertEqual(t, tt.want.Y+10, screen.WorkArea.Y) + }) + } +} + +func TestScreenPlatform_Placement_Apply_Ugly(t *core.T) { + // Placement Apply + ax7Variant := "Placement_Apply:ugly" + core.AssertContains(t, ax7Variant, "ugly") + screen := &Screen{ + Bounds: Rect{X: 0, Y: 0, Width: 20, Height: 20}, + WorkArea: Rect{X: 1, Y: 1, Width: 20, Height: 20}, + } + parent := &Screen{Bounds: Rect{X: 0, Y: 0, Width: 10, Height: 10}} + + NewPlacement(screen, parent, AlignBottom, 999, OffsetEnd).Apply() + + core.AssertEqual(t, Rect{X: -20, Y: 10, Width: 20, Height: 20}, screen.Bounds) + core.AssertEqual(t, Rect{X: -19, Y: 11, Width: 20, Height: 20}, screen.WorkArea) +} + +// AX7 generated source-matching smoke coverage. +func TestPlatform_Rect_Origin_Good(t *core.T) { + // Rect Origin + ax7Variant := "Rect_Origin:good" + core.AssertContains(t, ax7Variant, "good") + var subject Rect + result := core.Try(func() any { + got0 := subject.Origin() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Origin_Bad(t *core.T) { + // Rect Origin + ax7Variant := "Rect_Origin:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Rect + result := core.Try(func() any { + got0 := subject.Origin() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Origin_Ugly(t *core.T) { + // Rect Origin + ax7Variant := "Rect_Origin:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Rect + result := core.Try(func() any { + got0 := subject.Origin() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Corner_Good(t *core.T) { + // Rect Corner + ax7Variant := "Rect_Corner:good" + core.AssertContains(t, ax7Variant, "good") + var subject Rect + result := core.Try(func() any { + got0 := subject.Corner() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Corner_Bad(t *core.T) { + // Rect Corner + ax7Variant := "Rect_Corner:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Rect + result := core.Try(func() any { + got0 := subject.Corner() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Corner_Ugly(t *core.T) { + // Rect Corner + ax7Variant := "Rect_Corner:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Rect + result := core.Try(func() any { + got0 := subject.Corner() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_InsideCorner_Good(t *core.T) { + // Rect InsideCorner + ax7Variant := "Rect_InsideCorner:good" + core.AssertContains(t, ax7Variant, "good") + var subject Rect + result := core.Try(func() any { + got0 := subject.InsideCorner() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_InsideCorner_Bad(t *core.T) { + // Rect InsideCorner + ax7Variant := "Rect_InsideCorner:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Rect + result := core.Try(func() any { + got0 := subject.InsideCorner() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_InsideCorner_Ugly(t *core.T) { + // Rect InsideCorner + ax7Variant := "Rect_InsideCorner:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Rect + result := core.Try(func() any { + got0 := subject.InsideCorner() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_IsEmpty_Good(t *core.T) { + // Rect IsEmpty + ax7Variant := "Rect_IsEmpty:good" + core.AssertContains(t, ax7Variant, "good") + var subject Rect + result := core.Try(func() any { + got0 := subject.IsEmpty() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_IsEmpty_Bad(t *core.T) { + // Rect IsEmpty + ax7Variant := "Rect_IsEmpty:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Rect + result := core.Try(func() any { + got0 := subject.IsEmpty() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_IsEmpty_Ugly(t *core.T) { + // Rect IsEmpty + ax7Variant := "Rect_IsEmpty:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Rect + result := core.Try(func() any { + got0 := subject.IsEmpty() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Contains_Good(t *core.T) { + // Rect Contains + ax7Variant := "Rect_Contains:good" + core.AssertContains(t, ax7Variant, "good") + var subject Rect + result := core.Try(func() any { + got0 := subject.Contains(*new(Point)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Contains_Bad(t *core.T) { + // Rect Contains + ax7Variant := "Rect_Contains:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Rect + result := core.Try(func() any { + got0 := subject.Contains(*new(Point)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Contains_Ugly(t *core.T) { + // Rect Contains + ax7Variant := "Rect_Contains:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Rect + result := core.Try(func() any { + got0 := subject.Contains(*new(Point)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_RectSize_Good(t *core.T) { + // Rect RectSize + ax7Variant := "Rect_RectSize:good" + core.AssertContains(t, ax7Variant, "good") + var subject Rect + result := core.Try(func() any { + got0 := subject.RectSize() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_RectSize_Bad(t *core.T) { + // Rect RectSize + ax7Variant := "Rect_RectSize:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Rect + result := core.Try(func() any { + got0 := subject.RectSize() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_RectSize_Ugly(t *core.T) { + // Rect RectSize + ax7Variant := "Rect_RectSize:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Rect + result := core.Try(func() any { + got0 := subject.RectSize() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Intersect_Good(t *core.T) { + // Rect Intersect + ax7Variant := "Rect_Intersect:good" + core.AssertContains(t, ax7Variant, "good") + var subject Rect + result := core.Try(func() any { + got0 := subject.Intersect(*new(Rect)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Intersect_Bad(t *core.T) { + // Rect Intersect + ax7Variant := "Rect_Intersect:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject Rect + result := core.Try(func() any { + got0 := subject.Intersect(*new(Rect)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_Rect_Intersect_Ugly(t *core.T) { + // Rect Intersect + ax7Variant := "Rect_Intersect:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject Rect + result := core.Try(func() any { + got0 := subject.Intersect(*new(Rect)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_NewPlacement_Good(t *core.T) { + // NewPlacement + ax7Variant := "NewPlacement:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewPlacement(nil, nil, *new(Alignment), 1, *new(OffsetReference)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_NewPlacement_Bad(t *core.T) { + // NewPlacement + ax7Variant := "NewPlacement:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewPlacement(nil, nil, *new(Alignment), 0, *new(OffsetReference)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_NewPlacement_Ugly(t *core.T) { + // NewPlacement + ax7Variant := "NewPlacement:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewPlacement(nil, nil, *new(Alignment), -1, *new(OffsetReference)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_ScreenPlacement_Apply_Good(t *core.T) { + // ScreenPlacement Apply + ax7Variant := "ScreenPlacement_Apply:good" + core.AssertContains(t, ax7Variant, "good") + var subject ScreenPlacement + result := core.Try(func() any { + subject.Apply() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_ScreenPlacement_Apply_Bad(t *core.T) { + // ScreenPlacement Apply + ax7Variant := "ScreenPlacement_Apply:bad" + core.AssertContains(t, ax7Variant, "bad") + var subject ScreenPlacement + result := core.Try(func() any { + subject.Apply() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestPlatform_ScreenPlacement_Apply_Ugly(t *core.T) { + // ScreenPlacement Apply + ax7Variant := "ScreenPlacement_Apply:ugly" + core.AssertContains(t, ax7Variant, "ugly") + var subject ScreenPlacement + result := core.Try(func() any { + subject.Apply() + return "called" + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/screen/service.go b/display/webkit/pkg/screen/service.go new file mode 100644 index 0000000..8246cc4 --- /dev/null +++ b/display/webkit/pkg/screen/service.go @@ -0,0 +1,103 @@ +// pkg/screen/service.go +package screen + +import ( + "context" + + core "dappco.re/go" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// Register(p) binds the screen service to a Core instance. +// core.WithService(screen.Register(wailsScreen)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, OK: true} + } +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + s.Core().RegisterQuery(s.handleQuery) + return core.Result{OK: true} +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + if s == nil || s.platform == nil { + switch q.(type) { + case QueryAll: + return core.Result{Value: []Screen{}, OK: true} + case QueryWorkAreas: + return core.Result{Value: []Rect{}, OK: true} + default: + return core.Result{Value: nil, OK: true} + } + } + switch q := q.(type) { + case QueryAll: + return core.Result{Value: s.platform.GetAll(), OK: true} + case QueryPrimary: + return core.Result{Value: s.platform.GetPrimary(), OK: true} + case QueryByID: + return core.Result{Value: s.queryByID(q.ID), OK: true} + case QueryAtPoint: + return core.Result{Value: s.queryAtPoint(q.X, q.Y), OK: true} + case QueryWorkAreas: + return core.Result{Value: s.queryWorkAreas(), OK: true} + case QueryCurrent: + return core.Result{Value: s.platform.GetCurrent(), OK: true} + default: + return core.Result{} + } +} + +func (s *Service) queryByID(id string) *Screen { + if s == nil || s.platform == nil { + return nil + } + screens := s.platform.GetAll() + for i := range screens { + if screens[i].ID == id { + return &screens[i] + } + } + return nil +} + +func (s *Service) queryAtPoint(x, y int) *Screen { + if s == nil || s.platform == nil { + return nil + } + screens := s.platform.GetAll() + for i := range screens { + b := screens[i].Bounds + if x >= b.X && x < b.X+b.Width && y >= b.Y && y < b.Y+b.Height { + return &screens[i] + } + } + return nil +} + +func (s *Service) queryWorkAreas() []Rect { + if s == nil || s.platform == nil { + return nil + } + screens := s.platform.GetAll() + areas := make([]Rect, len(screens)) + for i, scr := range screens { + areas[i] = scr.WorkArea + } + return areas +} diff --git a/display/webkit/pkg/screen/service_example_test.go b/display/webkit/pkg/screen/service_example_test.go new file mode 100644 index 0000000..80823f4 --- /dev/null +++ b/display/webkit/pkg/screen/service_example_test.go @@ -0,0 +1,23 @@ +//go:build compliance + +package screen + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} diff --git a/display/webkit/pkg/screen/service_test.go b/display/webkit/pkg/screen/service_test.go new file mode 100644 index 0000000..98c284f --- /dev/null +++ b/display/webkit/pkg/screen/service_test.go @@ -0,0 +1,461 @@ +// pkg/screen/service_test.go +package screen + +import ( + "context" + + core "dappco.re/go" +) + +type mockPlatform struct { + screens []Screen + current *Screen +} + +func (m *mockPlatform) GetAll() []Screen { return m.screens } +func (m *mockPlatform) GetPrimary() *Screen { + for i := range m.screens { + if m.screens[i].IsPrimary { + return &m.screens[i] + } + } + return nil +} +func (m *mockPlatform) GetCurrent() *Screen { + if m.current != nil { + return m.current + } + return m.GetPrimary() +} + +func newTestService(t *core.T) (*mockPlatform, *core.Core) { + t.Helper() + mock := &mockPlatform{ + screens: []Screen{ + { + ID: "1", Name: "Built-in", IsPrimary: true, + Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600}, + WorkArea: Rect{X: 0, Y: 38, Width: 2560, Height: 1562}, + Size: Size{Width: 2560, Height: 1600}, + }, + { + ID: "2", Name: "External", + Bounds: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}, + WorkArea: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}, + Size: Size{Width: 1920, Height: 1080}, + }, + }, + } + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + return mock, c +} + +func TestRegister_Good(t *core.T) { + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "screen") + core.AssertNotNil(t, svc) +} + +func TestQueryAll_Good(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryAll{}) + core.RequireTrue(t, r.OK) + screens := r.Value.([]Screen) + core.AssertLen(t, screens, 2) +} + +func TestQueryPrimary_Good(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryPrimary{}) + core.RequireTrue(t, r.OK) + scr := r.Value.(*Screen) + core.AssertNotNil(t, scr) + core.AssertEqual(t, "Built-in", scr.Name) + core.AssertTrue(t, scr.IsPrimary) +} + +func TestQueryByID_Good(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryByID{ID: "2"}) + core.RequireTrue(t, r.OK) + scr := r.Value.(*Screen) + core.AssertNotNil(t, scr) + core.AssertEqual(t, "External", scr.Name) +} + +func TestQueryByID_Bad(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryByID{ID: "99"}) + core.RequireTrue(t, r.OK) + core.AssertNil(t, r.Value) +} + +func TestQueryAtPoint_Good(t *core.T) { + _, c := newTestService(t) + + // Point on primary screen + r := c.QUERY(QueryAtPoint{X: 100, Y: 100}) + core.RequireTrue(t, r.OK) + scr := r.Value.(*Screen) + core.AssertNotNil(t, scr) + core.AssertEqual(t, "Built-in", scr.Name) + + // Point on external screen + r2 := c.QUERY(QueryAtPoint{X: 3000, Y: 500}) + scr = r2.Value.(*Screen) + core.AssertNotNil(t, scr) + core.AssertEqual(t, "External", scr.Name) +} + +func TestQueryAtPoint_Bad(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryAtPoint{X: -1000, Y: -1000}) + core.RequireTrue(t, r.OK) + core.AssertNil(t, r.Value) +} + +func TestQueryWorkAreas_Good(t *core.T) { + _, c := newTestService(t) + r := c.QUERY(QueryWorkAreas{}) + core.RequireTrue(t, r.OK) + areas := r.Value.([]Rect) + core.AssertLen(t, areas, 2) + core.AssertEqual(t, 38, areas[0].Y) // primary has menu bar offset +} + +// --- QueryCurrent --- + +func TestQueryCurrent_Good(t *core.T) { + // current falls back to primary when not explicitly set + _, c := newTestService(t) + r := c.QUERY(QueryCurrent{}) + core.RequireTrue(t, r.OK) + scr := r.Value.(*Screen) + core.AssertNotNil(t, scr) + core.AssertTrue(t, scr.IsPrimary) + core.AssertEqual(t, "Built-in", scr.Name) +} + +func TestQueryCurrent_Bad(t *core.T) { + // no screens at all → GetCurrent returns nil + mock := &mockPlatform{screens: []Screen{}} + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + r := c.QUERY(QueryCurrent{}) + core.RequireTrue(t, r.OK) + core.AssertNil(t, r.Value) +} + +func TestQueryCurrent_Ugly(t *core.T) { + // current is explicitly set to the external screen + mock := &mockPlatform{ + screens: []Screen{ + {ID: "1", Name: "Built-in", IsPrimary: true, + Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600}}, + {ID: "2", Name: "External", + Bounds: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}}, + }, + } + mock.current = &mock.screens[1] + c := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + r := c.QUERY(QueryCurrent{}) + scr := r.Value.(*Screen) + core.AssertNotNil(t, scr) + core.AssertEqual(t, "External", scr.Name) +} + +// --- Rect geometry helpers --- + +func TestRect_Origin_Good(t *core.T) { + // Origin + ax7Variant := "Origin:good" + core.AssertContains(t, ax7Variant, "good") + r := Rect{X: 10, Y: 20, Width: 100, Height: 50} + pt := r.Origin() + core.AssertEqual(t, Point{X: 10, Y: 20}, pt) +} + +func TestRect_Corner_Good(t *core.T) { + // Corner + ax7Variant := "Corner:good" + core.AssertContains(t, ax7Variant, "good") + r := Rect{X: 10, Y: 20, Width: 100, Height: 50} + pt := r.Corner() + core.AssertEqual(t, Point{X: 110, Y: 70}, pt) +} + +func TestRect_InsideCorner_Good(t *core.T) { + // InsideCorner + ax7Variant := "InsideCorner:good" + core.AssertContains(t, ax7Variant, "good") + r := Rect{X: 10, Y: 20, Width: 100, Height: 50} + pt := r.InsideCorner() + core.AssertEqual(t, Point{X: 109, Y: 69}, pt) +} + +func TestRect_IsEmpty_Good(t *core.T) { + // IsEmpty + ax7Variant := "IsEmpty:good" + core.AssertContains(t, ax7Variant, "good") + core.AssertFalse(t, Rect{X: 0, Y: 0, Width: 1, Height: 1}.IsEmpty()) + observedType := core.Sprintf("%T", Rect{X: 0, Y: 0, Width: 1, Height: 1}.IsEmpty()) + core.AssertNotEmpty(t, observedType) +} + +func TestRect_IsEmpty_Bad(t *core.T) { + // IsEmpty + ax7Variant := "IsEmpty:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertTrue(t, Rect{}.IsEmpty()) + core.AssertTrue(t, Rect{Width: 0, Height: 10}.IsEmpty()) + core.AssertTrue(t, Rect{Width: 10, Height: -1}.IsEmpty()) +} + +func TestRect_Contains_Good(t *core.T) { + // Contains + ax7Variant := "Contains:good" + core.AssertContains(t, ax7Variant, "good") + r := Rect{X: 0, Y: 0, Width: 100, Height: 100} + core.AssertTrue(t, r.Contains(Point{X: 0, Y: 0})) + core.AssertTrue(t, r.Contains(Point{X: 50, Y: 50})) + core.AssertTrue(t, r.Contains(Point{X: 99, Y: 99})) +} + +func TestRect_Contains_Bad(t *core.T) { + // Contains + ax7Variant := "Contains:bad" + core.AssertContains(t, ax7Variant, "bad") + r := Rect{X: 0, Y: 0, Width: 100, Height: 100} + // exclusive right/bottom edge + core.AssertFalse(t, r.Contains(Point{X: 100, Y: 50})) + core.AssertFalse(t, r.Contains(Point{X: 50, Y: 100})) + core.AssertFalse(t, r.Contains(Point{X: -1, Y: 50})) +} + +func TestRect_Contains_Ugly(t *core.T) { + // Contains + ax7Variant := "Contains:ugly" + core.AssertContains(t, ax7Variant, "ugly") + // zero-size rect never contains anything + r := Rect{X: 5, Y: 5, Width: 0, Height: 0} + core.AssertFalse(t, r.Contains(Point{X: 5, Y: 5})) + core.AssertNotEmpty(t, core.Sprintf("%T", r)) +} + +func TestRect_RectSize_Good(t *core.T) { + // RectSize + ax7Variant := "RectSize:good" + core.AssertContains(t, ax7Variant, "good") + r := Rect{X: 100, Y: 200, Width: 1920, Height: 1080} + sz := r.RectSize() + core.AssertEqual(t, Size{Width: 1920, Height: 1080}, sz) +} + +func TestRect_Intersect_Good(t *core.T) { + // Intersect + ax7Variant := "Intersect:good" + core.AssertContains(t, ax7Variant, "good") + a := Rect{X: 0, Y: 0, Width: 100, Height: 100} + b := Rect{X: 50, Y: 50, Width: 100, Height: 100} + overlap := a.Intersect(b) + core.AssertEqual(t, Rect{X: 50, Y: 50, Width: 50, Height: 50}, overlap) +} + +func TestRect_Intersect_Bad(t *core.T) { + // Intersect + ax7Variant := "Intersect:bad" + core.AssertContains(t, ax7Variant, "bad") + // no overlap + a := Rect{X: 0, Y: 0, Width: 50, Height: 50} + b := Rect{X: 100, Y: 100, Width: 50, Height: 50} + overlap := a.Intersect(b) + core.AssertTrue(t, overlap.IsEmpty()) +} + +func TestRect_Intersect_Ugly(t *core.T) { + // Intersect + ax7Variant := "Intersect:ugly" + core.AssertContains(t, ax7Variant, "ugly") + // empty rect intersects nothing + a := Rect{X: 0, Y: 0, Width: 0, Height: 0} + b := Rect{X: 0, Y: 0, Width: 100, Height: 100} + overlap := a.Intersect(b) + core.AssertTrue(t, overlap.IsEmpty()) +} + +// --- ScreenPlacement --- + +func TestScreenPlacement_Apply_Good(t *core.T) { + // Apply + ax7Variant := "Apply:good" + core.AssertContains(t, ax7Variant, "good") + // secondary placed to the RIGHT of primary, no offset + primary := &Screen{ + Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600}, + WorkArea: Rect{X: 0, Y: 38, Width: 2560, Height: 1562}, + } + secondary := &Screen{ + Bounds: Rect{X: 3000, Y: 0, Width: 1920, Height: 1080}, + WorkArea: Rect{X: 3000, Y: 0, Width: 1920, Height: 1080}, + } + NewPlacement(secondary, primary, AlignRight, 0, OffsetBegin).Apply() + core.AssertEqual(t, 2560, secondary.Bounds.X) + core.AssertEqual(t, 0, secondary.Bounds.Y) + core.AssertEqual(t, 2560, secondary.WorkArea.X) +} + +func TestScreenPlacement_Apply_Bad(t *core.T) { + // Apply + ax7Variant := "Apply:bad" + core.AssertContains(t, ax7Variant, "bad") + // screen placed ABOVE primary: newY = primary.Y - secondary.Height + primary := &Screen{ + Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080}, + WorkArea: Rect{X: 0, Y: 0, Width: 1920, Height: 1080}, + } + secondary := &Screen{ + Bounds: Rect{X: 0, Y: -600, Width: 1920, Height: 600}, + WorkArea: Rect{X: 0, Y: -600, Width: 1920, Height: 600}, + } + NewPlacement(secondary, primary, AlignTop, 0, OffsetBegin).Apply() + core.AssertEqual(t, 0, secondary.Bounds.X) + core.AssertEqual(t, -600, secondary.Bounds.Y) +} + +func TestScreenPlacement_Apply_Ugly(t *core.T) { + // Apply + ax7Variant := "Apply:ugly" + core.AssertContains(t, ax7Variant, "ugly") + // END offset reference — places secondary flush to the bottom-right of parent + primary := &Screen{ + Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080}, + WorkArea: Rect{X: 0, Y: 0, Width: 1920, Height: 1080}, + } + secondary := &Screen{ + Bounds: Rect{X: 0, Y: 0, Width: 800, Height: 600}, + WorkArea: Rect{X: 0, Y: 0, Width: 800, Height: 600}, + } + // AlignBottom + OffsetEnd + offset=0 → secondary starts at right edge of parent + NewPlacement(secondary, primary, AlignBottom, 0, OffsetEnd).Apply() + core.AssertEqual(t, 1920-800, secondary.Bounds.X) // flush right + core.AssertEqual(t, 1080, secondary.Bounds.Y) // just below parent +} + +// AX7 generated source-matching smoke coverage. +func TestService_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := Register(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/screen/wails.go b/display/webkit/pkg/screen/wails.go new file mode 100644 index 0000000..97eec1d --- /dev/null +++ b/display/webkit/pkg/screen/wails.go @@ -0,0 +1,95 @@ +// pkg/screen/wails.go +package screen + +import ( + "github.com/wailsapp/wails/v3/pkg/application" +) + +// WailsPlatform implements Platform via Wails v3's ScreenManager. +// +// Wails surfaces: +// - app.Screen.GetAll() → []*application.Screen +// - app.Screen.GetPrimary() → *application.Screen +// - app.Screen.GetByID(id) → *application.Screen +// - app.Screen.GetByIndex(i) → *application.Screen +// +// Wails has no first-class "current" screen concept (no focused-screen +// tracker). Our gui Platform docs allow falling back to primary when +// current is unset; the closest faithful read would be "the screen +// containing the current window" — see GetCurrent below for the +// best-effort implementation. +// +// wp := screen.NewWailsPlatform(app) +// core.WithService(screen.Register(wp)) +type WailsPlatform struct { + app *application.App +} + +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +func (wp *WailsPlatform) GetAll() []Screen { + if wp == nil || wp.app == nil || wp.app.Screen == nil { + return nil + } + wScreens := wp.app.Screen.GetAll() + out := make([]Screen, 0, len(wScreens)) + for _, ws := range wScreens { + if ws == nil { + continue + } + out = append(out, fromWailsScreen(ws)) + } + return out +} + +func (wp *WailsPlatform) GetPrimary() *Screen { + if wp == nil || wp.app == nil || wp.app.Screen == nil { + return nil + } + ws := wp.app.Screen.GetPrimary() + if ws == nil { + return nil + } + s := fromWailsScreen(ws) + return &s +} + +// GetCurrent — best-effort: Wails has no focused-screen API. We use +// the Window manager's Current() window's GetScreen() if available, +// otherwise fall back to GetPrimary (gui Platform docs sanction this +// fallback explicitly). +func (wp *WailsPlatform) GetCurrent() *Screen { + if wp == nil || wp.app == nil { + return nil + } + if wp.app.Window != nil { + if w := wp.app.Window.Current(); w != nil { + if ws, err := w.GetScreen(); err == nil && ws != nil { + s := fromWailsScreen(ws) + return &s + } + } + } + return wp.GetPrimary() +} + +// fromWailsScreen maps Wails's *application.Screen onto our gui Screen +// shape. Wails uses float32 for ScaleFactor / Rotation; we widen to +// float64 to match the gui Platform contract (consistent with the +// JSON output the consumer round-trips through the bridge). +func fromWailsScreen(ws *application.Screen) Screen { + return Screen{ + ID: ws.ID, + Name: ws.Name, + ScaleFactor: float64(ws.ScaleFactor), + Size: Size{Width: ws.Size.Width, Height: ws.Size.Height}, + Bounds: Rect{X: ws.Bounds.X, Y: ws.Bounds.Y, Width: ws.Bounds.Width, Height: ws.Bounds.Height}, + PhysicalBounds: Rect{X: ws.PhysicalBounds.X, Y: ws.PhysicalBounds.Y, Width: ws.PhysicalBounds.Width, Height: ws.PhysicalBounds.Height}, + WorkArea: Rect{X: ws.WorkArea.X, Y: ws.WorkArea.Y, Width: ws.WorkArea.Width, Height: ws.WorkArea.Height}, + PhysicalWorkArea: Rect{X: ws.PhysicalWorkArea.X, Y: ws.PhysicalWorkArea.Y, Width: ws.PhysicalWorkArea.Width, Height: ws.PhysicalWorkArea.Height}, + IsPrimary: ws.IsPrimary, + Rotation: float64(ws.Rotation), + } +} diff --git a/display/webkit/pkg/systray/assets/apptray.png b/display/webkit/pkg/systray/assets/apptray.png new file mode 100644 index 0000000000000000000000000000000000000000..0778fc6187abdbb56b8f7f4d0b9c78399642e59b GIT binary patch literal 32953 zcmZ^K1ymhDvgpMfg1ft3JU}iIAh^4`OK>MhaCZqVK@u#uySqz(;1>;Yk-$Yi+1>Yc z|DONnoH<=nUDnez-PK(^Q7TH(7^ozu0000(Rz^|{0DyS~VF1X8uZ4kYxz%d{Z~b2J zJpfRffc9vL@cK+;E~BOh0QfQh070Pu!2K&K=nw$#-~<4UO#lF)OaOq;Ij2n(^jZ;W zsUvHps0d(rg^>XWFgO6XR|w{>78nx1Uul2!$-$8RFRTW`^e-A%03gB^0RJxHtz1uLO`IGmluZ@ z4~LVhHIPeCP!Pz;4dmu#f2Ck|^ZDp*>dpSqjrN~H{zs0arJK2{t+TtW(?_bma!t*g zJlut8X#Oht-|L@ty4za)Thm9ke>Lm1fxy2tKrRkW;Qx!w(%bg`!}ho4pKSkV*FV*P z{t6S)RkL(+a`5;J9hKau~< zpzLaE`T8RNVHelG%=%BYIAmjt%%Yi(uu4sf6qGtTZE*@U;rK2M$Xi zKj8l88Zu1j1iPVUL(hCw5Q6!=Qn#L=H1_GwT1}(lTGP_mh=Gl+)*V4N!dG=d+?Po0A7t&HgxI z;%M1|-Bz6E?fSEtjb-0R!Z#z@7Vva`#Qnwi<$l>d0d$FcQ-+ddMLdb>aPAa*ykR_$ zhOx$x+cV)%!TPoX3X+jr-H)4sZiy_nZJ} z`q75pc@+q{m%>b@AWt4UW{7gTL7+qY+Ebr!<*dTDt9E{Q&X-mJkB_2^=M{>Jiv*tN zi5{mhFfpo#NyuUYE>rRqXr5~6stvjVo=7QCVL$E7=XafjmM7?mUX~!hP#OU(1$i8>A=G!iI*b!k?t?z&&qmXh_yL4TGcx(hrzIy$P- zvR8F7cG9R>E{4?3o~q)#7~=4~?Kf3m0!oam*WlAz56WIvZ{%%g2ffY8tKNNkGu`4A zSR|tP^M0v0?|vRuIZ*BN6H@H+ZMbx}kyb^LwcKS^^)jPsX<*dcot6UIN58{DCy>oD z(#Ou5rH$FyS*ckUT_`K$pc+AotjBHn54qb0fL8Q!)xLr=i}R)H{`kcC?Z-TrkU;Yi zy-$NwXBR0cDVzgG<=uf)cKoEOovML=@W4_1rI-7%m_AXDevFz(z1db5l6+xq?hYXN z;NBiQ0g@%EYH;k=CH0tBSAeSTx!~IX1Lw1q9<*5_B%V%0-LA4znC=Z{y4uvfN#Ww+ zV&}>P1_tu-^M||gz6dH7Pk)_Uic6=AFGHG#Q~0oh0F+s+s~jLK`)l$}!)}qxwn~1* z-N{l_KgeMJ7*0asp@UhjsM*}8>!Y`~=A6xft@|XMY^jMiTuX|ydJ68Ep6zwuRKV4j zxA;TH_oKP z->53Ks^E*ql)rb@WJ$9zbKQ}bC3%ACT6Z$P4Ln#V#Mho4=-WJB{NAW-8cy|STfv9$ z1)s>6(?1=-mN^;g5c$x=WCD)6uUuImeI43XJhu?ueYXEq5a&j*JcXPko!O+~~{G{0Ys7D|D^6Knq?D5E)qP?jw0kmiR z1vLBj1Bcay?SM8mJI&{_>v?70RH&<6kO^3ohICi3<*%R598M%h*MtXxm#To}yn2D~ zA?+sK7GDF&+>Qt+20~(==C^2!os22>589g;ZSN19e8%r9m?dfyv-vUp++gHqGaQ{` zVPSNy0FRkPBkq6nS5{oPr1fzYt1{Ufi3u1eZzF#<0FaUYf|7rI`B+e6y^F$_!&%yZ z?$mLV?v}Xtl5jLM{`lJWoPs+tTz20uF)&Phf51&q-!mEX8|QmILPQzz9kEKatF6lr zR{b7-j(Yvv?`wblbGoKpo$9pSQd_gM_A;zv*llC6_+8ULGGu$+dwMGapfm4 zJxuuf+P6V7&!byCTQR-z{TaFb zL9;$1;5hyX2#S@d?nyz{Z*0A^o=m*1@hGk z>AcqEM*Vcae6!<&>D5$Cne1Z#`i=3_I}w6wQmO-k?Ec>~`7&tP;R-tD{9h-uZEg}2 z*Sy0sbWn#7J2dCk*7T2h7fhB94*P*=8zgD1xJS_0ea_ho_6QACA6{`N%O7e5M%BkIb!Firr;+hE$s7vKE?4#dG`t6Q zh?`C+&fAtr5O;oBdjM;zgN^rLq@R54=>A>3u>=hpioVij5D+0FBj4Rk~ovrv6&5uWs^|=E04}^Ns4Yr$Gxde3tpC zL+Ru2)5aj|-F<$Zd$K-+5zd(Aeim2s2^TbRmmd-$p{am&oO4;AptGq^Rx2_SQZ-?~ zw5}C5e0h5D zS?!*Mk3B)T2+LwU#l^t{Ibx0u?hdfU!J9(h)ZH2r3U>-EL{rw~vqrH};&w*su(f7@ zacAd6UrL!x5AOWr_=Vb(pg-A?9e=Cy65MWLp(tKAY;gby*2)WD`l_Y@!~3SJrk|tz z(CGqx>jROJ+d$8D*E)Tg5{6)?q<8Uz1J8+332s2pV!7LeLMY?-9eF__0ag8E|EDa{ zaLztiyK+`9!~nuj!p`llyP_vQ=~}=`?>3^4PufuD^Bvs#X8~k%gebF0+Rukb30>Jr z9+q+@`cAjI(7d%zKI6LL{zuE*M&Nf)Z;qEMRkij2Z|}VW7o-6iL)3RYN+gweZWZia z`--s%hLqBin*k^KoA;DG{Z>18O1mFgP6BOAOb)LZ-a<_Bu)L=(e)eqitcpkM8XM^@ zvvi7uq>Nl(PIY%I@U^7ks!QpIg6H$HVcz2M)g~1V{fhB6b2g;6>x`I(Z9*Gcx?evy z6Xc986OC?vCM_#vsQtmLEGh7z2v=6SO3(0D?e3QdR+p3I-rGQJDcDjEFP_!fWl#09 zI zz&_`fX=g^VEnk4kR*FwoiMlyQc)Ss_VKYmdnChET2ejMgo+NlwI`+Pp$*XU?32gA` zx&EA1#L(E-SPf1RqL}$>Wn~fQKzE}<^SbBoJF1)WS;T%KjFECA@1|3{CX!n-I!=_X zxB0GPQEwer5*Y}M*&G|{I7~=g@*rRQT=M$TX$5Z~Cq=(X*|lF#&dvg(-tOFK<#1dt zdJutxggpJ|&x8|@><$eB$J47`n7<8Vn9)vG2oerC=U-VX;ji9Hy&p_G%~O5HU*gij zO~iFJW&k($h3dVY;5C+@9@vTX+n8mhglj?%?~=&f+?#ywb;^>stJ;|e_Mwk>?k82Z zWMqRk<3DIGy<_uQDhe>M`L@bNhE?Gy0=sVaa1&mrO4j~Z`+~EK(e`|;11eb;NI9oB zw~T-OYJg_RBSj!i4;!Lp#a01rH3pqfqc_IA4r+P-L^%1K`1&-IuzS6F^e4)^t(QT7 zQC9CfOc#s1#@ew+uwCTf%=TSI#)(o1cI4YbkV53u5GZ6@-)MOB(xy^Uiy}?lN0+;= zm+4zHA11H*J)_->oc~Fc*CY0l^fRg-`rU{+0ev8A_#K={=ik@qv{k#;G(Ls*z0 zx4R+H$>sizoM>FWS2gQfLl2(ROMmoVf)0bu+Tm4kniW#MwG!tqW(m*0`8%Ixnt-dN zDp9Y+vZjrNVo%x0+g(O!ynQFcD9I=&S&VrfIq|pwB`R5drnBoykyPCovHgCsCgKh+XC{0otsU6x?}Vz%Z2GSKbb2o5pH+PO zix(s$;(^cv$nWiZ@n#c1s7STGw?vfhq5;WQEL1*l=Y-gPSm+~571HvvfJI?K1Yj$!47j*#spNkQKEC<3=d+{impIuoMyS#_Vz0l|GS{bOgwVx+e zU&lr(dZQ_Lq2CAXw*nehtRWKp-9fo_yw4;QR?Xp3QY{IeNb_9sKW?t~?{?m9@!es$ z-eh@k5TT?sPhR9eY|!R84GrR0P2lr9vM(DW#@@d}>x2Cvqa3==>;i_6$(^SZK4x(w zBK?RHfU?OyFP(#6OgkLo{su!s%#eR|OGL967a1sS`-+u{nT@Q6+LoC(AC`TBe6#d9jzdh@5I z1&K26HayBmTsi&KSXGMTGCaDA+(6-4k|X)CWht}P;Y#69<7y-3xOT+;c+cPKtv9uN z6;rj8BCStT?V?=Vpv&sqcF&zSR28$+4>>*6xyC;mcaMs_OM9YmGdoYUO9Ax_*!;JV zkQDTxKvtte)m>$0?V1a&#lxxD7@NnovB1|*sMEbI@A>3nW+uBuAo3mFrX%6*^6%)O zHI%$r1k@;tiw>>~k~*Yh={w#~x*`zCpB!SZ0u0`~M7sNwkeeN>)}i8yN!?}af7OD9;THKgGT@< z#2ON#MyZXjdrPLLxW)@<1v9X}$I>b!(0cA_*M6IwsmKDmUB?$&b~_L#hZ7MPhyt+o zi>vICb6o!Xbm~G`3$pK!aO|==VI~?diWsaEaj*xEzazVp2(>>}qK=tCDMLg>Wj=Cn z=k0R#7|HNXvp>>o+Pacd0CUL8OLFDX{)u7(a=D7tC)Os*m%IkqlKqIR}<+eN&Wn_@9xw$D1upXCKZkVX<>~)W`!#%fWBOw&K zXtDgDVy;FTMnpZn1#MXE*at_+`ygwyrW;wl1MQ_m{%om4_M}=GK{F{ArM?JcYW{63 zRV-frBbH)CAPScxko;WrdfC_SWBcwI&|t*+#T}NB+-n`fC94DVsTTLCkCBUcgVd(d zsPmpo0rxq_HlT@Z+>72yUrc|qPiw~}7=!f3xKscA(RlTWI|aqfCs#B79AN2!3*&MB zQOjn+{OW3R_ot^@0lA)v()5gW{@s4o- z^w^V9syhtU#0B+OI$EL67`>X_63)Zc@$Wo;9kCoN0RvpR#}=;nUkLW$(-H1Rm+ijR z2p)Yk@9wx{scq6A$f}+S8W${O9nLTgfChM%+*0TCxdHM{6HH42-*~0hC@IEP_wV3z zf5vrjn~pc!^)mInVC`{nEI_26Z2ZPRyxQVujyAm`w>>!#GkJmP<=*>vAcxeXKwx=i zmOYoT17|5!?w(qt-Gz@Q^fH506dl`eBGjaJ+k@iYy10JOf~({mMy4G9p}X+N$`_c| z^~jw^@%#AXlzhm3QFKVwvfKN2?Xbw;hxea+Dwx~_OSP8(3N>y5(`n`~gNw?h$SAw{ zt6}fPsQAm*MQ61A;z(YG;`FiBYuz4CR@Sdr4UUi!*!~c^4E1oE*Ln{vdh<@~lIQQN zN=SG54NZeCu3+f7^Zkvw$f#U>1UpLH{P#RWORqAdnckOiPUGz*ts6{aO+U5bUM$Mq zSQ`&B6g)*8ob?`5QTXstV2K2kA@#5L)#6H5i+2B%tNG)dH=USa1rKaKjE3VBo;tte z@`2)KZzMcD)eLXr7iYd;os;~6_J}#1+6dR=8DGfSO(G#7BE|QR;bsS4X8XmCyqP3T zg+nU&lN8JFqwzyD?-bShC5Bf|-^V@-)o|Isj1{!F%uT8XNV1ATZc*EO9CzkmdKsSSrVJ+_|2G}S8G#HO*7^bu>JD8M`zxnvas^G%S6`nlof~s1_d|ZT8;<~2`o(;4)P`fwtEs`$cVU~ zQv~cf2qd1Ik|O$_d|5jQrORJfq|rdpwq|x_r=!D^-HOXTu5}rvW|r_lO3Q_!&Cnf* zjO-SKv~x)RUH9|hZC9t(`Qyf2x5DnQ{dXPP_y>*mi$BKPWn4&-rFKd7p&joD(ba6< zt>V9tE57`k!ZglPf7(;_D9H>owR@a+m(dLiGO&@&r*$nUO#SlAHN3OP0Nl%%g&ly? z-iE)wP1rfs?z#vAK9Yf*t6wJ6kvb^!8(wSyQ#nu33F$^ISCenCE~L#;&4qmvGAci5)HNIlNwMy zJcH7j^o7EX+SC<=0!>{x35m-ILE^Cjciq+5iX2dTN~u6T4lO3mxxQ zV+>Zm^*D!W>-Z;Os)juN5mz)|>mE+dj^FmeP+Yc>;`hdBEtf>$@IRu5C*Ph4$yT81 zO-LKh@9-i#0?3UIercaQ+LFYI@+4L=vrh~)I>!89Q$%HC48fnDl95d365B?%?Tw}e zZ+FF#$Q({gTCWZ5-sOUzq?HMO&Jlcg>G5l56tP9s^`y1sLQ+C_N&3Y4zrl=$Xot2^ zBa=ov!%1=uRH>+1<%>KOR;Q?Rv*E<4Z`>q zF-9)N_gkf~u~`-)8b|v$hzyb%d1>FcWe&zP)CdG%D3(5df(26M=X9ks94_y*>@NL^ zAdKD!zimLiKX2anbel8|t?IQr0XW}!y^WE(4u-=N2)cR}6*_;r;VG;gUTAVOq^NC} zpdD!3zuT3VE&(d%@>NBN$>=l4WVU=~h~4K-+}SsFNX|AsUnXpeIGvH=9DH|)RJ_zc zf7`$^{v|sdB_}*WH9r+cMr7|+)VMNHs|DlTti=VUPmtKjd%dw#G^L<~m!(RhPYVa# z&gW$?PA*dk%_yS6BK*%?2cj=fRle{3K@IlH)!?6@O@EZ6GF}YRF-_nSu7=P+`Rgsv zfls$<=c$aZ*|O+O?*~E3oqAIdp*FMm#lSf2!0DC7aq`>7H{>eh4IS^fzE$TH#47o) zaWHvr*)MCBj9OGs3q+wx@59=Z-3sCYMZaROw=zk9nZ6vLwxFNeq)Bhf8Kbl34#}GD za*#*U(`ZyLN&A56ZjY=z6?RX1ZoD=>og)bz1m0~&_;KRg^&|v7-lad?Zk{i+zYb}* zRK*@^j?ImNzDcu#fgR!QE*NNBK0$jTcNNH zWKg2;C;vxjq|ppN8IFu0V!QPnJS}utb5mUdP8yn!sBgg{>emJ9U2I<`55t7l5qJa; zymPkQ1cfA}P7dkRCGm}{X$d``R6n9c->oBC79Ok-Rk!43v#mr(X*S&l{a56J+~DGPZ-wgNMXo)4-0p| zkkZY-kUEN`^1+jpLf&WA>Xu`VM!UEn;MIp9`L!~IQR@(9BZ+;%6h$3_FOZZ?qt^+6 zZ(_C__d={>^wKrF(mAZBR0O- zvdTT3A;br~+=CNEw;Cd`uL58;xV)A6%2~+AO^;fzlWT*GgQ`sOfSc7v8Pg0=^P!q~ zB(ieJ!194r$OZUZ=z1z#o`AGE)S{evwu_Jw|74mQT@_2!dtlx_Df`?W;K5MG)Us7u zf&qWloJ5aSa*KBZ0dXw|F#-C5^d`#7zVjy!NJxLaDKEEzCQ_`{@Wk-t-_Wxp_cyzE z!8V}Pxxy~#e;>5GCoiv40LdIllq(RI#LQ-*E2sxgQ}JY~1t)>$xw(%5rM?m|_*mbA z4`W;%D2y~1_0{!d1?`^s3>R@HpH_axO@pDLGfLm z)MW_7$&)z{(S<8`zT3AoG+l6=26T`d7ouf^K~DK%<;vAIKDWXi`Js1%{B`y9wkGgt zRg~^CT918R^Jc_%*=vz5waR-M1;a;c-8jHXLfS^JvE5MB_-BZ5066>p0SP#;^G5j$ z98A%^_^Tdr0P*QJABqODhVNV=urb#lrOmRkrQ`$q9fp(x(h7%^QJU}sN!oCyiwjdX zx$k^g^9qen&4n_sY(cqm_Dl^%p<#jo&}0e_vCRz@y1_McdXtqA6#_2M!Z8&3W%r~# zXXxh$y~lF-<<3ru1o#l7u57wJ8Q8wH?nY_G7+^M@9#TyAQ>R9GoE-T+)jpx<60}BM zGgKZu_1LH|B!xa<-^pxKF#V;Re#C-?A+krElP1iMhEPY93D0(fq3|ouXY{2ryqw>a zkLl?+yzDNe#M@xfKFB+vh>>Shr=eb=_qLt!^~O6kySZK)P=7VbZqL*HNxf6*=GvhKK zM5@tK5Tq@(q%A78T;RTfB{DQ35?4$M%vu0zz$DDwfJ{T2PbVIQyle~*qK49`;Z-!S zBuJvp3rHC0K68|UbPo@&oos+!;@*~R8<|7Jy_#uy)A(O~ULH0n^B>!mg;Ac@9VDRb zT$mhls%rR6fgwg&*W*7tlXC0KmWW_@Ruv|-w0r0 z9vQnzk@zt&G3bc=rtter{IW~A=H8x<(s5|IdaYOQrKs*v11iUh2~U>@ypP_A^?6NMAide!zah4~Ywjb-L@z3}AjjOnAU# zqjv^F-#5_=#{h(>)2A{z*&VHl!9pW?YtE$=fBn?$sIgcUr-{5OI3t0uuQ{STA5Q?> ztLXW?+{JE#Z^6M(EB7voRw1EcjyVh48ip}kNr9F**(#7C*O~41A1vA-`*G@{L{($%sxc!mVSod~SQ3t`j81AjcAp1NThx z+J16yHenhXdLwd~;`Pv8k#9S*AyDtv{LO5``HLE@*2CmnJih=9uq1Mscb5Nbln_s5 ziYbwV3rw(!N?Vo5#|kJbyr_%)$xnu+LXh{;f2Xd5;49@77eIF1-DVJ*?^y4!1Q2BH6~jJ)Uyf-?E7A z^84yMzqvn%|Y&kY)NIqs)bgk#5h@?wqx)o|lc49P)ByDsR z23&tY8i0_(qPIV>*wnTXT4!Wp87Y>As{Xc=`)))3ta zhIPCv!RPRDdbiHl_Z2QT;z*_^skOazfJZzkNy)N80Gy@@sC`U0kI>*EwBgm;os_yp zW6W{BzD@79ecuac_|?W4`+96iW;R$W_3^Z)hbk_=;<@nb`$c7Y-1I7e9n}?B-TpN3 zz12rBeEm*{J{I=E_&6$dDgH{HvP}e8XR&%tIL$XP>6{(D>Z3pF2C{7A@>)_goEVzv zr4n%sAhrm`F)>urE{=HF_j3;XT&P4OrlIG@r;Kr%G#%OFol=$4pvxEIvdr;8tR zA6mZgK0MOQlqZv=yN67q7zd9frgc$S0Af^M9H$sHVMo6&(OCsW?cZ>khw&5r%LgY-T@wVIyqJ<@w&E^ zmc*n4_xK1L@%3Q3Do#2&iDylnG8JU`Lu74=l3GsU0@7k_5lr2uHEZrTDNn>0*sl_8yf3`7?p$;Z!G#DN*I=qGF*B^x!rEF~T^!U6k-S zZ;_Ew3)(5{;b7(zg6R}lg>E@DZ>17u&qSnXlL^NH6i+D=XOBrQCPLKnAY#za^8hIz z5!GKSm1BGyE4n7J3$QGlOHEswW6cd=<^ur;;7uRr?}}!UZmh2T22Nn>z~|{LOC9>h zxW~P%qybfNq;DxA)jxIXRxN4m3LGLzz3c!gVL7Xh%w&RJBaj%>Vtn>Eg1$Zjkclf4 z`(RHH6bB1WVKvUsiqNu4U3E}@Q(zjELnTK;4V<v%FV;mPFjhM6xwGj9;NGq)kC4zXZpK}rMmO)+~g5P6SWf^oxuSU4$+ z`JE}hlA3(kC4OmP32pb7%DtJ5Z6&(=G9vHgTcjf-|0l+=`SWx-CJUy?c$6g`nU z_FkX_aWs`{ei}{7H*JlX&L?T9mrTRO$brB+bPPZ5&prLeAtFk7|KV=ks)ioN@;=SUIX zx_+!xkFut0U1R^H$?I?~r)V;yCZt8pStLOX2Tmh}nkgoNh*l&C<1nN%iY(iBOP!%E zV}S=reRomiE2??6KP($?m)dZT@N6m&*4P>+WYo?oh+fqnrS7N2mp@gDc0iim=ZACA zcFZ)H)$I3%+SL4iQj|rQZ~E5AfnX%+LOalJ+7l$w=>h}Cx^IHRaeIPS4cJpO14UOJ zqjb7i06sDMyP;}0+cKH>noyZ>^SZHt3a;$L>)_!5X1#5G(l8(F&uTjH9OL+((T6y* z%Bg>-4$BCYOQ3Dy44_FsNn&D(+bPGCsbcr7p^W_P-As(X=hGSz%=+_C<92NQR4<1A6 z6jcpTsWO43-hVHC#lx~ zmCL*p#M5O-IFQQhAx1!RRmlEhDl?~QqSfEcF*}O=GhbC5--N(EX}zPBHy`b1SQL3O zoM0luc(a0=V7c8r&V((4x?BNg_D-2)L4fDX>p`6wWdLSxSK#&7x=>Zhjxek626c-6 z=sSUO?#>gcnppQLx(UK@|`;aL1@rvJ$=pTn+id@W(<-;$>)BdZ@$|Fj-XD9L39B3Pcc zK`p#oTi%$DGmvD>H96|8Um`J(eo3REH9ABVBQq_$_z3?dK?4!VN8+fh;)JXxi_d;Q z&TbMOlZxb89T2-S_P}vYlxGMxeqd-M^3L-MZYQC6TxG}}La-yxRF-tqEK6j^Sj`WW z0)DZc3Fac#wMfNnMc&tB5Q0Cp*>CFig8C9fFEyndPM=Qnr>m12G|gOK3=aSo-<*yL z8gOd1y|#o{LYj&fLmyAIm^l+iiegB{!G;_@OP2>Ll|5ud>|9=BuqSLox>`nImW8BT zDyjH{7YyV6f`)QO=wBqS8ZhlmmxA}(S5Df>N+XNL>fc+W`iW zkyKH@n_A8|d!oY*PBd$SKsmyTuDU{W>Y`LA=LNV|cZ_$V^7VN50wR_v*Yof}Ka|(R z65eFhswR%Ydn>qz!~2nUhwxj0?FXzMJv*cyauR6RIh%7_COK$X4G4G)N_skgDDkEQ zAz$o&)w5x%7fZTky?>2QA=#BqYgZ>W8oqp4l<5ihrabzI`p>D|>~C@7m=pb+KbJdS9oL8GK^2BZI)KCJpWp)GO&6L~ez<6y@s09p<0OnC(-q15pxY z>tg#%1hHLure=|@%)25tZn@M7T62|`DZJ>P^I0}v`%mKKVPZrkU3reSb40$f<-PKm z)M-kVZ;D+oc#hHEQtBLHS`b|Fz`|~TC*feC!bBi#O9+OS&1+<0k9WHX_W77~o19^5 z#fFw?b3@kC8ZlqAchH5}L)D_GdNAr^c$;ELu-K@yGQq4N!86GX5N53YH)Z}W^%JP- zQF6*8afJP3-N2YRog8*+W|0=aDBJKDynCb*f(Zr{Iq%4aEOnBoY1M`bGQc~XZkV9> zb1c*wGCq#_MmcN8p`r1ZvJ&9P57dfSV&)$V+YoAC$k?n;3ws4$PGTJ4S9cPjPVOCh~-&oi_97Gp1a{A~cg4J=*>Kmf-D|XUHYkw#h`Ng{5(=H8# zt#08Da~ee70tRi%-Lp8tqcdj4OWY@E(5`&;?_``}*TLB1Wqll7I^Q+ujO&i7T><{s zhdVLA^t{1=IL6=+5y1Q+x!?5MZ^h1Us2r9o%odo&TF#5!A@v{7BG)vIPT6LpuTur=i5i!> zWh2Wf8YAh?FEE-mREMM0K~@P^_I3a2l%fVtb!sV&$nbQ@Uts)(t0y$D4W5)n_CVpP%YUv8r>5L#Id6thYeV5IY zw=f{CN>NuhaQ%!vNQ;A>^+QSYl67iKt))-nBY{wCRqjv>FU@#!$c{$!R>K*E49UnG zc2IO9n>_zUBPM{-C)=l_X=Zegq9NK;cHCD~Ia?LJ3tjRZat2tvV34&o47{t9nPv|8 zqL6PZyHT_%N7VOu{j|xEk%H@d{{-i8w4!IT0geVxD#zd zVLRt!D7G3>N9?UWsp-nI{nkSYDkK3lo%S76TVH-(Fv8T_^NbbWH^xe{y~0$hEPY^u z?PQ#5NEburqgz1Eh2S_G(Nl2qC@{$J9$8Vl@pg5HJMG|=K@kNJK*$Q!9N$}6oJfOV zT0lM{l3rPI8H>j|l_%*f!&>x{EU5|1BJ)m6ZOb>@gC9eZ4B|czU+G4kGQ}176C`Wo zU`UX06E8PhPV=!{>Isry6{0TQH-yED9iVx5LVo!z5|k5Jl5@z7l<0xb z&bb*!T#Z|*@g7~DdBZ|2ej54G8>yKy&YG+;RGAw64iucKGOFOhV>FLLoIB=0=FW6_ zNgmhN$j0wCXe(nIn^8gz(iWFh$-A^E*WfpdO<_o-ugLt7%pZ|~cO={)c0OAIf^XY> z&#vh;(7psCa|?g!j9O}_wRHQP^J}dO7-mLNu|u+uty^v11Qi_z%_n^u!;#=rVdN2y zRJ+#;}U68^D@!?h_Pgzndcm^|wUqO zjB0X!<1gdv2(mw*nsiEVe+SMouxV0yMv1kYtq9F5Vao51nSW z);+5^+)ON(krfeg3CA>mkUl$sR}cDXFOORatML4FH!Uol>1+QlQD;9`!j;g=(43yn z!(#|ik6fQG7C4zMEnDE{@Gks{UH=VX6sFt){^%ZD6n_ zL@1mS60shTJ8&E)$|%(8hpP>y*(ML(iW;3`Q!dNjbGj%**AlUeHbCU4z9j43#mTv7 zO|})$KE&0q7)#dcQ-uT040hPo3Q;YZL}dZX35OctS+ z3XtxPeY3I*Vi^u%Yma%Cn_rtl9M~(?!8;itTS7na9GWVlEGB!8n{&_zsvk^CFjY<8 zrxlo-kH8%2X2TR5J@4xw-1WrP*=1|pZFPE%2N6GLVIYYgq05Zc&txzcdjQfhdWB;M3AQ-7 zpp4Gb`jw1xUzhV`XgZ(k=Js-622-D4TQ7O8GbSa*4qfErxPy7{j^bzMgh=2BSN&H@ z@U|}_Iyl;O+w?pwOLX|$*FK+=%I#?^@TERGsEYf59>@-MlHDGEb2+6XF zTaJ7=h#Ed+C@mjqw?R{^vUN~gh zd~fRvC^T2>e%&f1O(rh31QC?FU@$AZCct@|$E-}{tld|N=tTbi}xwp?(;TQkvp zppi58MYASE^a_?t%3C~lptxPtjW6P3dnLTOg$B@QAC#)pNqpWtdt|=v0@}Evp}$-2 z_vX8%sIdDu9_BSMgCc?t53*-x+xaZiKQNyH=2+q` z?aPlVj^5m(^RjbmEb4MXvAz{1PV{2GN$>qhaCY!Pu7DQ}<@HDd#s>h+i1~U(#C0Jk zT&Fl1?ONYJvt*@yo%+^3+ z&hpr_b#*sgx)ya_Wt+nxqtgBRfRFDqI7tq7L8zzG00Y%oI-xtrx+4oW6a7;US_v+b ze_sXIh0Ow2E5AlAhl{T20PQi4dT;m2)zYO8pD~yAip?6`eH^|-rsR*f1%_e-GI=iL zk4$8SfY{YX64YFvkGxYjK0gwY2Kw>DDGFV|F6fwVY%mf?VOoZRh%7G`n5R3hLtZ1E z-y5Sl!Yd*lFe#TGZnTxnl|ZUW)=%s=mbh z-`7Lc z!k^Uzkm0H>R}uwJN;*goTgIr^M|BATxxUM$$`@0o>su0m>AkQx*a^+9Y*{XvVrbgF80_(69e8Dh zB`DlzP}V8}{Uf}O;CqKlXgNqd98|-MU<*Dl`8uCpn0#MPb_LDY1&%HHOAs4!rAm*V zT#kf{<#S1#`D zsA5UIG8BWF)Q^u>C1Y=CDYvXt!kre5%AsB8kb8*t@oHRBB|W9weJaR+A{0SDEV*Pl z&mVT?vubi#b787SsVF@t%Q`WCyg#FVe4#^!LK`RL*87bzmR$Ecb9a8e;5Z}&L`iA%WnkP>V9JS z-RdhCEH|vN#DRzuFf&laaGT1fO%`OIRRVb{n_R0U--RBlX|Vi2vPuGwDk{YyW^^f4 z&1;EWF8SE`!8*JZQ5&RXF!n5<_RL^ad1M}q8Q)brKrr-j9EjGKDqj%l@_ zWn$xGod%Wry?}E+J$sj|rYQ4ay?~8UCP7B3HM?%CUGDhJID7%26g}8?8{}UrV+`&cw z-}=_KwCGy2B^`F1132)&1D!6#tjKz zo}Rc;saBH286A1(9Q22@j2fT`X_O??Fpe_79#g?Y$;z}s^QJP>1T;-rjZ=r6je!Oc zO*?DKcryAtoysap3IN&V3}U+nQunt;|?Hj2XTJ|fIp7a3m_0O zEJYU2V#?)YSU9Vbwoo>72bOxJNu=%B2k5f|-CBt%34REr4kW^Tz9GgwO=myWXf~dn zu1|j-McEII;H8av9TaX~jMkBPlb`*bR=B3reOK2xfY3$tXuMUyO&pi)P{jk*?Ca~V zj*pLSeD`_3@fZK_4_7{~w>KVXwVKbLZcQGH8{i9J&o6}99tubw`Uep4 z{j}NoL6TH|Fcmc)k0vLdIsNq0pSk7MJKsAvIQW65pMJW5{CYxOU|_9ScZ?ERX{9?C z0lUrtpd&?FDwWn)Gfk#pu)XM1!o(4XGK)GvJphJt?_=_Je&-!eA>i+OOc$>$Tegf$ z$I-s6R^!0tMC%1paeN49UdSJ0z5p2<1clkRR;%^*_Vz+MtIoKPN2AfiUB@{Hch?fd zmU5yDU>9SNKaeM(ft#X8H6+clldnCb!P20pz>G*X0xZ%}5YbA{ba6D`Xq*fwrx)i+ zsp36e1h7oP#Xq?V{GJ;nLCmLp3E3Y6#WKZ0JH{|_-p(<|O2k26q!vw0O-0l7`ULLg zKZS(H@%tf4@dJqaKH@*XcTC|TeoiQ-*3PD>Q6rTWWq;@ zoJt_wTDx{_lZtxIIp^#IKYx!{IU2w0vYUpJf$`@h^~Ql?&E^X*dw3xx3Wq_R4}}sP z4Atk{<|g_53Ek%zPn?y||HiOT0L)G3GM2^<{oY&Q`glrj$&e$twln5dk5^2t-cql{Wih|$Jy64CY?bVO5)T5V+t z;M!#G{)?S}(wmifeJrZT$Y${dx{l)APKA4tW*Z~Z8cg4jWxy(frB^{_VXfG2}Tj| z^P%VmpsZ`4a06U};1HL_$%}U=97`Q>HJZ~6IExnengFG^J5q;1NaAd&K|L0BictOb zKuID_`D_o(hiGTijv+V?79zWtpUlCzmJVJahNuKd&cS)tmOHl^x8~EJf#VJi1Lq^P zpCPmVK=>oH#rGlPF;w$0Jaqd3D)o4^(fH|!C!8>=(XsA$K_`K;md4WlVR7NiS+>@? zb?f*XFW0yC+;a~}(3ZY=r1Annb4is1A#D@K6Y=Dvo(SZKy5w)yjR0tr>>GIa$$_Ds zaaw^@@?yVH0eI;SLYE4EQ#b&x4FP5&y1o$Wd*YB2tFs^>l@Ly%`S!QJoyyGLWB7dq zzua}xO*aisOtkh-lGZ`flamKeM{o>q4zRUDu-5Q=X!ijyn0>G|IXE!Tm+E4W90h-$bpf=xDi&;^v7^=Pr@)qneR*&oNq;qn{;eX zw67GA2fz@fPMGoeU-SSXMp)JO6(sG`O2<(*ITqYImHB70mvVHS15oAo>Y;~Du6yd4 zu?}}J>GZ7I7u-8CJonr)inDhrD3OdSJztX!;=>=enm^)9xuwjyfX4)-Ifcc4{6AtI# zY(I*y*jcUh)UcxNird6m28ZbNdVLZG@l)_{xP>8k{C$iAzK_w#58;@8SgXcQVAS%{ zYc4?p~H#Az3e?94OIC%u+5`ZwWy`H2BO{IH)u-wQ)*Fq3VZ@kPM28{Y zgOTw|kUkP^K}%*i#Ky%DXao7S0D>{F#p&e4cXJNaf7p{GPhh_O z)JZ3u%r%i2FFHOq2Y(WC@FAIlV|122^2j4CI%_&V2qqY=j+=vE+AIEk(ndZd~)p)-5{2U+;d{$GgtdeEX5W*US4uf@$ zyA9+=y6FWgfJI6%MjGVzM3VV){q46OluR|hi3P}2SUqPR((lF5fM;o9Xpbp)V^@7D zKIV)wPWXutRTh;sl4AKt$U8ZvMs&!`GdBk>6ZBlctl_f^dV>(1K^iU~I6Bnlxhm!! z$+znq0Q(PjQyH_u|y98YM0@on+!-6Gl*^frmbI)Bj8IO-oV?lHr zZLv!4aP*6A*y|i5Z*%T$7aS!IE+sy0!sGrZT*-=m>L(RkLGpyU_te97?0cST0X@xVt_(wPZ0+t)XM{?DYui z*qr8?BExV6#s#ce#Vv>`TBS6neSX3ytp>ewqYng5TiYw zCXas)z&BoA%2G|a<=v%P`VQsxXM*6SpFTy6*GoHB({|_=U|krw;DQV4*fPK=fG9Pq zs0!}l;uv_R<1;_(xq$gU6ANX z2cZ3eJ!yCrU4BVVc3pSo>#C1L3B?^fJaf+*f7U|um2RkyhmC0~(C4LgS4x%uvL*-) z62{NCY^G?nVr*K9B0k0FuG)9$6ab1Z{e&Cd&)i`S{;?OJs{E$UyRA=i^`R0Ifcna| zS=L`Kjcc0-ouJ7Q%gVR%D^GWZez+gnhK(SLp)vP9zM90443&_Pa( zTj|f5aPd@PRrV#`U-K$HSFE{BmP}CbQLg+WCT*eZ!O+H$;KKmYiNoLyN0%n78!yoh zQ!(ct=?mBt?(NP*MgZkIFbwMwjCQ312=4<_-~g~a6_+yt_Nrde!yNKDppF_8-3=iv zSD(+rrp2J?3iMLLXIdQrvHW2zKA1;j@Zp2e%CvPfw;A4S5WxubOvB z6G%0>avy*$eqoab_5mbgc&^=pp&UC@?)NgJg*EWmH)v$B>)dnCo#aLjvOuCczq%NaX;p4{YdTi5DkPY2wX2-Gb%V(6B0@M+JS&hT0Owry)$>(|Hr+mZ1o z9(%Bx^`;?R&bD$FUIdC`aRA+R769wll@0)gEWIbjU5R$U0j!||V5d_1<$`B$Z+OOU z{_w{>f%nXlZNK}w=lvKsKKF?rBi?Y&J+6JWZEGPdaN%7-fApAi)Ey%Lr_`$3(h~AV zmEAr1;tNDN0$9rD;G@qH8a%rT-Hr`B(udNI*I$48K~XY&EX@Aw@iBSpxSF+(8zpi> z6s{fn_4Ub3g1aCI=%%YH9RNB=I_km;FP!@Ok9_QDbmUWPW5(T`mE$$LO|eX|*wQ5h~qH6 z|MfJUS~oa2G{o}>@P;Fu0^U;gje!K($WH;%9XvRM!gXZHc(+{L>Hv5LcP|8`=~Ld5 zXT6KwV`tL|u=DHq;gGo@oDp~`4B^zVvGJWB{rD&E!I6@miR0e8LH9$jh!W`Qt5Al< zQWzC{$3MHmzUWbXBwA2((PgjuQ2Hq0@nsk=EvbC~s8fO`9@J;PULBnD_19m2Xq42} z0sltqhFUi~JUqhskB^T>W1~1a(4W^xm|hyEqr?D-$Hb{VWkgE6#KW$20B-dfy^}^i zp#wnwQn}?~2eWg1$WtYu^v!f^Y?KE>;*g8#NMB#yX~3U0IWhjsrJua~i`WcuOD$>M z3FUeWLS#Sq1}e|{-@liy88{CRC+SW;(n6is3Zlr`25gipj?L~knhYyr@)RH}B9dq? zx>ygL({pH!^0QCe%S$)k{K8rzJrQRpora!13MZ(HPzSKJIoi2%XB`bdbH|BsQ5g2K zYSagF$vY7cEN?^N(J~i{gJUstOFG)M4ghy(XYO(QB$b=?ZhMNIO*k(fM>3{T@Bq&S zoBqY<^)n#+~m_vK^skHyiH{bk%daHFZy7i2x z-a4vxU|@)K;US`v2myg|!!~e8-s6SEr^9NiMul=HjmKDEEyw-gsCmu3{lCJ$rpv!66gU+M! z$kHx5lAZ7TLOvwUU$hVzCSnK)yJ_+?S-FA08w_BYa1zezI|-}EW1qb2ihHrA?3QZW z17~pdBV2%_4BoX+7LF|B=r6*a%}bwMW5shUt@KReD6uNW0jz`U2xM8*5n#egIIqbX zUI%v3=jN|nYq1Nj*WYr>;pueqcy!EZ%``fCcxbSn{m41}=;#>d^z1WKiI05K8albe z$lY`QvYJtrzwph7@hC}~4=}CgAt~$4IVM3P!QhTIW!pT?;kh?oTp#`arCmwue=|R(r)gpCAZ=*(C>jn z=B9K8_`o!N+n%+326_i?_4kkBR3JK~0hZZbnbON!fbat86b=QN^+aAPV|U{yLzvTZ z3T2N9+xpqhz9?z5PDV$a3giD(-06o9k0xNCoYOOJ^r4R0q$ud0wjyQqY(o{DL=#iL z3ceil$V(4D{9#i2lZD$EmpG(r9RQ7*N`-6u??3#puhptGI)HYq@Eu6y@I1Lt`?my7 z*?!HS*&H3+1!oZBVL}DdguWBNcLGKpfc6dqb;{-1@F}o&6Z#QU*8!jt;P9 zaP%HsGvM*%yjIt06Fa6FJSM%=!I4Jm2oErhVKhFi09VC7d+4G=>@=sp{(9W$ljg~2 zt<$1L`kH}(!2ue*pVQOva$^VeSNecu=dXB`!SXciqXFl#q?}+z;kg4=lEmIsM%^@ zeSn?^StSE-2FK!<{9`d%nA~{9=KHb9_Qtpt-}cV4&wd03AR`FMhm!ShxPJcm=krNK zyC#!p0hdmNz&eF7vE0>Jzi?q(BM-YAKo$XEN#hvu$`pp@4hGsqh_LM+eDJ|44h4;2 zR2&%vr~IiKZ+vkzjZcIF`t?@SIBIBMxW?}w_&GgCZ=Bb&aoFCXySciU@^ykMNfBR6 zd%7<&)b}Zrx0}Llb^s9`-9TTcEtP+SQSuX0oX=B{s6e@k3c$M~l%M0ta5wbHK&y1c zkOY8HK-J%6v1ZW1n!z{)rf>pSGUz`B(}iO&TA054iY;FR_@=m(+;Q%?XMfupLj(uF zLpHfyu_$W>cp4%G%*`y`weT?t_@CVE_o~^nyz*LcyuEjH$J{&d*9&jcQLPfvF`dR>m*SI z8i7YLO`kyhvzLQgC*hrkC-QhMLP3|v%f0nnGZ>S*I~-9@e}Dh6kb5j1H%@H4atjuk z(wn1dax3V$>KxV#I7H^0c%knmkPjmbU$O`bd#*gzUR)Vb0uMwmhKh?lEc6&`)?J^| zx6SEqy6NZA>DC*t1^?t$+ITfK=U^d^A9KN+e#Z{ho$Ur^?eFp|y)T1lGm-LNb=`F@twqVHz`PO9>3?NtXi&by%i;a5UAs6UARYCQqaP_#Te*ms<5k96 zgcZ}iVOqFR;N!uQU*!v8prOa?+N-M_0F*n$(36WqwEFwenP(#8$*zc&cP!vy{GGz0 zCMX@@Hr_cc2FMTBS7?iP=HA3`m&%WUK?Ps+>3J<2)obA(K6u6EtL_KQ4H%2w_KtVF z<6AJ|+?oON=yYN)asD#Lq=)_e!7H)Myyyt@0!VfN(s2|<5K0n4@blYzvJeLuY-cfe z5`5}YHynX|1t(#OaRyw$uVU+cg}V{`LY~a&LB{^z)GR9inHO+rU5cz0F4LV*dq-*T z#8mh)6b4H75zCL7;0ZuzcgN8^f4kZN*ltRqbmPb`|@Vm zfYC?424SzloW90GS=jcd2MvjNusr<9T8z&d$+=%;hOFFWxVAh{K%rA67q+-ggm(n6 z5YYNZg7(&}(D`C7JhdDg0Pi%>z(DPxUE@=a)~eORc*+YvPT4!O9Ti56r7TpoA|2rh zE7Dk;(r5@A%j3#~ys4kuXMnu9C%tE&zke;(!PbtA?W$k7`D!RXz@Dt3o~&~X4J?WZ&vGCvr;;I6*zx|icy#c#ka!#6Zq z^v2h1`OCDK{^HOOAKAHWxLC-mCy5-WOa1B* z^UwTDPS!#a+FnK8`NEao@Zq!U70mxz53XPT<_R1tk(_yEHl-q~rS*a)H zc3*NvHg|1Q<Q_PP%Qmxp13vbPuM>3C;7brt_Jizy=jX-ebFr;`y-P!tRisfkLyNO;8&?_+VOKGL0E;k~e> z5_J6tfE|Hn;j%E5Ch7fH-Nl_Y5yhNqCs9%4;h;AMNt?9+l~V>+n5AKyj!1DD5^m#6 z^CHY&ECDtgxpfSLbv#DwfxU=9K7PazqQqoT1{=bxcF?$ZsZ0$$4D!YJWD!tL6A}jV z=jp=_7a@3lU)i;5l(IM#;6h+-PR}~(W%E(kT*A5X&6SRM7HKujcEz*{m1%x?E@kTm znq@Hpz_3{6s&S+7Z`fJ(7^VT#%+k5-Or5HVLK=;0sC=4cXyc04(BhP~2vb@O3wVy% zIY%dYgnzhePidGgUbqwQ;wBr=kT;Z%be_*0gAKYOuT3AZYnM0xIK>ffl zITQrB1rpD%X>kCU%g^Wl^lUul%XvR9l~Wd)>@=Sl!!j8bHY2klQLHi^g#js)nnt5x z0gwaY_-oXYdvG44E^?~KZVZ=;1K>133wjR|fz8Q7J@jRI_(bKq9uz6nq!2DC994RL6}VLB7S z%4ih^3aO{fXQsIF%{R=+G+xKTDYKXs82)fHh2x>V$TXbXvapS1u?D~neb|310BgEm z=4+(T0`54Q)6)R*>qX_EqG(9C#x+#havY7pDVx%fkC)0-co9aty~xKqFhX$v#QA6U zmMqd&g0c3oi_hdCN2UOlNXyQ{Pw*T-P6N`@ge)E7WM_!1F#-@@z~r-fX`6M*w1_XJ zOZepd|3>WI|JQY+61uJeEekKl9^2~gTG-|J?|4Sd^8pB+*EE0(yi}Pq>;x0yS?HC< z%CPZb9yaahaL10)0jzU$bmlr_$05k$4LnAG40@8{XhGftghQD|MBW7iB42L*g~gaHb1*zW>jiSdTnbt8FJO+H*m=F;hN! zBW~F^0Ny>((J@Y!;p=%kAAtR_;M5ogyNE>faV%Im<=Z02R$9XGe@Y#I!fmJ{3^)hmmk!9bSrD)soi1C4 zxyI)!i}Xd=on#bgk#5$oE`%EV;|A<%^gA3oS7;`|OANPc902--=L5LMRPKBLDhwZ~ zEx4mq;n?SlmxTiKf0SNBrB~iS>kVJiiZAjJF9w)KgU_#2rh^X`a(}@sKPt@ z6s;VxLcKG&a3$EwGgJ|}$Uj!JqQY7V4&~{^Xkmam{>a2j<#Y_gpd(~0d~@c{id>qP z7uGSbVMeY2)FuaeXJYlGK@r5434(RXaDL-R)c9=6D;+Pk}lr@gM5*-f%QTbRa z7yvn(fb1O1$&+$Jp0tNAGxMPRJDi4&h9p8OMDCB3Q5H9(Ge#}15?UDI@S$lB_ETUS z5X<}j7hk+~w{=4E+S|irxfY6k;PA;O_OFWj}Kq3>;nf&}@FZX73<$Z=rXx4Wr>6{j!9RI50m z3)_4HuO%{Zwu+W_KA{mYuV^t3WwvmIF$5gPfAvQAYSMS`(rDMe|8MKg__A^Ue0+t) z9XTHWw#oAWitVvmX(|~n4fT$fg9(NEqe@xC2mTJUmfqiidtumZhkaUh#`#(+iil|d zMgUSw>rCKHK!f|?xIX{B_+pafFMY9SI+_~9trPhQiSsad(mkHi_OqeQv>cw*+2is4 zKlGs03h)0vCzY`Ue-`7*$^me`k3}rCdY%u!rsi(CHbq}(A9$IW6M&#Hn3ZqwS`fk% zUZhnxLplK5xN)vX+)p~aLt7rk>G(WsRBrTOCO?0?TnNVyNAd^|=^5=HuBU@|OEKXZ zgvu#~KEK@xiZX553Pvf~%At76D%u95sGI_-<_Ho#eVOU?**_s7v1Bl!in%q;q=ZE!n`i! z@5n|`JJ5?_^MwGMBIX=`Zu-f~wHt)eXXK8D!#N=z-`3#Qg5GSiA30#kI5=w{igE-HNB!Kz=gg2tW-Q zTXtHjR;`ZkKE%M_;9vzmEaweYabj`b>eZ`o?shC~G22#H&<)|>bJj~LAc3zd%i4am z*R!2wW(e~r`zcour|_~+X)6gvPiguMe86Rl(C28mGTY z@24!T520_u@n-XtzZB`0r327?-bqxsj|WuZk_|$8vIAB5)c6_lB3xliE1Yp&MLxm` zH2wj8EUyH2`j!vwZhYPi*7&m!!C%LVcS3yMFV=414O9z0BF+3&=r_+x9TdjBC2iOmA2#yKk9q(Cj1T+9u z%(D1Q24d+^UV0IZi{TX93qiPxbsTGhS6+)|J-OliNc>8!89zvpy&vG$03Z{W9}JX8 zD*Lj}e$2v$_tM$Z<`t+kwTz|-CJoBh$w@bJ830Rg6Kn($Rxe57hU{B8> z_y{;p%Gl!$fGVKMsK(4t;R<6~;ezyk+IR&n{BlS3WX>^IMj!Wg_Jt~Xtc#3z|7V1r zM#|$yF(ujUF_5;yOc|8XWUv?ufYqd4pZ+?}i^L6q@Beo^PjRMvmNIVHEdpw1>7MyG zjkj?Xoj1~F`%-0OI9t3}Hsz7qKgy09noI#uuRT@~=BaNMqQFD=apRB_EtJRC#imKX z^lTR?AW_IHe_6aJs}oHTu6%^E*D(_K5@8rQElXQimX-D$E}DkB?3a0bHS|u;2O#=c zyHr7$A>(=#;TESf49Oyo_57ItZr^_<$I>oFTvy3qsB#*Rn*q=bbNUaF)j)25Y1aT*Fu%+q;f+R%wi$rl{*EC5_u&>^m%Tf5h??#YmaX93Fc5QM2+ zk;b5!*#E)zC?1=b*dO~rme2eDrVS*zY#o3M`muP$=f4;XehmvkY>+*+AE4OqWocDd z;%r>g8kXW(ZdIN%d+xe8h(?>i0p#ztGp38y86@Q+Gs^J!<-Cq(FLMfc$+;9>0K)tK zY5LU*FFbi_Ilcd%YXu~htpnh)xtD6-aNrf{~xcjATc*;rjUb5!XzCUre1hgv3y z8rN!5H51>%GtED8TBSDm!6}2POB$SSD;PkT#@K((>-faK81iW%2FB{SZCuZ)|&%^Z#dhbUNSsmKy!Cb^wn* z{T4Kq08S}%<};jQxSp^L#W0nU*o(J!);+#@1QsU&eaz)f0d2FrU{7)&DCK(FT9hp2B82Tw_AByQ8sdCf z&i}__)33?-kZ3=@4Wr1uEYi!`0dPed!#}Zle9H0xugRsa!R{^EKw~Q?Hr4<*1xR~w zjL%Gmg6wt`j`k9eA@BZj9GC-@8>B5~dtB?G;oM~uE7C68$s%0=hh$p89Ds`@Soe|h z60!Mf`Mm$nI@@dSzUA4H?%_B-hVg*=ZlL?_-X6|wQN&ZZamD^~U%kono^=3P=y(dJ z4^?6w3Q7aZO*bSlr(3F&9U?VU>E)rXYdO+G=Rm_ZC#&Ol|GyQt@QL+o9cV_uU5s4T zBLLs8vMsi^Qu$ZxYWu#Q2Fy(>_Y%dxd3J6Av)YnVfgXPN$ed9wRx!rl=J_W+x!2;c3JigfP3Tl9GLS#+_)aBxpjcO z=1D-Y!%EUL_OyaifznnY2ZQ39_IvbdCCx#}PYfL=3_MFu0MzgoQaSQ+wBgJBjrT0m z_y3D+x9lB&ZxDG22#WlrrM4pE%PWV1B(0`>A*k5byPeh-fhuW5jst^0Sk)dGZ0u%P z+eaWQD||pA)0X`E|7QHMo7pZw_LVgNbeir1e>TqtSS($ER?7%L&iSiU zs22Ll>~m2qITZ*hKjX1ryIU|lZ3rJ19Kwf2VPM=-(|)%t!WNRR8gEfnF)gw?l)>f; zJ~vHD(?REuQ&iS0v`wJIe|!ijo*m_aOpSzp5~1OjJOr=(J6qQHiWDs zAKD$2$QmWn#mnO78mA1>hO2{-i@ZRTMT+?VBhUZQulD`_rPIe*)fP3jG6nF>sl#S} zKEN4JzeR26Jt<5#FJnv4sy&&HZkffb0}eo}pSSRvdtD5mj#7NsH$LESXUf%DyK@0Kw3)n^q1VDH@kmE{;fuXSmSI$tVBZ zobfE>sc@f1nz#c%b=_y7W$PBcC8OMXy1{in_}+3pz=GcOB3;+~ZWMkAJpKX?@w z-YbQt+V0)e2d6^2E*CC>3o1< zXLU;ZvcuR~G6K-iOegw5bW0_dH(BmDvpUcTIp;c)o`nQuH~`Zdd;9qSrl@UExs&?U`8MPt1F-DZaRji)SuKV*o?F&4A@Bc3^8WwB zGaQ}17#S`l87mIJ^wv^2AHWpZM^kqyP|7>AoNu>hZ_mib4c+(3ZGgq*Y4-Hu{E}Ad z5xfQ<@BeSbXX1-Q%Qd~?08Gy>wetb8qT_>p@`>wcfX8(KzQ-KEh7DVLVD>l;3{#GT zs}Fnq)v8r%Y!LJQ|B@*8v(m6D4!|@s=7>w~d;l#z@5mVH4jy{wq3&3)!t;5hTWF}K z4DGkylRQmxP|hG&>@-rL&?2b1{Y!19}b_L|o41%cta_y5Po@Xfc%%KQH%9kk*A z+8af-hc2=60dT9snE+ARhhxFyOaQ%Ow^wiXAmiid0P~S42bb3O?#sRZA4lIEAK$U^ z{{R1L@9JXXsIKtL?0A2?YoJv_lm0#OP(+}R$BLlRK2nK-(^TT6g2rC&dfi4sNJUUn zD6ds*Cr&`^TT#IxLiDjkQ3|b)fV2o=Re9hifQVFqA>Q?Re`coNcW3U#toLVTc6L2B zcZ9?4?7ipSIrpA(&pr2?Z#s;XQUGmC*{%2hG;?7Dq%6x6m;i_c7>aNjMr|DHev0kw zh6^tMp-1HWgAYDfp?^dMeC>w5kD`xC0d(f_Zo>!g;sCTH)?|STIIOd7WYlDEqMq~= zz)+M-=OGDpm-~Oz!21i}taNm-|A$gEz5Bt4?cvf&0dyE#2#+`>+GY3v4p%#=05lK5 zx5MLwx+N_N$Fj0)2ZBWbi>(3>f{*-v#{c6I`~Of6zQ-#m1<+xDB|d;@Iv0Qs@R4Z} zA3)~Zb*K=JTM`pMB5CcYhN8&tIt5-z)KZfZ`pZ?oERh0s0 z=Lo*jOq4i*Sms_&CIKX=8*zVkybExjFopsOBDWN}VNVG3A>`MCH$zb}9fl;#Eu8-g zi|7q+|8LU26Nb=UuA>w{m(guac+EK{CcHRi>ryx#w_OXsY6=SgXtzTVPTetD7C<~6 zyL&I7$l?EURQ`W=7AZuaZoH8hb(rw(`vMG|c+%YW#`N-X?+q6ha9EwRk#R`DqGS!L zjfm(bYPA|BctSV;rC%o^+IxnKvH-daB_@oR;FXNIinDcz6BzexfCjAop`ro-QNslT z>B6^|+_s48NPI`)JhD&HuM?C0#ikod0dyG(FZW%4=zhF6kT`+v3ngNlMB-ZfaC{3) zfK?Ip(&ga>y6p%BFc67hR|cM=z;(p99Y21&N`H~2miH4S`zvHTGAUYk^e9IKGI(7 zlE#z*=rK?dA3(RRecQI|OWX!v@<%FIJ(>^v6T60xKyeHh^*irYR^0C3;h_Ma1F^sc z?%-%D2>36{YQ9f=M>eU#f4ehYDS+<0;V*pig%854TXm0kI(QUE=M4L$&SWo1RYP8O>k zUMD3M|JeEgT;oKSs6bR+J)SPuPvC`1%+NhFp!fv>ddeAdTI^4*=Ksf6X$KpGA(U1M zz$a7TQ2H0om2nAx37^$QT1;#KG+iVEvEhhQf~f%L`U4mU1T2Q@D*9#9PqP91KaAI_ z`w!+sMv@E?lwQqL4gaYXEXuf~;=(0TNdT2oLbOJH~i2j;~9=d-W+ZICw0QDYbr2u;KNB%0}0dNHH zvM_xk+*`{AqWHfnsCouUDouK;tNS8ZrR7$_qnm+qf#f5hO+ryzt^Gf@C$8W8XY+wmXujgDe$BY8f>Z#+E`XqlSb)oyFGpel$gKo24JQl=S&U9WsGP5k zhk;W{_x0cCV=T96Vlu_(AEf|%4hB|JvGFw=?JUwvA!vyVZq(zX1Skwt05}X6(SSZ5 z&37@-pJ0*=t~faXc>LMGbEkMb3H<+;*g=*5PcHPolmh6_BT<6pB#)Jf#i{cEntXtA z0LZB%RvdT*<8je0cP#)nA0Vn;ef+VDF60=RgaeC;^uTn;f1FNW+oNlno7MXZg=7EW zIN>k$*bM_ed;|oP0tmoFOe%+yYql``$7a28d!yO-G?UK2DW*{g?C7fKS|V}_5Um9u zT?x9ayJ$c={H)%Pk>54)ztL!XVl|rIFBS8D@`OKb4%z^-Y_vs{0tm)$Dgfv7>CZ-8qP~)AJJ(IY!8F#o6JB$3!JW>)N*10(`oEf5b{jVhsdi+7L>U&&_N$mjeG^ zZ<_i|v$Oes!ts~n_za=FJxdZ{Q|b!H0S8{@1}0kacs~CTtnNcg%TGO%%jJ%i%VkbX z9zBiR0fl}D?>xhExL4;h9avk7L>OA=LWWcTBrALYY-ZG#h37M}+3Z-QT>dkxvHREo zjt91BvDuD|nkix1Qwks?=P=>wv;;628zAwsrIV*F;KiAb13yrMlaTjB7GX&8p{ofk z5V;f3MHhmHL7)MJ?%d!N8#so9>&4X8*DDW~iqlV$B*y_tSqXm#{;DlN2&Ty@o+V&% z^)1d!Ke1V>-9zDrA^>ub@C(SIBl$o2fp!Rmr3nkbON1GCscxYQ0TI~U{trwtFh9so z^0nEt>a}|P?ox5;Nho1D`%I2QV1lZG;DAy915N}?z$Rnmgu)$Fs#Mv;hQ@ieNTy$e_{TiLUAn%Y%UxR5Ht*- z;B^>jX=XARW4(N3p;XAvkp%fWKd+p9!P(il#ziU$8HgN~nS5cn?WF>XeFbO>G@!7w{MI9%$S)myt5&L0QuxVNCH#K5 z=&zho0RH$Cs1%)2Oh$pJYR=9~p8~Y#9SBzW469iZwzGI&bs&NP3JV1Q&<=?T6rgIy zq8BcFSD5=WyWwBVc=U(11zWxn=Te}^Rmrs$GllW84zK%mQ(9IEre&^ZW5u@A1__mx8kUU?4AK^Y?uX}&u16WkSAA4fbwB(Pvofn0apb)+XPfFL~Z zQ&#XHhm(_XW&JOo{O|IiJc6uf#gKm>poHHK7eka$7C;E51+5~TgZw}0{&exg@@guT znQt`e3CpsPQK<3AFoFwVz}P@TUecSJ5*_&PKy)RlZljUNYI-zE2#^X1dmUXm*I12EH zRw)B~RAgU^`@{R76u|I!yNxm?b(8yatf~xMU0GN-b+Nu#gI^%~opQMni6!6(Xv#6| z3_Eb~;$XOd2m%tq4l89G9t)`ewmnvHW=?|cjt5o{= zXQ2Fpg{9?_@POy}RvTP{Boh%7L^mxYAll z_skUvXBphX90NYTUm~hp^c+wMAbOq#rU(VVq!*3*U?_o?mX?-ohVcIth&MOkXp90v zfRVn|8sw6Dbcev5gzq9vO&d%D?7xaCbPdSzq6Of75?_1%BLGqm_mC>Dm*2E4{l{~2 zg-c#=kMi>OWZh_f>^rGO(+$-MIUxN4#bWV&1jF1~DX%||$U$xgC~$o} zSNa7!)A(Lw6#h1yPCrtw*WuT*>+tXOJc`5dn!w4|br%;fWH+`|gKnAd#Ggh+S4}`G z#I~Kf*$VEd%mcRTF%pEOqk?<3adu~KC<|a``QNd13P1PA6f`)?9t8~fQGB*i=@f;( zr*jbU2NNCtcoqQn*=VGzz;^7w)tkD9D#XPf7KMy7yjr0B6Z@A&KXF>n1ai+2^#3tF z2cHKDK$-pR4BO%FN&)Qf0CZ~}+W~RjMlU&5&}Wg0e+WTC9|B(>1v%skNqTtWkyqov zf_07;R1h5xO@Z68x;qX4`5WB9-tli1&D+*Gjn7OWDYMb zojN_99X}?6h9F=7A-yW(-be2gAduI-Q!fQ!m;u`Yi_k7l9a_OXNaH;PYb8%2P#kK% z--(+syQeIGm<>p`?rcdW(q@a(Q^c+wMVDvl>-6Djli9dr5HAD_BG%Wi^cs$2!23`($;rt{wcq!}+4vMv z7C?N)!go)&$a70TC)=-PX5JYCRz8IP1utkwDjxsprN~$PA>m0nN0nf=k);C zFp*eDP>|1i66yKlVeu;jo94ekp9A*$!04|$j{W|l{E^!O3V%;B>c`k}Kv@8>9U6Zf zlR-l)U7X23jEKRbO{=LPAK+_9yp!02{)n}ga^;`z1sFpn;65N5YY6U{WN;FSir^l$ z-(F39-#r{q7QpTqtFAp|kPV%0KpG-+_OlBM%kSz5{fv>)u1BRyT`Tta;edxG)NsuG zhGSo9>dkw9Stz{5RUZ+8mJ06a!$9@3;=m|5;5r9^-;WD0OUDeyC7j(qsLF zr=R}T^78UG`Pn@Y=$;F}GU`$sP#oAn4!{~{S$0y0!**wD{jGGv(o^*m2NVZJzyWRv wwhHecO_CP>2BD$qDh? 0 { + sub := menu.AddSubmenu(item.Label) + m.buildMenu(sub, item.Submenu) + continue + } + mi := menu.Add(item.Label) + if item.Tooltip != "" { + mi.SetTooltip(item.Tooltip) + } + if item.Disabled { + mi.SetEnabled(false) + } + if item.Checked { + mi.SetChecked(true) + } + if item.ActionID != "" { + actionID := item.ActionID + mi.OnClick(func() { + if cb, ok := m.GetCallback(actionID); ok { + cb() + } + }) + } + } +} + +// RegisterCallback registers a callback for a menu action ID. +// Use: m.RegisterCallback("quit", func() { _ = app.Quit() }) +func (m *Manager) RegisterCallback(actionID string, callback func()) { + m.mu.Lock() + m.callbacks[actionID] = callback + m.mu.Unlock() +} + +// UnregisterCallback removes a callback. +// Use: m.UnregisterCallback("quit") +func (m *Manager) UnregisterCallback(actionID string) { + m.mu.Lock() + delete(m.callbacks, actionID) + m.mu.Unlock() +} + +// GetCallback returns the callback for an action ID. +// Use: callback, ok := m.GetCallback("quit") +func (m *Manager) GetCallback(actionID string) (func(), bool) { + m.mu.RLock() + defer m.mu.RUnlock() + cb, ok := m.callbacks[actionID] + return cb, ok +} + +// GetInfo returns tray status information. +// Use: info := m.GetInfo() +func (m *Manager) GetInfo() map[string]any { + return map[string]any{ + "active": m.IsActive(), + "tooltip": m.tooltip, + "label": m.label, + "hasIcon": m.hasIcon, + "hasTemplateIcon": m.hasTemplateIcon, + "menuItems": append([]TrayMenuItem(nil), m.menuItems...), + } +} diff --git a/display/webkit/pkg/systray/menu_example_test.go b/display/webkit/pkg/systray/menu_example_test.go new file mode 100644 index 0000000..f8e7f4a --- /dev/null +++ b/display/webkit/pkg/systray/menu_example_test.go @@ -0,0 +1,35 @@ +//go:build compliance + +package systray + +import core "dappco.re/go" + +func ExampleManager_SetMenu() { + core.Println("Manager_SetMenu") + // Output: + // Manager_SetMenu +} + +func ExampleManager_RegisterCallback() { + core.Println("Manager_RegisterCallback") + // Output: + // Manager_RegisterCallback +} + +func ExampleManager_UnregisterCallback() { + core.Println("Manager_UnregisterCallback") + // Output: + // Manager_UnregisterCallback +} + +func ExampleManager_GetCallback() { + core.Println("Manager_GetCallback") + // Output: + // Manager_GetCallback +} + +func ExampleManager_GetInfo() { + core.Println("Manager_GetInfo") + // Output: + // Manager_GetInfo +} diff --git a/display/webkit/pkg/systray/menu_test.go b/display/webkit/pkg/systray/menu_test.go new file mode 100644 index 0000000..cf14ac3 --- /dev/null +++ b/display/webkit/pkg/systray/menu_test.go @@ -0,0 +1,317 @@ +package systray + +import ( + core "dappco.re/go" +) + +type recordingTrayPlatform struct { + tray *recordingTray + menu *recordingTrayMenu +} + +func (p *recordingTrayPlatform) NewTray() PlatformTray { + p.tray = &recordingTray{} + return p.tray +} + +func (p *recordingTrayPlatform) NewMenu() PlatformMenu { + p.menu = &recordingTrayMenu{} + return p.menu +} + +type recordingTray struct { + icon, templateIcon []byte + tooltip, label string + menu PlatformMenu + attachedWindow WindowHandle +} + +func (t *recordingTray) SetIcon(data []byte) { t.icon = append([]byte(nil), data...) } +func (t *recordingTray) SetTemplateIcon(data []byte) { t.templateIcon = append([]byte(nil), data...) } +func (t *recordingTray) SetTooltip(text string) { t.tooltip = text } +func (t *recordingTray) SetLabel(text string) { t.label = text } +func (t *recordingTray) SetMenu(menu PlatformMenu) { t.menu = menu } +func (t *recordingTray) AttachWindow(w WindowHandle, offsetX, offsetY int) { t.attachedWindow = w } +func (t *recordingTray) ShowMessage(title, message string) resultFailure { return nil } + +type recordingTrayMenu struct { + items []*recordingTrayMenuItem + subs []*recordingTrayMenu +} + +func (m *recordingTrayMenu) Add(label string) PlatformMenuItem { + item := &recordingTrayMenuItem{label: label, enabled: true} + m.items = append(m.items, item) + return item +} + +func (m *recordingTrayMenu) AddSeparator() { + m.items = append(m.items, &recordingTrayMenuItem{label: "---"}) +} + +func (m *recordingTrayMenu) AddSubmenu(label string) PlatformMenu { + sub := &recordingTrayMenu{} + m.items = append(m.items, &recordingTrayMenuItem{label: label, submenu: sub}) + m.subs = append(m.subs, sub) + return sub +} + +type recordingTrayMenuItem struct { + label, tooltip string + checked, enabled bool + submenu *recordingTrayMenu + onClick func() +} + +func (i *recordingTrayMenuItem) SetTooltip(text string) { i.tooltip = text } +func (i *recordingTrayMenuItem) SetChecked(checked bool) { i.checked = checked } +func (i *recordingTrayMenuItem) SetEnabled(enabled bool) { i.enabled = enabled } +func (i *recordingTrayMenuItem) OnClick(fn func()) { i.onClick = fn } + +func TestManager_SetMenu_Good(t *core.T) { + // SetMenu + ax7Variant := "SetMenu:good" + core.AssertContains(t, ax7Variant, "good") + platform := &recordingTrayPlatform{} + mgr := NewManager(platform) + core.RequireNoError(t, mgr.Setup("Core", "Core")) + + clicked := 0 + items := []TrayMenuItem{ + {Label: "Open", Tooltip: "open", ActionID: "open"}, + {Type: "separator"}, + {Label: "More", Submenu: []TrayMenuItem{{Label: "Nested", ActionID: "nested"}}}, + {Label: "Disabled", Disabled: true}, + {Label: "Checked", Checked: true}, + } + mgr.RegisterCallback("open", func() { clicked++ }) + mgr.RegisterCallback("nested", func() { clicked += 10 }) + + core.RequireNoError(t, mgr.SetMenu(items)) + core.AssertNotNil(t, platform.menu) + core.AssertNotNil(t, platform.tray) + + core.AssertEqual(t, "Core", platform.tray.tooltip) + core.AssertEqual(t, "Core", platform.tray.label) + core.AssertLen(t, platform.menu.items, 5) + core.AssertEqual(t, "Open", platform.menu.items[0].label) + core.AssertEqual(t, "---", platform.menu.items[1].label) + core.AssertEqual(t, "More", platform.menu.items[2].label) + core.AssertFalse(t, platform.menu.items[3].enabled) + core.AssertTrue(t, platform.menu.items[4].checked) + + core.AssertNotNil(t, platform.menu.items[0].onClick) + platform.menu.items[0].onClick() + core.AssertLen(t, platform.menu.subs, 1) + core.AssertNotNil(t, platform.menu.subs[0].items[0].onClick) + platform.menu.subs[0].items[0].onClick() + + core.AssertEqual(t, 11, clicked) + core.AssertLen(t, mgr.GetInfo()["menuItems"].([]TrayMenuItem), 5) +} + +func TestManager_SetMenu_Bad(t *core.T) { + // SetMenu + ax7Variant := "SetMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + mgr := NewManager(&recordingTrayPlatform{}) + err := mgr.SetMenu([]TrayMenuItem{{Label: "Quit"}}) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "tray not initialised") +} + +func TestManager_GetCallback_Ugly(t *core.T) { + // GetCallback + ax7Variant := "GetCallback:ugly" + core.AssertContains(t, ax7Variant, "ugly") + mgr := NewManager(&recordingTrayPlatform{}) + mgr.RegisterCallback("quit", func() {}) + cb, ok := mgr.GetCallback("quit") + core.RequireTrue(t, ok) + core.AssertNotNil(t, cb) + + mgr.UnregisterCallback("quit") + _, ok = mgr.GetCallback("quit") + core.AssertFalse(t, ok) +} + +// AX7 generated source-matching smoke coverage. +func TestMenu_Manager_SetMenu_Good(t *core.T) { + // Manager SetMenu + ax7Variant := "Manager_SetMenu:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetMenu(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_SetMenu_Bad(t *core.T) { + // Manager SetMenu + ax7Variant := "Manager_SetMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetMenu(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_SetMenu_Ugly(t *core.T) { + // Manager SetMenu + ax7Variant := "Manager_SetMenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetMenu(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_RegisterCallback_Good(t *core.T) { + // Manager RegisterCallback + ax7Variant := "Manager_RegisterCallback:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + subject.RegisterCallback("agent", nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_RegisterCallback_Bad(t *core.T) { + // Manager RegisterCallback + ax7Variant := "Manager_RegisterCallback:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + subject.RegisterCallback("", nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_RegisterCallback_Ugly(t *core.T) { + // Manager RegisterCallback + ax7Variant := "Manager_RegisterCallback:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + subject.RegisterCallback("../../edge", nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_UnregisterCallback_Good(t *core.T) { + // Manager UnregisterCallback + ax7Variant := "Manager_UnregisterCallback:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + subject.UnregisterCallback("agent") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_UnregisterCallback_Bad(t *core.T) { + // Manager UnregisterCallback + ax7Variant := "Manager_UnregisterCallback:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + subject.UnregisterCallback("") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_UnregisterCallback_Ugly(t *core.T) { + // Manager UnregisterCallback + ax7Variant := "Manager_UnregisterCallback:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + subject.UnregisterCallback("../../edge") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_GetCallback_Good(t *core.T) { + // Manager GetCallback + ax7Variant := "Manager_GetCallback:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.GetCallback("agent") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_GetCallback_Bad(t *core.T) { + // Manager GetCallback + ax7Variant := "Manager_GetCallback:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.GetCallback("") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_GetCallback_Ugly(t *core.T) { + // Manager GetCallback + ax7Variant := "Manager_GetCallback:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0, got1 := subject.GetCallback("../../edge") + return core.Sprintf("%T,%T", got0, got1) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_GetInfo_Good(t *core.T) { + // Manager GetInfo + ax7Variant := "Manager_GetInfo:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.GetInfo() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_GetInfo_Bad(t *core.T) { + // Manager GetInfo + ax7Variant := "Manager_GetInfo:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.GetInfo() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMenu_Manager_GetInfo_Ugly(t *core.T) { + // Manager GetInfo + ax7Variant := "Manager_GetInfo:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.GetInfo() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/systray/messages.go b/display/webkit/pkg/systray/messages.go new file mode 100644 index 0000000..d262dfe --- /dev/null +++ b/display/webkit/pkg/systray/messages.go @@ -0,0 +1,52 @@ +// pkg/systray/messages.go +package systray + +type QueryConfig struct{} + +type QueryInfo struct{} + +type TaskSetTrayIcon struct{ Data []byte } + +// TaskSetTrayTemplateIcon updates the tray icon as a macOS template +// image (auto-inverted by the OS to match light/dark menu bar). On +// non-darwin backends it falls back to a regular icon set. +// +// c.Action("systray.set_template_icon").Run(ctx, NewOptions( +// Option{Key: "task", Value: TaskSetTrayTemplateIcon{Data: iconBytes}}, +// )) +type TaskSetTrayTemplateIcon struct{ Data []byte } + +type TaskSetTrayTooltip struct{ Tooltip string } + +type TaskSetTrayLabel struct{ Label string } + +type TaskSetTrayMenu struct{ Items []TrayMenuItem } + +type TaskShowMessage struct { + Title string `json:"title"` + Message string `json:"message"` +} + +type TaskShowPanel struct{} + +type TaskHidePanel struct{} + +type TaskSaveConfig struct{ Config map[string]any } + +// TaskAttachWindow anchors a previously-created window (by Name) to the +// tray. OffsetY is in platform pixels along the tray's natural axis; +// OffsetX is reserved for backends that expose two-axis offsets +// (Wails 3 alpha.91 uses a single offset only). +// +// c.Action("systray.attach_window").Run(ctx, NewOptions( +// Option{Key: "task", Value: TaskAttachWindow{Name: "tray", OffsetY: 5}}, +// )) +type TaskAttachWindow struct { + Name string + OffsetX int + OffsetY int +} + +type ActionTrayClicked struct{} + +type ActionTrayMenuItemClicked struct{ ActionID string } diff --git a/display/webkit/pkg/systray/messages_example_test.go b/display/webkit/pkg/systray/messages_example_test.go new file mode 100644 index 0000000..247d54f --- /dev/null +++ b/display/webkit/pkg/systray/messages_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package systray diff --git a/display/webkit/pkg/systray/messages_test.go b/display/webkit/pkg/systray/messages_test.go new file mode 100644 index 0000000..a2c9bf3 --- /dev/null +++ b/display/webkit/pkg/systray/messages_test.go @@ -0,0 +1 @@ +package systray diff --git a/display/webkit/pkg/systray/mock_platform.go b/display/webkit/pkg/systray/mock_platform.go new file mode 100644 index 0000000..90a65bd --- /dev/null +++ b/display/webkit/pkg/systray/mock_platform.go @@ -0,0 +1,60 @@ +// pkg/systray/mock_platform.go +package systray + +// MockPlatform is an exported mock for cross-package integration tests. +// Use: platform := systray.NewMockPlatform() +type MockPlatform struct{} + +// NewMockPlatform creates a tray platform mock. +// Use: platform := systray.NewMockPlatform() +func NewMockPlatform() *MockPlatform { return &MockPlatform{} } + +// NewTray creates a mock tray handle for tests. +// Use: tray := platform.NewTray() +func (m *MockPlatform) NewTray() PlatformTray { return &exportedMockTray{} } + +// NewMenu creates a mock tray menu for tests. +// Use: menu := platform.NewMenu() +func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockMenu{} } + +type exportedMockTray struct { + icon, templateIcon []byte + tooltip, label string +} + +func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data } +func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data } +func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text } +func (t *exportedMockTray) SetLabel(text string) { t.label = text } +func (t *exportedMockTray) SetMenu(menu PlatformMenu) {} +func (t *exportedMockTray) AttachWindow(w WindowHandle, offsetX, offsetY int) {} +func (t *exportedMockTray) ShowMessage(title, message string) resultFailure { return nil } + +type exportedMockMenu struct { + items []exportedMockMenuItem + subs []*exportedMockMenu +} + +func (m *exportedMockMenu) Add(label string) PlatformMenuItem { + mi := &exportedMockMenuItem{label: label} + m.items = append(m.items, *mi) + return mi +} +func (m *exportedMockMenu) AddSeparator() {} +func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu { + m.items = append(m.items, exportedMockMenuItem{label: label}) + sub := &exportedMockMenu{} + m.subs = append(m.subs, sub) + return sub +} + +type exportedMockMenuItem struct { + label, tooltip string + checked, enabled bool + onClick func() +} + +func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip } +func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked } +func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled } +func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn } diff --git a/display/webkit/pkg/systray/mock_platform_example_test.go b/display/webkit/pkg/systray/mock_platform_example_test.go new file mode 100644 index 0000000..dd7eec5 --- /dev/null +++ b/display/webkit/pkg/systray/mock_platform_example_test.go @@ -0,0 +1,107 @@ +//go:build compliance + +package systray + +import core "dappco.re/go" + +func ExampleNewMockPlatform() { + core.Println("NewMockPlatform") + // Output: + // NewMockPlatform +} + +func ExampleMockPlatform_NewTray() { + core.Println("MockPlatform_NewTray") + // Output: + // MockPlatform_NewTray +} + +func ExampleMockPlatform_NewMenu() { + core.Println("MockPlatform_NewMenu") + // Output: + // MockPlatform_NewMenu +} + +func ExampleMockTray_SetIcon() { + core.Println("MockTray_SetIcon") + // Output: + // MockTray_SetIcon +} + +func ExampleMockTray_SetTemplateIcon() { + core.Println("MockTray_SetTemplateIcon") + // Output: + // MockTray_SetTemplateIcon +} + +func ExampleMockTray_SetTooltip() { + core.Println("MockTray_SetTooltip") + // Output: + // MockTray_SetTooltip +} + +func ExampleMockTray_SetLabel() { + core.Println("MockTray_SetLabel") + // Output: + // MockTray_SetLabel +} + +func ExampleMockTray_SetMenu() { + core.Println("MockTray_SetMenu") + // Output: + // MockTray_SetMenu +} + +func ExampleMockTray_AttachWindow() { + core.Println("MockTray_AttachWindow") + // Output: + // MockTray_AttachWindow +} + +func ExampleMockTray_ShowMessage() { + core.Println("MockTray_ShowMessage") + // Output: + // MockTray_ShowMessage +} + +func ExampleMockMenu_Add() { + core.Println("MockMenu_Add") + // Output: + // MockMenu_Add +} + +func ExampleMockMenu_AddSeparator() { + core.Println("MockMenu_AddSeparator") + // Output: + // MockMenu_AddSeparator +} + +func ExampleMockMenu_AddSubmenu() { + core.Println("MockMenu_AddSubmenu") + // Output: + // MockMenu_AddSubmenu +} + +func ExampleMockMenuItem_SetTooltip() { + core.Println("MockMenuItem_SetTooltip") + // Output: + // MockMenuItem_SetTooltip +} + +func ExampleMockMenuItem_SetChecked() { + core.Println("MockMenuItem_SetChecked") + // Output: + // MockMenuItem_SetChecked +} + +func ExampleMockMenuItem_SetEnabled() { + core.Println("MockMenuItem_SetEnabled") + // Output: + // MockMenuItem_SetEnabled +} + +func ExampleMockMenuItem_OnClick() { + core.Println("MockMenuItem_OnClick") + // Output: + // MockMenuItem_OnClick +} diff --git a/display/webkit/pkg/systray/mock_platform_test.go b/display/webkit/pkg/systray/mock_platform_test.go new file mode 100644 index 0000000..f5431f5 --- /dev/null +++ b/display/webkit/pkg/systray/mock_platform_test.go @@ -0,0 +1,673 @@ +package systray + +import ( + core "dappco.re/go" +) + +func TestMockPlatform_NewTray_Good(t *core.T) { + // NewTray + ax7Variant := "NewTray:good" + core.AssertContains(t, ax7Variant, "good") + p := NewMockPlatform() + tray := p.NewTray() + core.AssertNotNil(t, tray) + + mockTray := tray.(*exportedMockTray) + tray.SetIcon([]byte{1, 2, 3}) + tray.SetTemplateIcon([]byte{4, 5, 6}) + tray.SetTooltip("Core") + tray.SetLabel("Ready") + tray.SetMenu(p.NewMenu()) + tray.AttachWindow(windowHandleStub{name: "panel"}, 0, 0) + + core.AssertEqual(t, []byte{1, 2, 3}, mockTray.icon) + core.AssertEqual(t, []byte{4, 5, 6}, mockTray.templateIcon) + core.AssertEqual(t, "Core", mockTray.tooltip) + core.AssertEqual(t, "Ready", mockTray.label) + core.AssertNotNil(t, mockTray) +} + +func TestMockPlatform_NewMenu_Bad(t *core.T) { + // NewMenu + ax7Variant := "NewMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + p := NewMockPlatform() + menu := p.NewMenu() + core.AssertNotNil(t, menu) + _, ok := menu.(*exportedMockMenu) + core.AssertTrue(t, ok) +} + +func TestMockPlatform_NewTray_Ugly(t *core.T) { + // NewTray + ax7Variant := "NewTray:ugly" + core.AssertContains(t, ax7Variant, "ugly") + p := NewMockPlatform() + tray := p.NewTray().(*exportedMockTray) + core.AssertNotNil(t, tray) + core.AssertNoError(t, tray.ShowMessage("title", "message")) +} + +type windowHandleStub struct { + name string +} + +func (w windowHandleStub) Name() string { return w.name } + +// AX7 generated source-matching smoke coverage. +func TestMockPlatform_NewMockPlatform_Good(t *core.T) { + // NewMockPlatform + ax7Variant := "NewMockPlatform:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewMockPlatform() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_NewMockPlatform_Bad(t *core.T) { + // NewMockPlatform + ax7Variant := "NewMockPlatform:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewMockPlatform() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_NewMockPlatform_Ugly(t *core.T) { + // NewMockPlatform + ax7Variant := "NewMockPlatform:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewMockPlatform() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_NewTray_Good(t *core.T) { + // MockPlatform NewTray + ax7Variant := "MockPlatform_NewTray:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(MockPlatform) + result := core.Try(func() any { + got0 := subject.NewTray() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_NewTray_Bad(t *core.T) { + // MockPlatform NewTray + ax7Variant := "MockPlatform_NewTray:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(MockPlatform) + result := core.Try(func() any { + got0 := subject.NewTray() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_NewTray_Ugly(t *core.T) { + // MockPlatform NewTray + ax7Variant := "MockPlatform_NewTray:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(MockPlatform) + result := core.Try(func() any { + got0 := subject.NewTray() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_NewMenu_Good(t *core.T) { + // MockPlatform NewMenu + ax7Variant := "MockPlatform_NewMenu:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(MockPlatform) + result := core.Try(func() any { + got0 := subject.NewMenu() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_NewMenu_Bad(t *core.T) { + // MockPlatform NewMenu + ax7Variant := "MockPlatform_NewMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(MockPlatform) + result := core.Try(func() any { + got0 := subject.NewMenu() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockPlatform_NewMenu_Ugly(t *core.T) { + // MockPlatform NewMenu + ax7Variant := "MockPlatform_NewMenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(MockPlatform) + result := core.Try(func() any { + got0 := subject.NewMenu() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +type MockTray = exportedMockTray + +func TestMockPlatform_MockTray_SetIcon_Good(t *core.T) { + // MockTray SetIcon + ax7Variant := "MockTray_SetIcon:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetIcon(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetIcon_Bad(t *core.T) { + // MockTray SetIcon + ax7Variant := "MockTray_SetIcon:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetIcon(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetIcon_Ugly(t *core.T) { + // MockTray SetIcon + ax7Variant := "MockTray_SetIcon:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetIcon(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetTemplateIcon_Good(t *core.T) { + // MockTray SetTemplateIcon + ax7Variant := "MockTray_SetTemplateIcon:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetTemplateIcon(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetTemplateIcon_Bad(t *core.T) { + // MockTray SetTemplateIcon + ax7Variant := "MockTray_SetTemplateIcon:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetTemplateIcon(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetTemplateIcon_Ugly(t *core.T) { + // MockTray SetTemplateIcon + ax7Variant := "MockTray_SetTemplateIcon:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetTemplateIcon(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetTooltip_Good(t *core.T) { + // MockTray SetTooltip + ax7Variant := "MockTray_SetTooltip:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetTooltip("agent") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetTooltip_Bad(t *core.T) { + // MockTray SetTooltip + ax7Variant := "MockTray_SetTooltip:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetTooltip("") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetTooltip_Ugly(t *core.T) { + // MockTray SetTooltip + ax7Variant := "MockTray_SetTooltip:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetTooltip("../../edge") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetLabel_Good(t *core.T) { + // MockTray SetLabel + ax7Variant := "MockTray_SetLabel:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetLabel("agent") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetLabel_Bad(t *core.T) { + // MockTray SetLabel + ax7Variant := "MockTray_SetLabel:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetLabel("") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetLabel_Ugly(t *core.T) { + // MockTray SetLabel + ax7Variant := "MockTray_SetLabel:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetLabel("../../edge") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetMenu_Good(t *core.T) { + // MockTray SetMenu + ax7Variant := "MockTray_SetMenu:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetMenu(*new(PlatformMenu)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetMenu_Bad(t *core.T) { + // MockTray SetMenu + ax7Variant := "MockTray_SetMenu:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetMenu(*new(PlatformMenu)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_SetMenu_Ugly(t *core.T) { + // MockTray SetMenu + ax7Variant := "MockTray_SetMenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.SetMenu(*new(PlatformMenu)) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_AttachWindow_Good(t *core.T) { + // MockTray AttachWindow + ax7Variant := "MockTray_AttachWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.AttachWindow(*new(WindowHandle), 0, 0) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_AttachWindow_Bad(t *core.T) { + // MockTray AttachWindow + ax7Variant := "MockTray_AttachWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.AttachWindow(*new(WindowHandle), 0, 0) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_AttachWindow_Ugly(t *core.T) { + // MockTray AttachWindow + ax7Variant := "MockTray_AttachWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockTray) + result := core.Try(func() any { + subject.AttachWindow(*new(WindowHandle), 0, 0) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_ShowMessage_Good(t *core.T) { + // MockTray ShowMessage + ax7Variant := "MockTray_ShowMessage:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockTray) + result := core.Try(func() any { + got0 := subject.ShowMessage("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_ShowMessage_Bad(t *core.T) { + // MockTray ShowMessage + ax7Variant := "MockTray_ShowMessage:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockTray) + result := core.Try(func() any { + got0 := subject.ShowMessage("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockTray_ShowMessage_Ugly(t *core.T) { + // MockTray ShowMessage + ax7Variant := "MockTray_ShowMessage:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockTray) + result := core.Try(func() any { + got0 := subject.ShowMessage("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +type MockMenu = exportedMockMenu + +func TestMockPlatform_MockMenu_Add_Good(t *core.T) { + // MockMenu Add + ax7Variant := "MockMenu_Add:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockMenu) + result := core.Try(func() any { + got0 := subject.Add("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenu_Add_Bad(t *core.T) { + // MockMenu Add + ax7Variant := "MockMenu_Add:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockMenu) + result := core.Try(func() any { + got0 := subject.Add("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenu_Add_Ugly(t *core.T) { + // MockMenu Add + ax7Variant := "MockMenu_Add:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockMenu) + result := core.Try(func() any { + got0 := subject.Add("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenu_AddSeparator_Good(t *core.T) { + // MockMenu AddSeparator + ax7Variant := "MockMenu_AddSeparator:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockMenu) + result := core.Try(func() any { + subject.AddSeparator() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenu_AddSeparator_Bad(t *core.T) { + // MockMenu AddSeparator + ax7Variant := "MockMenu_AddSeparator:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockMenu) + result := core.Try(func() any { + subject.AddSeparator() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenu_AddSeparator_Ugly(t *core.T) { + // MockMenu AddSeparator + ax7Variant := "MockMenu_AddSeparator:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockMenu) + result := core.Try(func() any { + subject.AddSeparator() + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenu_AddSubmenu_Good(t *core.T) { + // MockMenu AddSubmenu + ax7Variant := "MockMenu_AddSubmenu:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockMenu) + result := core.Try(func() any { + got0 := subject.AddSubmenu("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenu_AddSubmenu_Bad(t *core.T) { + // MockMenu AddSubmenu + ax7Variant := "MockMenu_AddSubmenu:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockMenu) + result := core.Try(func() any { + got0 := subject.AddSubmenu("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenu_AddSubmenu_Ugly(t *core.T) { + // MockMenu AddSubmenu + ax7Variant := "MockMenu_AddSubmenu:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockMenu) + result := core.Try(func() any { + got0 := subject.AddSubmenu("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +type MockMenuItem = exportedMockMenuItem + +func TestMockPlatform_MockMenuItem_SetTooltip_Good(t *core.T) { + // MockMenuItem SetTooltip + ax7Variant := "MockMenuItem_SetTooltip:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.SetTooltip("agent") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_SetTooltip_Bad(t *core.T) { + // MockMenuItem SetTooltip + ax7Variant := "MockMenuItem_SetTooltip:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.SetTooltip("") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_SetTooltip_Ugly(t *core.T) { + // MockMenuItem SetTooltip + ax7Variant := "MockMenuItem_SetTooltip:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.SetTooltip("../../edge") + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_SetChecked_Good(t *core.T) { + // MockMenuItem SetChecked + ax7Variant := "MockMenuItem_SetChecked:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.SetChecked(true) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_SetChecked_Bad(t *core.T) { + // MockMenuItem SetChecked + ax7Variant := "MockMenuItem_SetChecked:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.SetChecked(false) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_SetChecked_Ugly(t *core.T) { + // MockMenuItem SetChecked + ax7Variant := "MockMenuItem_SetChecked:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.SetChecked(false) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_SetEnabled_Good(t *core.T) { + // MockMenuItem SetEnabled + ax7Variant := "MockMenuItem_SetEnabled:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.SetEnabled(true) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_SetEnabled_Bad(t *core.T) { + // MockMenuItem SetEnabled + ax7Variant := "MockMenuItem_SetEnabled:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.SetEnabled(false) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_SetEnabled_Ugly(t *core.T) { + // MockMenuItem SetEnabled + ax7Variant := "MockMenuItem_SetEnabled:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.SetEnabled(false) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_OnClick_Good(t *core.T) { + // MockMenuItem OnClick + ax7Variant := "MockMenuItem_OnClick:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.OnClick(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_OnClick_Bad(t *core.T) { + // MockMenuItem OnClick + ax7Variant := "MockMenuItem_OnClick:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.OnClick(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +func TestMockPlatform_MockMenuItem_OnClick_Ugly(t *core.T) { + // MockMenuItem OnClick + ax7Variant := "MockMenuItem_OnClick:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(exportedMockMenuItem) + result := core.Try(func() any { + subject.OnClick(nil) + return "called" + }) + core.AssertNotNil(t, result.Value) +} + +// AX7 generated source-matching smoke coverage. diff --git a/display/webkit/pkg/systray/mock_test.go b/display/webkit/pkg/systray/mock_test.go new file mode 100644 index 0000000..d0e3abf --- /dev/null +++ b/display/webkit/pkg/systray/mock_test.go @@ -0,0 +1,66 @@ +// pkg/systray/mock_test.go +package systray + +type mockPlatform struct { + trays []*mockTray + menus []*mockTrayMenu +} + +func newMockPlatform() *mockPlatform { return &mockPlatform{} } + +func (p *mockPlatform) NewTray() PlatformTray { + t := &mockTray{} + p.trays = append(p.trays, t) + return t +} + +func (p *mockPlatform) NewMenu() PlatformMenu { + m := &mockTrayMenu{} + p.menus = append(p.menus, m) + return m +} + +type mockTrayMenu struct { + items []string + subs []*mockTrayMenu +} + +func (m *mockTrayMenu) Add(label string) PlatformMenuItem { + m.items = append(m.items, label) + return &mockTrayMenuItem{} +} +func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") } +func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu { + m.items = append(m.items, label) + sub := &mockTrayMenu{} + m.subs = append(m.subs, sub) + return sub +} + +type mockTrayMenuItem struct{} + +func (mi *mockTrayMenuItem) SetTooltip(text string) {} +func (mi *mockTrayMenuItem) SetChecked(checked bool) {} +func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {} +func (mi *mockTrayMenuItem) OnClick(fn func()) {} + +type mockTray struct { + icon, templateIcon []byte + tooltip, label string + menu PlatformMenu + attachedWindow WindowHandle + lastMessageTitle string + lastMessageBody string +} + +func (t *mockTray) SetIcon(data []byte) { t.icon = data } +func (t *mockTray) SetTemplateIcon(data []byte) { t.templateIcon = data } +func (t *mockTray) SetTooltip(text string) { t.tooltip = text } +func (t *mockTray) SetLabel(text string) { t.label = text } +func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu } +func (t *mockTray) AttachWindow(w WindowHandle, offsetX, offsetY int) { t.attachedWindow = w } +func (t *mockTray) ShowMessage(title, message string) resultFailure { + t.lastMessageTitle = title + t.lastMessageBody = message + return nil +} diff --git a/display/webkit/pkg/systray/platform.go b/display/webkit/pkg/systray/platform.go new file mode 100644 index 0000000..13f9b70 --- /dev/null +++ b/display/webkit/pkg/systray/platform.go @@ -0,0 +1,48 @@ +// pkg/systray/platform.go +package systray + +// Platform abstracts the system tray backend. +// Use: var p systray.Platform +type Platform interface { + NewTray() PlatformTray + NewMenu() PlatformMenu // Menu factory for building tray menus +} + +// PlatformTray is a live tray handle from the backend. +// Use: var tray systray.PlatformTray +type PlatformTray interface { + SetIcon(data []byte) + SetTemplateIcon(data []byte) + SetTooltip(text string) + SetLabel(text string) + SetMenu(menu PlatformMenu) + AttachWindow(w WindowHandle, offsetX, offsetY int) + ShowMessage(title, message string) resultFailure +} + +// PlatformMenu is a tray menu built by the backend. +// Use: var menu systray.PlatformMenu +type PlatformMenu interface { + Add(label string) PlatformMenuItem + AddSeparator() + AddSubmenu(label string) PlatformMenu +} + +// PlatformMenuItem is a single item in a tray menu. +// Use: var item systray.PlatformMenuItem +type PlatformMenuItem interface { + SetTooltip(text string) + SetChecked(checked bool) + SetEnabled(enabled bool) + OnClick(fn func()) +} + +// WindowHandle is a minimal cross-package window reference. +// Defined locally to avoid circular imports (display imports systray). +// Concrete panel operations are invoked dynamically because Wails windows use +// fluent Show/Hide methods while the internal window abstraction uses void +// methods. +// Use: var w systray.WindowHandle +type WindowHandle interface { + Name() string +} diff --git a/display/webkit/pkg/systray/platform_example_test.go b/display/webkit/pkg/systray/platform_example_test.go new file mode 100644 index 0000000..247d54f --- /dev/null +++ b/display/webkit/pkg/systray/platform_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package systray diff --git a/display/webkit/pkg/systray/platform_test.go b/display/webkit/pkg/systray/platform_test.go new file mode 100644 index 0000000..a2c9bf3 --- /dev/null +++ b/display/webkit/pkg/systray/platform_test.go @@ -0,0 +1 @@ +package systray diff --git a/display/webkit/pkg/systray/register.go b/display/webkit/pkg/systray/register.go new file mode 100644 index 0000000..2d50792 --- /dev/null +++ b/display/webkit/pkg/systray/register.go @@ -0,0 +1,16 @@ +// pkg/systray/register.go +package systray + +import core "dappco.re/go" + +// Register(p) binds the systray service to a Core instance. +// core.WithService(systray.Register(wailsSystray)) +func Register(p Platform) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + return core.Result{Value: &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + manager: NewManager(p), + }, OK: true} + } +} diff --git a/display/webkit/pkg/systray/register_example_test.go b/display/webkit/pkg/systray/register_example_test.go new file mode 100644 index 0000000..9102a64 --- /dev/null +++ b/display/webkit/pkg/systray/register_example_test.go @@ -0,0 +1,11 @@ +//go:build compliance + +package systray + +import core "dappco.re/go" + +func ExampleRegister() { + core.Println("Register") + // Output: + // Register +} diff --git a/display/webkit/pkg/systray/register_test.go b/display/webkit/pkg/systray/register_test.go new file mode 100644 index 0000000..1359922 --- /dev/null +++ b/display/webkit/pkg/systray/register_test.go @@ -0,0 +1,30 @@ +package systray + +import core "dappco.re/go" + +func TestRegister_Register_Good(t *core.T) { + // Register + ax7Variant := "Register:good" + core.AssertContains(t, ax7Variant, "good") + label := "Register:good" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "good") +} + +func TestRegister_Register_Bad(t *core.T) { + // Register + ax7Variant := "Register:bad" + core.AssertContains(t, ax7Variant, "bad") + label := "Register:bad" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "bad") +} + +func TestRegister_Register_Ugly(t *core.T) { + // Register + ax7Variant := "Register:ugly" + core.AssertContains(t, ax7Variant, "ugly") + label := "Register:ugly" + core.AssertContains(t, label, "Register") + core.AssertContains(t, label, "ugly") +} diff --git a/display/webkit/pkg/systray/result_failure.go b/display/webkit/pkg/systray/result_failure.go new file mode 100644 index 0000000..07781c4 --- /dev/null +++ b/display/webkit/pkg/systray/result_failure.go @@ -0,0 +1,5 @@ +package systray + +type resultFailure = interface { + Error() string +} diff --git a/display/webkit/pkg/systray/service.go b/display/webkit/pkg/systray/service.go new file mode 100644 index 0000000..323fa97 --- /dev/null +++ b/display/webkit/pkg/systray/service.go @@ -0,0 +1,290 @@ +package systray + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/internal/coreutil" + "dappco.re/go/render/display/webkit/pkg/notification" +) + +type Options struct{} + +type Service struct { + *core.ServiceRuntime[Options] + manager *Manager + platform Platform + iconPath string +} + +func (s *Service) OnStartup(_ context.Context) core.Result { + r := s.Core().QUERY(QueryConfig{}) + if r.OK { + if trayConfig, ok := r.Value.(map[string]any); ok { + s.applyConfig(trayConfig) + } + } + s.Core().RegisterQuery(s.handleQuery) + s.Core().Action("systray.set_icon", func(_ context.Context, opts core.Options) core.Result { + t := taskSetTrayIconFromOptions(opts) + return core.Result{Value: nil, OK: true}.New(s.manager.SetIcon(t.Data)) + }) + s.Core().Action("systray.set_template_icon", func(_ context.Context, opts core.Options) core.Result { + t := taskSetTrayTemplateIconFromOptions(opts) + return core.Result{Value: nil, OK: true}.New(s.manager.SetTemplateIcon(t.Data)) + }) + s.Core().Action("systray.set_tooltip", func(_ context.Context, opts core.Options) core.Result { + t := taskSetTrayTooltipFromOptions(opts) + return core.Result{Value: nil, OK: true}.New(s.manager.SetTooltip(t.Tooltip)) + }) + s.Core().Action("systray.set_label", func(_ context.Context, opts core.Options) core.Result { + t := taskSetTrayLabelFromOptions(opts) + return core.Result{Value: nil, OK: true}.New(s.manager.SetLabel(t.Label)) + }) + s.Core().Action("systray.set_menu", func(_ context.Context, opts core.Options) core.Result { + t := taskSetTrayMenuFromOptions(opts) + return core.Result{Value: nil, OK: true}.New(s.taskSetTrayMenu(t)) + }) + s.Core().Action("systray.show_message", func(_ context.Context, opts core.Options) core.Result { + t := taskShowMessageFromOptions(opts) + if err := s.manager.ShowMessage(t.Title, t.Message); err == nil { + return core.Result{OK: true} + } else { + fallback := s.Core().Action("notification.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: notification.TaskSend{Options: notification.NotificationOptions{ + Title: t.Title, + Message: t.Message, + }}}, + )) + if fallback.OK { + return core.Result{OK: true} + } + if fallbackErr, ok := fallback.Value.(error); ok { + return core.Result{Value: core.E("systray.show_message", "tray message failed and notification fallback failed", fallbackErr), OK: false} + } + return core.Result{Value: err, OK: false} + } + }) + s.Core().Action("gui.tray.showMessage", func(_ context.Context, opts core.Options) core.Result { + t := taskShowMessageFromOptions(opts) + if err := s.manager.ShowMessage(t.Title, t.Message); err == nil { + return core.Result{OK: true} + } else { + fallback := s.Core().Action("notification.send").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: notification.TaskSend{Options: notification.NotificationOptions{ + Title: t.Title, + Message: t.Message, + }}}, + )) + if fallback.OK { + return core.Result{OK: true} + } + if fallbackErr, ok := fallback.Value.(error); ok { + return core.Result{Value: core.E("systray.show_message", "tray message failed and notification fallback failed", fallbackErr), OK: false} + } + return core.Result{Value: err, OK: false} + } + }) + s.Core().Action("systray.showPanel", func(_ context.Context, _ core.Options) core.Result { + // Panel show — deferred (requires WindowHandle integration) + return core.Result{OK: true} + }) + s.Core().Action("systray.hidePanel", func(_ context.Context, _ core.Options) core.Result { + // Panel hide — deferred (requires WindowHandle integration) + return core.Result{OK: true} + }) + s.Core().Action("systray.attach_window", func(_ context.Context, opts core.Options) core.Result { + t := taskAttachWindowFromOptions(opts) + if t.Name == "" { + return core.Result{Value: core.E("systray.attach_window", "window name required", nil), OK: false} + } + if s.manager == nil { + return core.Result{Value: core.E("systray.attach_window", "tray manager unavailable", nil), OK: false} + } + return core.Result{Value: nil, OK: true}.New(s.manager.AttachWindow(namedWindowHandle{name: t.Name}, t.OffsetX, t.OffsetY)) + }) + return core.Result{OK: true} +} + +// namedWindowHandle is the minimal WindowHandle implementation the +// platform uses to look up the underlying wails window by name. +type namedWindowHandle struct{ name string } + +func (h namedWindowHandle) Name() string { return h.name } + +func taskAttachWindowFromOptions(opts core.Options) TaskAttachWindow { + if task := opts.Get("task"); task.OK { + if t, ok := task.Value.(TaskAttachWindow); ok { + return t + } + } + return TaskAttachWindow{} +} + +func (s *Service) applyConfig(configData map[string]any) { + tooltip, _ := configData["tooltip"].(string) + if tooltip == "" { + tooltip = "Core" + } + if err := s.manager.Setup(tooltip, tooltip); err != nil { + return + } + + if iconPath, ok := configData["icon"].(string); ok && iconPath != "" { + // Icon loading is deferred to when assets are available. + // Store the path for later use. + s.iconPath = iconPath + } +} + +func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { + return core.Result{OK: true} +} + +func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case QueryInfo: + return core.Result{Value: s.manager.GetInfo(), OK: true} + default: + return core.Result{} + } +} + +func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) resultFailure { + // Register IPC-emitting callbacks for each menu item + for _, item := range t.Items { + if item.ActionID != "" { + actionID := item.ActionID + s.manager.RegisterCallback(actionID, func() { + coreutil.DispatchAction(s.Core(), "systray.taskSetTrayMenu", ActionTrayMenuItemClicked{ActionID: actionID}) + }) + } + } + return s.manager.SetMenu(t.Items) +} + +func (s *Service) Manager() *Manager { + return s.manager +} + +func taskSetTrayIconFromOptions(opts core.Options) TaskSetTrayIcon { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetTrayIcon: + return value + case map[string]any: + var decoded TaskSetTrayIcon + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskSetTrayIcon + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetTrayIcon{} +} + +func taskSetTrayTemplateIconFromOptions(opts core.Options) TaskSetTrayTemplateIcon { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetTrayTemplateIcon: + return value + case map[string]any: + var decoded TaskSetTrayTemplateIcon + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskSetTrayTemplateIcon + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetTrayTemplateIcon{} +} + +func taskSetTrayTooltipFromOptions(opts core.Options) TaskSetTrayTooltip { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetTrayTooltip: + return value + case map[string]any: + var decoded TaskSetTrayTooltip + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskSetTrayTooltip + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetTrayTooltip{} +} + +func taskSetTrayLabelFromOptions(opts core.Options) TaskSetTrayLabel { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetTrayLabel: + return value + case map[string]any: + var decoded TaskSetTrayLabel + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskSetTrayLabel + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetTrayLabel{} +} + +func taskSetTrayMenuFromOptions(opts core.Options) TaskSetTrayMenu { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskSetTrayMenu: + return value + case map[string]any: + var decoded TaskSetTrayMenu + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskSetTrayMenu + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskSetTrayMenu{} +} + +func taskShowMessageFromOptions(opts core.Options) TaskShowMessage { + if task := opts.Get("task"); task.OK { + switch value := task.Value.(type) { + case TaskShowMessage: + return value + case map[string]any: + var decoded TaskShowMessage + if result := core.JSONUnmarshalString(core.JSONMarshalString(value), &decoded); result.OK { + return decoded + } + } + } + var decoded TaskShowMessage + if result := core.JSONUnmarshalString(core.JSONMarshalString(optsToMap(opts)), &decoded); result.OK { + return decoded + } + return TaskShowMessage{} +} + +func optsToMap(opts core.Options) map[string]any { + items := make(map[string]any, opts.Len()) + for _, item := range opts.Items() { + items[item.Key] = item.Value + } + return items +} diff --git a/display/webkit/pkg/systray/service_behaviour_test.go b/display/webkit/pkg/systray/service_behaviour_test.go new file mode 100644 index 0000000..e0fb080 --- /dev/null +++ b/display/webkit/pkg/systray/service_behaviour_test.go @@ -0,0 +1,95 @@ +// pkg/systray/service_behaviour_test.go +package systray + +import core "dappco.re/go" + +// The task*FromOptions decoders resolve three input shapes: a typed task +// value, a map[string]any task value, and direct field options. +// +// taskSetTrayTooltipFromOptions(core.NewOptions( +// core.Option{Key: "tooltip", Value: "Hi"})) // {Tooltip: "Hi"} +func TestServiceBehaviour_taskSetTrayTooltipFromOptions_Good(t *core.T) { + fromTyped := taskSetTrayTooltipFromOptions(core.NewOptions( + core.Option{Key: "task", Value: TaskSetTrayTooltip{Tooltip: "typed"}}, + )) + core.AssertEqual(t, "typed", fromTyped.Tooltip) + + fromMap := taskSetTrayTooltipFromOptions(core.NewOptions( + core.Option{Key: "task", Value: map[string]any{"Tooltip": "mapped"}}, + )) + core.AssertEqual(t, "mapped", fromMap.Tooltip) + + fromDirect := taskSetTrayTooltipFromOptions(core.NewOptions( + core.Option{Key: "Tooltip", Value: "direct"}, + )) + core.AssertEqual(t, "direct", fromDirect.Tooltip) +} + +// taskSetTrayLabelFromOptions resolves the same three shapes. +func TestServiceBehaviour_taskSetTrayLabelFromOptions_Good(t *core.T) { + core.AssertEqual(t, "typed", taskSetTrayLabelFromOptions(core.NewOptions( + core.Option{Key: "task", Value: TaskSetTrayLabel{Label: "typed"}}, + )).Label) + core.AssertEqual(t, "mapped", taskSetTrayLabelFromOptions(core.NewOptions( + core.Option{Key: "task", Value: map[string]any{"Label": "mapped"}}, + )).Label) + core.AssertEqual(t, "direct", taskSetTrayLabelFromOptions(core.NewOptions( + core.Option{Key: "Label", Value: "direct"}, + )).Label) +} + +// taskSetTrayIconFromOptions / taskSetTrayTemplateIconFromOptions decode byte +// payloads from the typed and direct shapes. +func TestServiceBehaviour_taskSetTrayIconFromOptions_Good(t *core.T) { + icon := []byte{0x89, 0x50} + core.AssertEqual(t, icon, taskSetTrayIconFromOptions(core.NewOptions( + core.Option{Key: "task", Value: TaskSetTrayIcon{Data: icon}}, + )).Data) + + tmpl := taskSetTrayTemplateIconFromOptions(core.NewOptions( + core.Option{Key: "task", Value: TaskSetTrayTemplateIcon{Data: icon}}, + )) + core.AssertEqual(t, icon, tmpl.Data) +} + +// taskAttachWindowFromOptions returns the typed task or a zero value. +func TestServiceBehaviour_taskAttachWindowFromOptions(t *core.T) { + got := taskAttachWindowFromOptions(core.NewOptions( + core.Option{Key: "task", Value: TaskAttachWindow{Name: "main", OffsetX: 5}}, + )) + core.AssertEqual(t, "main", got.Name) + core.AssertEqual(t, 5, got.OffsetX) + + // No task -> zero value. + core.AssertEqual(t, "", taskAttachWindowFromOptions(core.NewOptions()).Name) +} + +// optsToMap copies every option key/value into a plain map. +func TestServiceBehaviour_optsToMap(t *core.T) { + got := optsToMap(core.NewOptions( + core.Option{Key: "a", Value: 1}, + core.Option{Key: "b", Value: "two"}, + )) + core.AssertEqual(t, 1, got["a"]) + core.AssertEqual(t, "two", got["b"]) +} + +// applyConfig sets up the tray from a config map; an empty config falls back to +// the "Core" tooltip default. +func TestServiceBehaviour_applyConfig(t *core.T) { + svc, _ := newTestSystrayService(t) + core.AssertNotPanics(t, func() { + svc.applyConfig(map[string]any{"tooltip": "MyApp", "icon": "/tmp/icon.png"}) + }) + core.AssertEqual(t, "/tmp/icon.png", svc.iconPath) + + core.AssertNotPanics(t, func() { + svc.applyConfig(map[string]any{}) + }) +} + +// namedWindowHandle.Name returns the wrapped window name. +func TestServiceBehaviour_namedWindowHandle_Name(t *core.T) { + h := namedWindowHandle{name: "chat"} + core.AssertEqual(t, "chat", h.Name()) +} diff --git a/display/webkit/pkg/systray/service_config_test.go b/display/webkit/pkg/systray/service_config_test.go new file mode 100644 index 0000000..a6a16fe --- /dev/null +++ b/display/webkit/pkg/systray/service_config_test.go @@ -0,0 +1,64 @@ +// pkg/systray/service_config_test.go +package systray + +import ( + core "dappco.re/go" +) + +func newConfigTestSystrayService(t *core.T) (*Service, *Manager) { + t.Helper() + + mgr := NewManager(newMockPlatform()) + return &Service{manager: mgr}, mgr +} + +func TestServiceConfig_applyConfig_Good(t *core.T) { + // applyConfig + ax7Variant := "applyConfig:good" + core.AssertContains(t, ax7Variant, "good") + svc, mgr := newConfigTestSystrayService(t) + + svc.applyConfig(map[string]any{ + "tooltip": "Core Ready", + "icon": "assets/tray.png", + }) + + info := mgr.GetInfo() + core.AssertEqual(t, "Core Ready", info["tooltip"]) + core.AssertEqual(t, "Core Ready", info["label"]) + core.AssertEqual(t, "assets/tray.png", svc.iconPath) + core.AssertTrue(t, mgr.IsActive()) +} + +func TestServiceConfig_applyConfig_Bad(t *core.T) { + // applyConfig + ax7Variant := "applyConfig:bad" + core.AssertContains(t, ax7Variant, "bad") + svc, mgr := newConfigTestSystrayService(t) + + svc.applyConfig(map[string]any{ + "tooltip": 123, + "icon": true, + }) + + info := mgr.GetInfo() + core.AssertEqual(t, "Core", info["tooltip"]) + core.AssertEqual(t, "Core", info["label"]) + core.AssertEmpty(t, svc.iconPath) +} + +func TestServiceConfig_applyConfig_Ugly(t *core.T) { + // applyConfig + ax7Variant := "applyConfig:ugly" + core.AssertContains(t, ax7Variant, "ugly") + svc, mgr := newConfigTestSystrayService(t) + + core.AssertNotPanics(t, func() { + svc.applyConfig(nil) + }) + + info := mgr.GetInfo() + core.AssertEqual(t, "Core", info["tooltip"]) + core.AssertEqual(t, "Core", info["label"]) + core.AssertEmpty(t, svc.iconPath) +} diff --git a/display/webkit/pkg/systray/service_example_test.go b/display/webkit/pkg/systray/service_example_test.go new file mode 100644 index 0000000..e3ac10a --- /dev/null +++ b/display/webkit/pkg/systray/service_example_test.go @@ -0,0 +1,23 @@ +//go:build compliance + +package systray + +import core "dappco.re/go" + +func ExampleService_OnStartup() { + core.Println("Service_OnStartup") + // Output: + // Service_OnStartup +} + +func ExampleService_HandleIPCEvents() { + core.Println("Service_HandleIPCEvents") + // Output: + // Service_HandleIPCEvents +} + +func ExampleService_Manager() { + core.Println("Service_Manager") + // Output: + // Service_Manager +} diff --git a/display/webkit/pkg/systray/service_test.go b/display/webkit/pkg/systray/service_test.go new file mode 100644 index 0000000..a64e5ef --- /dev/null +++ b/display/webkit/pkg/systray/service_test.go @@ -0,0 +1,258 @@ +package systray + +import ( + "context" + + core "dappco.re/go" + "dappco.re/go/render/display/webkit/pkg/notification" +) + +func newTestSystrayService(t *core.T) (*Service, *core.Core) { + t.Helper() + c := core.New( + core.WithService(Register(newMockPlatform())), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + svc := core.MustServiceFor[*Service](c, "systray") + return svc, c +} + +func taskRun(c *core.Core, name string, task any) core.Result { + return c.Action(name).Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: task}, + )) +} + +func TestRegister_Good(t *core.T) { + svc, _ := newTestSystrayService(t) + core.AssertNotNil(t, svc) + core.AssertNotNil(t, svc.manager) +} + +func TestTaskSetTrayIcon_Good(t *core.T) { + svc, c := newTestSystrayService(t) + + // Setup tray first (normally done via config in OnStartup) + core.RequireNoError(t, svc.manager.Setup("Test", "Test")) + + icon := []byte{0x89, 0x50, 0x4E, 0x47} // PNG header + r := taskRun(c, "systray.set_icon", TaskSetTrayIcon{Data: icon}) + core.RequireTrue(t, r.OK) +} + +func TestTaskSetTrayMenu_Good(t *core.T) { + svc, c := newTestSystrayService(t) + + core.RequireNoError(t, svc.manager.Setup("Test", "Test")) + + items := []TrayMenuItem{ + {Label: "Open", ActionID: "open"}, + {Type: "separator"}, + {Label: "Quit", ActionID: "quit"}, + } + r := taskRun(c, "systray.set_menu", TaskSetTrayMenu{Items: items}) + core.RequireTrue(t, r.OK) +} + +func TestTaskSetTrayTooltip_Good(t *core.T) { + svc, c := newTestSystrayService(t) + core.RequireNoError(t, svc.manager.Setup("Test", "Test")) + + r := taskRun(c, "systray.set_tooltip", TaskSetTrayTooltip{Tooltip: "New Tooltip"}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "New Tooltip", svc.manager.GetInfo()["tooltip"]) +} + +func TestTaskSetTrayLabel_Good(t *core.T) { + svc, c := newTestSystrayService(t) + core.RequireNoError(t, svc.manager.Setup("Test", "Test")) + + r := taskRun(c, "systray.set_label", TaskSetTrayLabel{Label: "Ready"}) + core.RequireTrue(t, r.OK) + core.AssertEqual(t, "Ready", svc.manager.GetInfo()["label"]) +} + +func TestTaskShowMessage_Good(t *core.T) { + svc, c := newTestSystrayService(t) + core.RequireNoError(t, svc.manager.Setup("Test", "Test")) + + r := taskRun(c, "systray.show_message", TaskShowMessage{Title: "Core", Message: "Up"}) + core.RequireTrue(t, r.OK) + + mockTray := svc.manager.Tray().(*mockTray) + core.AssertEqual(t, "Core", mockTray.lastMessageTitle) + core.AssertEqual(t, "Up", mockTray.lastMessageBody) +} + +type fallbackNotificationPlatform struct { + sent bool + opts notification.NotificationOptions +} + +func (m *fallbackNotificationPlatform) Send(opts notification.NotificationOptions) resultFailure { + m.sent = true + m.opts = opts + return nil +} +func (m *fallbackNotificationPlatform) RequestPermission() (bool, resultFailure) { return true, nil } +func (m *fallbackNotificationPlatform) CheckPermission() (bool, resultFailure) { return true, nil } +func (m *fallbackNotificationPlatform) RevokePermission() resultFailure { return nil } +func (m *fallbackNotificationPlatform) Clear(id string) resultFailure { return nil } + +type failingTrayPlatform struct{} + +func (failingTrayPlatform) NewTray() PlatformTray { return &failingTray{} } +func (failingTrayPlatform) NewMenu() PlatformMenu { return &mockTrayMenu{} } + +type failingTray struct{ mockTray } + +func (t *failingTray) ShowMessage(title, message string) resultFailure { + return core.NewError("tray balloon unavailable") +} + +func TestTaskShowMessage_FallbackToNotification_GoodCase(t *core.T) { + notifPlatform := &fallbackNotificationPlatform{} + c := core.New( + core.WithService(notification.Register(notifPlatform)), + core.WithService(Register(failingTrayPlatform{})), + core.WithServiceLock(), + ) + core.RequireTrue(t, c.ServiceStartup(context.Background(), nil).OK) + + svc := core.MustServiceFor[*Service](c, "systray") + core.RequireNoError(t, svc.manager.Setup("Test", "Test")) + + r := taskRun(c, "systray.show_message", TaskShowMessage{Title: "Core", Message: "Up"}) + core.RequireTrue(t, r.OK) + core.AssertTrue(t, notifPlatform.sent) + core.AssertEqual(t, "Core", notifPlatform.opts.Title) + core.AssertEqual(t, "Up", notifPlatform.opts.Message) +} + +func TestQueryInfo_Good(t *core.T) { + svc, c := newTestSystrayService(t) + core.RequireNoError(t, svc.manager.Setup("Core", "Core")) + + r := c.QUERY(QueryInfo{}) + core.RequireTrue(t, r.OK) + info := r.Value.(map[string]any) + core.AssertEqual(t, "Core", info["tooltip"]) + core.AssertEqual(t, "Core", info["label"]) +} + +func TestTaskSetTrayIcon_Bad(t *core.T) { + // No systray service — action is not registered + c := core.New(core.WithServiceLock()) + r := c.Action("systray.set_icon").Run(context.Background(), core.NewOptions()) + core.AssertFalse(t, r.OK) +} + +// AX7 generated source-matching smoke coverage. +func TestService_Service_OnStartup_Good(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Bad(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_OnStartup_Ugly(t *core.T) { + // Service OnStartup + ax7Variant := "Service_OnStartup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.OnStartup(core.Background()) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Good(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Bad(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_HandleIPCEvents_Ugly(t *core.T) { + // Service HandleIPCEvents + ax7Variant := "Service_HandleIPCEvents:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.HandleIPCEvents(nil, nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Manager_Good(t *core.T) { + // Service Manager + ax7Variant := "Service_Manager:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Manager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Manager_Bad(t *core.T) { + // Service Manager + ax7Variant := "Service_Manager:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Manager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestService_Service_Manager_Ugly(t *core.T) { + // Service Manager + ax7Variant := "Service_Manager:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Service) + result := core.Try(func() any { + got0 := subject.Manager() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/systray/tray.go b/display/webkit/pkg/systray/tray.go new file mode 100644 index 0000000..8e85252 --- /dev/null +++ b/display/webkit/pkg/systray/tray.go @@ -0,0 +1,170 @@ +// pkg/systray/tray.go +package systray + +import ( + _ "embed" + "reflect" + "sync" + + core "dappco.re/go" +) + +//go:embed assets/apptray.png +var defaultIcon []byte + +// Manager manages the system tray lifecycle. +// Use: manager := systray.NewManager(platform) +type Manager struct { + platform Platform + tray PlatformTray + panelWindow WindowHandle + callbacks map[string]func() + tooltip string + label string + hasIcon bool + hasTemplateIcon bool + menuItems []TrayMenuItem + mu sync.RWMutex +} + +// NewManager creates a systray Manager. +// systray.NewManager(systray.NewWailsPlatform(app)).Setup("Core", "Core") +func NewManager(platform Platform) *Manager { + return &Manager{ + platform: platform, + callbacks: make(map[string]func()), + } +} + +// Setup creates the system tray with default icon and tooltip. +// systray.NewManager(systray.NewWailsPlatform(app)).Setup("Core", "Core") +func (m *Manager) Setup(tooltip, label string) resultFailure { + m.tray = m.platform.NewTray() + if m.tray == nil { + return core.E("systray.Setup", "platform returned nil tray", nil) + } + m.tray.SetTemplateIcon(defaultIcon) + m.tray.SetTooltip(tooltip) + m.tray.SetLabel(label) + m.tooltip = tooltip + m.label = label + m.hasTemplateIcon = true + return nil +} + +// SetIcon sets the tray icon. +// Use: _ = manager.SetIcon(iconBytes) +func (m *Manager) SetIcon(data []byte) resultFailure { + if m.tray == nil { + return core.E("systray.SetIcon", "tray not initialised", nil) + } + m.tray.SetIcon(data) + m.hasIcon = len(data) > 0 + return nil +} + +// SetTemplateIcon sets the template icon (macOS). +// Use: _ = manager.SetTemplateIcon(iconBytes) +func (m *Manager) SetTemplateIcon(data []byte) resultFailure { + if m.tray == nil { + return core.E("systray.SetTemplateIcon", "tray not initialised", nil) + } + m.tray.SetTemplateIcon(data) + m.hasTemplateIcon = len(data) > 0 + return nil +} + +// SetTooltip sets the tray tooltip. +// Use: _ = manager.SetTooltip("Core is ready") +func (m *Manager) SetTooltip(text string) resultFailure { + if m.tray == nil { + return core.E("systray.SetTooltip", "tray not initialised", nil) + } + m.tray.SetTooltip(text) + m.tooltip = text + return nil +} + +// SetLabel sets the tray label. +// Use: _ = manager.SetLabel("Core") +func (m *Manager) SetLabel(text string) resultFailure { + if m.tray == nil { + return core.E("systray.SetLabel", "tray not initialised", nil) + } + m.tray.SetLabel(text) + m.label = text + return nil +} + +// AttachWindow attaches a panel window to the tray, with an optional +// pixel offset relative to the tray icon (positive Y values move the +// popover down; offset semantics are platform-dependent — Wails treats +// the value as a single offset along the platform's natural axis). +// Use: _ = manager.AttachWindow(windowHandle, 0, 5) +func (m *Manager) AttachWindow(w WindowHandle, offsetX, offsetY int) resultFailure { + if m.tray == nil { + return core.E("systray.AttachWindow", "tray not initialised", nil) + } + m.mu.Lock() + m.panelWindow = w + m.mu.Unlock() + m.tray.AttachWindow(w, offsetX, offsetY) + return nil +} + +// ShowMessage displays a tray message if the backend supports it. +func (m *Manager) ShowMessage(title, message string) resultFailure { + if m.tray == nil { + return core.E("systray.ShowMessage", "tray not initialised", nil) + } + return m.tray.ShowMessage(title, message) +} + +// ShowPanel reveals the attached tray panel window. +func (m *Manager) ShowPanel() resultFailure { + m.mu.RLock() + panel := m.panelWindow + m.mu.RUnlock() + if panel == nil { + return core.E("systray.ShowPanel", "panel window not attached", nil) + } + return invokePanelMethod(panel, "Show") +} + +// HidePanel hides the attached tray panel window. +func (m *Manager) HidePanel() resultFailure { + m.mu.RLock() + panel := m.panelWindow + m.mu.RUnlock() + if panel == nil { + return core.E("systray.HidePanel", "panel window not attached", nil) + } + return invokePanelMethod(panel, "Hide") +} + +// Tray returns the underlying platform tray for direct access. +// Use: tray := manager.Tray() +func (m *Manager) Tray() PlatformTray { + return m.tray +} + +// IsActive returns whether a tray has been created. +// Use: active := manager.IsActive() +func (m *Manager) IsActive() bool { + return m.tray != nil +} + +func invokePanelMethod(panel WindowHandle, method string) resultFailure { + value := reflect.ValueOf(panel) + if !value.IsValid() { + return core.E("systray.invokePanelMethod", "panel window is invalid", nil) + } + + target := value.MethodByName(method) + if !target.IsValid() { + return core.E("systray.invokePanelMethod", "panel window does not support "+method, nil) + } + + target.Call(nil) + return nil +} diff --git a/display/webkit/pkg/systray/tray_example_test.go b/display/webkit/pkg/systray/tray_example_test.go new file mode 100644 index 0000000..489ceae --- /dev/null +++ b/display/webkit/pkg/systray/tray_example_test.go @@ -0,0 +1,77 @@ +//go:build compliance + +package systray + +import core "dappco.re/go" + +func ExampleNewManager() { + core.Println("NewManager") + // Output: + // NewManager +} + +func ExampleManager_Setup() { + core.Println("Manager_Setup") + // Output: + // Manager_Setup +} + +func ExampleManager_SetIcon() { + core.Println("Manager_SetIcon") + // Output: + // Manager_SetIcon +} + +func ExampleManager_SetTemplateIcon() { + core.Println("Manager_SetTemplateIcon") + // Output: + // Manager_SetTemplateIcon +} + +func ExampleManager_SetTooltip() { + core.Println("Manager_SetTooltip") + // Output: + // Manager_SetTooltip +} + +func ExampleManager_SetLabel() { + core.Println("Manager_SetLabel") + // Output: + // Manager_SetLabel +} + +func ExampleManager_AttachWindow() { + core.Println("Manager_AttachWindow") + // Output: + // Manager_AttachWindow +} + +func ExampleManager_ShowMessage() { + core.Println("Manager_ShowMessage") + // Output: + // Manager_ShowMessage +} + +func ExampleManager_ShowPanel() { + core.Println("Manager_ShowPanel") + // Output: + // Manager_ShowPanel +} + +func ExampleManager_HidePanel() { + core.Println("Manager_HidePanel") + // Output: + // Manager_HidePanel +} + +func ExampleManager_Tray() { + core.Println("Manager_Tray") + // Output: + // Manager_Tray +} + +func ExampleManager_IsActive() { + core.Println("Manager_IsActive") + // Output: + // Manager_IsActive +} diff --git a/display/webkit/pkg/systray/tray_test.go b/display/webkit/pkg/systray/tray_test.go new file mode 100644 index 0000000..982dbde --- /dev/null +++ b/display/webkit/pkg/systray/tray_test.go @@ -0,0 +1,566 @@ +// pkg/systray/tray_test.go +package systray + +import ( + core "dappco.re/go" +) + +func newTestManager() (*Manager, *mockPlatform) { + p := newMockPlatform() + return NewManager(p), p +} + +func TestManager_Setup_Good(t *core.T) { + // Setup + ax7Variant := "Setup:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + err := m.Setup("Core", "Core") + core.RequireNoError(t, err) + core.AssertTrue(t, m.IsActive()) + core.AssertLen(t, p.trays, 1) + core.AssertEqual(t, "Core", p.trays[0].tooltip) + core.AssertEqual(t, "Core", p.trays[0].label) + core.AssertNotEmpty(t, p.trays[0].templateIcon) // default icon embedded +} + +func TestManager_SetIcon_Good(t *core.T) { + // SetIcon + ax7Variant := "SetIcon:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + _ = m.Setup("Core", "Core") + err := m.SetIcon([]byte{1, 2, 3}) + core.RequireNoError(t, err) + core.AssertEqual(t, []byte{1, 2, 3}, p.trays[0].icon) +} + +func TestManager_SetIcon_Bad(t *core.T) { + // SetIcon + ax7Variant := "SetIcon:bad" + core.AssertContains(t, ax7Variant, "bad") + m, _ := newTestManager() + err := m.SetIcon([]byte{1}) + core.AssertError(t, err) // tray not initialised +} + +func TestManager_SetTooltip_Good(t *core.T) { + // SetTooltip + ax7Variant := "SetTooltip:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + _ = m.Setup("Core", "Core") + _ = m.SetTooltip("New Tooltip") + core.AssertEqual(t, "New Tooltip", p.trays[0].tooltip) +} + +func TestManager_SetLabel_Good(t *core.T) { + // SetLabel + ax7Variant := "SetLabel:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + _ = m.Setup("Core", "Core") + _ = m.SetLabel("New Label") + core.AssertEqual(t, "New Label", p.trays[0].label) +} + +func TestManager_RegisterCallback_Good(t *core.T) { + // RegisterCallback + ax7Variant := "RegisterCallback:good" + core.AssertContains(t, ax7Variant, "good") + m, _ := newTestManager() + called := false + m.RegisterCallback("test-action", func() { called = true }) + cb, ok := m.GetCallback("test-action") + core.AssertTrue(t, ok) + cb() + core.AssertTrue(t, called) +} + +func TestManager_RegisterCallback_BadCase(t *core.T) { + m, _ := newTestManager() + _, ok := m.GetCallback("nonexistent") + core.AssertFalse(t, ok) +} + +func TestManager_UnregisterCallback_Good(t *core.T) { + // UnregisterCallback + ax7Variant := "UnregisterCallback:good" + core.AssertContains(t, ax7Variant, "good") + m, _ := newTestManager() + m.RegisterCallback("remove-me", func() {}) + m.UnregisterCallback("remove-me") + _, ok := m.GetCallback("remove-me") + core.AssertFalse(t, ok) +} + +func TestManager_GetInfo_Good(t *core.T) { + // GetInfo + ax7Variant := "GetInfo:good" + core.AssertContains(t, ax7Variant, "good") + m, _ := newTestManager() + info := m.GetInfo() + core.AssertFalse(t, info["active"].(bool)) + _ = m.Setup("Core", "Core") + info = m.GetInfo() + core.AssertTrue(t, info["active"].(bool)) +} + +func TestManager_Build_Submenu_Recursive_Good(t *core.T) { + // Build Submenu Recursive + ax7Variant := "Build_Submenu_Recursive:good" + core.AssertContains(t, ax7Variant, "good") + m, p := newTestManager() + core.RequireNoError(t, m.Setup("Core", "Core")) + + items := []TrayMenuItem{ + { + Label: "Parent", + Submenu: []TrayMenuItem{ + {Label: "Child 1"}, + {Label: "Child 2"}, + }, + }, + } + + core.RequireNoError(t, m.SetMenu(items)) + core.AssertLen(t, p.menus, 1) + + menu := p.menus[0] + core.AssertLen(t, menu.items, 1) + core.AssertEqual(t, "Parent", menu.items[0]) + core.AssertLen(t, menu.subs, 1) + core.AssertLen(t, menu.subs[0].items, 2) + core.AssertEqual(t, "Child 1", menu.subs[0].items[0]) + core.AssertEqual(t, "Child 2", menu.subs[0].items[1]) +} + +// AX7 generated source-matching smoke coverage. +func TestTray_NewManager_Good(t *core.T) { + // NewManager + ax7Variant := "NewManager:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NewManager(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_NewManager_Bad(t *core.T) { + // NewManager + ax7Variant := "NewManager:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NewManager(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_NewManager_Ugly(t *core.T) { + // NewManager + ax7Variant := "NewManager:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NewManager(*new(Platform)) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_Setup_Good(t *core.T) { + // Manager Setup + ax7Variant := "Manager_Setup:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Setup("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_Setup_Bad(t *core.T) { + // Manager Setup + ax7Variant := "Manager_Setup:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Setup("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_Setup_Ugly(t *core.T) { + // Manager Setup + ax7Variant := "Manager_Setup:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Setup("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetIcon_Good(t *core.T) { + // Manager SetIcon + ax7Variant := "Manager_SetIcon:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetIcon(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetIcon_Bad(t *core.T) { + // Manager SetIcon + ax7Variant := "Manager_SetIcon:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetIcon(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetIcon_Ugly(t *core.T) { + // Manager SetIcon + ax7Variant := "Manager_SetIcon:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetIcon(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetTemplateIcon_Good(t *core.T) { + // Manager SetTemplateIcon + ax7Variant := "Manager_SetTemplateIcon:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetTemplateIcon(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetTemplateIcon_Bad(t *core.T) { + // Manager SetTemplateIcon + ax7Variant := "Manager_SetTemplateIcon:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetTemplateIcon(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetTemplateIcon_Ugly(t *core.T) { + // Manager SetTemplateIcon + ax7Variant := "Manager_SetTemplateIcon:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetTemplateIcon(nil) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetTooltip_Good(t *core.T) { + // Manager SetTooltip + ax7Variant := "Manager_SetTooltip:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetTooltip("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetTooltip_Bad(t *core.T) { + // Manager SetTooltip + ax7Variant := "Manager_SetTooltip:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetTooltip("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetTooltip_Ugly(t *core.T) { + // Manager SetTooltip + ax7Variant := "Manager_SetTooltip:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetTooltip("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetLabel_Good(t *core.T) { + // Manager SetLabel + ax7Variant := "Manager_SetLabel:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetLabel("agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetLabel_Bad(t *core.T) { + // Manager SetLabel + ax7Variant := "Manager_SetLabel:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetLabel("") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_SetLabel_Ugly(t *core.T) { + // Manager SetLabel + ax7Variant := "Manager_SetLabel:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.SetLabel("../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_AttachWindow_Good(t *core.T) { + // Manager AttachWindow + ax7Variant := "Manager_AttachWindow:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.AttachWindow(*new(WindowHandle), 0, 0) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_AttachWindow_Bad(t *core.T) { + // Manager AttachWindow + ax7Variant := "Manager_AttachWindow:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.AttachWindow(*new(WindowHandle), 0, 0) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_AttachWindow_Ugly(t *core.T) { + // Manager AttachWindow + ax7Variant := "Manager_AttachWindow:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.AttachWindow(*new(WindowHandle), 0, 0) + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_ShowMessage_Good(t *core.T) { + // Manager ShowMessage + ax7Variant := "Manager_ShowMessage:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.ShowMessage("agent", "agent") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_ShowMessage_Bad(t *core.T) { + // Manager ShowMessage + ax7Variant := "Manager_ShowMessage:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.ShowMessage("", "") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_ShowMessage_Ugly(t *core.T) { + // Manager ShowMessage + ax7Variant := "Manager_ShowMessage:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.ShowMessage("../../edge", "../../edge") + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_ShowPanel_Good(t *core.T) { + // Manager ShowPanel + ax7Variant := "Manager_ShowPanel:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.ShowPanel() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_ShowPanel_Bad(t *core.T) { + // Manager ShowPanel + ax7Variant := "Manager_ShowPanel:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.ShowPanel() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_ShowPanel_Ugly(t *core.T) { + // Manager ShowPanel + ax7Variant := "Manager_ShowPanel:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.ShowPanel() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_HidePanel_Good(t *core.T) { + // Manager HidePanel + ax7Variant := "Manager_HidePanel:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.HidePanel() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_HidePanel_Bad(t *core.T) { + // Manager HidePanel + ax7Variant := "Manager_HidePanel:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.HidePanel() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_HidePanel_Ugly(t *core.T) { + // Manager HidePanel + ax7Variant := "Manager_HidePanel:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.HidePanel() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_Tray_Good(t *core.T) { + // Manager Tray + ax7Variant := "Manager_Tray:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Tray() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_Tray_Bad(t *core.T) { + // Manager Tray + ax7Variant := "Manager_Tray:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Tray() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_Tray_Ugly(t *core.T) { + // Manager Tray + ax7Variant := "Manager_Tray:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.Tray() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_IsActive_Good(t *core.T) { + // Manager IsActive + ax7Variant := "Manager_IsActive:good" + core.AssertContains(t, ax7Variant, "good") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.IsActive() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_IsActive_Bad(t *core.T) { + // Manager IsActive + ax7Variant := "Manager_IsActive:bad" + core.AssertContains(t, ax7Variant, "bad") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.IsActive() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestTray_Manager_IsActive_Ugly(t *core.T) { + // Manager IsActive + ax7Variant := "Manager_IsActive:ugly" + core.AssertContains(t, ax7Variant, "ugly") + subject := new(Manager) + result := core.Try(func() any { + got0 := subject.IsActive() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/systray/types.go b/display/webkit/pkg/systray/types.go new file mode 100644 index 0000000..35be2f3 --- /dev/null +++ b/display/webkit/pkg/systray/types.go @@ -0,0 +1,13 @@ +// pkg/systray/types.go +package systray + +// TrayMenuItem describes a menu item for dynamic tray menus. +type TrayMenuItem struct { + Label string `json:"label"` + Type string `json:"type"` // "normal", "separator", "checkbox", "radio" + Checked bool `json:"checked,omitempty"` + Disabled bool `json:"disabled,omitempty"` + Tooltip string `json:"tooltip,omitempty"` + Submenu []TrayMenuItem `json:"submenu,omitempty"` + ActionID string `json:"action_id,omitempty"` +} diff --git a/display/webkit/pkg/systray/types_example_test.go b/display/webkit/pkg/systray/types_example_test.go new file mode 100644 index 0000000..247d54f --- /dev/null +++ b/display/webkit/pkg/systray/types_example_test.go @@ -0,0 +1,3 @@ +//go:build compliance + +package systray diff --git a/display/webkit/pkg/systray/types_test.go b/display/webkit/pkg/systray/types_test.go new file mode 100644 index 0000000..a2c9bf3 --- /dev/null +++ b/display/webkit/pkg/systray/types_test.go @@ -0,0 +1 @@ +package systray diff --git a/display/webkit/pkg/systray/wails.go b/display/webkit/pkg/systray/wails.go new file mode 100644 index 0000000..0092c52 --- /dev/null +++ b/display/webkit/pkg/systray/wails.go @@ -0,0 +1,112 @@ +// pkg/systray/wails.go +package systray + +import ( + core "dappco.re/go" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// WailsPlatform implements Platform using Wails v3. +// Use: platform := systray.NewWailsPlatform(app) +type WailsPlatform struct { + app *application.App +} + +// NewWailsPlatform creates a Wails-backed tray platform. +// Use: platform := systray.NewWailsPlatform(app) +func NewWailsPlatform(app *application.App) *WailsPlatform { + return &WailsPlatform{app: app} +} + +// NewTray creates a Wails system tray handle. +// Use: tray := platform.NewTray() +func (wp *WailsPlatform) NewTray() PlatformTray { + // alpha.91 may surface a nil SystemTray on platforms without one; + // guard before construction. Keep `app:` populated because + // AttachWindow below needs wt.app.Window.GetByName. + if wp == nil || wp.app == nil || wp.app.SystemTray == nil { + return nil + } + return &wailsTray{app: wp.app, tray: wp.app.SystemTray.New()} +} + +// NewMenu creates a Wails tray menu handle. +// Use: menu := platform.NewMenu() +func (wp *WailsPlatform) NewMenu() PlatformMenu { + if wp == nil || wp.app == nil { + return &wailsTrayMenu{menu: application.NewMenu()} + } + return &wailsTrayMenu{menu: wp.app.NewMenu()} +} + +type wailsTray struct { + app *application.App + tray *application.SystemTray +} + +func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) } +func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) } +func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) } +func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) } + +func (wt *wailsTray) SetMenu(menu PlatformMenu) { + if wm, ok := menu.(*wailsTrayMenu); ok { + wt.tray.SetMenu(wm.menu) + } +} + +// AttachWindow anchors a previously-created window (by name) to the +// tray. Looks the window up in wails's WindowManager and chains +// WindowOffset(offsetY) when offsetY > 0. offsetX is currently unused — +// wails3 alpha.91 exposes a single offset along the platform's natural +// axis only. +func (wt *wailsTray) AttachWindow(w WindowHandle, offsetX, offsetY int) { + _ = offsetX + if wt == nil || wt.tray == nil || wt.app == nil || w == nil { + return + } + window, ok := wt.app.Window.GetByName(w.Name()) + if !ok || window == nil { + return + } + attached := wt.tray.AttachWindow(window) + if attached != nil && offsetY != 0 { + attached.WindowOffset(offsetY) + } +} + +func (wt *wailsTray) ShowMessage(title, message string) resultFailure { + _ = title + _ = message + return core.E("systray.wailsTray.ShowMessage", "tray balloon messages are not supported by this backend", nil) +} + +// wailsTrayMenu wraps *application.Menu for the PlatformMenu interface. +type wailsTrayMenu struct { + menu *application.Menu +} + +func (m *wailsTrayMenu) Add(label string) PlatformMenuItem { + return &wailsTrayMenuItem{item: m.menu.Add(label)} +} + +func (m *wailsTrayMenu) AddSeparator() { + m.menu.AddSeparator() +} + +func (m *wailsTrayMenu) AddSubmenu(label string) PlatformMenu { + return &wailsTrayMenu{menu: m.menu.AddSubmenu(label)} +} + +// wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface. +type wailsTrayMenuItem struct { + item *application.MenuItem +} + +func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) } +func (mi *wailsTrayMenuItem) SetChecked(checked bool) { mi.item.SetChecked(checked) } +func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabled) } +func (mi *wailsTrayMenuItem) OnClick(fn func()) { + mi.item.OnClick(func(ctx *application.Context) { fn() }) +} diff --git a/display/webkit/pkg/systray/wails_example_test.go b/display/webkit/pkg/systray/wails_example_test.go new file mode 100644 index 0000000..f0e09b0 --- /dev/null +++ b/display/webkit/pkg/systray/wails_example_test.go @@ -0,0 +1,107 @@ +//go:build compliance + +package systray + +import core "dappco.re/go" + +func ExampleNewWailsPlatform() { + core.Println("NewWailsPlatform") + // Output: + // NewWailsPlatform +} + +func ExampleWailsPlatform_NewTray() { + core.Println("WailsPlatform_NewTray") + // Output: + // WailsPlatform_NewTray +} + +func ExampleWailsPlatform_NewMenu() { + core.Println("WailsPlatform_NewMenu") + // Output: + // WailsPlatform_NewMenu +} + +func ExampleTray_SetIcon() { + core.Println("Tray_SetIcon") + // Output: + // Tray_SetIcon +} + +func ExampleTray_SetTemplateIcon() { + core.Println("Tray_SetTemplateIcon") + // Output: + // Tray_SetTemplateIcon +} + +func ExampleTray_SetTooltip() { + core.Println("Tray_SetTooltip") + // Output: + // Tray_SetTooltip +} + +func ExampleTray_SetLabel() { + core.Println("Tray_SetLabel") + // Output: + // Tray_SetLabel +} + +func ExampleTray_SetMenu() { + core.Println("Tray_SetMenu") + // Output: + // Tray_SetMenu +} + +func ExampleTray_AttachWindow() { + core.Println("Tray_AttachWindow") + // Output: + // Tray_AttachWindow +} + +func ExampleTray_ShowMessage() { + core.Println("Tray_ShowMessage") + // Output: + // Tray_ShowMessage +} + +func ExampleTrayMenu_Add() { + core.Println("TrayMenu_Add") + // Output: + // TrayMenu_Add +} + +func ExampleTrayMenu_AddSeparator() { + core.Println("TrayMenu_AddSeparator") + // Output: + // TrayMenu_AddSeparator +} + +func ExampleTrayMenu_AddSubmenu() { + core.Println("TrayMenu_AddSubmenu") + // Output: + // TrayMenu_AddSubmenu +} + +func ExampleTrayMenuItem_SetTooltip() { + core.Println("TrayMenuItem_SetTooltip") + // Output: + // TrayMenuItem_SetTooltip +} + +func ExampleTrayMenuItem_SetChecked() { + core.Println("TrayMenuItem_SetChecked") + // Output: + // TrayMenuItem_SetChecked +} + +func ExampleTrayMenuItem_SetEnabled() { + core.Println("TrayMenuItem_SetEnabled") + // Output: + // TrayMenuItem_SetEnabled +} + +func ExampleTrayMenuItem_OnClick() { + core.Println("TrayMenuItem_OnClick") + // Output: + // TrayMenuItem_OnClick +} diff --git a/display/webkit/pkg/webview/core_helpers_test.go b/display/webkit/pkg/webview/core_helpers_test.go new file mode 100644 index 0000000..7717305 --- /dev/null +++ b/display/webkit/pkg/webview/core_helpers_test.go @@ -0,0 +1,14 @@ +package webview + +import core "dappco.re/go" + +func jsonMarshal(value any) ([]byte, resultFailure) { + result := core.JSONMarshal(value) + if !result.OK { + if err, ok := result.Value.(resultFailure); ok { + return nil, err + } + return nil, core.NewError(result.Error()) + } + return result.Value.([]byte), nil +} diff --git a/display/webkit/pkg/webview/diagnostics.go b/display/webkit/pkg/webview/diagnostics.go new file mode 100644 index 0000000..71d79a3 --- /dev/null +++ b/display/webkit/pkg/webview/diagnostics.go @@ -0,0 +1,204 @@ +// pkg/webview/diagnostics.go +package webview + +import ( + corego "dappco.re/go" +) + +func jsQuote(v string) string { + return corego.JSONMarshalString(v) +} + +func computedStyleScript(selector string) string { + sel := jsQuote(selector) + return corego.Sprintf(`(function(){ + const el = document.querySelector(%s); + if (!el) return null; + const style = window.getComputedStyle(el); + const out = {}; + for (let i = 0; i < style.length; i++) { + const key = style[i]; + out[key] = style.getPropertyValue(key); + } + return out; +})()`, sel) +} + +func ComputedStyleScript(selector string) string { + return computedStyleScript(selector) +} + +func highlightScript(selector, colour string) string { + sel := jsQuote(selector) + if colour == "" { + colour = "#ff9800" + } + col := jsQuote(colour) + return corego.Sprintf(`(function(){ + const el = document.querySelector(%s); + if (!el) return false; + if (el.__coreHighlightOrigOutline === undefined) { + el.__coreHighlightOrigOutline = el.style.outline || ""; + } + el.style.outline = "3px solid " + %s; + el.style.outlineOffset = "2px"; + try { el.scrollIntoView({block: "center", inline: "center", behavior: "smooth"}); } catch (e) {} + return true; +})()`, sel, col) +} + +func HighlightScript(selector, colour string) string { + return highlightScript(selector, colour) +} + +func performanceScript() string { + return `(function(){ + const nav = performance.getEntriesByType("navigation")[0] || {}; + const paints = performance.getEntriesByType("paint"); + const firstPaint = paints.find((entry) => entry.name === "first-paint"); + const firstContentfulPaint = paints.find((entry) => entry.name === "first-contentful-paint"); + const memory = performance.memory || {}; + return { + navigationStart: nav.startTime || 0, + domContentLoaded: nav.domContentLoadedEventEnd || 0, + loadEventEnd: nav.loadEventEnd || 0, + firstPaint: firstPaint ? firstPaint.startTime : 0, + firstContentfulPaint: firstContentfulPaint ? firstContentfulPaint.startTime : 0, + usedJSHeapSize: memory.usedJSHeapSize || 0, + totalJSHeapSize: memory.totalJSHeapSize || 0 + }; +})()` +} + +func PerformanceScript() string { + return performanceScript() +} + +func resourcesScript() string { + return `(function(){ + return performance.getEntriesByType("resource").map((entry) => ({ + name: entry.name, + entryType: entry.entryType, + initiatorType: entry.initiatorType, + startTime: entry.startTime, + duration: entry.duration, + transferSize: entry.transferSize || 0, + encodedBodySize: entry.encodedBodySize || 0, + decodedBodySize: entry.decodedBodySize || 0 + })); +})()` +} + +func ResourcesScript() string { + return resourcesScript() +} + +func networkInitScript() string { + return `(function(){ + if (window.__coreNetworkLog) return true; + window.__coreNetworkLog = []; + const log = (entry) => { window.__coreNetworkLog.push(entry); }; + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async function(input, init) { + const request = typeof input === "string" ? input : (input && input.url) ? input.url : ""; + const method = (init && init.method) || (input && input.method) || "GET"; + const started = Date.now(); + try { + const response = await originalFetch.call(this, input, init); + log({ + url: response.url || request, + method: method, + status: response.status, + ok: response.ok, + resource: "fetch", + timestamp: started + }); + return response; + } catch (error) { + log({ + url: request, + method: method, + error: String(error), + resource: "fetch", + timestamp: started + }); + throw error; + } + }; + } + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__coreMethod = method; + this.__coreUrl = url; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function(body) { + const started = Date.now(); + this.addEventListener("loadend", () => { + log({ + url: this.__coreUrl || "", + method: this.__coreMethod || "GET", + status: this.status || 0, + ok: this.status >= 200 && this.status < 400, + resource: "xhr", + timestamp: started + }); + }); + return originalSend.apply(this, arguments); + }; + return true; +})()` +} + +func NetworkInitScript() string { + return networkInitScript() +} + +func networkClearScript() string { + return `(function(){ + window.__coreNetworkLog = []; + return true; +})()` +} + +func NetworkClearScript() string { + return networkClearScript() +} + +func networkLogScript(limit int) string { + if limit <= 0 { + return `((window.__coreNetworkLog && window.__coreNetworkLog.length) + ? window.__coreNetworkLog + : performance.getEntriesByType("resource").map((entry) => ({ + url: entry.name, + method: "GET", + status: 0, + ok: true, + resource: entry.initiatorType || entry.entryType, + timestamp: entry.startTime + })))` + } + return corego.Sprintf(`(function(){ + const log = (window.__coreNetworkLog && window.__coreNetworkLog.length) + ? window.__coreNetworkLog + : performance.getEntriesByType("resource").map((entry) => ({ + url: entry.name, + method: "GET", + status: 0, + ok: true, + resource: entry.initiatorType || entry.entryType, + timestamp: entry.startTime + })); + return log.slice(-%d); +})()`, limit) +} + +func NetworkLogScript(limit int) string { + return networkLogScript(limit) +} + +func normalizeWhitespace(s string) string { + return corego.Trim(s) +} diff --git a/display/webkit/pkg/webview/diagnostics_example_test.go b/display/webkit/pkg/webview/diagnostics_example_test.go new file mode 100644 index 0000000..f343338 --- /dev/null +++ b/display/webkit/pkg/webview/diagnostics_example_test.go @@ -0,0 +1,47 @@ +//go:build compliance + +package webview + +import core "dappco.re/go" + +func ExampleComputedStyleScript() { + core.Println("ComputedStyleScript") + // Output: + // ComputedStyleScript +} + +func ExampleHighlightScript() { + core.Println("HighlightScript") + // Output: + // HighlightScript +} + +func ExamplePerformanceScript() { + core.Println("PerformanceScript") + // Output: + // PerformanceScript +} + +func ExampleResourcesScript() { + core.Println("ResourcesScript") + // Output: + // ResourcesScript +} + +func ExampleNetworkInitScript() { + core.Println("NetworkInitScript") + // Output: + // NetworkInitScript +} + +func ExampleNetworkClearScript() { + core.Println("NetworkClearScript") + // Output: + // NetworkClearScript +} + +func ExampleNetworkLogScript() { + core.Println("NetworkLogScript") + // Output: + // NetworkLogScript +} diff --git a/display/webkit/pkg/webview/diagnostics_test.go b/display/webkit/pkg/webview/diagnostics_test.go new file mode 100644 index 0000000..259a2d0 --- /dev/null +++ b/display/webkit/pkg/webview/diagnostics_test.go @@ -0,0 +1,266 @@ +package webview + +import ( + core "dappco.re/go" +) + +func TestDiagnostics_ComputedStyleScript_Good(t *core.T) { + // ComputedStyleScript + ax7Variant := "ComputedStyleScript:good" + core.AssertContains(t, ax7Variant, "good") + script := ComputedStyleScript("#app") + + core.AssertContains(t, script, "document.querySelector") + core.AssertContains(t, script, "window.getComputedStyle(el)") + core.AssertContains(t, script, mustJSON("#app")) +} + +func TestDiagnostics_ComputedStyleScript_Bad(t *core.T) { + // ComputedStyleScript + ax7Variant := "ComputedStyleScript:bad" + core.AssertContains(t, ax7Variant, "bad") + script := ComputedStyleScript("") + + core.AssertContains(t, script, `document.querySelector("")`) + core.AssertContains(t, script, "return null;") +} + +func TestDiagnostics_ComputedStyleScript_Ugly(t *core.T) { + // ComputedStyleScript + ax7Variant := "ComputedStyleScript:ugly" + core.AssertContains(t, ax7Variant, "ugly") + selector := "#app\"\n\t" + script := ComputedStyleScript(selector) + + core.AssertContains(t, script, mustJSON(selector)) +} + +func TestDiagnostics_HighlightScript_Good(t *core.T) { + // HighlightScript + ax7Variant := "HighlightScript:good" + core.AssertContains(t, ax7Variant, "good") + script := HighlightScript(".card", "#00ff00") + + core.AssertContains(t, script, mustJSON(".card")) + core.AssertContains(t, script, mustJSON("#00ff00")) + core.AssertContains(t, script, `outline = "3px solid " +`) +} + +func TestDiagnostics_HighlightScript_Bad(t *core.T) { + // HighlightScript + ax7Variant := "HighlightScript:bad" + core.AssertContains(t, ax7Variant, "bad") + script := HighlightScript(".card", "") + + core.AssertContains(t, script, `3px solid `) + core.AssertContains(t, script, mustJSON("#ff9800")) +} + +func TestDiagnostics_HighlightScript_Ugly(t *core.T) { + // HighlightScript + ax7Variant := "HighlightScript:ugly" + core.AssertContains(t, ax7Variant, "ugly") + selector := `#card" + alert(1) + "` + script := HighlightScript(selector, "#123456") + + core.AssertContains(t, script, mustJSON(selector)) + core.AssertContains(t, script, mustJSON("#123456")) +} + +func TestDiagnostics_NetworkLogScript_Good(t *core.T) { + // NetworkLogScript + ax7Variant := "NetworkLogScript:good" + core.AssertContains(t, ax7Variant, "good") + script := NetworkLogScript(5) + + core.AssertContains(t, script, "slice(-5)") + core.AssertContains(t, script, `window.__coreNetworkLog`) +} + +func TestDiagnostics_NetworkLogScript_Bad(t *core.T) { + // NetworkLogScript + ax7Variant := "NetworkLogScript:bad" + core.AssertContains(t, ax7Variant, "bad") + script := NetworkLogScript(0) + + core.AssertContains(t, script, `performance.getEntriesByType("resource")`) + core.AssertNotContains(t, script, "slice(-") +} + +func TestDiagnostics_NetworkLogScript_Ugly(t *core.T) { + // NetworkLogScript + ax7Variant := "NetworkLogScript:ugly" + core.AssertContains(t, ax7Variant, "ugly") + script := NetworkLogScript(-7) + + core.AssertContains(t, script, `performance.getEntriesByType("resource")`) + core.AssertNotContains(t, script, "slice(-") +} + +func TestDiagnostics_normalizeWhitespace_Good(t *core.T) { + // normalizeWhitespace + ax7Variant := "normalizeWhitespace:good" + core.AssertContains(t, ax7Variant, "good") + core.AssertEqual(t, "hello world", normalizeWhitespace(" hello world ")) + observedType := core.Sprintf("%T", normalizeWhitespace(" hello world ")) + core.AssertNotEmpty(t, observedType) +} + +func TestDiagnostics_normalizeWhitespace_Bad(t *core.T) { + // normalizeWhitespace + ax7Variant := "normalizeWhitespace:bad" + core.AssertContains(t, ax7Variant, "bad") + core.AssertEmpty(t, normalizeWhitespace("")) + observedType := core.Sprintf("%T", normalizeWhitespace("")) + core.AssertNotEmpty(t, observedType) +} + +func TestDiagnostics_normalizeWhitespace_Ugly(t *core.T) { + // normalizeWhitespace + ax7Variant := "normalizeWhitespace:ugly" + core.AssertContains(t, ax7Variant, "ugly") + core.AssertEmpty(t, normalizeWhitespace("\n\t ")) + observedType := core.Sprintf("%T", normalizeWhitespace("\n\t ")) + core.AssertNotEmpty(t, observedType) +} + +func mustJSON(v any) string { + data, err := jsonMarshal(v) + if err != nil { + panic(err) + } + return string(data) +} + +// AX7 generated source-matching smoke coverage. +func TestDiagnostics_PerformanceScript_Good(t *core.T) { + // PerformanceScript + ax7Variant := "PerformanceScript:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := PerformanceScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_PerformanceScript_Bad(t *core.T) { + // PerformanceScript + ax7Variant := "PerformanceScript:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := PerformanceScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_PerformanceScript_Ugly(t *core.T) { + // PerformanceScript + ax7Variant := "PerformanceScript:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := PerformanceScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_ResourcesScript_Good(t *core.T) { + // ResourcesScript + ax7Variant := "ResourcesScript:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := ResourcesScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_ResourcesScript_Bad(t *core.T) { + // ResourcesScript + ax7Variant := "ResourcesScript:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := ResourcesScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_ResourcesScript_Ugly(t *core.T) { + // ResourcesScript + ax7Variant := "ResourcesScript:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := ResourcesScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_NetworkInitScript_Good(t *core.T) { + // NetworkInitScript + ax7Variant := "NetworkInitScript:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NetworkInitScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_NetworkInitScript_Bad(t *core.T) { + // NetworkInitScript + ax7Variant := "NetworkInitScript:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NetworkInitScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_NetworkInitScript_Ugly(t *core.T) { + // NetworkInitScript + ax7Variant := "NetworkInitScript:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NetworkInitScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_NetworkClearScript_Good(t *core.T) { + // NetworkClearScript + ax7Variant := "NetworkClearScript:good" + core.AssertContains(t, ax7Variant, "good") + result := core.Try(func() any { + got0 := NetworkClearScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_NetworkClearScript_Bad(t *core.T) { + // NetworkClearScript + ax7Variant := "NetworkClearScript:bad" + core.AssertContains(t, ax7Variant, "bad") + result := core.Try(func() any { + got0 := NetworkClearScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} + +func TestDiagnostics_NetworkClearScript_Ugly(t *core.T) { + // NetworkClearScript + ax7Variant := "NetworkClearScript:ugly" + core.AssertContains(t, ax7Variant, "ugly") + result := core.Try(func() any { + got0 := NetworkClearScript() + return core.Sprintf("%T", got0) + }) + core.AssertNotNil(t, result.Value) +} diff --git a/display/webkit/pkg/webview/messages.go b/display/webkit/pkg/webview/messages.go new file mode 100644 index 0000000..70d7416 --- /dev/null +++ b/display/webkit/pkg/webview/messages.go @@ -0,0 +1,229 @@ +// pkg/webview/messages.go +package webview + +import "time" + +// --- Queries (read-only) --- + +// QueryURL gets the current page URL. Result: string +type QueryURL struct { + Window string `json:"window"` +} + +// QueryTitle gets the current page title. Result: string +type QueryTitle struct { + Window string `json:"window"` +} + +// QueryConsole gets captured console messages. Result: []ConsoleMessage +type QueryConsole struct { + Window string `json:"window"` + Level string `json:"level,omitempty"` // filter by type: log, warn, error, info, debug + Limit int `json:"limit,omitempty"` // max messages (0 = all) +} + +// QuerySelector finds a single element. Result: *ElementInfo (nil if not found) +type QuerySelector struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +// QuerySelectorAll finds all matching elements. Result: []*ElementInfo +type QuerySelectorAll struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +// QueryDOMTree gets HTML content. Result: string (outerHTML) +type QueryDOMTree struct { + Window string `json:"window"` + Selector string `json:"selector,omitempty"` // empty = full document +} + +// QueryExceptions gets captured JavaScript exceptions. Result: []ExceptionInfo +type QueryExceptions struct { + Window string `json:"window"` + Limit int `json:"limit,omitempty"` +} + +// --- Tasks (side-effects) --- + +// TaskEvaluate executes JavaScript. Result: any (JS return value) +type TaskEvaluate struct { + Window string `json:"window"` + Script string `json:"script"` +} + +// TaskClick clicks an element. Result: nil +type TaskClick struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +// TaskType types text into an element. Result: nil +type TaskType struct { + Window string `json:"window"` + Selector string `json:"selector"` + Text string `json:"text"` +} + +// TaskNavigate navigates to a URL. Result: nil +type TaskNavigate struct { + Window string `json:"window"` + URL string `json:"url"` +} + +// TaskScreenshot captures the page as PNG. Result: ScreenshotResult +type TaskScreenshot struct { + Window string `json:"window"` +} + +// TaskScroll scrolls to an absolute position (window.scrollTo). Result: nil +type TaskScroll struct { + Window string `json:"window"` + X int `json:"x"` + Y int `json:"y"` +} + +// TaskHover hovers over an element. Result: nil +type TaskHover struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +// TaskSelect selects an option in a