From 086d4908aba9c1c73b245afa3e5563232c8df201 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Sun, 31 May 2026 23:03:02 +0330 Subject: [PATCH 01/10] apply Butil improvements --- src/Butil/Bit.Butil/BitButil.cs | 67 +- .../Extensions/InternalJSRuntimeExtensions.cs | 30 +- .../Extensions/JSRuntimeExtensions.cs | 40 +- .../BroadcastChannelListenersManager.cs | 40 + .../DomClipboardEventListenersManager.cs | 44 + .../DomCompositionEventListenersManager.cs | 44 + .../Events/DomDragEventListenersManager.cs | 44 + .../Internals/Events/DomEventArgs.cs | 63 ++ .../Internals/Events/DomEventDispatcher.cs | 135 +++- .../Events/DomEventListenersManager.cs | 2 + .../Events/DomFocusEventListenersManager.cs | 44 + .../Events/DomInputEventListenersManager.cs | 44 + .../DomKeyboardEventListenersManager.cs | 2 + .../Events/DomMouseEventListenersManager.cs | 2 + .../Events/DomPointerEventListenersManager.cs | 44 + .../Events/DomTouchEventListenersManager.cs | 44 + .../Events/DomWheelEventListenersManager.cs | 44 + .../Fetch/FetchProgressListenersManager.cs | 22 + .../GeolocationListenersManager.cs | 57 ++ .../IdleDetectorListenersManager.cs | 27 + .../IntersectionObserverListenersManager.cs | 27 + .../MutationObserverListenersManager.cs | 27 + .../Internals/Nfc/NdefListenersManager.cs | 40 + .../NotificationListenersManager.cs | 56 ++ .../PerformanceObserverListenersManager.cs | 28 + .../Reporting/ReportingListenersManager.cs | 27 + .../ResizeObserverListenersManager.cs | 27 + .../ServiceWorkerListenersManager.cs | 43 + .../SpeechRecognitionListenersManager.cs | 48 ++ .../Storage/StorageListenersManager.cs | 47 ++ .../Window/MediaQueryListenersManager.cs | 51 ++ .../Publics/Animation/AnimationHandle.cs | 52 ++ .../Publics/Animation/AnimationKeyframes.cs | 9 + .../Publics/Animation/AnimationOptions.cs | 32 + .../ElementReferenceAnimationExtensions.cs | 32 + src/Butil/Bit.Butil/Publics/BackgroundSync.cs | 43 + src/Butil/Bit.Butil/Publics/Battery.cs | 23 + .../Publics/Battery/BatteryStatus.cs | 19 + .../Bit.Butil/Publics/BroadcastChannel.cs | 89 +++ src/Butil/Bit.Butil/Publics/CacheStorage.cs | 69 ++ .../Publics/CacheStorage/CachedResponse.cs | 20 + src/Butil/Bit.Butil/Publics/ContactPicker.cs | 36 + .../Bit.Butil/Publics/Contacts/ContactInfo.cs | 18 + src/Butil/Bit.Butil/Publics/Cookie.cs | 14 +- .../Bit.Butil/Publics/Cookie/ButilCookie.cs | 49 +- .../Publics/Cookie/CookieStoreItem.cs | 25 + src/Butil/Bit.Butil/Publics/CookieStore.cs | 34 + src/Butil/Bit.Butil/Publics/Crypto.cs | 146 +++- .../Bit.Butil/Publics/Crypto/EcKeyPair.cs | 14 + .../Bit.Butil/Publics/Crypto/RsaKeyPair.cs | 11 + src/Butil/Bit.Butil/Publics/Document.cs | 194 ++++- .../Publics/Document/VisibilityState.cs | 19 + .../ElementReferenceEventExtensions.cs | 135 ++++ .../Publics/Events/ButilClipboardEventArgs.cs | 20 + .../Events/ButilCompositionEventArgs.cs | 20 + .../Publics/Events/ButilDragEventArgs.cs | 37 + .../Bit.Butil/Publics/Events/ButilEvents.cs | 83 ++ .../Publics/Events/ButilFocusEventArgs.cs | 14 + .../Publics/Events/ButilInputEventArgs.cs | 21 + .../Publics/Events/ButilPointerEventArgs.cs | 66 ++ .../Publics/Events/ButilSubscription.cs | 44 + .../Publics/Events/ButilTouchEventArgs.cs | 43 + .../Publics/Events/ButilWheelEventArgs.cs | 41 + src/Butil/Bit.Butil/Publics/EyeDropper.cs | 22 + src/Butil/Bit.Butil/Publics/Fetch.cs | 69 ++ .../Bit.Butil/Publics/Fetch/AbortableFetch.cs | 38 + .../Bit.Butil/Publics/Fetch/FetchProgress.cs | 11 + .../Bit.Butil/Publics/Fetch/FetchRequest.cs | 37 + .../Bit.Butil/Publics/Fetch/FetchResponse.cs | 30 + src/Butil/Bit.Butil/Publics/File/BlobInfo.cs | 20 + src/Butil/Bit.Butil/Publics/FileReader.cs | 45 ++ src/Butil/Bit.Butil/Publics/Geolocation.cs | 130 +++ .../Geolocation/GeolocationCoordinates.cs | 28 + .../Publics/Geolocation/GeolocationError.cs | 34 + .../Publics/Geolocation/GeolocationOptions.cs | 17 + .../Geolocation/GeolocationPosition.cs | 13 + src/Butil/Bit.Butil/Publics/History.cs | 18 + src/Butil/Bit.Butil/Publics/IdleDetector.cs | 60 ++ .../Publics/IdleDetector/IdleState.cs | 13 + src/Butil/Bit.Butil/Publics/IndexedDb.cs | 38 + .../Publics/IndexedDb/IndexedDbHandle.cs | 89 +++ .../Publics/IndexedDb/IndexedDbStoreSchema.cs | 27 + .../IntersectionObserverEntry.cs | 20 + .../IntersectionObserverExtensions.cs | 44 + .../IntersectionObserverOptions.cs | 13 + src/Butil/Bit.Butil/Publics/Keyboard.cs | 46 ++ .../Bit.Butil/Publics/Locks/WebLockMode.cs | 13 + .../Publics/Locks/WebLockSnapshot.cs | 18 + src/Butil/Bit.Butil/Publics/MediaDevices.cs | 43 + .../Publics/MediaDevices/MediaDeviceInfo.cs | 15 + .../Publics/MediaDevices/MediaStreamHandle.cs | 38 + .../MutationObserverExtensions.cs | 46 ++ .../MutationObserverOptions.cs | 28 + .../MutationObserver/MutationRecord.cs | 32 + src/Butil/Bit.Butil/Publics/Navigator.cs | 34 +- .../Bit.Butil/Publics/Navigator/ShareFile.cs | 16 + .../Bit.Butil/Publics/NetworkInformation.cs | 17 + .../NetworkConnectionStatus.cs | 28 + src/Butil/Bit.Butil/Publics/Nfc.cs | 64 ++ src/Butil/Bit.Butil/Publics/Nfc/NdefRecord.cs | 35 + src/Butil/Bit.Butil/Publics/Notification.cs | 41 +- .../Notification/NotificationHandle.cs | 32 + src/Butil/Bit.Butil/Publics/ObjectUrls.cs | 43 + src/Butil/Bit.Butil/Publics/Performance.cs | 101 +++ .../Publics/Performance/PerformanceMemory.cs | 12 + src/Butil/Bit.Butil/Publics/Permissions.cs | 32 + .../Publics/Permissions/PermissionState.cs | 19 + src/Butil/Bit.Butil/Publics/Push.cs | 40 + .../Publics/Push/PushSubscriptionInfo.cs | 22 + src/Butil/Bit.Butil/Publics/Reporting.cs | 47 ++ .../Publics/Reporting/BrowserReport.cs | 22 + .../ResizeObserver/ResizeObserverBox.cs | 12 + .../ResizeObserver/ResizeObserverEntry.cs | 14 + .../ResizeObserverExtensions.cs | 49 ++ src/Butil/Bit.Butil/Publics/Screen.cs | 10 + .../Bit.Butil/Publics/ScreenOrientation.cs | 10 + src/Butil/Bit.Butil/Publics/ServiceWorker.cs | 94 +++ .../ServiceWorkerRegistrationInfo.cs | 25 + .../Publics/Speech/SpeechUtterance.cs | 25 + .../Bit.Butil/Publics/Speech/SpeechVoice.cs | 21 + .../Bit.Butil/Publics/SpeechRecognition.cs | 65 ++ .../SpeechRecognitionOptions.cs | 17 + .../SpeechRecognitionResult.cs | 16 + .../Bit.Butil/Publics/SpeechSynthesis.cs | 41 + .../Bit.Butil/Publics/Storage/ButilStorage.cs | 61 +- .../Bit.Butil/Publics/Storage/StorageEvent.cs | 23 + src/Butil/Bit.Butil/Publics/StorageManager.cs | 28 + .../Publics/StorageManager/StorageEstimate.cs | 11 + src/Butil/Bit.Butil/Publics/UserAgent.cs | 32 + .../Publics/UserAgent/HighEntropyUserAgent.cs | 19 + .../Publics/UserAgent/UserAgentBrand.cs | 8 + src/Butil/Bit.Butil/Publics/VisualViewport.cs | 20 + src/Butil/Bit.Butil/Publics/WakeLock.cs | 77 ++ src/Butil/Bit.Butil/Publics/WebAudio.cs | 57 ++ .../Publics/WebAudio/AudioPlaybackHandle.cs | 35 + src/Butil/Bit.Butil/Publics/WebLocks.cs | 87 ++ src/Butil/Bit.Butil/Publics/Window.cs | 181 ++++- .../Publics/Window/WindowSelection.cs | 41 + src/Butil/Bit.Butil/Scripts/animation.ts | 48 ++ src/Butil/Bit.Butil/Scripts/backgroundSync.ts | 44 + src/Butil/Bit.Butil/Scripts/battery.ts | 20 + .../Bit.Butil/Scripts/broadcastChannel.ts | 67 ++ src/Butil/Bit.Butil/Scripts/cacheStorage.ts | 99 +++ src/Butil/Bit.Butil/Scripts/contactPicker.ts | 41 + src/Butil/Bit.Butil/Scripts/cookieStore.ts | 54 ++ src/Butil/Bit.Butil/Scripts/crypto.ts | 188 ++++- src/Butil/Bit.Butil/Scripts/document.ts | 8 +- src/Butil/Bit.Butil/Scripts/element.ts | 27 + src/Butil/Bit.Butil/Scripts/events.ts | 51 +- src/Butil/Bit.Butil/Scripts/eyeDropper.ts | 19 + src/Butil/Bit.Butil/Scripts/fetch.ts | 113 +++ src/Butil/Bit.Butil/Scripts/fileReader.ts | 73 ++ src/Butil/Bit.Butil/Scripts/geolocation.ts | 73 ++ src/Butil/Bit.Butil/Scripts/idleDetector.ts | 44 + src/Butil/Bit.Butil/Scripts/indexedDb.ts | 133 ++++ .../Bit.Butil/Scripts/intersectionObserver.ts | 46 ++ src/Butil/Bit.Butil/Scripts/keyboard.ts | 35 +- src/Butil/Bit.Butil/Scripts/mediaDevices.ts | 54 ++ .../Bit.Butil/Scripts/mutationObserver.ts | 49 ++ src/Butil/Bit.Butil/Scripts/navigator.ts | 27 +- .../Bit.Butil/Scripts/networkInformation.ts | 19 + src/Butil/Bit.Butil/Scripts/nfc.ts | 88 ++ src/Butil/Bit.Butil/Scripts/notification.ts | 55 +- src/Butil/Bit.Butil/Scripts/objectUrls.ts | 14 + src/Butil/Bit.Butil/Scripts/performance.ts | 59 ++ src/Butil/Bit.Butil/Scripts/permissions.ts | 18 + src/Butil/Bit.Butil/Scripts/push.ts | 60 ++ src/Butil/Bit.Butil/Scripts/reporting.ts | 31 + src/Butil/Bit.Butil/Scripts/resizeObserver.ts | 52 ++ src/Butil/Bit.Butil/Scripts/serviceWorker.ts | 101 +++ src/Butil/Bit.Butil/Scripts/speech.ts | 37 + .../Bit.Butil/Scripts/speechRecognition.ts | 62 ++ src/Butil/Bit.Butil/Scripts/storage.ts | 25 + src/Butil/Bit.Butil/Scripts/storageManager.ts | 30 + src/Butil/Bit.Butil/Scripts/userAgent.ts | 34 + src/Butil/Bit.Butil/Scripts/utils.ts | 5 +- src/Butil/Bit.Butil/Scripts/wakeLock.ts | 57 ++ src/Butil/Bit.Butil/Scripts/webAudio.ts | 84 ++ src/Butil/Bit.Butil/Scripts/webLocks.ts | 64 ++ src/Butil/Bit.Butil/Scripts/window.ts | 87 +- .../Pages/ClipboardPage.razor | 94 +-- .../Pages/ConsolePage.razor | 195 ++--- .../Pages/CookiePage.razor | 121 +-- .../Pages/CryptoPage.razor | 82 +- .../Pages/DocumentPage.razor | 180 ++--- .../Pages/E2EObserversPage.razor | 214 +++++ .../Bit.Butil.Demo.Core/Pages/E2EPage.razor | 182 +++++ .../Pages/ElementPage.razor | 751 ++++++------------ .../Pages/HistoryPage.razor | 132 ++- .../Bit.Butil.Demo.Core/Pages/Index.razor | 49 +- .../Pages/KeyboardPage.razor | 46 +- .../Pages/LocationPage.razor | 336 +++----- .../Pages/NavigatorPage.razor | 285 +++---- .../Pages/NotificationPage.razor | 81 +- .../Pages/ScreenOrientationPage.razor | 102 ++- .../Pages/ScreenPage.razor | 105 +-- .../Pages/StoragePage.razor | 216 +++-- .../Pages/UserAgentPage.razor | 43 +- .../Pages/VisualViewportPage.razor | 122 ++- .../Pages/WebAuthnPage.razor | 89 ++- .../Pages/WindowPage.razor | 483 ++++------- .../Bit.Butil.Demo.Core/Shared/DemoCard.razor | 21 + .../Shared/DemoConsole.razor | 79 ++ .../Bit.Butil.Demo.Core/Shared/Header.razor | 21 - .../Shared/Header.razor.css | 2 - .../Shared/MainLayout.razor | 29 +- .../Bit.Butil.Demo.Core/Shared/NavMenu.razor | 92 +++ .../Shared/PageHeader.razor | 21 + .../Demo/Bit.Butil.Demo.Core/wwwroot/app.css | 685 ++++++++++++++++ .../Bit.Butil.Demo.Maui/wwwroot/index.html | 1 + .../Bit.Butil.Demo.Web/wwwroot/index.html | 1 + .../Bit.Butil.E2ETests.csproj | 26 + .../BroadcastAndIndexedDbTests.cs | 21 + .../tests/Bit.Butil.E2ETests/CookieTests.cs | 25 + .../tests/Bit.Butil.E2ETests/CryptoTests.cs | 41 + .../Infrastructure/ButilHarnessTestBase.cs | 80 ++ .../Infrastructure/ButilObserversPageTest.cs | 7 + .../Infrastructure/ButilPageTest.cs | 7 + .../Infrastructure/DemoServerFixture.cs | 134 ++++ .../tests/Bit.Butil.E2ETests/ObserverTests.cs | 34 + .../PerformanceAndPlatformTests.cs | 32 + src/Butil/tests/Bit.Butil.E2ETests/README.md | 58 ++ .../tests/Bit.Butil.E2ETests/StorageTests.cs | 32 + .../WindowDocumentHistoryTests.cs | 39 + .../ci/bit.ci.Butil.e2e.yml | 65 ++ 225 files changed, 10964 insertions(+), 2303 deletions(-) create mode 100644 src/Butil/Bit.Butil/Internals/BroadcastChannel/BroadcastChannelListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Events/DomClipboardEventListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Events/DomCompositionEventListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Events/DomDragEventListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Events/DomFocusEventListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Events/DomInputEventListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Events/DomPointerEventListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Events/DomTouchEventListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Events/DomWheelEventListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Fetch/FetchProgressListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Geolocation/GeolocationListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/IdleDetector/IdleDetectorListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Nfc/NdefListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Notification/NotificationListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Performance/PerformanceObserverListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Reporting/ReportingListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/ServiceWorker/ServiceWorkerListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/SpeechRecognition/SpeechRecognitionListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Storage/StorageListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Window/MediaQueryListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Publics/Animation/AnimationHandle.cs create mode 100644 src/Butil/Bit.Butil/Publics/Animation/AnimationKeyframes.cs create mode 100644 src/Butil/Bit.Butil/Publics/Animation/AnimationOptions.cs create mode 100644 src/Butil/Bit.Butil/Publics/Animation/ElementReferenceAnimationExtensions.cs create mode 100644 src/Butil/Bit.Butil/Publics/BackgroundSync.cs create mode 100644 src/Butil/Bit.Butil/Publics/Battery.cs create mode 100644 src/Butil/Bit.Butil/Publics/Battery/BatteryStatus.cs create mode 100644 src/Butil/Bit.Butil/Publics/BroadcastChannel.cs create mode 100644 src/Butil/Bit.Butil/Publics/CacheStorage.cs create mode 100644 src/Butil/Bit.Butil/Publics/CacheStorage/CachedResponse.cs create mode 100644 src/Butil/Bit.Butil/Publics/ContactPicker.cs create mode 100644 src/Butil/Bit.Butil/Publics/Contacts/ContactInfo.cs create mode 100644 src/Butil/Bit.Butil/Publics/Cookie/CookieStoreItem.cs create mode 100644 src/Butil/Bit.Butil/Publics/CookieStore.cs create mode 100644 src/Butil/Bit.Butil/Publics/Crypto/EcKeyPair.cs create mode 100644 src/Butil/Bit.Butil/Publics/Crypto/RsaKeyPair.cs create mode 100644 src/Butil/Bit.Butil/Publics/Document/VisibilityState.cs create mode 100644 src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs create mode 100644 src/Butil/Bit.Butil/Publics/Events/ButilClipboardEventArgs.cs create mode 100644 src/Butil/Bit.Butil/Publics/Events/ButilCompositionEventArgs.cs create mode 100644 src/Butil/Bit.Butil/Publics/Events/ButilDragEventArgs.cs create mode 100644 src/Butil/Bit.Butil/Publics/Events/ButilFocusEventArgs.cs create mode 100644 src/Butil/Bit.Butil/Publics/Events/ButilInputEventArgs.cs create mode 100644 src/Butil/Bit.Butil/Publics/Events/ButilPointerEventArgs.cs create mode 100644 src/Butil/Bit.Butil/Publics/Events/ButilSubscription.cs create mode 100644 src/Butil/Bit.Butil/Publics/Events/ButilTouchEventArgs.cs create mode 100644 src/Butil/Bit.Butil/Publics/Events/ButilWheelEventArgs.cs create mode 100644 src/Butil/Bit.Butil/Publics/EyeDropper.cs create mode 100644 src/Butil/Bit.Butil/Publics/Fetch.cs create mode 100644 src/Butil/Bit.Butil/Publics/Fetch/AbortableFetch.cs create mode 100644 src/Butil/Bit.Butil/Publics/Fetch/FetchProgress.cs create mode 100644 src/Butil/Bit.Butil/Publics/Fetch/FetchRequest.cs create mode 100644 src/Butil/Bit.Butil/Publics/Fetch/FetchResponse.cs create mode 100644 src/Butil/Bit.Butil/Publics/File/BlobInfo.cs create mode 100644 src/Butil/Bit.Butil/Publics/FileReader.cs create mode 100644 src/Butil/Bit.Butil/Publics/Geolocation.cs create mode 100644 src/Butil/Bit.Butil/Publics/Geolocation/GeolocationCoordinates.cs create mode 100644 src/Butil/Bit.Butil/Publics/Geolocation/GeolocationError.cs create mode 100644 src/Butil/Bit.Butil/Publics/Geolocation/GeolocationOptions.cs create mode 100644 src/Butil/Bit.Butil/Publics/Geolocation/GeolocationPosition.cs create mode 100644 src/Butil/Bit.Butil/Publics/IdleDetector.cs create mode 100644 src/Butil/Bit.Butil/Publics/IdleDetector/IdleState.cs create mode 100644 src/Butil/Bit.Butil/Publics/IndexedDb.cs create mode 100644 src/Butil/Bit.Butil/Publics/IndexedDb/IndexedDbHandle.cs create mode 100644 src/Butil/Bit.Butil/Publics/IndexedDb/IndexedDbStoreSchema.cs create mode 100644 src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverEntry.cs create mode 100644 src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverExtensions.cs create mode 100644 src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverOptions.cs create mode 100644 src/Butil/Bit.Butil/Publics/Locks/WebLockMode.cs create mode 100644 src/Butil/Bit.Butil/Publics/Locks/WebLockSnapshot.cs create mode 100644 src/Butil/Bit.Butil/Publics/MediaDevices.cs create mode 100644 src/Butil/Bit.Butil/Publics/MediaDevices/MediaDeviceInfo.cs create mode 100644 src/Butil/Bit.Butil/Publics/MediaDevices/MediaStreamHandle.cs create mode 100644 src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverExtensions.cs create mode 100644 src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverOptions.cs create mode 100644 src/Butil/Bit.Butil/Publics/MutationObserver/MutationRecord.cs create mode 100644 src/Butil/Bit.Butil/Publics/Navigator/ShareFile.cs create mode 100644 src/Butil/Bit.Butil/Publics/NetworkInformation.cs create mode 100644 src/Butil/Bit.Butil/Publics/NetworkInformation/NetworkConnectionStatus.cs create mode 100644 src/Butil/Bit.Butil/Publics/Nfc.cs create mode 100644 src/Butil/Bit.Butil/Publics/Nfc/NdefRecord.cs create mode 100644 src/Butil/Bit.Butil/Publics/Notification/NotificationHandle.cs create mode 100644 src/Butil/Bit.Butil/Publics/ObjectUrls.cs create mode 100644 src/Butil/Bit.Butil/Publics/Performance.cs create mode 100644 src/Butil/Bit.Butil/Publics/Performance/PerformanceMemory.cs create mode 100644 src/Butil/Bit.Butil/Publics/Permissions.cs create mode 100644 src/Butil/Bit.Butil/Publics/Permissions/PermissionState.cs create mode 100644 src/Butil/Bit.Butil/Publics/Push.cs create mode 100644 src/Butil/Bit.Butil/Publics/Push/PushSubscriptionInfo.cs create mode 100644 src/Butil/Bit.Butil/Publics/Reporting.cs create mode 100644 src/Butil/Bit.Butil/Publics/Reporting/BrowserReport.cs create mode 100644 src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverBox.cs create mode 100644 src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverEntry.cs create mode 100644 src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverExtensions.cs create mode 100644 src/Butil/Bit.Butil/Publics/ServiceWorker.cs create mode 100644 src/Butil/Bit.Butil/Publics/ServiceWorker/ServiceWorkerRegistrationInfo.cs create mode 100644 src/Butil/Bit.Butil/Publics/Speech/SpeechUtterance.cs create mode 100644 src/Butil/Bit.Butil/Publics/Speech/SpeechVoice.cs create mode 100644 src/Butil/Bit.Butil/Publics/SpeechRecognition.cs create mode 100644 src/Butil/Bit.Butil/Publics/SpeechRecognition/SpeechRecognitionOptions.cs create mode 100644 src/Butil/Bit.Butil/Publics/SpeechRecognition/SpeechRecognitionResult.cs create mode 100644 src/Butil/Bit.Butil/Publics/SpeechSynthesis.cs create mode 100644 src/Butil/Bit.Butil/Publics/Storage/StorageEvent.cs create mode 100644 src/Butil/Bit.Butil/Publics/StorageManager.cs create mode 100644 src/Butil/Bit.Butil/Publics/StorageManager/StorageEstimate.cs create mode 100644 src/Butil/Bit.Butil/Publics/UserAgent/HighEntropyUserAgent.cs create mode 100644 src/Butil/Bit.Butil/Publics/UserAgent/UserAgentBrand.cs create mode 100644 src/Butil/Bit.Butil/Publics/WakeLock.cs create mode 100644 src/Butil/Bit.Butil/Publics/WebAudio.cs create mode 100644 src/Butil/Bit.Butil/Publics/WebAudio/AudioPlaybackHandle.cs create mode 100644 src/Butil/Bit.Butil/Publics/WebLocks.cs create mode 100644 src/Butil/Bit.Butil/Publics/Window/WindowSelection.cs create mode 100644 src/Butil/Bit.Butil/Scripts/animation.ts create mode 100644 src/Butil/Bit.Butil/Scripts/backgroundSync.ts create mode 100644 src/Butil/Bit.Butil/Scripts/battery.ts create mode 100644 src/Butil/Bit.Butil/Scripts/broadcastChannel.ts create mode 100644 src/Butil/Bit.Butil/Scripts/cacheStorage.ts create mode 100644 src/Butil/Bit.Butil/Scripts/contactPicker.ts create mode 100644 src/Butil/Bit.Butil/Scripts/cookieStore.ts create mode 100644 src/Butil/Bit.Butil/Scripts/eyeDropper.ts create mode 100644 src/Butil/Bit.Butil/Scripts/fetch.ts create mode 100644 src/Butil/Bit.Butil/Scripts/fileReader.ts create mode 100644 src/Butil/Bit.Butil/Scripts/geolocation.ts create mode 100644 src/Butil/Bit.Butil/Scripts/idleDetector.ts create mode 100644 src/Butil/Bit.Butil/Scripts/indexedDb.ts create mode 100644 src/Butil/Bit.Butil/Scripts/intersectionObserver.ts create mode 100644 src/Butil/Bit.Butil/Scripts/mediaDevices.ts create mode 100644 src/Butil/Bit.Butil/Scripts/mutationObserver.ts create mode 100644 src/Butil/Bit.Butil/Scripts/networkInformation.ts create mode 100644 src/Butil/Bit.Butil/Scripts/nfc.ts create mode 100644 src/Butil/Bit.Butil/Scripts/objectUrls.ts create mode 100644 src/Butil/Bit.Butil/Scripts/performance.ts create mode 100644 src/Butil/Bit.Butil/Scripts/permissions.ts create mode 100644 src/Butil/Bit.Butil/Scripts/push.ts create mode 100644 src/Butil/Bit.Butil/Scripts/reporting.ts create mode 100644 src/Butil/Bit.Butil/Scripts/resizeObserver.ts create mode 100644 src/Butil/Bit.Butil/Scripts/serviceWorker.ts create mode 100644 src/Butil/Bit.Butil/Scripts/speech.ts create mode 100644 src/Butil/Bit.Butil/Scripts/speechRecognition.ts create mode 100644 src/Butil/Bit.Butil/Scripts/storageManager.ts create mode 100644 src/Butil/Bit.Butil/Scripts/wakeLock.ts create mode 100644 src/Butil/Bit.Butil/Scripts/webAudio.ts create mode 100644 src/Butil/Bit.Butil/Scripts/webLocks.ts create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EObserversPage.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EPage.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoCard.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoConsole.razor delete mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Shared/Header.razor delete mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Shared/Header.razor.css create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Shared/NavMenu.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Shared/PageHeader.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/wwwroot/app.css create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/Bit.Butil.E2ETests.csproj create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/BroadcastAndIndexedDbTests.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/CookieTests.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/CryptoTests.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilHarnessTestBase.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilObserversPageTest.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilPageTest.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/DemoServerFixture.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/ObserverTests.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/PerformanceAndPlatformTests.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/README.md create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/StorageTests.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/WindowDocumentHistoryTests.cs create mode 100644 src/Butil/tests/Bit.Butil.E2ETests/ci/bit.ci.Butil.e2e.yml diff --git a/src/Butil/Bit.Butil/BitButil.cs b/src/Butil/Bit.Butil/BitButil.cs index db906418aa..ecafce281d 100644 --- a/src/Butil/Bit.Butil/BitButil.cs +++ b/src/Butil/Bit.Butil/BitButil.cs @@ -6,24 +6,55 @@ public static class BitButil { public static IServiceCollection AddBitButilServices(this IServiceCollection services) { - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + // Scoped matches Blazor's "one circuit / one WASM app instance per user" model. + // Transient would create a fresh wrapper on every @inject, fragmenting per-instance + // listener bookkeeping and keeping captured component delegates alive longer than + // the component itself. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs b/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs index 1d6445af61..13aec55147 100644 --- a/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs +++ b/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Threading; -using System.Reflection; using System.Threading.Tasks; using System.Diagnostics.CodeAnalysis; using Microsoft.JSInterop; @@ -57,19 +56,24 @@ internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifie } - [SuppressMessage("Trimming", "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.", Justification = "")] - internal static bool IsJsRuntimeInvalid(this IJSRuntime jsRuntime) + /// + /// Returns true when calling into JavaScript right now would either be impossible + /// (no runtime / pre-render) or guaranteed to fail (disposed circuit). + /// + /// + /// We deliberately avoid reflecting over private fields of RemoteJSRuntime + /// or WebViewJSRuntime; those internals have changed across .NET releases. + /// Instead we rely on the only documented sentinel — the + /// UnsupportedJavaScriptRuntime type used during static SSR / pre-render — + /// and let actual disconnect surface as at + /// the call site, which callers already catch. + /// + internal static bool IsJsRuntimeInvalid(this IJSRuntime? jsRuntime) { - if (jsRuntime is null) return false; + if (jsRuntime is null) return true; - var type = jsRuntime.GetType(); - - return type.Name switch - { - "UnsupportedJavaScriptRuntime" => true, // Prerendering - "RemoteJSRuntime" => (bool)type.GetProperty("IsInitialized")!.GetValue(jsRuntime)! is false, // Blazor server - "WebViewJSRuntime" => type.GetField("_ipcSender", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(jsRuntime) is null, // Blazor Hybrid - _ => false // Blazor WASM - }; + // During pre-rendering ASP.NET injects an UnsupportedJavaScriptRuntime that + // throws on every call. We special-case it to keep prerender silent. + return jsRuntime.GetType().Name == "UnsupportedJavaScriptRuntime"; } } diff --git a/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs b/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs index d5970ea7be..07fb3c29d1 100644 --- a/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs +++ b/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -60,20 +59,14 @@ public static class JSRuntimeExtensions { if (jsRuntime is IJSInProcessRuntime jsInProcessRuntime) { - try - { - return ValueTask.FromResult(jsInProcessRuntime.Invoke(identifier, args)); - } - catch (JsonException ex) - { - System.Console.Error.WriteLine($"Error invoking '{identifier}' using {nameof(IJSInProcessRuntime)}. A JSON-related issue occurred: {ex.Message}."); - return ValueTask.FromResult(default(TResult)!); - } - } - else - { - return jsRuntime.InvokeAsync(identifier, cancellationToken, args); + // We deliberately do not catch JsonException here. Calling the synchronous + // Invoke against a JS function that returns a Promise produces a JSON + // payload that cannot deserialize to TResult; surfacing the error makes the + // mistake visible instead of silently returning default(TResult). + return ValueTask.FromResult(jsInProcessRuntime.Invoke(identifier, args)); } + + return jsRuntime.InvokeAsync(identifier, cancellationToken, args); } @@ -117,21 +110,12 @@ public static ValueTask FastInvokeVoidAsync(this IJSRuntime jsRuntime, string id { if (jsRuntime is IJSInProcessRuntime jsInProcessRuntime) { - try - { - jsInProcessRuntime.Invoke(identifier, args); - return ValueTask.CompletedTask; - } - catch (JsonException ex) - { - System.Console.Error.WriteLine($"Error invoking '{identifier}' using {nameof(IJSInProcessRuntime)}. A JSON-related issue occurred: {ex.Message}."); - return ValueTask.CompletedTask; - } - } - else - { - return jsRuntime.InvokeVoidAsync(identifier, cancellationToken, args); + // Don't swallow JsonException — see FastInvokeAsync for rationale. + jsInProcessRuntime.Invoke(identifier, args); + return ValueTask.CompletedTask; } + + return jsRuntime.InvokeVoidAsync(identifier, cancellationToken, args); } diff --git a/src/Butil/Bit.Butil/Internals/BroadcastChannel/BroadcastChannelListenersManager.cs b/src/Butil/Bit.Butil/Internals/BroadcastChannel/BroadcastChannelListenersManager.cs new file mode 100644 index 0000000000..a15a5da59a --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/BroadcastChannel/BroadcastChannelListenersManager.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class BroadcastChannelListenersManager +{ + internal const string MessageMethodName = "InvokeBroadcastChannelMessage"; + internal const string ErrorMethodName = "InvokeBroadcastChannelError"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid AddListener(Action? onMessage, Action? onError) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { OnMessage = onMessage, OnError = onError }); + return id; + } + + internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(MessageMethodName)] + public static void InvokeMessage(Guid id, System.Text.Json.JsonElement data) + { + if (Listeners.TryGetValue(id, out var listener)) listener.OnMessage?.Invoke(data); + } + + [JSInvokable(ErrorMethodName)] + public static void InvokeError(Guid id) + { + if (Listeners.TryGetValue(id, out var listener)) listener.OnError?.Invoke(); + } + + private class Listener + { + public Action? OnMessage { get; set; } + public Action? OnError { get; set; } + } +} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomClipboardEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomClipboardEventListenersManager.cs new file mode 100644 index 0000000000..5e5670c1a1 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Events/DomClipboardEventListenersManager.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class DomClipboardEventListenersManager +{ + internal const string InvokeMethodName = "InvokeClipboardEvent"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid SetListener(Action action, string element, object options) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); + return id; + } + + internal static Guid[] RemoveListener(Action action, string element, object options) + { + var toRemove = Listeners + .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) + .ToArray(); + + return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); + } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, ButilClipboardEventArgs args) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); + } + + private class Listener + { + public string Element { get; set; } = string.Empty; + public object Options { get; set; } = default!; + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomCompositionEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomCompositionEventListenersManager.cs new file mode 100644 index 0000000000..8dc661e2c6 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Events/DomCompositionEventListenersManager.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class DomCompositionEventListenersManager +{ + internal const string InvokeMethodName = "InvokeCompositionEvent"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid SetListener(Action action, string element, object options) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); + return id; + } + + internal static Guid[] RemoveListener(Action action, string element, object options) + { + var toRemove = Listeners + .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) + .ToArray(); + + return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); + } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, ButilCompositionEventArgs args) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); + } + + private class Listener + { + public string Element { get; set; } = string.Empty; + public object Options { get; set; } = default!; + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomDragEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomDragEventListenersManager.cs new file mode 100644 index 0000000000..aa547e0631 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Events/DomDragEventListenersManager.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class DomDragEventListenersManager +{ + internal const string InvokeMethodName = "InvokeDragEvent"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid SetListener(Action action, string element, object options) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); + return id; + } + + internal static Guid[] RemoveListener(Action action, string element, object options) + { + var toRemove = Listeners + .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) + .ToArray(); + + return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); + } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, ButilDragEventArgs args) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); + } + + private class Listener + { + public string Element { get; set; } = string.Empty; + public object Options { get; set; } = default!; + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomEventArgs.cs b/src/Butil/Bit.Butil/Internals/Events/DomEventArgs.cs index 8239275e5c..cf9deaac88 100644 --- a/src/Butil/Bit.Butil/Internals/Events/DomEventArgs.cs +++ b/src/Butil/Bit.Butil/Internals/Events/DomEventArgs.cs @@ -8,10 +8,73 @@ internal static Type TypeOf(string domEvent) { return domEvent switch { + // Mouse ButilEvents.Click => typeof(ButilMouseEventArgs), + ButilEvents.DblClick => typeof(ButilMouseEventArgs), + ButilEvents.MouseDown => typeof(ButilMouseEventArgs), + ButilEvents.MouseUp => typeof(ButilMouseEventArgs), + ButilEvents.MouseMove => typeof(ButilMouseEventArgs), + ButilEvents.MouseEnter => typeof(ButilMouseEventArgs), + ButilEvents.MouseLeave => typeof(ButilMouseEventArgs), + ButilEvents.MouseOver => typeof(ButilMouseEventArgs), + ButilEvents.MouseOut => typeof(ButilMouseEventArgs), + ButilEvents.ContextMenu => typeof(ButilMouseEventArgs), + + // Keyboard ButilEvents.KeyDown => typeof(ButilKeyboardEventArgs), ButilEvents.KeyUp => typeof(ButilKeyboardEventArgs), ButilEvents.KeyPress => typeof(ButilKeyboardEventArgs), + + // Pointer + ButilEvents.PointerDown => typeof(ButilPointerEventArgs), + ButilEvents.PointerUp => typeof(ButilPointerEventArgs), + ButilEvents.PointerMove => typeof(ButilPointerEventArgs), + ButilEvents.PointerEnter => typeof(ButilPointerEventArgs), + ButilEvents.PointerLeave => typeof(ButilPointerEventArgs), + ButilEvents.PointerOver => typeof(ButilPointerEventArgs), + ButilEvents.PointerOut => typeof(ButilPointerEventArgs), + ButilEvents.PointerCancel => typeof(ButilPointerEventArgs), + ButilEvents.GotPointerCapture => typeof(ButilPointerEventArgs), + ButilEvents.LostPointerCapture => typeof(ButilPointerEventArgs), + + // Touch + ButilEvents.TouchStart => typeof(ButilTouchEventArgs), + ButilEvents.TouchEnd => typeof(ButilTouchEventArgs), + ButilEvents.TouchMove => typeof(ButilTouchEventArgs), + ButilEvents.TouchCancel => typeof(ButilTouchEventArgs), + + // Wheel + ButilEvents.Wheel => typeof(ButilWheelEventArgs), + + // Focus + ButilEvents.Focus => typeof(ButilFocusEventArgs), + ButilEvents.Blur => typeof(ButilFocusEventArgs), + ButilEvents.FocusIn => typeof(ButilFocusEventArgs), + ButilEvents.FocusOut => typeof(ButilFocusEventArgs), + + // Input + ButilEvents.Input => typeof(ButilInputEventArgs), + ButilEvents.BeforeInput => typeof(ButilInputEventArgs), + + // Drag + ButilEvents.DragStart => typeof(ButilDragEventArgs), + ButilEvents.Drag => typeof(ButilDragEventArgs), + ButilEvents.DragEnd => typeof(ButilDragEventArgs), + ButilEvents.DragEnter => typeof(ButilDragEventArgs), + ButilEvents.DragLeave => typeof(ButilDragEventArgs), + ButilEvents.DragOver => typeof(ButilDragEventArgs), + ButilEvents.Drop => typeof(ButilDragEventArgs), + + // Clipboard + ButilEvents.Copy => typeof(ButilClipboardEventArgs), + ButilEvents.Cut => typeof(ButilClipboardEventArgs), + ButilEvents.Paste => typeof(ButilClipboardEventArgs), + + // Composition + ButilEvents.CompositionStart => typeof(ButilCompositionEventArgs), + ButilEvents.CompositionUpdate => typeof(ButilCompositionEventArgs), + ButilEvents.CompositionEnd => typeof(ButilCompositionEventArgs), + _ => typeof(object), }; } diff --git a/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs b/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs index 27c62b2e69..02c912f88e 100644 --- a/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs +++ b/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs @@ -12,10 +12,27 @@ internal static class DomEventDispatcher [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilMouseEventArgs))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilKeyboardEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilPointerEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilWheelEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilTouchEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilTouchPoint))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilFocusEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilInputEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilDragEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilClipboardEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilCompositionEventArgs))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomEventListenersManager))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomMouseEventListenersManager))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomKeyboardEventListenersManager))] - internal static async Task AddEventListener(IJSRuntime js, + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomPointerEventListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomWheelEventListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomTouchEventListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomFocusEventListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomInputEventListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomDragEventListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomClipboardEventListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomCompositionEventListenersManager))] + internal static async Task AddEventListener(IJSRuntime js, string elementName, string domEvent, Action listener, @@ -38,15 +55,61 @@ internal static async Task AddEventListener(IJSRuntime js, { args = ButilKeyboardEventArgs.EventArgsMembers; methodName = DomKeyboardEventListenersManager.InvokeMethodName; - var action = listener as Action; - id = DomKeyboardEventListenersManager.SetListener(action!, elementName, options); + id = DomKeyboardEventListenersManager.SetListener((listener as Action)!, elementName, options); } else if (argType == typeof(ButilMouseEventArgs)) { args = ButilMouseEventArgs.EventArgsMembers; methodName = DomMouseEventListenersManager.InvokeMethodName; - var action = listener as Action; - id = DomMouseEventListenersManager.SetListener(action!, elementName, options); + id = DomMouseEventListenersManager.SetListener((listener as Action)!, elementName, options); + } + else if (argType == typeof(ButilPointerEventArgs)) + { + args = ButilPointerEventArgs.EventArgsMembers; + methodName = DomPointerEventListenersManager.InvokeMethodName; + id = DomPointerEventListenersManager.SetListener((listener as Action)!, elementName, options); + } + else if (argType == typeof(ButilWheelEventArgs)) + { + args = ButilWheelEventArgs.EventArgsMembers; + methodName = DomWheelEventListenersManager.InvokeMethodName; + id = DomWheelEventListenersManager.SetListener((listener as Action)!, elementName, options); + } + else if (argType == typeof(ButilTouchEventArgs)) + { + args = ButilTouchEventArgs.EventArgsMembers; + methodName = DomTouchEventListenersManager.InvokeMethodName; + id = DomTouchEventListenersManager.SetListener((listener as Action)!, elementName, options); + } + else if (argType == typeof(ButilFocusEventArgs)) + { + args = ButilFocusEventArgs.EventArgsMembers; + methodName = DomFocusEventListenersManager.InvokeMethodName; + id = DomFocusEventListenersManager.SetListener((listener as Action)!, elementName, options); + } + else if (argType == typeof(ButilInputEventArgs)) + { + args = ButilInputEventArgs.EventArgsMembers; + methodName = DomInputEventListenersManager.InvokeMethodName; + id = DomInputEventListenersManager.SetListener((listener as Action)!, elementName, options); + } + else if (argType == typeof(ButilDragEventArgs)) + { + args = ButilDragEventArgs.EventArgsMembers; + methodName = DomDragEventListenersManager.InvokeMethodName; + id = DomDragEventListenersManager.SetListener((listener as Action)!, elementName, options); + } + else if (argType == typeof(ButilClipboardEventArgs)) + { + args = ButilClipboardEventArgs.EventArgsMembers; + methodName = DomClipboardEventListenersManager.InvokeMethodName; + id = DomClipboardEventListenersManager.SetListener((listener as Action)!, elementName, options); + } + else if (argType == typeof(ButilCompositionEventArgs)) + { + args = ButilCompositionEventArgs.EventArgsMembers; + methodName = DomCompositionEventListenersManager.InvokeMethodName; + id = DomCompositionEventListenersManager.SetListener((listener as Action)!, elementName, options); } else { @@ -56,9 +119,11 @@ internal static async Task AddEventListener(IJSRuntime js, } await js.AddEventListener(elementName, domEvent, methodName, id, args, options, preventDefault, stopPropagation); + + return id; } - internal static async Task RemoveEventListener(IJSRuntime js, + internal static async Task RemoveEventListener(IJSRuntime js, string elementName, string domEvent, Action listener, @@ -70,25 +135,57 @@ internal static async Task RemoveEventListener(IJSRuntime js, if (argType != eventType) throw new InvalidOperationException($"Invalid listener type ({argType}) for this dom event type ({eventType})"); - Guid[] ids = []; + Guid[] ids; var options = useCapture ? TrueUseCapture : FalseUseCapture; if (argType == typeof(ButilKeyboardEventArgs)) - { - var action = listener as Action; - ids = DomKeyboardEventListenersManager.RemoveListener(action!, elementName, options); - } + ids = DomKeyboardEventListenersManager.RemoveListener((listener as Action)!, elementName, options); else if (argType == typeof(ButilMouseEventArgs)) - { - var action = listener as Action; - ids = DomMouseEventListenersManager.RemoveListener(action!, elementName, options); - } + ids = DomMouseEventListenersManager.RemoveListener((listener as Action)!, elementName, options); + else if (argType == typeof(ButilPointerEventArgs)) + ids = DomPointerEventListenersManager.RemoveListener((listener as Action)!, elementName, options); + else if (argType == typeof(ButilWheelEventArgs)) + ids = DomWheelEventListenersManager.RemoveListener((listener as Action)!, elementName, options); + else if (argType == typeof(ButilTouchEventArgs)) + ids = DomTouchEventListenersManager.RemoveListener((listener as Action)!, elementName, options); + else if (argType == typeof(ButilFocusEventArgs)) + ids = DomFocusEventListenersManager.RemoveListener((listener as Action)!, elementName, options); + else if (argType == typeof(ButilInputEventArgs)) + ids = DomInputEventListenersManager.RemoveListener((listener as Action)!, elementName, options); + else if (argType == typeof(ButilDragEventArgs)) + ids = DomDragEventListenersManager.RemoveListener((listener as Action)!, elementName, options); + else if (argType == typeof(ButilClipboardEventArgs)) + ids = DomClipboardEventListenersManager.RemoveListener((listener as Action)!, elementName, options); + else if (argType == typeof(ButilCompositionEventArgs)) + ids = DomCompositionEventListenersManager.RemoveListener((listener as Action)!, elementName, options); else - { - var action = listener as Action; - ids = DomEventListenersManager.RemoveListener(action!, elementName, options); - } + ids = DomEventListenersManager.RemoveListener((listener as Action)!, elementName, options); await js.RemoveEventListener(elementName, domEvent, ids, options); + + return ids; + } + + /// + /// Detaches a single listener by id, regardless of which typed manager owns it. + /// Used by when the original delegate isn't available. + /// + internal static async Task RemoveEventListenerById(IJSRuntime js, string elementName, string domEvent, Guid id, bool useCapture = false) + { + // Try every typed store; the one that owns the id will succeed. + DomKeyboardEventListenersManager.RemoveById(id); + DomMouseEventListenersManager.RemoveById(id); + DomPointerEventListenersManager.RemoveById(id); + DomWheelEventListenersManager.RemoveById(id); + DomTouchEventListenersManager.RemoveById(id); + DomFocusEventListenersManager.RemoveById(id); + DomInputEventListenersManager.RemoveById(id); + DomDragEventListenersManager.RemoveById(id); + DomClipboardEventListenersManager.RemoveById(id); + DomCompositionEventListenersManager.RemoveById(id); + DomEventListenersManager.RemoveById(id); + + var options = useCapture ? TrueUseCapture : FalseUseCapture; + await js.RemoveEventListener(elementName, domEvent, [id], options); } } diff --git a/src/Butil/Bit.Butil/Internals/Events/DomEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomEventListenersManager.cs index 0cd014b297..6ddefa766b 100644 --- a/src/Butil/Bit.Butil/Internals/Events/DomEventListenersManager.cs +++ b/src/Butil/Bit.Butil/Internals/Events/DomEventListenersManager.cs @@ -31,6 +31,8 @@ internal static Guid[] RemoveListener(Action action, string element, obj }).ToArray(); } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + [JSInvokable(InvokeMethodName)] public static void Invoke(Guid id, object args) { diff --git a/src/Butil/Bit.Butil/Internals/Events/DomFocusEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomFocusEventListenersManager.cs new file mode 100644 index 0000000000..381d0f46a6 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Events/DomFocusEventListenersManager.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class DomFocusEventListenersManager +{ + internal const string InvokeMethodName = "InvokeFocusEvent"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid SetListener(Action action, string element, object options) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); + return id; + } + + internal static Guid[] RemoveListener(Action action, string element, object options) + { + var toRemove = Listeners + .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) + .ToArray(); + + return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); + } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, ButilFocusEventArgs args) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); + } + + private class Listener + { + public string Element { get; set; } = string.Empty; + public object Options { get; set; } = default!; + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomInputEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomInputEventListenersManager.cs new file mode 100644 index 0000000000..0bd2677de8 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Events/DomInputEventListenersManager.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class DomInputEventListenersManager +{ + internal const string InvokeMethodName = "InvokeInputEvent"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid SetListener(Action action, string element, object options) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); + return id; + } + + internal static Guid[] RemoveListener(Action action, string element, object options) + { + var toRemove = Listeners + .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) + .ToArray(); + + return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); + } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, ButilInputEventArgs args) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); + } + + private class Listener + { + public string Element { get; set; } = string.Empty; + public object Options { get; set; } = default!; + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomKeyboardEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomKeyboardEventListenersManager.cs index fe451bcd63..468865a9df 100644 --- a/src/Butil/Bit.Butil/Internals/Events/DomKeyboardEventListenersManager.cs +++ b/src/Butil/Bit.Butil/Internals/Events/DomKeyboardEventListenersManager.cs @@ -31,6 +31,8 @@ internal static Guid[] RemoveListener(Action action, str }).ToArray(); } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + [JSInvokable(InvokeMethodName)] public static void Invoke(Guid id, ButilKeyboardEventArgs args) { diff --git a/src/Butil/Bit.Butil/Internals/Events/DomMouseEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomMouseEventListenersManager.cs index d0e128a664..f19fed62f1 100644 --- a/src/Butil/Bit.Butil/Internals/Events/DomMouseEventListenersManager.cs +++ b/src/Butil/Bit.Butil/Internals/Events/DomMouseEventListenersManager.cs @@ -31,6 +31,8 @@ internal static Guid[] RemoveListener(Action action, string }).ToArray(); } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + [JSInvokable(InvokeMethodName)] public static void Invoke(Guid id, ButilMouseEventArgs args) { diff --git a/src/Butil/Bit.Butil/Internals/Events/DomPointerEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomPointerEventListenersManager.cs new file mode 100644 index 0000000000..e61235e944 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Events/DomPointerEventListenersManager.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class DomPointerEventListenersManager +{ + internal const string InvokeMethodName = "InvokePointerEvent"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid SetListener(Action action, string element, object options) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); + return id; + } + + internal static Guid[] RemoveListener(Action action, string element, object options) + { + var toRemove = Listeners + .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) + .ToArray(); + + return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); + } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, ButilPointerEventArgs args) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); + } + + private class Listener + { + public string Element { get; set; } = string.Empty; + public object Options { get; set; } = default!; + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomTouchEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomTouchEventListenersManager.cs new file mode 100644 index 0000000000..19c647aedb --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Events/DomTouchEventListenersManager.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class DomTouchEventListenersManager +{ + internal const string InvokeMethodName = "InvokeTouchEvent"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid SetListener(Action action, string element, object options) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); + return id; + } + + internal static Guid[] RemoveListener(Action action, string element, object options) + { + var toRemove = Listeners + .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) + .ToArray(); + + return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); + } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, ButilTouchEventArgs args) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); + } + + private class Listener + { + public string Element { get; set; } = string.Empty; + public object Options { get; set; } = default!; + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomWheelEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomWheelEventListenersManager.cs new file mode 100644 index 0000000000..53bc5c8a50 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Events/DomWheelEventListenersManager.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class DomWheelEventListenersManager +{ + internal const string InvokeMethodName = "InvokeWheelEvent"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid SetListener(Action action, string element, object options) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); + return id; + } + + internal static Guid[] RemoveListener(Action action, string element, object options) + { + var toRemove = Listeners + .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) + .ToArray(); + + return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); + } + internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); + + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, ButilWheelEventArgs args) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); + } + + private class Listener + { + public string Element { get; set; } = string.Empty; + public object Options { get; set; } = default!; + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Internals/Fetch/FetchProgressListenersManager.cs b/src/Butil/Bit.Butil/Internals/Fetch/FetchProgressListenersManager.cs new file mode 100644 index 0000000000..5bd98d10fa --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Fetch/FetchProgressListenersManager.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class FetchProgressListenersManager +{ + internal const string InvokeMethodName = "InvokeFetchProgress"; + + private static readonly ConcurrentDictionary> Listeners = []; + + internal static void AddListener(Guid id, Action action) => Listeners[id] = action; + + internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, FetchProgress progress) + { + if (Listeners.TryGetValue(id, out var l)) l.Invoke(progress); + } +} diff --git a/src/Butil/Bit.Butil/Internals/Geolocation/GeolocationListenersManager.cs b/src/Butil/Bit.Butil/Internals/Geolocation/GeolocationListenersManager.cs new file mode 100644 index 0000000000..be1ed6bdc2 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Geolocation/GeolocationListenersManager.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class GeolocationListenersManager +{ + internal const string PositionMethodName = "InvokeGeolocationPosition"; + internal const string ErrorMethodName = "InvokeGeolocationError"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid AddListener(Action? onPosition, Action? onError) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { OnPosition = onPosition, OnError = onError }); + return id; + } + + internal static void RemoveListeners(Guid[] ids) + { + foreach (var id in ids) Listeners.TryRemove(id, out _); + } + + [JSInvokable(PositionMethodName)] + public static void InvokePosition(Guid id, GeolocationPosition position) + { + if (Listeners.TryGetValue(id, out var listener)) + { + listener.OnPosition?.Invoke(position); + } + } + + [JSInvokable(ErrorMethodName)] + public static void InvokeError(Guid id, int code, string message) + { + if (Listeners.TryGetValue(id, out var listener)) + { + var enumCode = code switch + { + 1 => GeolocationErrorCode.PermissionDenied, + 2 => GeolocationErrorCode.PositionUnavailable, + 3 => GeolocationErrorCode.Timeout, + _ => GeolocationErrorCode.Unknown, + }; + listener.OnError?.Invoke(new GeolocationException(enumCode, message)); + } + } + + private class Listener + { + public Action? OnPosition { get; set; } + public Action? OnError { get; set; } + } +} diff --git a/src/Butil/Bit.Butil/Internals/IdleDetector/IdleDetectorListenersManager.cs b/src/Butil/Bit.Butil/Internals/IdleDetector/IdleDetectorListenersManager.cs new file mode 100644 index 0000000000..27409071b5 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/IdleDetector/IdleDetectorListenersManager.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class IdleDetectorListenersManager +{ + internal const string InvokeMethodName = "InvokeIdleDetector"; + + private static readonly ConcurrentDictionary> Listeners = []; + + internal static Guid AddListener(Action action) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, action); + return id; + } + + internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, IdleState state) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(state); + } +} diff --git a/src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverListenersManager.cs b/src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverListenersManager.cs new file mode 100644 index 0000000000..ead564c9b6 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverListenersManager.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class IntersectionObserverListenersManager +{ + internal const string InvokeMethodName = "InvokeIntersectionObserver"; + + private static readonly ConcurrentDictionary> Listeners = []; + + internal static Guid AddListener(Action action) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, action); + return id; + } + + internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, IntersectionObserverEntry[] entries) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(entries); + } +} diff --git a/src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverListenersManager.cs b/src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverListenersManager.cs new file mode 100644 index 0000000000..59ee1acd39 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverListenersManager.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class MutationObserverListenersManager +{ + internal const string InvokeMethodName = "InvokeMutationObserver"; + + private static readonly ConcurrentDictionary> Listeners = []; + + internal static Guid AddListener(Action action) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, action); + return id; + } + + internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, MutationRecord[] records) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(records); + } +} diff --git a/src/Butil/Bit.Butil/Internals/Nfc/NdefListenersManager.cs b/src/Butil/Bit.Butil/Internals/Nfc/NdefListenersManager.cs new file mode 100644 index 0000000000..4f90eb0336 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Nfc/NdefListenersManager.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class NdefListenersManager +{ + internal const string ReadingMethodName = "InvokeNdefReading"; + internal const string ErrorMethodName = "InvokeNdefError"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid Add(Listener listener) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, listener); + return id; + } + + internal static void Remove(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(ReadingMethodName)] + public static void InvokeReading(Guid id, NdefMessage message) + { + if (Listeners.TryGetValue(id, out var l)) l.OnReading?.Invoke(message); + } + + [JSInvokable(ErrorMethodName)] + public static void InvokeError(Guid id, string message) + { + if (Listeners.TryGetValue(id, out var l)) l.OnError?.Invoke(message); + } + + internal class Listener + { + public Action? OnReading { get; set; } + public Action? OnError { get; set; } + } +} diff --git a/src/Butil/Bit.Butil/Internals/Notification/NotificationListenersManager.cs b/src/Butil/Bit.Butil/Internals/Notification/NotificationListenersManager.cs new file mode 100644 index 0000000000..f0d36bfaf6 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Notification/NotificationListenersManager.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class NotificationListenersManager +{ + internal const string ClickMethodName = "InvokeNotificationClick"; + internal const string ShowMethodName = "InvokeNotificationShow"; + internal const string CloseMethodName = "InvokeNotificationClose"; + internal const string ErrorMethodName = "InvokeNotificationError"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid Add(Listener listener) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, listener); + return id; + } + + internal static void Remove(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(ClickMethodName)] + public static void InvokeClick(Guid id) + { + if (Listeners.TryGetValue(id, out var l)) l.OnClick?.Invoke(); + } + + [JSInvokable(ShowMethodName)] + public static void InvokeShow(Guid id) + { + if (Listeners.TryGetValue(id, out var l)) l.OnShow?.Invoke(); + } + + [JSInvokable(CloseMethodName)] + public static void InvokeClose(Guid id) + { + if (Listeners.TryGetValue(id, out var l)) l.OnClose?.Invoke(); + } + + [JSInvokable(ErrorMethodName)] + public static void InvokeError(Guid id) + { + if (Listeners.TryGetValue(id, out var l)) l.OnError?.Invoke(); + } + + internal class Listener + { + public Action? OnClick { get; set; } + public Action? OnShow { get; set; } + public Action? OnClose { get; set; } + public Action? OnError { get; set; } + } +} diff --git a/src/Butil/Bit.Butil/Internals/Performance/PerformanceObserverListenersManager.cs b/src/Butil/Bit.Butil/Internals/Performance/PerformanceObserverListenersManager.cs new file mode 100644 index 0000000000..cf96338528 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Performance/PerformanceObserverListenersManager.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class PerformanceObserverListenersManager +{ + internal const string InvokeMethodName = "InvokePerformanceObserver"; + + private static readonly ConcurrentDictionary> Listeners = []; + + internal static Guid AddListener(Action action) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, action); + return id; + } + + internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, JsonElement[] entries) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(entries); + } +} diff --git a/src/Butil/Bit.Butil/Internals/Reporting/ReportingListenersManager.cs b/src/Butil/Bit.Butil/Internals/Reporting/ReportingListenersManager.cs new file mode 100644 index 0000000000..67698524ce --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Reporting/ReportingListenersManager.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class ReportingListenersManager +{ + internal const string InvokeMethodName = "InvokeBrowserReport"; + + private static readonly ConcurrentDictionary> Listeners = []; + + internal static Guid AddListener(Action action) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, action); + return id; + } + + internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, BrowserReport[] reports) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(reports); + } +} diff --git a/src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverListenersManager.cs b/src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverListenersManager.cs new file mode 100644 index 0000000000..9d69dc7e94 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverListenersManager.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class ResizeObserverListenersManager +{ + internal const string InvokeMethodName = "InvokeResizeObserver"; + + private static readonly ConcurrentDictionary> Listeners = []; + + internal static Guid AddListener(Action action) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, action); + return id; + } + + internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, ResizeObserverEntry[] entries) + { + if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(entries); + } +} diff --git a/src/Butil/Bit.Butil/Internals/ServiceWorker/ServiceWorkerListenersManager.cs b/src/Butil/Bit.Butil/Internals/ServiceWorker/ServiceWorkerListenersManager.cs new file mode 100644 index 0000000000..877414fe23 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/ServiceWorker/ServiceWorkerListenersManager.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class ServiceWorkerListenersManager +{ + internal const string MessageMethodName = "InvokeServiceWorkerMessage"; + internal const string ControllerChangeMethodName = "InvokeServiceWorkerControllerChange"; + + private static readonly ConcurrentDictionary> MessageListeners = []; + private static readonly ConcurrentDictionary ControllerChangeListeners = []; + + internal static Guid AddMessageListener(Action action) + { + var id = Guid.NewGuid(); + MessageListeners.TryAdd(id, action); + return id; + } + internal static void RemoveMessageListener(Guid id) => MessageListeners.TryRemove(id, out _); + + internal static Guid AddControllerChangeListener(Action action) + { + var id = Guid.NewGuid(); + ControllerChangeListeners.TryAdd(id, action); + return id; + } + internal static void RemoveControllerChangeListener(Guid id) => ControllerChangeListeners.TryRemove(id, out _); + + [JSInvokable(MessageMethodName)] + public static void InvokeMessage(Guid id, JsonElement data) + { + if (MessageListeners.TryGetValue(id, out var l)) l.Invoke(data); + } + + [JSInvokable(ControllerChangeMethodName)] + public static void InvokeControllerChange(Guid id) + { + if (ControllerChangeListeners.TryGetValue(id, out var l)) l.Invoke(); + } +} diff --git a/src/Butil/Bit.Butil/Internals/SpeechRecognition/SpeechRecognitionListenersManager.cs b/src/Butil/Bit.Butil/Internals/SpeechRecognition/SpeechRecognitionListenersManager.cs new file mode 100644 index 0000000000..eaba5deb72 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/SpeechRecognition/SpeechRecognitionListenersManager.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class SpeechRecognitionListenersManager +{ + internal const string ResultMethodName = "InvokeSpeechRecognitionResult"; + internal const string ErrorMethodName = "InvokeSpeechRecognitionError"; + internal const string EndMethodName = "InvokeSpeechRecognitionEnd"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid Add(Listener listener) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, listener); + return id; + } + + internal static void Remove(Guid id) => Listeners.TryRemove(id, out _); + + [JSInvokable(ResultMethodName)] + public static void InvokeResult(Guid id, SpeechRecognitionResult result) + { + if (Listeners.TryGetValue(id, out var l)) l.OnResult?.Invoke(result); + } + + [JSInvokable(ErrorMethodName)] + public static void InvokeError(Guid id, string message) + { + if (Listeners.TryGetValue(id, out var l)) l.OnError?.Invoke(message); + } + + [JSInvokable(EndMethodName)] + public static void InvokeEnd(Guid id) + { + if (Listeners.TryGetValue(id, out var l)) l.OnEnd?.Invoke(); + } + + internal class Listener + { + public Action? OnResult { get; set; } + public Action? OnError { get; set; } + public Action? OnEnd { get; set; } + } +} diff --git a/src/Butil/Bit.Butil/Internals/Storage/StorageListenersManager.cs b/src/Butil/Bit.Butil/Internals/Storage/StorageListenersManager.cs new file mode 100644 index 0000000000..93d07063c4 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Storage/StorageListenersManager.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class StorageListenersManager +{ + internal const string InvokeMethodName = "InvokeStorageEvent"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid AddListener(Action action, string area) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action, Area = area }); + return id; + } + + internal static Guid[] RemoveListener(Action action) + { + var toRemove = Listeners.Where(l => l.Value.Action == action).ToArray(); + return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); + } + + internal static void RemoveListeners(Guid[] ids) + { + foreach (var id in ids) Listeners.TryRemove(id, out _); + } + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, StorageEvent evt) + { + if (Listeners.TryGetValue(id, out var listener) && + (string.IsNullOrEmpty(listener.Area) || string.Equals(listener.Area, evt.StorageArea, StringComparison.Ordinal))) + { + listener.Action.Invoke(evt); + } + } + + private class Listener + { + public string Area { get; set; } = string.Empty; + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Internals/Window/MediaQueryListenersManager.cs b/src/Butil/Bit.Butil/Internals/Window/MediaQueryListenersManager.cs new file mode 100644 index 0000000000..539114cf74 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Window/MediaQueryListenersManager.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +public static class MediaQueryListenersManager +{ + internal const string InvokeMethodName = "InvokeMediaQueryChange"; + + private static readonly ConcurrentDictionary Listeners = []; + + internal static Guid AddListener(Action action) + { + var id = Guid.NewGuid(); + Listeners.TryAdd(id, new Listener { Action = action }); + return id; + } + + internal static Guid[] RemoveListener(Action action) + { + var toRemove = Listeners.Where(l => l.Value.Action == action).ToArray(); + + return toRemove.Select(l => + { + Listeners.TryRemove(l.Key, out _); + return l.Key; + }).ToArray(); + } + + internal static void RemoveListeners(Guid[] ids) + { + foreach (var id in ids) + { + Listeners.TryRemove(id, out _); + } + } + + [JSInvokable(InvokeMethodName)] + public static void Invoke(Guid id, MediaQueryList state) + { + Listeners.TryGetValue(id, out Listener? listener); + listener?.Action.Invoke(state); + } + + private class Listener + { + public Action Action { get; set; } = default!; + } +} diff --git a/src/Butil/Bit.Butil/Publics/Animation/AnimationHandle.cs b/src/Butil/Bit.Butil/Publics/Animation/AnimationHandle.cs new file mode 100644 index 0000000000..1f8647154a --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Animation/AnimationHandle.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Handle to an in-flight Web Animation. Always dispose (or cancel) so the animation +/// is removed from the engine — long-running animations otherwise sit on the element +/// indefinitely with set. +/// +public sealed class AnimationHandle : IAsyncDisposable +{ + private readonly IJSRuntime _js; + private readonly Guid _id; + private bool _disposed; + + internal AnimationHandle(IJSRuntime js, Guid id) + { + _js = js; + _id = id; + } + + /// Plays a paused animation. + public ValueTask Play() => _js.InvokeVoid("BitButil.animation.play", _id); + + /// Pauses the animation at its current time. + public ValueTask Pause() => _js.InvokeVoid("BitButil.animation.pause", _id); + + /// Reverses playback direction. + public ValueTask Reverse() => _js.InvokeVoid("BitButil.animation.reverse", _id); + + /// Cancels and removes the animation immediately. + public ValueTask Cancel() => _js.InvokeVoid("BitButil.animation.cancel", _id); + + /// Jumps to the end of the animation, applying . + public ValueTask Finish() => _js.InvokeVoid("BitButil.animation.finish", _id); + + /// Awaits the animation's finished Promise. + public ValueTask WhenFinished() => _js.InvokeVoid("BitButil.animation.whenFinished", _id); + + /// Sets the playback rate (1 = normal speed; -1 = reverse at normal speed). + public ValueTask SetPlaybackRate(double rate) => _js.InvokeVoid("BitButil.animation.setPlaybackRate", _id, rate); + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try { await _js.InvokeVoid("BitButil.animation.cancel", _id); } + catch (JSDisconnectedException) { } + } +} diff --git a/src/Butil/Bit.Butil/Publics/Animation/AnimationKeyframes.cs b/src/Butil/Bit.Butil/Publics/Animation/AnimationKeyframes.cs new file mode 100644 index 0000000000..2a84d19f71 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Animation/AnimationKeyframes.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Bit.Butil; + +/// +/// Keyframe payload for . Each entry is a +/// dictionary of CSS property → string value, e.g. { "opacity", "0" }, { "transform", "translateX(0px)" }. +/// +public class AnimationKeyframes : List> { } diff --git a/src/Butil/Bit.Butil/Publics/Animation/AnimationOptions.cs b/src/Butil/Bit.Butil/Publics/Animation/AnimationOptions.cs new file mode 100644 index 0000000000..b21c578c99 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Animation/AnimationOptions.cs @@ -0,0 +1,32 @@ +namespace Bit.Butil; + +/// +/// Subset of KeyframeEffectOptions +/// that we forward across interop. +/// +public class AnimationOptions +{ + /// Total duration in milliseconds. + public double Duration { get; set; } = 1000; + + /// Delay before playback starts, in milliseconds. + public double Delay { get; set; } + + /// Delay after playback completes, in milliseconds. + public double EndDelay { get; set; } + + /// Number of iterations. Use to loop forever. + public double Iterations { get; set; } = 1; + + /// Easing — e.g. "linear", "ease-in", "cubic-bezier(0,0,0.2,1)". + public string Easing { get; set; } = "linear"; + + /// One of "normal", "reverse", "alternate", "alternate-reverse". + public string Direction { get; set; } = "normal"; + + /// One of "none", "forwards", "backwards", "both", "auto". + public string Fill { get; set; } = "none"; + + /// Composite operation: "replace", "add", "accumulate". + public string Composite { get; set; } = "replace"; +} diff --git a/src/Butil/Bit.Butil/Publics/Animation/ElementReferenceAnimationExtensions.cs b/src/Butil/Bit.Butil/Publics/Animation/ElementReferenceAnimationExtensions.cs new file mode 100644 index 0000000000..9e6dd22561 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Animation/ElementReferenceAnimationExtensions.cs @@ -0,0 +1,32 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Extension methods that wrap the Element.animate() +/// method. +/// +public static class ElementReferenceAnimationExtensions +{ + /// + /// Starts a Web Animation on the element. Returns an for play / + /// pause / cancel / finish; dispose to cancel. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AnimationOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AnimationKeyframes))] + public static async Task Animate( + this ElementReference element, + IJSRuntime js, + AnimationKeyframes keyframes, + AnimationOptions? options = null) + { + options ??= new AnimationOptions(); + var id = Guid.NewGuid(); + await js.InvokeVoid("BitButil.animation.animate", id, element, keyframes, options); + return new AnimationHandle(js, id); + } +} diff --git a/src/Butil/Bit.Butil/Publics/BackgroundSync.cs b/src/Butil/Bit.Butil/Publics/BackgroundSync.cs new file mode 100644 index 0000000000..d52202378b --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/BackgroundSync.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Background Sync API +/// (SyncManager) plus the related +/// Periodic Background Sync. +/// +/// +/// Both APIs require an active service worker registration. The actual work runs inside the +/// service worker; from C# you can register/unregister tags and inspect the registered ones. +/// +public class BackgroundSync(IJSRuntime js) +{ + /// True when the runtime exposes ServiceWorkerRegistration.sync. + public ValueTask IsSupported() => js.Invoke("BitButil.backgroundSync.isSupported"); + + /// True when ServiceWorkerRegistration.periodicSync is available. + public ValueTask IsPeriodicSupported() => js.Invoke("BitButil.backgroundSync.isPeriodicSupported"); + + /// + /// Registers a one-shot sync. The service worker's sync event fires once the device is online. + /// + public ValueTask Register(string tag) => js.Invoke("BitButil.backgroundSync.register", tag); + + /// Lists tags currently registered for one-shot sync. + public ValueTask GetTags() => js.Invoke("BitButil.backgroundSync.getTags"); + + /// + /// Registers a periodic sync. Requires the periodic-background-sync permission. + /// + /// Minimum interval between fires, in milliseconds. The browser may extend it. + public ValueTask RegisterPeriodic(string tag, long minInterval) + => js.Invoke("BitButil.backgroundSync.registerPeriodic", tag, minInterval); + + /// Lists tags currently registered for periodic sync. + public ValueTask GetPeriodicTags() => js.Invoke("BitButil.backgroundSync.getPeriodicTags"); + + /// Removes a periodic sync registration. Returns true when a matching tag was unregistered. + public ValueTask UnregisterPeriodic(string tag) => js.Invoke("BitButil.backgroundSync.unregisterPeriodic", tag); +} diff --git a/src/Butil/Bit.Butil/Publics/Battery.cs b/src/Butil/Bit.Butil/Publics/Battery.cs new file mode 100644 index 0000000000..87df54d609 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Battery.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Battery Status API. +/// +/// +/// Browser support is uneven (Firefox/Safari intentionally don't expose this). When unsupported, +/// returns false and reports a charged-AC-power +/// stub so callers don't have to special-case missing data. +/// +public class Battery(IJSRuntime js) +{ + /// True when the runtime exposes navigator.getBattery. + public ValueTask IsSupported() => js.Invoke("BitButil.battery.isSupported"); + + /// One-shot snapshot of the battery state. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BatteryStatus))] + public ValueTask GetStatus() => js.Invoke("BitButil.battery.getStatus"); +} diff --git a/src/Butil/Bit.Butil/Publics/Battery/BatteryStatus.cs b/src/Butil/Bit.Butil/Publics/Battery/BatteryStatus.cs new file mode 100644 index 0000000000..a2767ed7d2 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Battery/BatteryStatus.cs @@ -0,0 +1,19 @@ +namespace Bit.Butil; + +/// +/// Snapshot of BatteryManager. +/// +public class BatteryStatus +{ + /// True if the device is currently charging. + public bool Charging { get; set; } + + /// Seconds remaining until fully charged. when unknown. + public double ChargingTime { get; set; } + + /// Seconds remaining until discharged. when unknown. + public double DischargingTime { get; set; } + + /// Battery level, in [0, 1]. + public double Level { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/BroadcastChannel.cs b/src/Butil/Bit.Butil/Publics/BroadcastChannel.cs new file mode 100644 index 0000000000..62721abde3 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/BroadcastChannel.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.JSInterop; +using static Bit.Butil.LinkerFlags; + +namespace Bit.Butil; + +/// +/// Wraps the BroadcastChannel +/// API for cross-tab pub/sub on the same origin. +/// +/// +/// Each instance can host any number of named channels — a +/// new JS-side channel object is created on first per name and torn +/// down only when every subscription on that name has been disposed. +/// +public class BroadcastChannel(IJSRuntime js) : IAsyncDisposable +{ + private readonly ConcurrentDictionary _subscriptions = new(); + + /// True when the runtime exposes BroadcastChannel. + public ValueTask IsSupported() => js.Invoke("BitButil.broadcastChannel.isSupported"); + + /// + /// Sends to every other listener on + /// in the same origin (the sender does not receive its own message — that's the spec). + /// + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON serialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public ValueTask Post<[DynamicallyAccessedMembers(JsonSerialized)] T>(string channelName, T message) + => js.InvokeVoid("BitButil.broadcastChannel.post", channelName, message); + + /// + /// Subscribes to . The handler receives every message as a + /// so callers can deserialize into whatever shape they expect. + /// Use the returned to detach. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BroadcastChannelListenersManager))] + public async Task Subscribe(string channelName, + Action? onMessage, + Action? onError = null) + { + if (onMessage is null && onError is null) + throw new ArgumentException("At least one of onMessage or onError must be provided."); + + var id = BroadcastChannelListenersManager.AddListener(onMessage, onError); + _subscriptions.TryAdd(id, channelName); + + await js.InvokeVoid("BitButil.broadcastChannel.subscribe", + BroadcastChannelListenersManager.MessageMethodName, + BroadcastChannelListenersManager.ErrorMethodName, + id, + channelName); + + return new ButilSubscription(id, async () => + { + BroadcastChannelListenersManager.RemoveListener(id); + _subscriptions.TryRemove(id, out _); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.broadcastChannel.unsubscribe", id); + }); + } + + public async ValueTask DisposeAsync() + { + try + { + if (_subscriptions.IsEmpty is false) + { + var ids = _subscriptions.Keys.ToArray(); + _subscriptions.Clear(); + foreach (var id in ids) + { + BroadcastChannelListenersManager.RemoveListener(id); + if (OperatingSystem.IsBrowser()) + { + await js.InvokeVoid("BitButil.broadcastChannel.unsubscribe", id); + } + } + } + } + catch (JSDisconnectedException) { } + GC.SuppressFinalize(this); + } +} diff --git a/src/Butil/Bit.Butil/Publics/CacheStorage.cs b/src/Butil/Bit.Butil/Publics/CacheStorage.cs new file mode 100644 index 0000000000..3b8bd55a44 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/CacheStorage.cs @@ -0,0 +1,69 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the CacheStorage +/// and Cache APIs. +/// +/// +/// All operations target a named cache. The browser persists caches per origin and they +/// outlive the page, so this service is intentionally side-effect-only — no instance state. +/// +public class CacheStorage(IJSRuntime js) +{ + /// True when the runtime exposes caches. + public ValueTask IsSupported() => js.Invoke("BitButil.cacheStorage.isSupported"); + + /// Lists every cache name visible to the current origin. + public ValueTask Keys() => js.Invoke("BitButil.cacheStorage.keys"); + + /// Returns true when a cache with the given name exists. + public ValueTask Has(string cacheName) => js.Invoke("BitButil.cacheStorage.has", cacheName); + + /// Deletes the named cache. Returns true when something was removed. + public ValueTask Delete(string cacheName) => js.Invoke("BitButil.cacheStorage.delete", cacheName); + + /// + /// Adds to the cache by issuing a fetch and storing the response. + /// + public ValueTask Add(string cacheName, string url) => js.InvokeVoid("BitButil.cacheStorage.add", cacheName, url); + + /// Adds many URLs to the cache atomically. + public ValueTask AddAll(string cacheName, params string[] urls) + => js.InvokeVoid("BitButil.cacheStorage.addAll", cacheName, urls); + + /// + /// Stores a response built from raw bytes against . Use this when the + /// payload is generated client-side and you don't want to fetch it. + /// + public ValueTask PutBytes(string cacheName, string url, byte[] data, + string contentType = "application/octet-stream", + int status = 200, + string statusText = "OK") + => js.InvokeVoid("BitButil.cacheStorage.putBytes", cacheName, url, data, contentType, status, statusText); + + /// Stores a UTF-8 text response against . + public ValueTask PutText(string cacheName, string url, string text, + string contentType = "text/plain;charset=utf-8", + int status = 200, + string statusText = "OK") + => js.InvokeVoid("BitButil.cacheStorage.putText", cacheName, url, text, contentType, status, statusText); + + /// + /// Looks up a cached response. is false when nothing matched. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CachedResponse))] + public ValueTask Match(string cacheName, string url) + => js.Invoke("BitButil.cacheStorage.match", cacheName, url); + + /// Removes a single entry. Returns true when something was removed. + public ValueTask DeleteEntry(string cacheName, string url) + => js.Invoke("BitButil.cacheStorage.deleteEntry", cacheName, url); + + /// Lists the URLs currently stored in the named cache. + public ValueTask EntryKeys(string cacheName) + => js.Invoke("BitButil.cacheStorage.entryKeys", cacheName); +} diff --git a/src/Butil/Bit.Butil/Publics/CacheStorage/CachedResponse.cs b/src/Butil/Bit.Butil/Publics/CacheStorage/CachedResponse.cs new file mode 100644 index 0000000000..077feea58b --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/CacheStorage/CachedResponse.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Bit.Butil; + +/// +/// Snapshot of a cached Response retrieved from . +/// +public class CachedResponse +{ + /// True when a response was found. + public bool Found { get; set; } + + public int Status { get; set; } + public string StatusText { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public Dictionary Headers { get; set; } = new(); + + /// Body bytes. Empty for 204/304 or when the cache stored an opaque response. + public byte[] Body { get; set; } = []; +} diff --git a/src/Butil/Bit.Butil/Publics/ContactPicker.cs b/src/Butil/Bit.Butil/Publics/ContactPicker.cs new file mode 100644 index 0000000000..3808cf4928 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/ContactPicker.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Contact Picker API +/// (navigator.contacts). +/// +/// +/// Available on Chromium-based mobile browsers only. Users always see a native picker +/// — there's no programmatic access to a user's contacts. +/// +public class ContactPicker(IJSRuntime js) +{ + /// True when the runtime exposes navigator.contacts. + public ValueTask IsSupported() => js.Invoke("BitButil.contactPicker.isSupported"); + + /// + /// Returns the list of properties the platform can expose. Common values: "name", + /// "email", "tel", "address", "icon". + /// + public ValueTask GetProperties() => js.Invoke("BitButil.contactPicker.getProperties"); + + /// + /// Opens the contact picker and returns the user's selection. Must be invoked from a + /// user-gesture handler. + /// + /// Subset of . Defaults to name/email/tel. + /// When true, the user can pick more than one contact. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ContactInfo))] + public ValueTask Select(string[]? properties = null, bool multiple = false) + => js.Invoke("BitButil.contactPicker.select", + properties ?? new[] { "name", "email", "tel" }, multiple); +} diff --git a/src/Butil/Bit.Butil/Publics/Contacts/ContactInfo.cs b/src/Butil/Bit.Butil/Publics/Contacts/ContactInfo.cs new file mode 100644 index 0000000000..a6d7f89bbc --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Contacts/ContactInfo.cs @@ -0,0 +1,18 @@ +namespace Bit.Butil; + +/// +/// One result from . All collections are arrays so the +/// shape is friendly to common UI consumption. +/// +public class ContactInfo +{ + public string[] Name { get; set; } = []; + public string[] Email { get; set; } = []; + public string[] Tel { get; set; } = []; + + /// Postal addresses serialized as plain strings. + public string[] Address { get; set; } = []; + + /// Avatar URLs (object-URL form), if exposed by the platform. + public string[] Icon { get; set; } = []; +} diff --git a/src/Butil/Bit.Butil/Publics/Cookie.cs b/src/Butil/Bit.Butil/Publics/Cookie.cs index 09551d7948..f9b2d103b4 100644 --- a/src/Butil/Bit.Butil/Publics/Cookie.cs +++ b/src/Butil/Bit.Butil/Publics/Cookie.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -17,8 +18,15 @@ public class Cookie(IJSRuntime js) /// public async Task GetAll() { - var cookie = await js.Invoke("BitButil.cookie.get"); - return cookie.Split(';').Select(ButilCookie.Parse).ToArray(); + var raw = await js.Invoke("BitButil.cookie.get"); + + if (string.IsNullOrWhiteSpace(raw)) return []; + + return raw.Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(ButilCookie.Parse) + .Where(c => c is not null) + .Select(c => c!) + .ToArray(); } /// diff --git a/src/Butil/Bit.Butil/Publics/Cookie/ButilCookie.cs b/src/Butil/Bit.Butil/Publics/Cookie/ButilCookie.cs index e7ae67a656..c5c514d35b 100644 --- a/src/Butil/Bit.Butil/Publics/Cookie/ButilCookie.cs +++ b/src/Butil/Bit.Butil/Publics/Cookie/ButilCookie.cs @@ -1,4 +1,6 @@ using System; +using System.Globalization; +using System.Net; using System.Text; namespace Bit.Butil; @@ -17,25 +19,34 @@ public class ButilCookie public override string ToString() { - if (Name is null) return string.Empty; + if (string.IsNullOrEmpty(Name)) return string.Empty; var sb = new StringBuilder(); - sb.Append($"{Name}={Value}"); + // Per RFC 6265, name and value must be encoded so that reserved characters + // (=, ;, ,, whitespace, non-ASCII) don't break the cookie. + sb.Append(WebUtility.UrlEncode(Name)); + sb.Append('='); + if (Value is not null) + { + sb.Append(WebUtility.UrlEncode(Value)); + } if (Domain is not null) { - sb.Append($";domain={Domain}"); + sb.Append(";domain=").Append(Domain); } if (Expires is not null) { - sb.Append($";expires={Expires.Value.UtcDateTime.ToString("ddd, MMM dd yyyy HH:mm:ss \"GMT\"")}"); + // RFC 1123 / RFC 7231 IMF-fixdate: e.g. "Wed, 21 Oct 2015 07:28:00 GMT". + sb.Append(";expires=") + .Append(Expires.Value.UtcDateTime.ToString("R", CultureInfo.InvariantCulture)); } if (MaxAge is not null) { - sb.Append($";max-age={MaxAge}"); + sb.Append(";max-age=").Append(MaxAge.Value.ToString(CultureInfo.InvariantCulture)); } if (Partitioned) @@ -45,12 +56,12 @@ public override string ToString() if (Path is not null) { - sb.Append($";path={Path}"); + sb.Append(";path=").Append(Path); } if (SameSite is not null) { - sb.Append($";samesite={SameSite.ToString()!.ToLowerInvariant()}"); + sb.Append(";samesite=").Append(SameSite.ToString()!.ToLowerInvariant()); } if (Secure) @@ -61,15 +72,23 @@ public override string ToString() return sb.ToString(); } - public static ButilCookie Parse(string rawCookie) + public static ButilCookie? Parse(string rawCookie) { - var cookie = new ButilCookie(); - if (rawCookie.Contains('=')) + if (string.IsNullOrWhiteSpace(rawCookie)) return null; + + var trimmed = rawCookie.Trim(); + var eqIndex = trimmed.IndexOf('='); + + // A cookie with no '=' or with an empty name is not valid; skip it. + if (eqIndex <= 0) return null; + + var name = trimmed.Substring(0, eqIndex).Trim(); + var value = trimmed.Substring(eqIndex + 1).Trim(); + + return new ButilCookie { - var split = rawCookie.Split('='); - cookie.Name = split[0].Trim(); - cookie.Value = split[1].Trim(); - } - return cookie; + Name = WebUtility.UrlDecode(name), + Value = WebUtility.UrlDecode(value), + }; } } diff --git a/src/Butil/Bit.Butil/Publics/Cookie/CookieStoreItem.cs b/src/Butil/Bit.Butil/Publics/Cookie/CookieStoreItem.cs new file mode 100644 index 0000000000..44999c472b --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Cookie/CookieStoreItem.cs @@ -0,0 +1,25 @@ +using System; + +namespace Bit.Butil; + +/// +/// Cookie returned by CookieStore. +/// Unlike , this carries all attributes the browser knows. +/// +public class CookieStoreItem +{ + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string? Domain { get; set; } + public string? Path { get; set; } + + /// Expiration time. Null for session cookies. + public DateTimeOffset? Expires { get; set; } + + public bool Secure { get; set; } + + /// One of "strict", "lax", "none", or null. + public string? SameSite { get; set; } + + public bool? Partitioned { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/CookieStore.cs b/src/Butil/Bit.Butil/Publics/CookieStore.cs new file mode 100644 index 0000000000..000e3db442 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/CookieStore.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the modern async CookieStore API. +/// +/// +/// The legacy service still works on every browser, but it can only see Name/Value +/// because document.cookie doesn't expose other attributes. Use this service when you need the full +/// metadata (Domain/Path/Expires/SameSite). Browser support is Chromium-only at the time of writing. +/// +public class CookieStore(IJSRuntime js) +{ + /// True when the runtime exposes cookieStore. + public ValueTask IsSupported() => js.Invoke("BitButil.cookieStore.isSupported"); + + /// Returns every cookie visible to the current document. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CookieStoreItem))] + public ValueTask GetAll() => js.Invoke("BitButil.cookieStore.getAll"); + + /// Returns the cookie with the given name, or null when absent. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CookieStoreItem))] + public ValueTask Get(string name) => js.Invoke("BitButil.cookieStore.get", name); + + /// Sets a cookie. Use to remove one (don't pass MaxAge=0 — that's the legacy trick). + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CookieStoreItem))] + public ValueTask Set(CookieStoreItem cookie) => js.InvokeVoid("BitButil.cookieStore.set", cookie); + + /// Deletes the named cookie. + public ValueTask Delete(string name) => js.InvokeVoid("BitButil.cookieStore.delete", name); +} diff --git a/src/Butil/Bit.Butil/Publics/Crypto.cs b/src/Butil/Bit.Butil/Publics/Crypto.cs index e28b53fe50..1522dc9f2c 100644 --- a/src/Butil/Bit.Butil/Publics/Crypto.cs +++ b/src/Butil/Bit.Butil/Publics/Crypto.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -12,6 +13,149 @@ namespace Bit.Butil; /// public class Crypto(IJSRuntime js) { + /// + /// Returns a cryptographically strong random Guid (v4 UUID). + ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID + ///
+ public async ValueTask RandomUuid() + { + var raw = await js.Invoke("BitButil.crypto.randomUUID"); + return Guid.Parse(raw); + } + + /// + /// Fills bytes with cryptographically strong random values. + ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues + ///
+ /// When is negative or above the + /// browser's per-call limit (65 536). + public ValueTask GetRandomValues(int length) + { + if (length < 0) + throw new ArgumentOutOfRangeException(nameof(length), "length must be non-negative."); + if (length > 65536) + throw new ArgumentOutOfRangeException(nameof(length), "Web Crypto rejects requests larger than 65 536 bytes."); + + return js.Invoke("BitButil.crypto.getRandomValues", length); + } + + /// + /// Computes a digest of using the requested algorithm. + ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest + ///
+ public ValueTask Digest(CryptoKeyHash algorithm, byte[] data) + { + var algo = algorithm switch + { + CryptoKeyHash.Sha384 => "SHA-384", + CryptoKeyHash.Sha512 => "SHA-512", + _ => "SHA-256", + }; + return js.Invoke("BitButil.crypto.digest", algo, data); + } + + /// + /// Produces an HMAC tag for using the given symmetric key. + ///
+ /// SubtleCrypto.sign() + ///
+ public ValueTask SignHmac(CryptoKeyHash algorithm, byte[] key, byte[] data) + { + var algo = HashAlgorithmName(algorithm); + return js.Invoke("BitButil.crypto.signHmac", algo, key, data); + } + + /// + /// Verifies an HMAC tag previously produced by (or any compatible producer). + ///
+ /// SubtleCrypto.verify() + ///
+ public ValueTask VerifyHmac(CryptoKeyHash algorithm, byte[] key, byte[] signature, byte[] data) + { + var algo = HashAlgorithmName(algorithm); + return js.Invoke("BitButil.crypto.verifyHmac", algo, key, signature, data); + } + + private static string HashAlgorithmName(CryptoKeyHash algorithm) => algorithm switch + { + CryptoKeyHash.Sha384 => "SHA-384", + CryptoKeyHash.Sha512 => "SHA-512", + _ => "SHA-256", + }; + + // ─── Key generation / import / export ────────────────────────────────────── + + /// + /// Generates a fresh AES key as raw bytes. + /// + /// Key length in bits — 128, 192, or 256. + public ValueTask GenerateAesKey(int bits = 256) + => js.Invoke("BitButil.crypto.generateAesKey", bits); + + /// + /// Generates an HMAC key of the requested length and hash. + /// + public ValueTask GenerateHmacKey(CryptoKeyHash algorithm = CryptoKeyHash.Sha256, int? lengthBits = null) + => js.Invoke("BitButil.crypto.generateHmacKey", HashAlgorithmName(algorithm), lengthBits); + + /// + /// Generates an RSA key pair (RSA-OAEP). Returns spki/pkcs8 DER bytes for public/private. + /// + public ValueTask GenerateRsaKeyPair(int modulusLengthBits = 2048, + CryptoKeyHash algorithm = CryptoKeyHash.Sha256) + => js.Invoke("BitButil.crypto.generateRsaKeyPair", modulusLengthBits, HashAlgorithmName(algorithm)); + + /// + /// Generates an ECDSA key pair on the named curve. + /// + /// One of "P-256", "P-384", "P-521". + public ValueTask GenerateEcdsaKeyPair(string curve = "P-256") + => js.Invoke("BitButil.crypto.generateEcdsaKeyPair", curve); + + // ─── Derivation ──────────────────────────────────────────────────────────── + + /// + /// Derives raw bytes from a password using PBKDF2. + /// + public ValueTask DerivePbkdf2(byte[] password, byte[] salt, int iterations, + int outputLengthBits, CryptoKeyHash algorithm = CryptoKeyHash.Sha256) + => js.Invoke("BitButil.crypto.derivePbkdf2", password, salt, iterations, outputLengthBits, HashAlgorithmName(algorithm)); + + // ─── RSA-PSS sign / verify ───────────────────────────────────────────────── + + /// + /// Produces an RSA-PSS signature using a PKCS8 private key. + /// + public ValueTask SignRsaPss(byte[] privateKey, byte[] data, int saltLength = 32, + CryptoKeyHash algorithm = CryptoKeyHash.Sha256) + => js.Invoke("BitButil.crypto.signRsaPss", privateKey, data, saltLength, HashAlgorithmName(algorithm)); + + /// + /// Verifies an RSA-PSS signature using an SPKI public key. + /// + public ValueTask VerifyRsaPss(byte[] publicKey, byte[] signature, byte[] data, int saltLength = 32, + CryptoKeyHash algorithm = CryptoKeyHash.Sha256) + => js.Invoke("BitButil.crypto.verifyRsaPss", publicKey, signature, data, saltLength, HashAlgorithmName(algorithm)); + + // ─── ECDSA sign / verify ─────────────────────────────────────────────────── + + /// + /// Produces an ECDSA signature using a PKCS8 private key. + /// + public ValueTask SignEcdsa(byte[] privateKey, byte[] data, string curve = "P-256", + CryptoKeyHash algorithm = CryptoKeyHash.Sha256) + => js.Invoke("BitButil.crypto.signEcdsa", privateKey, data, curve, HashAlgorithmName(algorithm)); + + /// + /// Verifies an ECDSA signature using an SPKI public key. + /// + public ValueTask VerifyEcdsa(byte[] publicKey, byte[] signature, byte[] data, string curve = "P-256", + CryptoKeyHash algorithm = CryptoKeyHash.Sha256) + => js.Invoke("BitButil.crypto.verifyEcdsa", publicKey, signature, data, curve, HashAlgorithmName(algorithm)); + /// /// The Encrypt method of the Crypto interface that encrypts data. ///
diff --git a/src/Butil/Bit.Butil/Publics/Crypto/EcKeyPair.cs b/src/Butil/Bit.Butil/Publics/Crypto/EcKeyPair.cs new file mode 100644 index 0000000000..2c5bcc8e00 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Crypto/EcKeyPair.cs @@ -0,0 +1,14 @@ +namespace Bit.Butil; + +/// Elliptic curve key pair returned by . +public class EcKeyPair +{ + /// Public key in SubjectPublicKeyInfo (SPKI) DER format. + public byte[] PublicKey { get; set; } = []; + + /// Private key in PKCS8 DER format. + public byte[] PrivateKey { get; set; } = []; + + /// The named curve (P-256, P-384, P-521). + public string Curve { get; set; } = "P-256"; +} diff --git a/src/Butil/Bit.Butil/Publics/Crypto/RsaKeyPair.cs b/src/Butil/Bit.Butil/Publics/Crypto/RsaKeyPair.cs new file mode 100644 index 0000000000..4dab0973e7 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Crypto/RsaKeyPair.cs @@ -0,0 +1,11 @@ +namespace Bit.Butil; + +/// RSA key pair returned by . +public class RsaKeyPair +{ + /// Public key in SubjectPublicKeyInfo (SPKI) DER format. + public byte[] PublicKey { get; set; } = []; + + /// Private key in PKCS8 DER format. + public byte[] PrivateKey { get; set; } = []; +} diff --git a/src/Butil/Bit.Butil/Publics/Document.cs b/src/Butil/Bit.Butil/Publics/Document.cs index 17a4a52321..dd2c00303e 100644 --- a/src/Butil/Bit.Butil/Publics/Document.cs +++ b/src/Butil/Bit.Butil/Publics/Document.cs @@ -1,23 +1,57 @@ using System; +using System.Collections.Concurrent; +using System.Linq; using System.Threading.Tasks; using Microsoft.JSInterop; namespace Bit.Butil; -public class Document(IJSRuntime js) +public class Document(IJSRuntime js) : IAsyncDisposable { private const string ElementName = "document"; + // Track listener ids registered through this *instance* so dispose actually drains them. + private readonly ConcurrentDictionary<(Guid Id, string Event, bool UseCapture), byte> _listenerIds = new(); + public async Task AddEventListener( string domEvent, Action listener, bool useCapture = false, bool preventDefault = false, bool stopPropagation = false) - => await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation); + { + var id = await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation); + _listenerIds.TryAdd((id, domEvent, useCapture), 0); + } public async Task RemoveEventListener(string domEvent, Action listener, bool useCapture = false) - => await DomEventDispatcher.RemoveEventListener(js, ElementName, domEvent, listener, useCapture); + { + var ids = await DomEventDispatcher.RemoveEventListener(js, ElementName, domEvent, listener, useCapture); + foreach (var id in ids) _listenerIds.TryRemove((id, domEvent, useCapture), out _); + } + + /// + /// Subscribe variant of returning an handle. + /// Pair with await using to guarantee detachment. + /// + public async Task SubscribeEvent( + string domEvent, + Action listener, + bool useCapture = false, + bool preventDefault = false, + bool stopPropagation = false) + { + var id = await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation); + var key = (id, domEvent, useCapture); + _listenerIds.TryAdd(key, 0); + + return new ButilSubscription(id, async () => + { + _listenerIds.TryRemove(key, out _); + if (OperatingSystem.IsBrowser() is false) return; + await DomEventDispatcher.RemoveEventListenerById(js, ElementName, domEvent, id, useCapture); + }); + } /// /// Returns the character set being used by the document. @@ -148,4 +182,158 @@ public async Task ExitFullscreen() /// public async Task ExitPointerLock() => await js.InvokeVoid("BitButil.document.exitPointerLock"); + + /// + /// Indicates whether the document is currently visible. + ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilityState + ///
+ public async Task GetVisibilityState() + { + var raw = await js.Invoke("BitButil.document.visibilityState"); + return raw == "hidden" ? VisibilityState.Hidden : VisibilityState.Visible; + } + + /// + /// True when the document is currently hidden. + ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Document/hidden + ///
+ public async Task IsHidden() + => await js.Invoke("BitButil.document.hidden"); + + /// + /// True when the document or any element inside it has focus. + ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus + ///
+ public async Task HasFocus() + => await js.Invoke("BitButil.document.hasFocus"); + + /// + /// True when the page is restored from a discarded state by the browser + /// (e.g. tab was reclaimed under memory pressure and is now being reactivated). + ///
+ /// Document.wasDiscarded + ///
+ public ValueTask WasDiscarded() => js.Invoke("BitButil.document.wasDiscarded"); + + // ─── Convenience subscription helpers built on SubscribeEvent ─────────────── + + /// + /// Fires when flips. The handler receives the new state. + ///
+ /// visibilitychange + ///
+ public async Task SubscribeVisibilityChange(Action handler) + { + Action bridge = _ => + { + // We don't get the state on the event itself — fetch it on the fly. + // It's cheap (sync property) so the extra interop is acceptable. + _ = ReportVisibilityAsync(handler); + }; + return await SubscribeEvent(ButilEvents.VisibilityChange, bridge); + } + + private async Task ReportVisibilityAsync(Action handler) + { + try { handler(await GetVisibilityState()); } + catch (JSDisconnectedException) { } + } + + /// + /// Fires when an element enters or leaves fullscreen. The handler receives true when + /// the document currently has a fullscreen element. + ///
+ /// fullscreenchange + ///
+ public async Task SubscribeFullscreenChange(Action handler) + { + Action bridge = _ => _ = ReportFullscreenAsync(handler); + return await SubscribeEvent(ButilEvents.FullscreenChange, bridge); + } + + private async Task ReportFullscreenAsync(Action handler) + { + try + { + var hasFs = await js.Invoke("BitButil.document.hasFullscreenElement"); + handler(hasFs); + } + catch (JSDisconnectedException) { } + } + + /// + /// Fires when entering fullscreen fails. The handler receives no payload — the spec + /// doesn't expose a structured reason. + /// + public Task SubscribeFullscreenError(Action handler) + { + Action bridge = _ => handler(); + return SubscribeEvent(ButilEvents.FullscreenError, bridge); + } + + /// + /// Fires when pointer lock is entered or exited. The handler receives true when an + /// element currently has pointer lock. + /// + public async Task SubscribePointerLockChange(Action handler) + { + Action bridge = _ => _ = ReportPointerLockAsync(handler); + return await SubscribeEvent(ButilEvents.PointerLockChange, bridge); + } + + private async Task ReportPointerLockAsync(Action handler) + { + try + { + var hasLock = await js.Invoke("BitButil.document.hasPointerLockElement"); + handler(hasLock); + } + catch (JSDisconnectedException) { } + } + + /// Fires when entering pointer lock fails. + public Task SubscribePointerLockError(Action handler) + { + Action bridge = _ => handler(); + return SubscribeEvent(ButilEvents.PointerLockError, bridge); + } + + /// + /// Fires when the DOMContentLoaded event has just been raised. Useful when bootstrapping + /// post-render work after circuit reconnect. + /// + public Task SubscribeDomContentLoaded(Action handler) + { + Action bridge = _ => handler(); + return SubscribeEvent(ButilEvents.DomContentLoaded, bridge); + } + + public async ValueTask DisposeAsync() + { + await DisposeAsync(true); + GC.SuppressFinalize(this); + } + + protected virtual async ValueTask DisposeAsync(bool disposing) + { + if (disposing is false) return; + if (_listenerIds.IsEmpty) return; + + var snapshot = _listenerIds.Keys.ToArray(); + _listenerIds.Clear(); + + if (OperatingSystem.IsBrowser() is false) return; + + try + { + foreach (var (id, evt, useCapture) in snapshot) + { + await DomEventDispatcher.RemoveEventListenerById(js, ElementName, evt, id, useCapture); + } + } + catch (JSDisconnectedException) { } // we can ignore this exception here + } } diff --git a/src/Butil/Bit.Butil/Publics/Document/VisibilityState.cs b/src/Butil/Bit.Butil/Publics/Document/VisibilityState.cs new file mode 100644 index 0000000000..d41b15df03 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Document/VisibilityState.cs @@ -0,0 +1,19 @@ +namespace Bit.Butil; + +/// +/// Mirrors Document.visibilityState. +/// +public enum VisibilityState +{ + /// + /// The page content may be at least partially visible. In practice this means + /// the tab is the foreground tab of a non-minimized window. + /// + Visible, + + /// + /// The page content is not visible to the user — the tab is in the background or + /// the window is minimized, or the OS screen lock is active. + /// + Hidden +} diff --git a/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs b/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs new file mode 100644 index 0000000000..1d052a4711 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs @@ -0,0 +1,135 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Element-scoped DOM event subscriptions. Returns an handle +/// () so callers can await using for the lifetime of a +/// component without hand-rolling Add/Remove pairs. +/// +/// +/// Internally this routes through the same per-element JS plumbing used by Document and Window, +/// so all the typed event-arg classes (, , etc.) +/// are available with no extra wiring. +/// +public static class ElementReferenceEventExtensions +{ + /// + /// Subscribes to a DOM event on the given element. The returned handle detaches the listener on dispose. + /// + public static async Task SubscribeEvent( + this ElementReference element, + IJSRuntime js, + string domEvent, + Action listener, + bool useCapture = false, + bool preventDefault = false, + bool stopPropagation = false) + { + var argType = typeof(T); + var eventType = DomEventArgs.TypeOf(domEvent); + if (argType != eventType) + throw new InvalidOperationException($"Invalid listener type ({argType}) for this dom event type ({eventType})"); + + // Each element gets a generated id so the JS side can target it directly. + var elementId = Guid.NewGuid().ToString("N"); + var members = ResolveMembers(argType); + var methodName = ResolveMethodName(argType); + var listenerId = RegisterListener(argType, listener, elementId, useCapture); + + var options = useCapture; + + await js.InvokeVoid("BitButil.element.subscribeEvent", + element, + elementId, + domEvent, + methodName, + listenerId, + members, + options, + preventDefault, + stopPropagation); + + return new ButilSubscription(listenerId, async () => + { + UnregisterListener(argType, listenerId); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.element.unsubscribeEvent", elementId, domEvent, listenerId, options); + }); + } + + private static string[] ResolveMembers(Type argType) + { + if (argType == typeof(ButilKeyboardEventArgs)) return ButilKeyboardEventArgs.EventArgsMembers; + if (argType == typeof(ButilMouseEventArgs)) return ButilMouseEventArgs.EventArgsMembers; + if (argType == typeof(ButilPointerEventArgs)) return ButilPointerEventArgs.EventArgsMembers; + if (argType == typeof(ButilWheelEventArgs)) return ButilWheelEventArgs.EventArgsMembers; + if (argType == typeof(ButilTouchEventArgs)) return ButilTouchEventArgs.EventArgsMembers; + if (argType == typeof(ButilFocusEventArgs)) return ButilFocusEventArgs.EventArgsMembers; + if (argType == typeof(ButilInputEventArgs)) return ButilInputEventArgs.EventArgsMembers; + if (argType == typeof(ButilDragEventArgs)) return ButilDragEventArgs.EventArgsMembers; + if (argType == typeof(ButilClipboardEventArgs)) return ButilClipboardEventArgs.EventArgsMembers; + if (argType == typeof(ButilCompositionEventArgs)) return ButilCompositionEventArgs.EventArgsMembers; + return []; + } + + private static string ResolveMethodName(Type argType) + { + if (argType == typeof(ButilKeyboardEventArgs)) return DomKeyboardEventListenersManager.InvokeMethodName; + if (argType == typeof(ButilMouseEventArgs)) return DomMouseEventListenersManager.InvokeMethodName; + if (argType == typeof(ButilPointerEventArgs)) return DomPointerEventListenersManager.InvokeMethodName; + if (argType == typeof(ButilWheelEventArgs)) return DomWheelEventListenersManager.InvokeMethodName; + if (argType == typeof(ButilTouchEventArgs)) return DomTouchEventListenersManager.InvokeMethodName; + if (argType == typeof(ButilFocusEventArgs)) return DomFocusEventListenersManager.InvokeMethodName; + if (argType == typeof(ButilInputEventArgs)) return DomInputEventListenersManager.InvokeMethodName; + if (argType == typeof(ButilDragEventArgs)) return DomDragEventListenersManager.InvokeMethodName; + if (argType == typeof(ButilClipboardEventArgs)) return DomClipboardEventListenersManager.InvokeMethodName; + if (argType == typeof(ButilCompositionEventArgs)) return DomCompositionEventListenersManager.InvokeMethodName; + return DomEventListenersManager.InvokeMethodName; + } + + private static Guid RegisterListener(Type argType, Action listener, string elementId, bool useCapture) + { + // The existing element-scoped store key is the elementId — we reuse the same managers. + object options = useCapture; + if (argType == typeof(ButilKeyboardEventArgs)) + return DomKeyboardEventListenersManager.SetListener((listener as Action)!, elementId, options); + if (argType == typeof(ButilMouseEventArgs)) + return DomMouseEventListenersManager.SetListener((listener as Action)!, elementId, options); + if (argType == typeof(ButilPointerEventArgs)) + return DomPointerEventListenersManager.SetListener((listener as Action)!, elementId, options); + if (argType == typeof(ButilWheelEventArgs)) + return DomWheelEventListenersManager.SetListener((listener as Action)!, elementId, options); + if (argType == typeof(ButilTouchEventArgs)) + return DomTouchEventListenersManager.SetListener((listener as Action)!, elementId, options); + if (argType == typeof(ButilFocusEventArgs)) + return DomFocusEventListenersManager.SetListener((listener as Action)!, elementId, options); + if (argType == typeof(ButilInputEventArgs)) + return DomInputEventListenersManager.SetListener((listener as Action)!, elementId, options); + if (argType == typeof(ButilDragEventArgs)) + return DomDragEventListenersManager.SetListener((listener as Action)!, elementId, options); + if (argType == typeof(ButilClipboardEventArgs)) + return DomClipboardEventListenersManager.SetListener((listener as Action)!, elementId, options); + if (argType == typeof(ButilCompositionEventArgs)) + return DomCompositionEventListenersManager.SetListener((listener as Action)!, elementId, options); + return DomEventListenersManager.SetListener((listener as Action)!, elementId, options); + } + + private static void UnregisterListener(Type argType, Guid id) + { + if (argType == typeof(ButilKeyboardEventArgs)) { DomKeyboardEventListenersManager.RemoveById(id); return; } + if (argType == typeof(ButilMouseEventArgs)) { DomMouseEventListenersManager.RemoveById(id); return; } + if (argType == typeof(ButilPointerEventArgs)) { DomPointerEventListenersManager.RemoveById(id); return; } + if (argType == typeof(ButilWheelEventArgs)) { DomWheelEventListenersManager.RemoveById(id); return; } + if (argType == typeof(ButilTouchEventArgs)) { DomTouchEventListenersManager.RemoveById(id); return; } + if (argType == typeof(ButilFocusEventArgs)) { DomFocusEventListenersManager.RemoveById(id); return; } + if (argType == typeof(ButilInputEventArgs)) { DomInputEventListenersManager.RemoveById(id); return; } + if (argType == typeof(ButilDragEventArgs)) { DomDragEventListenersManager.RemoveById(id); return; } + if (argType == typeof(ButilClipboardEventArgs)) { DomClipboardEventListenersManager.RemoveById(id); return; } + if (argType == typeof(ButilCompositionEventArgs)) { DomCompositionEventListenersManager.RemoveById(id); return; } + DomEventListenersManager.RemoveById(id); + } +} diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilClipboardEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilClipboardEventArgs.cs new file mode 100644 index 0000000000..dbd246d82a --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Events/ButilClipboardEventArgs.cs @@ -0,0 +1,20 @@ +using System; + +namespace Bit.Butil; + +/// +/// Clipboard event payload — see ClipboardEvent. +/// +public class ButilClipboardEventArgs : EventArgs +{ + // The DataTransfer object isn't directly serializable; events.ts flattens the most + // common shape for us — the plain-text payload — and leaves richer types to the + // explicit Clipboard service. + internal static readonly string[] EventArgsMembers = ["type", "clipboardText"]; + + /// "copy", "cut" or "paste". + public string Type { get; set; } = string.Empty; + + /// Plain-text contents of the clipboard event, or null when absent. + public string? ClipboardText { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilCompositionEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilCompositionEventArgs.cs new file mode 100644 index 0000000000..297bf1f06c --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Events/ButilCompositionEventArgs.cs @@ -0,0 +1,20 @@ +using System; + +namespace Bit.Butil; + +/// +/// IME composition event — see CompositionEvent. +/// +public class ButilCompositionEventArgs : EventArgs +{ + internal static readonly string[] EventArgsMembers = ["type", "data", "locale"]; + + /// "compositionstart", "compositionupdate", or "compositionend". + public string Type { get; set; } = string.Empty; + + /// The current composition string. + public string? Data { get; set; } + + /// BCP-47 language tag for the input method, when supplied. + public string? Locale { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilDragEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilDragEventArgs.cs new file mode 100644 index 0000000000..9e71402121 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Events/ButilDragEventArgs.cs @@ -0,0 +1,37 @@ +using System; + +namespace Bit.Butil; + +/// +/// Drag event payload — see DragEvent. +/// +/// +/// The DataTransfer object can't be passed straight across JS interop because it holds +/// browser-side resources; we surface its inert metadata only. To actually read the +/// dropped files use the standard InputFile component or call back into JS for +/// each file. +/// +public class ButilDragEventArgs : EventArgs +{ + internal static readonly string[] EventArgsMembers = [ + "altKey", "button", "buttons", "clientX", "clientY", "ctrlKey", "metaKey", + "offsetX", "offsetY", "pageX", "pageY", "screenX", "screenY", "shiftKey", + "x", "y"]; + + public bool AltKey { get; set; } + public int Button { get; set; } + public int Buttons { get; set; } + public double ClientX { get; set; } + public double ClientY { get; set; } + public bool CtrlKey { get; set; } + public bool MetaKey { get; set; } + public double OffsetX { get; set; } + public double OffsetY { get; set; } + public double PageX { get; set; } + public double PageY { get; set; } + public double ScreenX { get; set; } + public double ScreenY { get; set; } + public bool ShiftKey { get; set; } + public double X { get; set; } + public double Y { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilEvents.cs b/src/Butil/Bit.Butil/Publics/Events/ButilEvents.cs index 00934f58dc..3057fd58ff 100644 --- a/src/Butil/Bit.Butil/Publics/Events/ButilEvents.cs +++ b/src/Butil/Bit.Butil/Publics/Events/ButilEvents.cs @@ -2,8 +2,91 @@ public class ButilEvents { + // ─── Mouse ──────────────────────────────────────────────────────────── public const string Click = "click"; + public const string DblClick = "dblclick"; + public const string MouseDown = "mousedown"; + public const string MouseUp = "mouseup"; + public const string MouseMove = "mousemove"; + public const string MouseEnter = "mouseenter"; + public const string MouseLeave = "mouseleave"; + public const string MouseOver = "mouseover"; + public const string MouseOut = "mouseout"; + public const string ContextMenu = "contextmenu"; + + // ─── Keyboard ───────────────────────────────────────────────────────── public const string KeyDown = "keydown"; public const string KeyUp = "keyup"; public const string KeyPress = "keypress"; + + // ─── Pointer ────────────────────────────────────────────────────────── + public const string PointerDown = "pointerdown"; + public const string PointerUp = "pointerup"; + public const string PointerMove = "pointermove"; + public const string PointerEnter = "pointerenter"; + public const string PointerLeave = "pointerleave"; + public const string PointerOver = "pointerover"; + public const string PointerOut = "pointerout"; + public const string PointerCancel = "pointercancel"; + public const string GotPointerCapture = "gotpointercapture"; + public const string LostPointerCapture = "lostpointercapture"; + + // ─── Touch ──────────────────────────────────────────────────────────── + public const string TouchStart = "touchstart"; + public const string TouchEnd = "touchend"; + public const string TouchMove = "touchmove"; + public const string TouchCancel = "touchcancel"; + + // ─── Wheel / scroll ─────────────────────────────────────────────────── + public const string Wheel = "wheel"; + public const string Scroll = "scroll"; + + // ─── Focus ──────────────────────────────────────────────────────────── + public const string Focus = "focus"; + public const string FocusIn = "focusin"; + public const string Blur = "blur"; + public const string FocusOut = "focusout"; + + // ─── Input ──────────────────────────────────────────────────────────── + public const string Input = "input"; + public const string Change = "change"; + public const string Submit = "submit"; + public const string Reset = "reset"; + public const string BeforeInput = "beforeinput"; + + // ─── Drag & drop ────────────────────────────────────────────────────── + public const string DragStart = "dragstart"; + public const string Drag = "drag"; + public const string DragEnd = "dragend"; + public const string DragEnter = "dragenter"; + public const string DragLeave = "dragleave"; + public const string DragOver = "dragover"; + public const string Drop = "drop"; + + // ─── Clipboard ──────────────────────────────────────────────────────── + public const string Copy = "copy"; + public const string Cut = "cut"; + public const string Paste = "paste"; + + // ─── Composition ────────────────────────────────────────────────────── + public const string CompositionStart = "compositionstart"; + public const string CompositionUpdate = "compositionupdate"; + public const string CompositionEnd = "compositionend"; + + // ─── Window-only ────────────────────────────────────────────────────── + public const string Resize = "resize"; + public const string Online = "online"; + public const string Offline = "offline"; + public const string HashChange = "hashchange"; + public const string LanguageChange = "languagechange"; + public const string Load = "load"; + public const string Unload = "unload"; + + // ─── Document-level visibility / fullscreen ─────────────────────────── + public const string VisibilityChange = "visibilitychange"; + public const string FullscreenChange = "fullscreenchange"; + public const string FullscreenError = "fullscreenerror"; + public const string PointerLockChange = "pointerlockchange"; + public const string PointerLockError = "pointerlockerror"; + public const string DomContentLoaded = "DOMContentLoaded"; } diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilFocusEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilFocusEventArgs.cs new file mode 100644 index 0000000000..a0a572603e --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Events/ButilFocusEventArgs.cs @@ -0,0 +1,14 @@ +using System; + +namespace Bit.Butil; + +/// +/// Focus event payload — see FocusEvent. +/// +public class ButilFocusEventArgs : EventArgs +{ + internal static readonly string[] EventArgsMembers = ["type"]; + + /// "focus", "focusin", "blur" or "focusout". + public string Type { get; set; } = string.Empty; +} diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilInputEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilInputEventArgs.cs new file mode 100644 index 0000000000..7d5a9d34d0 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Events/ButilInputEventArgs.cs @@ -0,0 +1,21 @@ +using System; + +namespace Bit.Butil; + +/// +/// Input/beforeinput event payload — see InputEvent. +/// +public class ButilInputEventArgs : EventArgs +{ + internal static readonly string[] EventArgsMembers = [ + "data", "inputType", "isComposing"]; + + /// The string representing the inserted text. Null for deletions. + public string? Data { get; set; } + + /// e.g. "insertText", "deleteContentBackward", "insertFromPaste". + public string InputType { get; set; } = string.Empty; + + /// True if the event was fired during an IME composition session. + public bool IsComposing { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilPointerEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilPointerEventArgs.cs new file mode 100644 index 0000000000..2075d64880 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Events/ButilPointerEventArgs.cs @@ -0,0 +1,66 @@ +using System; + +namespace Bit.Butil; + +/// +/// Pointer event payload — see PointerEvent. +/// Pointer events unify mouse, pen and touch interaction. +/// +public class ButilPointerEventArgs : EventArgs +{ + internal static readonly string[] EventArgsMembers = [ + "altKey", "button", "buttons", "clientX", "clientY", "ctrlKey", "metaKey", + "movementX", "movementY", "offsetX", "offsetY", "pageX", "pageY", + "screenX", "screenY", "shiftKey", "x", "y", + "pointerId", "width", "height", "pressure", "tangentialPressure", + "tiltX", "tiltY", "twist", "pointerType", "isPrimary"]; + + public bool AltKey { get; set; } + public int Button { get; set; } + public int Buttons { get; set; } + public double ClientX { get; set; } + public double ClientY { get; set; } + public bool CtrlKey { get; set; } + public bool MetaKey { get; set; } + public double MovementX { get; set; } + public double MovementY { get; set; } + public double OffsetX { get; set; } + public double OffsetY { get; set; } + public double PageX { get; set; } + public double PageY { get; set; } + public double ScreenX { get; set; } + public double ScreenY { get; set; } + public bool ShiftKey { get; set; } + public double X { get; set; } + public double Y { get; set; } + + /// Identifier for the pointer that produced the event (see PointerEvent.pointerId). + public int PointerId { get; set; } + + /// Width (magnitude on the X axis), in CSS pixels, of the contact geometry. + public double Width { get; set; } + + /// Height (magnitude on the Y axis), in CSS pixels, of the contact geometry. + public double Height { get; set; } + + /// Normalized pressure of the pointer input in the range 0 to 1. + public double Pressure { get; set; } + + /// Normalized tangential pressure (also called barrel pressure) for stylus inputs. + public double TangentialPressure { get; set; } + + /// Plane angle (degrees) between the Y-Z plane and the plane containing the pointer axis and Y axis. + public double TiltX { get; set; } + + /// Plane angle (degrees) between the X-Z plane and the plane containing the pointer axis and X axis. + public double TiltY { get; set; } + + /// Clockwise rotation of the pointer (e.g. pen barrel) in degrees, 0–359. + public double Twist { get; set; } + + /// "mouse", "pen", "touch", or empty for unknown. + public string PointerType { get; set; } = string.Empty; + + /// True if this pointer is the primary pointer of its type. + public bool IsPrimary { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilSubscription.cs b/src/Butil/Bit.Butil/Publics/Events/ButilSubscription.cs new file mode 100644 index 0000000000..62e34c852b --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Events/ButilSubscription.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; + +namespace Bit.Butil; + +/// +/// Lightweight token returned by Butil event/observer subscriptions. Disposing the token +/// detaches the underlying JS listener so consumers can await using a subscription +/// for the lifetime of a component without juggling Guids. +/// +/// +/// Disposal is idempotent and safe to call after a JS disconnect — the underlying remover +/// is wrapped to swallow . +/// The exposed is still useful when callers want to compose multiple +/// subscriptions and remove them in bulk. +/// +public sealed class ButilSubscription : IAsyncDisposable +{ + private Func? _remover; + + internal ButilSubscription(Guid id, Func remover) + { + Id = id; + _remover = remover; + } + + /// The internal listener id (also accepted by the matching Remove(Guid) API). + public Guid Id { get; } + + /// Detaches the underlying listener. Calling more than once is a no-op. + public async ValueTask DisposeAsync() + { + var remover = System.Threading.Interlocked.Exchange(ref _remover, null); + if (remover is null) return; + try + { + await remover(); + } + catch (Microsoft.JSInterop.JSDisconnectedException) + { + // Circuit gone — nothing to detach. + } + } +} diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilTouchEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilTouchEventArgs.cs new file mode 100644 index 0000000000..3acf1d3814 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Events/ButilTouchEventArgs.cs @@ -0,0 +1,43 @@ +using System; + +namespace Bit.Butil; + +/// +/// Touch event payload — see TouchEvent. +/// Note: many platforms have moved to ; expose touch when you need +/// access to multi-touch lists explicitly. +/// +public class ButilTouchEventArgs : EventArgs +{ + // Touches are object lists, not primitive members; events.ts maps them to JSON arrays. + internal static readonly string[] EventArgsMembers = [ + "altKey", "ctrlKey", "metaKey", "shiftKey", + "touches", "targetTouches", "changedTouches"]; + + public bool AltKey { get; set; } + public bool CtrlKey { get; set; } + public bool MetaKey { get; set; } + public bool ShiftKey { get; set; } + + public ButilTouchPoint[] Touches { get; set; } = []; + public ButilTouchPoint[] TargetTouches { get; set; } = []; + public ButilTouchPoint[] ChangedTouches { get; set; } = []; +} + +/// +/// Individual touch point inside a . +/// +public class ButilTouchPoint +{ + public int Identifier { get; set; } + public double ClientX { get; set; } + public double ClientY { get; set; } + public double PageX { get; set; } + public double PageY { get; set; } + public double ScreenX { get; set; } + public double ScreenY { get; set; } + public double RadiusX { get; set; } + public double RadiusY { get; set; } + public double RotationAngle { get; set; } + public double Force { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilWheelEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilWheelEventArgs.cs new file mode 100644 index 0000000000..d3703d3ffc --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Events/ButilWheelEventArgs.cs @@ -0,0 +1,41 @@ +using System; + +namespace Bit.Butil; + +/// +/// Wheel event payload — see WheelEvent. +/// +public class ButilWheelEventArgs : EventArgs +{ + internal static readonly string[] EventArgsMembers = [ + "altKey", "button", "buttons", "clientX", "clientY", "ctrlKey", "metaKey", + "offsetX", "offsetY", "pageX", "pageY", "screenX", "screenY", "shiftKey", + "deltaX", "deltaY", "deltaZ", "deltaMode"]; + + public bool AltKey { get; set; } + public int Button { get; set; } + public int Buttons { get; set; } + public double ClientX { get; set; } + public double ClientY { get; set; } + public bool CtrlKey { get; set; } + public bool MetaKey { get; set; } + public double OffsetX { get; set; } + public double OffsetY { get; set; } + public double PageX { get; set; } + public double PageY { get; set; } + public double ScreenX { get; set; } + public double ScreenY { get; set; } + public bool ShiftKey { get; set; } + + /// Horizontal scroll amount. + public double DeltaX { get; set; } + + /// Vertical scroll amount. + public double DeltaY { get; set; } + + /// Z-axis (depth) scroll amount. + public double DeltaZ { get; set; } + + /// 0 = pixel, 1 = line, 2 = page. + public int DeltaMode { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/EyeDropper.cs b/src/Butil/Bit.Butil/Publics/EyeDropper.cs new file mode 100644 index 0000000000..39b04f5c7a --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/EyeDropper.cs @@ -0,0 +1,22 @@ +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the EyeDropper API. +/// +public class EyeDropper(IJSRuntime js) +{ + /// True when the runtime exposes window.EyeDropper. + public ValueTask IsSupported() => js.Invoke("BitButil.eyeDropper.isSupported"); + + /// + /// Opens the eyedropper and returns the picked sRGB color in hex form (e.g. #1f2937). + /// Returns null when the user cancels or the runtime can't show the picker. + /// + /// + /// Must be called from a user-gesture handler — the browser will reject the request otherwise. + /// + public ValueTask Open() => js.Invoke("BitButil.eyeDropper.open"); +} diff --git a/src/Butil/Bit.Butil/Publics/Fetch.cs b/src/Butil/Bit.Butil/Publics/Fetch.cs new file mode 100644 index 0000000000..724687e506 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Fetch.cs @@ -0,0 +1,69 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Browser fetch() wrapper with progress reporting and an abortable handle. Prefer +/// HttpClient for normal API calls; reach for this when you need progress for big +/// downloads or fetch-only features (CORS modes, no-cors, etc.). +/// +public class Fetch(IJSRuntime js) +{ + /// + /// Sends the request and returns the full response. + /// + /// Optional callback fired as bytes arrive. + /// When triggered, aborts the request. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchRequest))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchResponse))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchProgress))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchProgressListenersManager))] + public async Task Send(FetchRequest request, + Action? onProgress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var id = Guid.NewGuid(); + if (onProgress is not null) + FetchProgressListenersManager.AddListener(id, onProgress); + + var registration = cancellationToken.CanBeCanceled + ? cancellationToken.Register(static state => + { + var (j, rid) = ((IJSRuntime, Guid))state!; + _ = j.InvokeVoid("BitButil.fetch.abort", rid); + }, (js, id)) + : default; + + try + { + return await js.Invoke("BitButil.fetch.send", + cancellationToken, + id, request, FetchProgressListenersManager.InvokeMethodName, onProgress is not null); + } + finally + { + registration.Dispose(); + FetchProgressListenersManager.RemoveListener(id); + } + } + + /// + /// Starts the request and immediately returns an . Await + /// won't give you the response — use this when you only + /// need fire-and-forget abort control. For typical use prefer . + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchRequest))] + public async Task Start(FetchRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var id = Guid.NewGuid(); + await js.InvokeVoid("BitButil.fetch.start", id, request); + return new AbortableFetch(js, id); + } +} diff --git a/src/Butil/Bit.Butil/Publics/Fetch/AbortableFetch.cs b/src/Butil/Bit.Butil/Publics/Fetch/AbortableFetch.cs new file mode 100644 index 0000000000..3b4f262967 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Fetch/AbortableFetch.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Handle to an in-flight . Disposing aborts the request unless it has +/// already completed. +/// +public sealed class AbortableFetch : IAsyncDisposable +{ + private readonly IJSRuntime _js; + private readonly Guid _id; + private bool _completed; + + internal AbortableFetch(IJSRuntime js, Guid id) { _js = js; _id = id; } + + /// The internal request id. + public Guid Id => _id; + + /// Aborts the request immediately if it's still in flight. + public ValueTask Abort() + { + _completed = true; + return _js.InvokeVoid("BitButil.fetch.abort", _id); + } + + internal void MarkCompleted() => _completed = true; + + public async ValueTask DisposeAsync() + { + if (_completed) return; + _completed = true; + try { await _js.InvokeVoid("BitButil.fetch.abort", _id); } + catch (JSDisconnectedException) { } + } +} diff --git a/src/Butil/Bit.Butil/Publics/Fetch/FetchProgress.cs b/src/Butil/Bit.Butil/Publics/Fetch/FetchProgress.cs new file mode 100644 index 0000000000..0b96676f39 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Fetch/FetchProgress.cs @@ -0,0 +1,11 @@ +namespace Bit.Butil; + +/// Progress event raised while a body is being received. +public class FetchProgress +{ + /// Bytes received so far. + public long Loaded { get; set; } + + /// Total bytes expected, or null when unknown (chunked / no Content-Length). + public long? Total { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Fetch/FetchRequest.cs b/src/Butil/Bit.Butil/Publics/Fetch/FetchRequest.cs new file mode 100644 index 0000000000..c207599f8e --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Fetch/FetchRequest.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace Bit.Butil; + +/// +/// Minimal request shape for . Use a real HttpClient for normal API calls; +/// reach for this wrapper when you need browser-side features such as progress reporting, an +/// handle, or fetch semantics like CORS / credentials. +/// +public class FetchRequest +{ + public string Url { get; set; } = string.Empty; + + /// HTTP verb. Defaults to GET. + public string Method { get; set; } = "GET"; + + /// Headers as a key/value dictionary. Repeating keys aren't supported here — use one comma-joined value. + public Dictionary Headers { get; set; } = new(); + + /// Optional body bytes. Set 's Content-Type when needed. + public byte[]? Body { get; set; } + + /// One of "omit", "same-origin", "include". + public string Credentials { get; set; } = "same-origin"; + + /// One of "cors", "no-cors", "same-origin", "navigate". + public string Mode { get; set; } = "cors"; + + /// + /// Cache mode: "default", "no-store", "reload", "no-cache", + /// "force-cache", "only-if-cached". + /// + public string Cache { get; set; } = "default"; + + /// One of "follow", "error", "manual". + public string Redirect { get; set; } = "follow"; +} diff --git a/src/Butil/Bit.Butil/Publics/Fetch/FetchResponse.cs b/src/Butil/Bit.Butil/Publics/Fetch/FetchResponse.cs new file mode 100644 index 0000000000..336be4765c --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Fetch/FetchResponse.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Bit.Butil; + +/// Response payload from . +public class FetchResponse +{ + /// True when the response status is in [200, 300). + public bool Ok { get; set; } + + /// HTTP status (or 0 when the request was aborted/failed before headers). + public int Status { get; set; } + + public string StatusText { get; set; } = string.Empty; + + /// Final URL after redirects. + public string Url { get; set; } = string.Empty; + + /// Response headers. + public Dictionary Headers { get; set; } = new(); + + /// Body bytes. May be empty for 204/304 or aborted responses. + public byte[] Body { get; set; } = []; + + /// True when the request was aborted (via or cancellation). + public bool Aborted { get; set; } + + /// Network/CORS error description, when one occurred. + public string? Error { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/File/BlobInfo.cs b/src/Butil/Bit.Butil/Publics/File/BlobInfo.cs new file mode 100644 index 0000000000..b554d5b36f --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/File/BlobInfo.cs @@ -0,0 +1,20 @@ +namespace Bit.Butil; + +/// +/// Lightweight description of a Blob/File usable across interop. Actual binary contents are +/// fetched through . +/// +public class BlobInfo +{ + /// For File objects: the original name. Empty for plain Blobs. + public string Name { get; set; } = string.Empty; + + /// MIME type (e.g. image/png). Empty when unknown. + public string Type { get; set; } = string.Empty; + + /// Size in bytes. + public long Size { get; set; } + + /// Last-modified time (Unix epoch milliseconds). 0 for plain Blobs. + public long LastModified { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/FileReader.cs b/src/Butil/Bit.Butil/Publics/FileReader.cs new file mode 100644 index 0000000000..65d93f0be5 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/FileReader.cs @@ -0,0 +1,45 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Helpers for reading bytes/text out of a Blob or File reference. Use it together with an +/// <input type="file"> element captured via . +/// +public class FileReader(IJSRuntime js) +{ + /// + /// Returns metadata for the file at index in the given input element's files list. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BlobInfo))] + public ValueTask GetFileInfo(ElementReference inputElement, int index = 0) + => js.Invoke("BitButil.fileReader.getFileInfo", inputElement, index); + + /// Returns metadata for every file in the given input. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BlobInfo))] + public ValueTask GetFileInfos(ElementReference inputElement) + => js.Invoke("BitButil.fileReader.getFileInfos", inputElement); + + /// Reads a single file as raw bytes. + public ValueTask ReadAsBytes(ElementReference inputElement, int index = 0) + => js.Invoke("BitButil.fileReader.readAsBytes", inputElement, index); + + /// Reads a single file as UTF-8 text. Pass a different when the source is non-UTF-8. + public ValueTask ReadAsText(ElementReference inputElement, int index = 0, string encoding = "utf-8") + => js.Invoke("BitButil.fileReader.readAsText", inputElement, index, encoding); + + /// + /// Reads a single file as a base-64 data URL (e.g. data:image/png;base64,...). + /// Convenient for image previews; prefer when you'll process the bytes. + /// + public ValueTask ReadAsDataUrl(ElementReference inputElement, int index = 0) + => js.Invoke("BitButil.fileReader.readAsDataUrl", inputElement, index); + + /// Clears the input's selection (resets files). + public ValueTask Clear(ElementReference inputElement) + => js.InvokeVoid("BitButil.fileReader.clear", inputElement); +} diff --git a/src/Butil/Bit.Butil/Publics/Geolocation.cs b/src/Butil/Bit.Butil/Publics/Geolocation.cs new file mode 100644 index 0000000000..5004bbb4c5 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Geolocation.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Geolocation API +/// (navigator.geolocation). +/// +public class Geolocation(IJSRuntime js) : IAsyncDisposable +{ + private readonly ConcurrentDictionary _watchIds = new(); + + /// True when the runtime exposes navigator.geolocation. + public async ValueTask IsSupported() + => await js.Invoke("BitButil.geolocation.isSupported"); + + /// + /// Returns the device's current position once. + /// + /// Thrown when permission is denied, the position + /// can't be determined, or the call times out. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationPosition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationCoordinates))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationOptions))] + public async Task GetCurrentPosition(GeolocationOptions? options = null) + { + var result = await js.Invoke("BitButil.geolocation.getCurrentPosition", options); + if (result.Position is not null) return result.Position; + + throw ToException(result); + } + + /// + /// Subscribes to continuous position updates. Use with the + /// returned id to stop. The handler runs on the Blazor sync context. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationPosition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationCoordinates))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationListenersManager))] + public async Task Watch(Action? onPosition, + Action? onError = null, + GeolocationOptions? options = null) + { + if (onPosition is null && onError is null) + throw new ArgumentException("At least one of onPosition or onError must be provided."); + + var id = GeolocationListenersManager.AddListener(onPosition, onError); + _watchIds.TryAdd(id, 0); + + await js.InvokeVoid("BitButil.geolocation.watchPosition", + GeolocationListenersManager.PositionMethodName, + GeolocationListenersManager.ErrorMethodName, + id, + options); + + return id; + } + + /// Stops a previously registered watch. + public async ValueTask ClearWatch(Guid id) + { + _watchIds.TryRemove(id, out _); + GeolocationListenersManager.RemoveListeners([id]); + + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.geolocation.clearWatch", id); + } + + /// + /// Subscribe variant of returning an handle. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationPosition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationCoordinates))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationListenersManager))] + public async Task SubscribeWatch(Action? onPosition, + Action? onError = null, + GeolocationOptions? options = null) + { + var id = await Watch(onPosition, onError, options); + return new ButilSubscription(id, () => ClearWatch(id)); + } + + /// Stops every watch this instance has started. + public async ValueTask ClearAllWatches() + { + if (_watchIds.IsEmpty) return; + var ids = _watchIds.Keys.ToArray(); + _watchIds.Clear(); + GeolocationListenersManager.RemoveListeners(ids); + if (OperatingSystem.IsBrowser() is false) return; + foreach (var id in ids) + { + await js.InvokeVoid("BitButil.geolocation.clearWatch", id); + } + } + + public async ValueTask DisposeAsync() + { + try { await ClearAllWatches(); } + catch (JSDisconnectedException) { } + GC.SuppressFinalize(this); + } + + private static GeolocationException ToException(GeolocationCallResult result) + { + var code = result.ErrorCode switch + { + 1 => GeolocationErrorCode.PermissionDenied, + 2 => GeolocationErrorCode.PositionUnavailable, + 3 => GeolocationErrorCode.Timeout, + _ => GeolocationErrorCode.Unknown, + }; + return new GeolocationException(code, result.ErrorMessage ?? "Geolocation request failed."); + } + + /// Internal — shape used to bridge a once-off call's success/error path. + public class GeolocationCallResult + { + public GeolocationPosition? Position { get; set; } + public int ErrorCode { get; set; } + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationCoordinates.cs b/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationCoordinates.cs new file mode 100644 index 0000000000..53feb0f3d8 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationCoordinates.cs @@ -0,0 +1,28 @@ +namespace Bit.Butil; + +/// +/// Mirrors GeolocationCoordinates. +/// +public class GeolocationCoordinates +{ + /// Decimal latitude in degrees. + public double Latitude { get; set; } + + /// Decimal longitude in degrees. + public double Longitude { get; set; } + + /// Accuracy in meters of the latitude/longitude (1-sigma). + public double Accuracy { get; set; } + + /// Altitude in meters relative to the WGS84 reference ellipsoid, or null if unavailable. + public double? Altitude { get; set; } + + /// Accuracy of the altitude in meters, or null if unavailable. + public double? AltitudeAccuracy { get; set; } + + /// Heading in degrees clockwise from true north, or null if unknown / not moving. + public double? Heading { get; set; } + + /// Speed in meters per second, or null if unknown. + public double? Speed { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationError.cs b/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationError.cs new file mode 100644 index 0000000000..033725d908 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationError.cs @@ -0,0 +1,34 @@ +using System; + +namespace Bit.Butil; + +/// +/// Mirrors GeolocationPositionError.code. +/// +public enum GeolocationErrorCode +{ + /// The user denied the request for geolocation. + PermissionDenied = 1, + + /// The position cannot be determined. + PositionUnavailable = 2, + + /// The request timed out. + Timeout = 3, + + /// The error is not one of the above. + Unknown = 0 +} + +/// +/// Wraps a GeolocationPositionError raised by the browser. +/// +public class GeolocationException : Exception +{ + public GeolocationErrorCode Code { get; } + + public GeolocationException(GeolocationErrorCode code, string message) : base(message) + { + Code = code; + } +} diff --git a/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationOptions.cs b/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationOptions.cs new file mode 100644 index 0000000000..a96ce4a387 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationOptions.cs @@ -0,0 +1,17 @@ +namespace Bit.Butil; + +/// +/// Optional knobs that match +/// PositionOptions. +/// +public class GeolocationOptions +{ + /// Request the most accurate result possible (may be slower / use more power). + public bool EnableHighAccuracy { get; set; } + + /// Maximum acceptable age of a cached position in milliseconds. 0 means never use a cache. + public long MaximumAge { get; set; } + + /// How long the device may take to return a position before is raised. + public long Timeout { get; set; } = long.MaxValue; +} diff --git a/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationPosition.cs b/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationPosition.cs new file mode 100644 index 0000000000..2b4cba2a83 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Geolocation/GeolocationPosition.cs @@ -0,0 +1,13 @@ +namespace Bit.Butil; + +/// +/// Mirrors GeolocationPosition. +/// +public class GeolocationPosition +{ + /// The geographic coordinates. + public GeolocationCoordinates Coords { get; set; } = new(); + + /// Time at which the position was acquired, as Unix epoch in milliseconds. + public long Timestamp { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/History.cs b/src/Butil/Bit.Butil/Publics/History.cs index d07f185c1f..af9714ae41 100644 --- a/src/Butil/Bit.Butil/Publics/History.cs +++ b/src/Butil/Bit.Butil/Publics/History.cs @@ -53,6 +53,13 @@ public async Task SetScrollRestoration(ScrollRestoration value) public async Task GetState() => await js.Invoke("BitButil.history.state"); + /// + /// Strongly-typed accessor for . + /// + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed.")] + public async Task GetState<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(LinkerFlags.JsonSerialized)] T>() + => await js.Invoke("BitButil.history.state"); + /// /// This asynchronous method goes to the previous page in session history, the same action as /// when the user clicks the browser's Back button. Calling this method to go back beyond the @@ -123,6 +130,17 @@ public async ValueTask AddPopState(Action handler) return listenerId; } + /// + /// Subscribes to popstate and returns an handle that + /// detaches the listener when disposed. Pair with await using. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(HistoryListenersManager))] + public async ValueTask SubscribePopState(Action handler) + { + var id = await AddPopState(handler); + return new ButilSubscription(id, () => RemovePopState(id)); + } + /// /// The popstate event of the Window interface is fired when the active history entry changes while the user /// navigates the session history. diff --git a/src/Butil/Bit.Butil/Publics/IdleDetector.cs b/src/Butil/Bit.Butil/Publics/IdleDetector.cs new file mode 100644 index 0000000000..8233078068 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/IdleDetector.cs @@ -0,0 +1,60 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the IdleDetector API. +/// Requires the idle-detection permission, which the browser will prompt for on first +/// . +/// +public class IdleDetector(IJSRuntime js) +{ + /// True when the runtime exposes IdleDetector. + public ValueTask IsSupported() => js.Invoke("BitButil.idleDetector.isSupported"); + + /// + /// Asks the browser for the idle-detection permission. + /// + /// The new permission state. + public ValueTask RequestPermission() + => RequestPermissionInternal(); + + private async ValueTask RequestPermissionInternal() + { + var raw = await js.Invoke("BitButil.idleDetector.requestPermission"); + return raw switch + { + "granted" => PermissionState.Granted, + "denied" => PermissionState.Denied, + "prompt" => PermissionState.Prompt, + _ => PermissionState.Unknown, + }; + } + + /// + /// Starts watching for idle changes. The handler fires whenever user/screen state changes. + /// + /// Idle threshold in seconds. Spec minimum is 60. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IdleState))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IdleDetectorListenersManager))] + public async Task Start(int threshold, Action handler) + { + if (threshold < 60) threshold = 60; + + var id = IdleDetectorListenersManager.AddListener(handler); + await js.InvokeVoid("BitButil.idleDetector.start", + IdleDetectorListenersManager.InvokeMethodName, + id, + threshold); + + return new ButilSubscription(id, async () => + { + IdleDetectorListenersManager.RemoveListener(id); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.idleDetector.stop", id); + }); + } +} diff --git a/src/Butil/Bit.Butil/Publics/IdleDetector/IdleState.cs b/src/Butil/Bit.Butil/Publics/IdleDetector/IdleState.cs new file mode 100644 index 0000000000..669e93538f --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/IdleDetector/IdleState.cs @@ -0,0 +1,13 @@ +namespace Bit.Butil; + +/// +/// Combined user/screen state from IdleDetector. +/// +public class IdleState +{ + /// "active" or "idle". + public string UserState { get; set; } = "active"; + + /// "locked" or "unlocked". + public string ScreenState { get; set; } = "unlocked"; +} diff --git a/src/Butil/Bit.Butil/Publics/IndexedDb.cs b/src/Butil/Bit.Butil/Publics/IndexedDb.cs new file mode 100644 index 0000000000..ae2fa005ce --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/IndexedDb.cs @@ -0,0 +1,38 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Lightweight wrapper over IndexedDB. +/// +/// +/// The wrapper deliberately surfaces the most commonly needed CRUD shape rather than the +/// full IDB transaction/cursor API; complex graph queries should drop down to interop. +/// Each call returns an that owns the JS +/// IDBDatabase reference — dispose it when you're done so the connection closes. +/// +public class IndexedDb(IJSRuntime js) +{ + /// True when the runtime exposes indexedDB. + public ValueTask IsSupported() => js.Invoke("BitButil.indexedDb.isSupported"); + + /// + /// Opens (and upgrades if needed) the named database. Stores listed in + /// that don't yet exist are created during the upgrade transaction; existing stores are left + /// alone (no automatic schema migration). + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IndexedDbStoreSchema))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IndexedDbIndexSchema))] + public async ValueTask Open(string name, int version = 1, IndexedDbStoreSchema[]? stores = null) + { + var id = Guid.NewGuid(); + await js.InvokeVoid("BitButil.indexedDb.open", id, name, version, stores ?? []); + return new IndexedDbHandle(js, id); + } + + /// Deletes the named database. Resolves once the deletion completes. + public ValueTask DeleteDatabase(string name) => js.InvokeVoid("BitButil.indexedDb.deleteDatabase", name); +} diff --git a/src/Butil/Bit.Butil/Publics/IndexedDb/IndexedDbHandle.cs b/src/Butil/Bit.Butil/Publics/IndexedDb/IndexedDbHandle.cs new file mode 100644 index 0000000000..cf4446be7e --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/IndexedDb/IndexedDbHandle.cs @@ -0,0 +1,89 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.JSInterop; +using static Bit.Butil.LinkerFlags; + +namespace Bit.Butil; + +/// +/// Live handle to an IndexedDB database. Operations are forwarded to JS, which keeps the +/// underlying IDBDatabase alive until closes it. +/// +public sealed class IndexedDbHandle : IAsyncDisposable +{ + private readonly IJSRuntime _js; + private readonly Guid _id; + private bool _disposed; + + internal IndexedDbHandle(IJSRuntime js, Guid id) { _js = js; _id = id; } + + /// Internal handle id (database is keyed by this in JS). + public Guid Id => _id; + + /// Inserts or updates a value. Pass for stores without a keypath. + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON serialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public ValueTask Put<[DynamicallyAccessedMembers(JsonSerialized)] T>(string store, T value, object? key = null) + => _js.InvokeVoid("BitButil.indexedDb.put", _id, store, value, key); + + /// Inserts a new value. Throws on duplicate key. + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON serialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public ValueTask Add<[DynamicallyAccessedMembers(JsonSerialized)] T>(string store, T value, object? key = null) + => _js.InvokeVoid("BitButil.indexedDb.add", _id, store, value, key); + + /// Reads a value by key. Returns default when the key isn't present. + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON deserialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public ValueTask Get<[DynamicallyAccessedMembers(JsonSerialized)] T>(string store, object key) + => _js.Invoke("BitButil.indexedDb.get", _id, store, key); + + /// Reads a value by key as a (no static type required). + public ValueTask GetRaw(string store, object key) + => _js.Invoke("BitButil.indexedDb.get", _id, store, key); + + /// Reads all values in a store, optionally limited to . + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON deserialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public ValueTask GetAll<[DynamicallyAccessedMembers(JsonSerialized)] T>(string store, int? count = null) + => _js.Invoke("BitButil.indexedDb.getAll", _id, store, count); + + /// Lists every key in a store. + public ValueTask GetAllKeys(string store, int? count = null) + => _js.Invoke("BitButil.indexedDb.getAllKeys", _id, store, count); + + /// Deletes the value with the given key. + public ValueTask Delete(string store, object key) + => _js.InvokeVoid("BitButil.indexedDb.delete", _id, store, key); + + /// Empties the store. + public ValueTask Clear(string store) => _js.InvokeVoid("BitButil.indexedDb.clear", _id, store); + + /// Counts records in a store. + public ValueTask Count(string store) => _js.Invoke("BitButil.indexedDb.count", _id, store); + + /// + /// Reads via an index. The lookup mode is implied by : + /// pass a single value for an exact match, or use for ranged queries. + /// + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON deserialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public ValueTask GetByIndex<[DynamicallyAccessedMembers(JsonSerialized)] T>(string store, string index, object key) + => _js.Invoke("BitButil.indexedDb.getByIndex", _id, store, index, key); + + /// Reads every match for the given index value. + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON deserialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public ValueTask GetAllByIndex<[DynamicallyAccessedMembers(JsonSerialized)] T>(string store, string index, object key, int? count = null) + => _js.Invoke("BitButil.indexedDb.getAllByIndex", _id, store, index, key, count); + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try { await _js.InvokeVoid("BitButil.indexedDb.close", _id); } + catch (JSDisconnectedException) { } + } +} diff --git a/src/Butil/Bit.Butil/Publics/IndexedDb/IndexedDbStoreSchema.cs b/src/Butil/Bit.Butil/Publics/IndexedDb/IndexedDbStoreSchema.cs new file mode 100644 index 0000000000..b551130c71 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/IndexedDb/IndexedDbStoreSchema.cs @@ -0,0 +1,27 @@ +namespace Bit.Butil; + +/// +/// Object-store schema definition supplied to . +/// +public class IndexedDbStoreSchema +{ + public string Name { get; set; } = string.Empty; + + /// The keypath to use as the store's primary key. Null means out-of-line keys. + public string? KeyPath { get; set; } + + /// True to auto-generate keys (only meaningful when is null). + public bool AutoIncrement { get; set; } + + /// Indexes to create alongside the store. + public IndexedDbIndexSchema[] Indexes { get; set; } = []; +} + +/// Index schema inside an . +public class IndexedDbIndexSchema +{ + public string Name { get; set; } = string.Empty; + public string KeyPath { get; set; } = string.Empty; + public bool Unique { get; set; } + public bool MultiEntry { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverEntry.cs b/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverEntry.cs new file mode 100644 index 0000000000..c1955b09d0 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverEntry.cs @@ -0,0 +1,20 @@ +namespace Bit.Butil; + +/// +/// Mirrors IntersectionObserverEntry. +/// +public class IntersectionObserverEntry +{ + /// True when the target intersects the root with at least one threshold. + public bool IsIntersecting { get; set; } + + /// Ratio of the target's bounding rect that intersects the root, in [0, 1]. + public double IntersectionRatio { get; set; } + + /// Time at which the intersection was detected (DOMHighResTimeStamp, in ms). + public double Time { get; set; } + + public Rect? BoundingClientRect { get; set; } + public Rect? IntersectionRect { get; set; } + public Rect? RootBounds { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverExtensions.cs b/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverExtensions.cs new file mode 100644 index 0000000000..cbb1cbd0f3 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverExtensions.cs @@ -0,0 +1,44 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Extension methods that wire IntersectionObserver onto an . +/// Use the returned to stop observing. +/// +public static class IntersectionObserverExtensions +{ + /// + /// Observes intersection events for the given element. The handler receives the + /// most recent batch of values. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IntersectionObserverEntry))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IntersectionObserverOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IntersectionObserverListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Rect))] + public static async Task ObserveIntersection( + this ElementReference element, + IJSRuntime js, + Action handler, + IntersectionObserverOptions? options = null) + { + var listenerId = IntersectionObserverListenersManager.AddListener(handler); + + await js.InvokeVoid("BitButil.intersectionObserver.observe", + IntersectionObserverListenersManager.InvokeMethodName, + listenerId, + element, + options); + + return new ButilSubscription(listenerId, async () => + { + IntersectionObserverListenersManager.RemoveListener(listenerId); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.intersectionObserver.unobserve", listenerId); + }); + } +} diff --git a/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverOptions.cs b/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverOptions.cs new file mode 100644 index 0000000000..e36732e1b9 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverOptions.cs @@ -0,0 +1,13 @@ +namespace Bit.Butil; + +/// +/// Options for IntersectionObserver(). +/// +public class IntersectionObserverOptions +{ + /// CSS-style margin around the root, e.g. "0px 0px -50px 0px". + public string? RootMargin { get; set; } + + /// One or more thresholds in [0, 1]. Defaults to a single 0 threshold. + public double[]? Thresholds { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Keyboard.cs b/src/Butil/Bit.Butil/Publics/Keyboard.cs index c02fb648c2..b9e6968936 100644 --- a/src/Butil/Bit.Butil/Publics/Keyboard.cs +++ b/src/Butil/Bit.Butil/Publics/Keyboard.cs @@ -32,6 +32,52 @@ await js.InvokeVoid("BitButil.keyboard.add", return listenerId; } + /// + /// Same as but returns an handle that + /// detaches the shortcut when disposed. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(KeyboardListenersManager))] + public async Task Subscribe(string code, Action handler, + ButilModifiers modifiers = ButilModifiers.None, + bool preventDefault = true, + bool stopPropagation = true, + bool repeat = false) + { + var id = await Add(code, handler, modifiers, preventDefault, stopPropagation, repeat); + return new ButilSubscription(id, () => Remove(id)); + } + + /// + /// Element-scoped variant of : the shortcut only fires while the given + /// element (or one of its descendants) has focus or receives the keyboard event. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(KeyboardListenersManager))] + public async Task SubscribeOn(Microsoft.AspNetCore.Components.ElementReference element, + string code, Action handler, + ButilModifiers modifiers = ButilModifiers.None, + bool preventDefault = true, + bool stopPropagation = true, + bool repeat = false) + { + var listenerId = KeyboardListenersManager.AddListener(handler); + _handlers.TryAdd(listenerId, handler); + + await js.InvokeVoid("BitButil.keyboard.addOn", + KeyboardListenersManager.InvokeMethodName, + listenerId, + element, + code, + modifiers.HasFlag(ButilModifiers.Alt), + modifiers.HasFlag(ButilModifiers.Ctrl), + modifiers.HasFlag(ButilModifiers.Meta), + modifiers.HasFlag(ButilModifiers.Shift), + preventDefault, + stopPropagation, + repeat); + + return new ButilSubscription(listenerId, () => Remove(listenerId)); + } + public async ValueTask Remove(Action handler) { var ids = KeyboardListenersManager.RemoveListener(handler); diff --git a/src/Butil/Bit.Butil/Publics/Locks/WebLockMode.cs b/src/Butil/Bit.Butil/Publics/Locks/WebLockMode.cs new file mode 100644 index 0000000000..a4ed9df76d --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Locks/WebLockMode.cs @@ -0,0 +1,13 @@ +namespace Bit.Butil; + +/// +/// See LockManager.request().mode. +/// +public enum WebLockMode +{ + /// Default. Only one exclusive holder at a time. + Exclusive, + + /// Multiple shared holders allowed; mutually exclusive with Exclusive. + Shared +} diff --git a/src/Butil/Bit.Butil/Publics/Locks/WebLockSnapshot.cs b/src/Butil/Bit.Butil/Publics/Locks/WebLockSnapshot.cs new file mode 100644 index 0000000000..dd87809199 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Locks/WebLockSnapshot.cs @@ -0,0 +1,18 @@ +namespace Bit.Butil; + +/// +/// Snapshot of LockManager.query(). +/// +public class WebLockSnapshot +{ + public WebLockInfo[] Held { get; set; } = []; + public WebLockInfo[] Pending { get; set; } = []; +} + +/// One entry inside a . +public class WebLockInfo +{ + public string Name { get; set; } = string.Empty; + public string Mode { get; set; } = "exclusive"; + public string ClientId { get; set; } = string.Empty; +} diff --git a/src/Butil/Bit.Butil/Publics/MediaDevices.cs b/src/Butil/Bit.Butil/Publics/MediaDevices.cs new file mode 100644 index 0000000000..46041ccab6 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/MediaDevices.cs @@ -0,0 +1,43 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps navigator.mediaDevices. +/// +public class MediaDevices(IJSRuntime js) +{ + /// True when the runtime exposes navigator.mediaDevices. + public ValueTask IsSupported() => js.Invoke("BitButil.mediaDevices.isSupported"); + + /// + /// Lists all input/output media devices. Labels may be empty strings until the user has + /// granted permission to a matching input. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaDeviceInfoItem))] + public ValueTask EnumerateDevices() + => js.Invoke("BitButil.mediaDevices.enumerate"); + + /// + /// Requests audio and/or video access from the user. Returns a + /// when the prompt is accepted, null when the user denies or the runtime can't satisfy the constraints. + /// + /// When true, requests audio. Pass detailed constraints via . + /// When true, requests video. + /// Optional MediaTrackConstraints-shaped object (deviceId, sampleRate, etc.). + /// Optional MediaTrackConstraints-shaped object (width, height, facingMode, ...). + public async ValueTask GetUserMedia(bool audio = true, + bool video = false, + object? audioConstraints = null, + object? videoConstraints = null) + { + if (!audio && !video) throw new ArgumentException("At least one of audio/video must be true."); + var id = Guid.NewGuid(); + var ok = await js.Invoke("BitButil.mediaDevices.getUserMedia", + id, audio, video, audioConstraints, videoConstraints); + return ok ? new MediaStreamHandle(js, id) : null; + } +} diff --git a/src/Butil/Bit.Butil/Publics/MediaDevices/MediaDeviceInfo.cs b/src/Butil/Bit.Butil/Publics/MediaDevices/MediaDeviceInfo.cs new file mode 100644 index 0000000000..88fbf653bc --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/MediaDevices/MediaDeviceInfo.cs @@ -0,0 +1,15 @@ +namespace Bit.Butil; + +/// +/// Mirrors MediaDeviceInfo. +/// +public class MediaDeviceInfoItem +{ + public string DeviceId { get; set; } = string.Empty; + + /// One of "audioinput", "audiooutput", "videoinput". + public string Kind { get; set; } = string.Empty; + + public string Label { get; set; } = string.Empty; + public string GroupId { get; set; } = string.Empty; +} diff --git a/src/Butil/Bit.Butil/Publics/MediaDevices/MediaStreamHandle.cs b/src/Butil/Bit.Butil/Publics/MediaDevices/MediaStreamHandle.cs new file mode 100644 index 0000000000..461983b0d2 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/MediaDevices/MediaStreamHandle.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Handle to a live MediaStream obtained via . +/// Stop the stream by disposing the handle — every track is stopped and the stream is dropped. +/// +public sealed class MediaStreamHandle : IAsyncDisposable +{ + private readonly IJSRuntime _js; + private readonly Guid _id; + private bool _disposed; + + internal MediaStreamHandle(IJSRuntime js, Guid id) { _js = js; _id = id; } + + /// The internal stream id (also accepted by ). + public Guid Id => _id; + + /// Attaches this stream to a <video> or <audio> element's srcObject. + public ValueTask AttachTo(ElementReference videoOrAudioElement) + => _js.InvokeVoid("BitButil.mediaDevices.attach", _id, videoOrAudioElement); + + /// Pauses every track without dropping the stream. + public ValueTask SetEnabled(bool enabled) + => _js.InvokeVoid("BitButil.mediaDevices.setEnabled", _id, enabled); + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try { await _js.InvokeVoid("BitButil.mediaDevices.stop", _id); } + catch (JSDisconnectedException) { } + } +} diff --git a/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverExtensions.cs b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverExtensions.cs new file mode 100644 index 0000000000..934ad40303 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverExtensions.cs @@ -0,0 +1,46 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Extension methods that wire MutationObserver onto an . +/// +public static class MutationObserverExtensions +{ + /// + /// Observes mutations on the given element. The handler receives every record batch. + /// Use the returned to stop observing. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MutationRecord))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MutationObserverOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MutationObserverListenersManager))] + public static async Task ObserveMutations( + this ElementReference element, + IJSRuntime js, + Action handler, + MutationObserverOptions? options = null) + { + // Default to a "watch everything" config matching the most common Blazor use case + // (watching for nodes being added/removed inside a region). + options ??= new MutationObserverOptions { ChildList = true, Subtree = true }; + + var listenerId = MutationObserverListenersManager.AddListener(handler); + + await js.InvokeVoid("BitButil.mutationObserver.observe", + MutationObserverListenersManager.InvokeMethodName, + listenerId, + element, + options); + + return new ButilSubscription(listenerId, async () => + { + MutationObserverListenersManager.RemoveListener(listenerId); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.mutationObserver.unobserve", listenerId); + }); + } +} diff --git a/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverOptions.cs b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverOptions.cs new file mode 100644 index 0000000000..f91fa0eaea --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverOptions.cs @@ -0,0 +1,28 @@ +namespace Bit.Butil; + +/// +/// Options for MutationObserver.observe(). +/// +public class MutationObserverOptions +{ + /// Watch for added or removed children. + public bool ChildList { get; set; } + + /// Watch for attribute changes on the target. + public bool Attributes { get; set; } + + /// Watch for character-data changes within the target. + public bool CharacterData { get; set; } + + /// Apply the chosen options to the entire subtree, not just the immediate target. + public bool Subtree { get; set; } + + /// Include the previous attribute value in each . + public bool AttributeOldValue { get; set; } + + /// Include the previous character-data value in each . + public bool CharacterDataOldValue { get; set; } + + /// Optional whitelist of attribute names to watch. null means all. + public string[]? AttributeFilter { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/MutationObserver/MutationRecord.cs b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationRecord.cs new file mode 100644 index 0000000000..62bad7f294 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationRecord.cs @@ -0,0 +1,32 @@ +namespace Bit.Butil; + +/// +/// Mirrors a subset of MutationRecord. +/// DOM nodes can't cross interop, so they're flattened to lightweight summaries. +/// +public class MutationRecord +{ + /// "attributes", "characterData", or "childList". + public string Type { get; set; } = string.Empty; + + /// Tag name of the target node, or empty for non-element targets. + public string TargetTagName { get; set; } = string.Empty; + + /// Id of the target node when present. + public string? TargetId { get; set; } + + /// Attribute name (only for "attributes" mutations). + public string? AttributeName { get; set; } + + /// Attribute namespace (only for "attributes" mutations). + public string? AttributeNamespace { get; set; } + + /// Previous value when AttributeOldValue / CharacterDataOldValue were enabled. + public string? OldValue { get; set; } + + /// Number of nodes added (only for "childList"). + public int AddedCount { get; set; } + + /// Number of nodes removed (only for "childList"). + public int RemovedCount { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Navigator.cs b/src/Butil/Bit.Butil/Publics/Navigator.cs index dabbef797c..748a77d986 100644 --- a/src/Butil/Bit.Butil/Publics/Navigator.cs +++ b/src/Butil/Bit.Butil/Publics/Navigator.cs @@ -92,6 +92,14 @@ public async Task IsWebDriver() public async Task CanShare() => await js.Invoke("BitButil.navigator.canShare"); + /// + /// Returns true if the data passed would be shareable by Navigator.share(). + ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/canShare + ///
+ public async Task CanShare(ShareData data) + => await js.Invoke("BitButil.navigator.canShare", data); + /// /// Clears a badge on the current app's icon and returns a Promise that resolves with undefined. ///
@@ -105,16 +113,19 @@ public async Task ClearAppBadge() ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon ///
- public async Task SendBeacon() - => await js.Invoke("BitButil.navigator.sendBeacon"); + /// The URL that will receive the data. + /// An optional payload (string, Blob, BufferSource, or FormData-shaped object) to send. + public async Task SendBeacon(string url, object? data = null) + => await js.Invoke("BitButil.navigator.sendBeacon", url, data); /// /// Sets a badge on the icon associated with this app and returns a Promise that resolves with undefined. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/setAppBadge ///
- public async Task SetAppBadge() - => await js.InvokeVoid("BitButil.navigator.setAppBadge"); + /// A non-negative integer to display, or null/0 to show a generic dot badge. + public async Task SetAppBadge(int? contents = null) + => await js.InvokeVoid("BitButil.navigator.setAppBadge", contents); /// /// Invokes the native sharing mechanism of the current platform. @@ -124,6 +135,21 @@ public async Task SetAppBadge() public async Task Share(ShareData data) => await js.InvokeVoid("BitButil.navigator.share", data); + /// + /// Web Share Level 2: shares one or more files alongside optional title/text/url. + ///
+ /// Navigator.share() + ///
+ /// True when the share completes (or no files were rejected). False when the + /// runtime can't share the supplied set, e.g. file shares aren't allowed. + [System.Diagnostics.CodeAnalysis.DynamicDependency(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(ShareFile))] + [System.Diagnostics.CodeAnalysis.DynamicDependency(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(ShareData))] + public async Task ShareFiles(string? title, ShareFile[] files, string? text = null, string? url = null) + { + if (files is null || files.Length == 0) return false; + return await js.Invoke("BitButil.navigator.shareFiles", title, text, url, files); + } + /// /// Causes vibration on devices with support for it. Does nothing if vibration support isn't available. ///
diff --git a/src/Butil/Bit.Butil/Publics/Navigator/ShareFile.cs b/src/Butil/Bit.Butil/Publics/Navigator/ShareFile.cs new file mode 100644 index 0000000000..25c55ad38e --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Navigator/ShareFile.cs @@ -0,0 +1,16 @@ +namespace Bit.Butil; + +/// +/// File payload for . +/// +public class ShareFile +{ + /// The file's display name (extension included). + public string Name { get; set; } = string.Empty; + + /// MIME type — image/png, application/pdf, etc. + public string MimeType { get; set; } = "application/octet-stream"; + + /// Raw file bytes. + public byte[] Data { get; set; } = []; +} diff --git a/src/Butil/Bit.Butil/Publics/NetworkInformation.cs b/src/Butil/Bit.Butil/Publics/NetworkInformation.cs new file mode 100644 index 0000000000..f7d8c52a3a --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/NetworkInformation.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Network Information API +/// (navigator.connection) plus the always-available navigator.onLine. +/// +public class NetworkInformation(IJSRuntime js) +{ + /// One-shot snapshot of the network state. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NetworkConnectionStatus))] + public ValueTask GetStatus() + => js.Invoke("BitButil.networkInformation.getStatus"); +} diff --git a/src/Butil/Bit.Butil/Publics/NetworkInformation/NetworkConnectionStatus.cs b/src/Butil/Bit.Butil/Publics/NetworkInformation/NetworkConnectionStatus.cs new file mode 100644 index 0000000000..e7c445be1d --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/NetworkInformation/NetworkConnectionStatus.cs @@ -0,0 +1,28 @@ +namespace Bit.Butil; + +/// +/// Snapshot of NetworkInformation. +/// +public class NetworkConnectionStatus +{ + /// True when the user-agent considers itself online. + public bool Online { get; set; } + + /// Effective connection type: "slow-2g", "2g", "3g", "4g", or null. + public string? EffectiveType { get; set; } + + /// Underlying connection type: "wifi", "cellular", "ethernet", etc., or null. + public string? Type { get; set; } + + /// Estimated effective downlink in megabits per second. + public double? Downlink { get; set; } + + /// Maximum advertised downlink in megabits per second. + public double? DownlinkMax { get; set; } + + /// Round-trip time estimate in milliseconds. + public int? Rtt { get; set; } + + /// True when the user has requested reduced data usage. + public bool? SaveData { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Nfc.cs b/src/Butil/Bit.Butil/Publics/Nfc.cs new file mode 100644 index 0000000000..f85d5087f1 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Nfc.cs @@ -0,0 +1,64 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Web NFC API +/// (NDEFReader). Available on Chromium for Android only. +/// +public class Nfc(IJSRuntime js) +{ + /// True when the runtime exposes NDEFReader. + public ValueTask IsSupported() => js.Invoke("BitButil.nfc.isSupported"); + + /// + /// Starts scanning for NDEF tags. Use the returned to stop. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NdefMessage))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NdefRecord))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NdefListenersManager))] + public async Task Scan(Action? onReading, Action? onError = null) + { + if (onReading is null && onError is null) + throw new ArgumentException("At least one of onReading/onError must be provided."); + + var listener = new NdefListenersManager.Listener { OnReading = onReading, OnError = onError }; + var id = NdefListenersManager.Add(listener); + + await js.InvokeVoid("BitButil.nfc.scan", + id, + NdefListenersManager.ReadingMethodName, + NdefListenersManager.ErrorMethodName); + + return new ScanHandle(js, id); + } + + /// + /// Writes a single NDEF text record to the next tag tapped against the device. + /// + public ValueTask WriteText(string text, string? lang = null, string? id = null) + => js.Invoke("BitButil.nfc.writeText", text, lang, id); + + /// + /// Writes a single NDEF URL record to the next tag tapped against the device. + /// + public ValueTask WriteUrl(string url, string? id = null) + => js.Invoke("BitButil.nfc.writeUrl", url, id); + + private sealed class ScanHandle(IJSRuntime js, Guid id) : IAsyncDisposable + { + private bool _disposed; + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + NdefListenersManager.Remove(id); + try { await js.InvokeVoid("BitButil.nfc.stop", id); } + catch (JSDisconnectedException) { } + } + } +} diff --git a/src/Butil/Bit.Butil/Publics/Nfc/NdefRecord.cs b/src/Butil/Bit.Butil/Publics/Nfc/NdefRecord.cs new file mode 100644 index 0000000000..58c09bd7ae --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Nfc/NdefRecord.cs @@ -0,0 +1,35 @@ +namespace Bit.Butil; + +/// +/// Mirrors NDEFRecord. +/// +public class NdefRecord +{ + /// One of "text", "url", "absolute-url", "mime", "empty", … + public string RecordType { get; set; } = string.Empty; + + /// MIME type when is "mime". + public string? MediaType { get; set; } + + /// Application-defined record id. + public string? Id { get; set; } + + /// BCP-47 language tag for text records. + public string? Lang { get; set; } + + /// Encoding for text records, e.g. "utf-8". + public string? Encoding { get; set; } + + /// Decoded text payload when applicable. + public string? Text { get; set; } + + /// Raw bytes when applicable (mime/etc.). + public byte[]? Data { get; set; } +} + +/// One scanned NDEF message. +public class NdefMessage +{ + public string SerialNumber { get; set; } = string.Empty; + public NdefRecord[] Records { get; set; } = []; +} diff --git a/src/Butil/Bit.Butil/Publics/Notification.cs b/src/Butil/Bit.Butil/Publics/Notification.cs index bfb08cc9cf..48e44f41f2 100644 --- a/src/Butil/Bit.Butil/Publics/Notification.cs +++ b/src/Butil/Bit.Butil/Publics/Notification.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -72,4 +73,42 @@ public async ValueTask Show(string title, NotificationOptions? options = null) await js.InvokeVoid("BitButil.notification.show", title, opts); } + + /// + /// Shows a notification and returns a that lets you wire up + /// click / show / close / error callbacks and close the toast programmatically. The notification + /// stays open until the user dismisses it (or you call ). + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NotificationOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(InternalNotificationOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NotificationListenersManager))] + public async ValueTask ShowTracked(string title, + NotificationOptions? options = null, + Action? onClick = null, + Action? onShow = null, + Action? onClose = null, + Action? onError = null) + { + var listener = new NotificationListenersManager.Listener + { + OnClick = onClick, + OnShow = onShow, + OnClose = onClose, + OnError = onError + }; + var id = NotificationListenersManager.Add(listener); + + InternalNotificationOptions? opts = options is null ? null : new(options); + + await js.InvokeVoid("BitButil.notification.showTracked", + id, + title, + opts, + NotificationListenersManager.ClickMethodName, + NotificationListenersManager.ShowMethodName, + NotificationListenersManager.CloseMethodName, + NotificationListenersManager.ErrorMethodName); + + return new NotificationHandle(js, id); + } } diff --git a/src/Butil/Bit.Butil/Publics/Notification/NotificationHandle.cs b/src/Butil/Bit.Butil/Publics/Notification/NotificationHandle.cs new file mode 100644 index 0000000000..c0344069ed --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Notification/NotificationHandle.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Handle to a live notification. Dispose to detach event listeners and close the toast. +/// +public sealed class NotificationHandle : IAsyncDisposable +{ + private readonly IJSRuntime _js; + private readonly Guid _id; + private bool _disposed; + + internal NotificationHandle(IJSRuntime js, Guid id) { _js = js; _id = id; } + + /// The internal notification id. + public Guid Id => _id; + + /// Closes the notification programmatically. + public ValueTask Close() => _js.InvokeVoid("BitButil.notification.close", _id); + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + NotificationListenersManager.Remove(_id); + try { await _js.InvokeVoid("BitButil.notification.dispose", _id); } + catch (JSDisconnectedException) { } + } +} diff --git a/src/Butil/Bit.Butil/Publics/ObjectUrls.cs b/src/Butil/Bit.Butil/Publics/ObjectUrls.cs new file mode 100644 index 0000000000..15a04f4bfd --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/ObjectUrls.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps URL.createObjectURL / URL.revokeObjectURL for arbitrary byte payloads. +/// +/// +/// Object URLs leak memory if not revoked. The instance tracks every URL it creates so +/// disposal automatically revokes outstanding ones. Use when you want +/// the URL to outlive disposal and call yourself. +/// +public class ObjectUrls(IJSRuntime js) : IAsyncDisposable +{ + private readonly ConcurrentBag _tracked = new(); + + /// Creates an object URL from raw bytes plus an optional MIME type. + public async ValueTask Create(byte[] data, string mimeType = "application/octet-stream", bool track = true) + { + var url = await js.Invoke("BitButil.objectUrls.create", data, mimeType); + if (track) _tracked.Add(url); + return url; + } + + /// Revokes a previously created object URL. + public ValueTask Revoke(string objectUrl) => js.InvokeVoid("BitButil.objectUrls.revoke", objectUrl); + + public async ValueTask DisposeAsync() + { + try + { + while (_tracked.TryTake(out var url)) + { + await Revoke(url); + } + } + catch (JSDisconnectedException) { } + GC.SuppressFinalize(this); + } +} diff --git a/src/Butil/Bit.Butil/Publics/Performance.cs b/src/Butil/Bit.Butil/Publics/Performance.cs new file mode 100644 index 0000000000..9cdcf598ff --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Performance.cs @@ -0,0 +1,101 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Performance +/// timing and marker API. +/// +public class Performance(IJSRuntime js) +{ + /// + /// High-resolution timestamp (DOMHighResTimeStamp) since the time origin, in milliseconds. + ///
+ /// Performance.now() + ///
+ public ValueTask Now() + => js.Invoke("BitButil.performance.now"); + + /// + /// The time origin of the document — typically the navigation start, in Unix epoch milliseconds. + ///
+ /// Performance.timeOrigin + ///
+ public ValueTask TimeOrigin() + => js.Invoke("BitButil.performance.timeOrigin"); + + /// + /// Adds a named mark to the browser's performance timeline. + ///
+ /// Performance.mark() + ///
+ public ValueTask Mark(string name) => js.InvokeVoid("BitButil.performance.mark", name); + + /// + /// Creates a named measure between two marks (or between a mark and "now"). + ///
+ /// Performance.measure() + ///
+ public ValueTask Measure(string name, string? startMark = null, string? endMark = null) + => js.InvokeVoid("BitButil.performance.measure", name, startMark, endMark); + + /// Removes performance marks. null clears all of them. + public ValueTask ClearMarks(string? name = null) => js.InvokeVoid("BitButil.performance.clearMarks", name); + + /// Removes performance measures. null clears all of them. + public ValueTask ClearMeasures(string? name = null) => js.InvokeVoid("BitButil.performance.clearMeasures", name); + + /// Empties the resource-timing buffer. + public ValueTask ClearResourceTimings() => js.InvokeVoid("BitButil.performance.clearResourceTimings"); + + /// + /// Returns all entries (PerformanceEntry) recorded so far. Optionally filter by name and/or type. + /// Returned shapes vary by entry type, so we surface them as . + ///
+ /// Performance.getEntries() + ///
+ public ValueTask GetEntries(string? name = null, string? type = null) + => js.Invoke("BitButil.performance.getEntries", name, type); + + /// + /// Chrome-only memory snapshot. All fields are null on browsers that don't expose performance.memory. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PerformanceMemory))] + public ValueTask GetMemory() + => js.Invoke("BitButil.performance.memory"); + + /// + /// Subscribes to PerformanceObserver + /// for one or more entry types. Common values: "resource", "navigation", + /// "longtask", "largest-contentful-paint", "layout-shift", + /// "first-input", "paint", "mark", "measure". + /// + /// When true, the observer is also notified about entries that + /// were already in the buffer when the observer registered. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PerformanceObserverListenersManager))] + public async Task SubscribeObserver(string[] entryTypes, + Action handler, + bool buffered = true) + { + if (entryTypes is null || entryTypes.Length == 0) + throw new ArgumentException("At least one entry type is required.", nameof(entryTypes)); + + var id = PerformanceObserverListenersManager.AddListener(handler); + await js.InvokeVoid("BitButil.performance.observe", + PerformanceObserverListenersManager.InvokeMethodName, + id, + entryTypes, + buffered); + + return new ButilSubscription(id, async () => + { + PerformanceObserverListenersManager.RemoveListener(id); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.performance.disconnect", id); + }); + } +} diff --git a/src/Butil/Bit.Butil/Publics/Performance/PerformanceMemory.cs b/src/Butil/Bit.Butil/Publics/Performance/PerformanceMemory.cs new file mode 100644 index 0000000000..1c2a5fee2a --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Performance/PerformanceMemory.cs @@ -0,0 +1,12 @@ +namespace Bit.Butil; + +/// +/// Snapshot of Performance.memory. +/// Chrome-only, hence the explicit nulls when not available. +/// +public class PerformanceMemory +{ + public long? JsHeapSizeLimit { get; set; } + public long? TotalJsHeapSize { get; set; } + public long? UsedJsHeapSize { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Permissions.cs b/src/Butil/Bit.Butil/Publics/Permissions.cs new file mode 100644 index 0000000000..3299a39581 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Permissions.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps navigator.permissions. +/// +public class Permissions(IJSRuntime js) +{ + /// True when the runtime exposes navigator.permissions. + public async ValueTask IsSupported() + => await js.Invoke("BitButil.permissions.isSupported"); + + /// + /// Returns the current state for a given permission descriptor name. + /// + /// A descriptor name such as "geolocation", "notifications", + /// "camera", "microphone", "clipboard-read", "clipboard-write", + /// "push", etc. Browser support varies; unknown names return . + public async Task Query(string name) + { + var raw = await js.Invoke("BitButil.permissions.query", name); + return raw switch + { + "granted" => PermissionState.Granted, + "denied" => PermissionState.Denied, + "prompt" => PermissionState.Prompt, + _ => PermissionState.Unknown, + }; + } +} diff --git a/src/Butil/Bit.Butil/Publics/Permissions/PermissionState.cs b/src/Butil/Bit.Butil/Publics/Permissions/PermissionState.cs new file mode 100644 index 0000000000..1e84196ee9 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Permissions/PermissionState.cs @@ -0,0 +1,19 @@ +namespace Bit.Butil; + +/// +/// Mirrors PermissionStatus.state. +/// +public enum PermissionState +{ + /// The user has granted the permission. + Granted, + + /// The user has denied the permission. + Denied, + + /// The user must be asked the next time the capability is used. + Prompt, + + /// The runtime does not implement the Permissions API or doesn't know about the descriptor. + Unknown +} diff --git a/src/Butil/Bit.Butil/Publics/Push.cs b/src/Butil/Bit.Butil/Publics/Push.cs new file mode 100644 index 0000000000..a5f64c65c1 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Push.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Push API's +/// subscription surface. +/// +/// +/// Push requires an active service worker registration. Register one via +/// before subscribing. +/// +public class Push(IJSRuntime js) +{ + /// True when the runtime exposes ServiceWorkerRegistration.pushManager. + public ValueTask IsSupported() => js.Invoke("BitButil.push.isSupported"); + + /// + /// Returns the existing subscription for the active service worker, or an inactive payload + /// when none exists. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PushSubscriptionInfo))] + public ValueTask GetSubscription() + => js.Invoke("BitButil.push.getSubscription"); + + /// + /// Subscribes the current service worker to push messages. + /// + /// VAPID public key, base64-url encoded. + /// Required to be true on Chromium; the browser must be able to + /// show a user-visible notification for each push. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PushSubscriptionInfo))] + public ValueTask Subscribe(string applicationServerKey, bool userVisibleOnly = true) + => js.Invoke("BitButil.push.subscribe", applicationServerKey, userVisibleOnly); + + /// Unsubscribes the active subscription. Returns true if a subscription was removed. + public ValueTask Unsubscribe() => js.Invoke("BitButil.push.unsubscribe"); +} diff --git a/src/Butil/Bit.Butil/Publics/Push/PushSubscriptionInfo.cs b/src/Butil/Bit.Butil/Publics/Push/PushSubscriptionInfo.cs new file mode 100644 index 0000000000..116407b666 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Push/PushSubscriptionInfo.cs @@ -0,0 +1,22 @@ +namespace Bit.Butil; + +/// +/// Mirrors PushSubscription. +/// +public class PushSubscriptionInfo +{ + /// True when a subscription was found / created. + public bool IsActive { get; set; } + + /// The endpoint URL the push service expects POSTs at. + public string Endpoint { get; set; } = string.Empty; + + /// Unix-epoch milliseconds expiration time, or null when the subscription doesn't expire. + public long? ExpirationTime { get; set; } + + /// Base64URL-encoded P-256 ECDH public key for payload encryption. + public string P256dh { get; set; } = string.Empty; + + /// Base64URL-encoded auth secret used by the Web Push protocol. + public string Auth { get; set; } = string.Empty; +} diff --git a/src/Butil/Bit.Butil/Publics/Reporting.cs b/src/Butil/Bit.Butil/Publics/Reporting.cs new file mode 100644 index 0000000000..016d9c1745 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Reporting.cs @@ -0,0 +1,47 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Reporting API +/// (ReportingObserver). +/// +/// +/// Useful for surfacing browser-emitted deprecation, intervention, CSP-violation, and crash +/// reports to your monitoring stack alongside ordinary errors. +/// +public class Reporting(IJSRuntime js) +{ + /// True when the runtime exposes ReportingObserver. + public ValueTask IsSupported() => js.Invoke("BitButil.reporting.isSupported"); + + /// + /// Subscribes to browser-generated reports. Use the returned to stop. + /// + /// Optional whitelist of report types (e.g. "deprecation", "intervention"). + /// Pass null to receive every type. + /// When true, also delivers reports queued before the observer registered. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BrowserReport))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ReportingListenersManager))] + public async Task Subscribe(Action handler, + string[]? types = null, + bool buffered = true) + { + var id = ReportingListenersManager.AddListener(handler); + await js.InvokeVoid("BitButil.reporting.observe", + ReportingListenersManager.InvokeMethodName, + id, + types, + buffered); + + return new ButilSubscription(id, async () => + { + ReportingListenersManager.RemoveListener(id); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.reporting.disconnect", id); + }); + } +} diff --git a/src/Butil/Bit.Butil/Publics/Reporting/BrowserReport.cs b/src/Butil/Bit.Butil/Publics/Reporting/BrowserReport.cs new file mode 100644 index 0000000000..79589327c5 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Reporting/BrowserReport.cs @@ -0,0 +1,22 @@ +using System.Text.Json; + +namespace Bit.Butil; + +/// +/// Mirrors a Report. +/// The body shape varies per , so it's surfaced as a . +/// +public class BrowserReport +{ + /// + /// Report type — typical values include "deprecation", "intervention", + /// "crash", "csp-violation". + /// + public string Type { get; set; } = string.Empty; + + /// URL the report applies to. + public string Url { get; set; } = string.Empty; + + /// The full body as supplied by the browser. + public JsonElement Body { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverBox.cs b/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverBox.cs new file mode 100644 index 0000000000..9581efc765 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverBox.cs @@ -0,0 +1,12 @@ +namespace Bit.Butil; + +/// +/// Selects which box dimensions trigger the observer. See +/// ResizeObserver.observe(). +/// +public enum ResizeObserverBox +{ + ContentBox, + BorderBox, + DevicePixelContentBox +} diff --git a/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverEntry.cs b/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverEntry.cs new file mode 100644 index 0000000000..9b0fbdb27c --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverEntry.cs @@ -0,0 +1,14 @@ +namespace Bit.Butil; + +/// +/// Mirrors ResizeObserverEntry +/// with a flattened set of fields convenient for typical layout work. +/// +public class ResizeObserverEntry +{ + public Rect? ContentRect { get; set; } + public double InlineSize { get; set; } + public double BlockSize { get; set; } + public double DevicePixelInlineSize { get; set; } + public double DevicePixelBlockSize { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverExtensions.cs b/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverExtensions.cs new file mode 100644 index 0000000000..d5c8317f53 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverExtensions.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Extension methods that wire ResizeObserver onto an . +/// +public static class ResizeObserverExtensions +{ + /// + /// Observes resize events for the given element. Use the returned + /// to stop observing. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ResizeObserverEntry))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ResizeObserverListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Rect))] + public static async Task ObserveResize( + this ElementReference element, + IJSRuntime js, + Action handler, + ResizeObserverBox box = ResizeObserverBox.ContentBox) + { + var listenerId = ResizeObserverListenersManager.AddListener(handler); + + var boxName = box switch + { + ResizeObserverBox.BorderBox => "border-box", + ResizeObserverBox.DevicePixelContentBox => "device-pixel-content-box", + _ => "content-box", + }; + + await js.InvokeVoid("BitButil.resizeObserver.observe", + ResizeObserverListenersManager.InvokeMethodName, + listenerId, + element, + boxName); + + return new ButilSubscription(listenerId, async () => + { + ResizeObserverListenersManager.RemoveListener(listenerId); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.resizeObserver.unobserve", listenerId); + }); + } +} diff --git a/src/Butil/Bit.Butil/Publics/Screen.cs b/src/Butil/Bit.Butil/Publics/Screen.cs index a8e114147f..6553b2ffc1 100644 --- a/src/Butil/Bit.Butil/Publics/Screen.cs +++ b/src/Butil/Bit.Butil/Publics/Screen.cs @@ -91,6 +91,16 @@ public async ValueTask AddChange(Action handler) return listenerId; } + /// + /// Subscribe variant returning an handle. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ScreenListenersManager))] + public async ValueTask SubscribeChange(Action handler) + { + var id = await AddChange(handler); + return new ButilSubscription(id, () => RemoveChange(id)); + } + /// /// Fired on a specific screen when it changes in some way — width or height, /// available width or height, color depth, or orientation. diff --git a/src/Butil/Bit.Butil/Publics/ScreenOrientation.cs b/src/Butil/Bit.Butil/Publics/ScreenOrientation.cs index 0637f108c0..f5dc0591ac 100644 --- a/src/Butil/Bit.Butil/Publics/ScreenOrientation.cs +++ b/src/Butil/Bit.Butil/Publics/ScreenOrientation.cs @@ -92,6 +92,16 @@ public async ValueTask AddChange(Action handler) return listenerId; } + /// + /// Subscribe variant returning an handle. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ScreenOrientationListenersManager))] + public async ValueTask SubscribeChange(Action handler) + { + var id = await AddChange(handler); + return new ButilSubscription(id, () => RemoveChange(id)); + } + /// /// The change event of the ScreenOrientation interface fires when the orientation of the /// screen has changed, for example when a user rotates their mobile phone. diff --git a/src/Butil/Bit.Butil/Publics/ServiceWorker.cs b/src/Butil/Bit.Butil/Publics/ServiceWorker.cs new file mode 100644 index 0000000000..731f1f1e95 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/ServiceWorker.cs @@ -0,0 +1,94 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.JSInterop; +using static Bit.Butil.LinkerFlags; + +namespace Bit.Butil; + +/// +/// Wraps navigator.serviceWorker. +/// +/// +/// Service workers are origin-scoped and outlive the page, so this service intentionally +/// does not auto-unregister anything on disposal — the consuming app decides when to call +/// . Subscriptions returned by / +/// are detached on dispose. +/// +public class ServiceWorker(IJSRuntime js) +{ + /// True when the runtime exposes navigator.serviceWorker. + public ValueTask IsSupported() => js.Invoke("BitButil.serviceWorker.isSupported"); + + /// + /// Registers a service worker script. The promise resolves once the registration is created. + /// + /// URL of the worker script (must be same-origin). + /// Optional scope URL. When null, the script's directory is used. + /// One of "imports", "all", "none"; null falls back to the browser default. + /// When true, registers the worker as an ES module. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ServiceWorkerRegistrationInfo))] + public ValueTask Register(string scriptUrl, + string? scope = null, + string? updateViaCache = null, + bool moduleType = false) + => js.Invoke("BitButil.serviceWorker.register", scriptUrl, scope, updateViaCache, moduleType); + + /// + /// Returns the registration matching (or the most specific one for the + /// document URL when null). is false + /// when no matching registration exists. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ServiceWorkerRegistrationInfo))] + public ValueTask GetRegistration(string? scope = null) + => js.Invoke("BitButil.serviceWorker.getRegistration", scope); + + /// Forces an update check for a registration. + public ValueTask Update(string? scope = null) => js.InvokeVoid("BitButil.serviceWorker.update", scope); + + /// Unregisters the worker matching . Returns true when something was removed. + public ValueTask Unregister(string? scope = null) => js.Invoke("BitButil.serviceWorker.unregister", scope); + + /// + /// Sends to the active worker controlling this page. + /// Returns false when no controller exists. + /// + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON serialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public ValueTask PostMessage<[DynamicallyAccessedMembers(JsonSerialized)] T>(T message) + => js.Invoke("BitButil.serviceWorker.postMessage", message); + + /// + /// Subscribes to messages broadcast from the service worker. The handler receives every + /// payload as a . + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ServiceWorkerListenersManager))] + public async Task SubscribeMessage(Action handler) + { + var id = ServiceWorkerListenersManager.AddMessageListener(handler); + await js.InvokeVoid("BitButil.serviceWorker.subscribeMessage", + ServiceWorkerListenersManager.MessageMethodName, id); + return new ButilSubscription(id, async () => + { + ServiceWorkerListenersManager.RemoveMessageListener(id); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.serviceWorker.unsubscribeMessage", id); + }); + } + + /// Fires when navigator.serviceWorker.controller changes. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ServiceWorkerListenersManager))] + public async Task SubscribeControllerChange(Action handler) + { + var id = ServiceWorkerListenersManager.AddControllerChangeListener(handler); + await js.InvokeVoid("BitButil.serviceWorker.subscribeControllerChange", + ServiceWorkerListenersManager.ControllerChangeMethodName, id); + return new ButilSubscription(id, async () => + { + ServiceWorkerListenersManager.RemoveControllerChangeListener(id); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.serviceWorker.unsubscribeControllerChange", id); + }); + } +} diff --git a/src/Butil/Bit.Butil/Publics/ServiceWorker/ServiceWorkerRegistrationInfo.cs b/src/Butil/Bit.Butil/Publics/ServiceWorker/ServiceWorkerRegistrationInfo.cs new file mode 100644 index 0000000000..6b68629ee0 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/ServiceWorker/ServiceWorkerRegistrationInfo.cs @@ -0,0 +1,25 @@ +namespace Bit.Butil; + +/// +/// Snapshot of ServiceWorkerRegistration. +/// +public class ServiceWorkerRegistrationInfo +{ + /// True when a registration was found / created. + public bool IsRegistered { get; set; } + + /// Scope URL the registration applies to. + public string Scope { get; set; } = string.Empty; + + /// The active worker's state: "installing", "installed", "activating", "activated", "redundant", or null when none. + public string? ActiveState { get; set; } + + /// The installing worker's state, when one is being installed. + public string? InstallingState { get; set; } + + /// The waiting worker's state, when an update is queued. + public string? WaitingState { get; set; } + + /// Update via cache strategy: "imports", "all", or "none". + public string? UpdateViaCache { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Speech/SpeechUtterance.cs b/src/Butil/Bit.Butil/Publics/Speech/SpeechUtterance.cs new file mode 100644 index 0000000000..6ca508be70 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Speech/SpeechUtterance.cs @@ -0,0 +1,25 @@ +namespace Bit.Butil; + +/// +/// Configuration for a single SpeechSynthesisUtterance. +/// +public class SpeechUtterance +{ + /// The text to speak. + public string Text { get; set; } = string.Empty; + + /// BCP-47 language tag, e.g. "en-US". null falls back to the document language. + public string? Lang { get; set; } + + /// Voice name to use; must match a voice from . + public string? VoiceName { get; set; } + + /// Speech rate (0.1–10, defaults to 1). + public double? Rate { get; set; } + + /// Pitch (0–2, defaults to 1). + public double? Pitch { get; set; } + + /// Volume (0–1, defaults to 1). + public double? Volume { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/Speech/SpeechVoice.cs b/src/Butil/Bit.Butil/Publics/Speech/SpeechVoice.cs new file mode 100644 index 0000000000..bc465fd88b --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Speech/SpeechVoice.cs @@ -0,0 +1,21 @@ +namespace Bit.Butil; + +/// +/// Mirrors SpeechSynthesisVoice. +/// +public class SpeechVoice +{ + public string Name { get; set; } = string.Empty; + + /// BCP-47 language tag — e.g. "en-US". + public string Lang { get; set; } = string.Empty; + + /// Voice URI (often the same as for local voices). + public string VoiceUri { get; set; } = string.Empty; + + /// True for the runtime's default voice for the chosen language. + public bool Default { get; set; } + + /// True when synthesis happens locally (no server round-trip). + public bool LocalService { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/SpeechRecognition.cs b/src/Butil/Bit.Butil/Publics/SpeechRecognition.cs new file mode 100644 index 0000000000..fece9fde72 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/SpeechRecognition.cs @@ -0,0 +1,65 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the SpeechRecognition +/// API (Web Speech, prefixed as webkitSpeechRecognition on Chromium). +/// +public class SpeechRecognition(IJSRuntime js) +{ + /// True when the runtime exposes a SpeechRecognition implementation. + public ValueTask IsSupported() => js.Invoke("BitButil.speechRecognition.isSupported"); + + /// + /// Starts recognition. Returns an that calls when disposed. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SpeechRecognitionResult))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SpeechRecognitionOptions))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SpeechRecognitionListenersManager))] + public async Task Start(SpeechRecognitionOptions options, + Action? onResult = null, + Action? onError = null, + Action? onEnd = null) + { + if (onResult is null && onError is null && onEnd is null) + throw new ArgumentException("At least one of onResult/onError/onEnd must be provided."); + + var listener = new SpeechRecognitionListenersManager.Listener + { + OnResult = onResult, + OnError = onError, + OnEnd = onEnd + }; + var id = SpeechRecognitionListenersManager.Add(listener); + + await js.InvokeVoid("BitButil.speechRecognition.start", + id, + options ?? new SpeechRecognitionOptions(), + SpeechRecognitionListenersManager.ResultMethodName, + SpeechRecognitionListenersManager.ErrorMethodName, + SpeechRecognitionListenersManager.EndMethodName); + + return new RecognitionHandle(js, id); + } + + /// Stops the matching recognition session early. Equivalent to disposing the handle. + public ValueTask Stop(Guid id) => js.InvokeVoid("BitButil.speechRecognition.stop", id); + + private sealed class RecognitionHandle(IJSRuntime js, Guid id) : IAsyncDisposable + { + private bool _disposed; + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + SpeechRecognitionListenersManager.Remove(id); + try { await js.InvokeVoid("BitButil.speechRecognition.stop", id); } + catch (JSDisconnectedException) { } + } + } +} diff --git a/src/Butil/Bit.Butil/Publics/SpeechRecognition/SpeechRecognitionOptions.cs b/src/Butil/Bit.Butil/Publics/SpeechRecognition/SpeechRecognitionOptions.cs new file mode 100644 index 0000000000..4f64c32379 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/SpeechRecognition/SpeechRecognitionOptions.cs @@ -0,0 +1,17 @@ +namespace Bit.Butil; + +/// Options for . +public class SpeechRecognitionOptions +{ + /// BCP-47 language tag, e.g. "en-US". Defaults to the document language. + public string? Lang { get; set; } + + /// When true, recognition keeps running across pauses until . + public bool Continuous { get; set; } + + /// When true, the engine reports interim (non-final) results too. + public bool InterimResults { get; set; } = true; + + /// How many alternative transcripts to surface per result. 1–5 is typical. + public int MaxAlternatives { get; set; } = 1; +} diff --git a/src/Butil/Bit.Butil/Publics/SpeechRecognition/SpeechRecognitionResult.cs b/src/Butil/Bit.Butil/Publics/SpeechRecognition/SpeechRecognitionResult.cs new file mode 100644 index 0000000000..d2d14f6d44 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/SpeechRecognition/SpeechRecognitionResult.cs @@ -0,0 +1,16 @@ +namespace Bit.Butil; + +/// +/// One transcript reported by . +/// +public class SpeechRecognitionResult +{ + /// The recognized text. + public string Transcript { get; set; } = string.Empty; + + /// Engine confidence in [0, 1]. + public double Confidence { get; set; } + + /// True when the engine considers this transcript final (no more revisions). + public bool IsFinal { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/SpeechSynthesis.cs b/src/Butil/Bit.Butil/Publics/SpeechSynthesis.cs new file mode 100644 index 0000000000..93911f8adb --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/SpeechSynthesis.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the SpeechSynthesis +/// API for text-to-speech. +/// +public class SpeechSynthesis(IJSRuntime js) +{ + /// True when the runtime exposes window.speechSynthesis. + public ValueTask IsSupported() => js.Invoke("BitButil.speech.isSupported"); + + /// Returns the list of voices the platform makes available. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SpeechVoice))] + public ValueTask GetVoices() => js.Invoke("BitButil.speech.getVoices"); + + /// Speaks the configured utterance. Resolves once the engine has accepted it (not when speech finishes). + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SpeechUtterance))] + public ValueTask Speak(SpeechUtterance utterance) => js.InvokeVoid("BitButil.speech.speak", utterance); + + /// Quick-shorthand: speak with default voice and rate. + public ValueTask Speak(string text) => Speak(new SpeechUtterance { Text = text }); + + /// Cancels all pending utterances and stops any current speech. + public ValueTask Cancel() => js.InvokeVoid("BitButil.speech.cancel"); + + /// Pauses the current utterance. + public ValueTask Pause() => js.InvokeVoid("BitButil.speech.pause"); + + /// Resumes a paused utterance. + public ValueTask Resume() => js.InvokeVoid("BitButil.speech.resume"); + + /// True when the engine is currently speaking (or paused). + public ValueTask IsSpeaking() => js.Invoke("BitButil.speech.isSpeaking"); + + /// True when an utterance is queued. + public ValueTask IsPending() => js.Invoke("BitButil.speech.isPending"); +} diff --git a/src/Butil/Bit.Butil/Publics/Storage/ButilStorage.cs b/src/Butil/Bit.Butil/Publics/Storage/ButilStorage.cs index 6db7ea9ea5..56da9cb756 100644 --- a/src/Butil/Bit.Butil/Publics/Storage/ButilStorage.cs +++ b/src/Butil/Bit.Butil/Publics/Storage/ButilStorage.cs @@ -1,5 +1,9 @@ -using System.Threading.Tasks; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading.Tasks; using Microsoft.JSInterop; +using static Bit.Butil.LinkerFlags; namespace Bit.Butil; @@ -27,6 +31,12 @@ public async Task GetLength() public async Task GetKey(int index) => await js.Invoke("BitButil.storage.key", storageName, index); + /// + /// True when the storage contains an item with the given key. + /// + public async Task ContainsKey(string key) + => await js.Invoke("BitButil.storage.containsKey", storageName, key); + /// /// When passed a key name, will return that key's value. ///
@@ -35,6 +45,22 @@ public async Task GetLength() public async Task GetItem(string? key) => await js.Invoke("BitButil.storage.getItem", storageName, key); + /// + /// Returns a JSON-deserialized value, or default() when the key is missing. + /// + [RequiresUnreferencedCode("JSON deserialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON deserialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public async Task GetItem<[DynamicallyAccessedMembers(JsonSerialized)] T>(string key, JsonSerializerOptions? options = null) + { + var raw = await GetItem(key); + if (raw is null) return default; + + // Strings round-trip without an extra Deserialize for the common case. + if (typeof(T) == typeof(string)) return (T?)(object?)raw; + + return JsonSerializer.Deserialize(raw, options); + } + /// /// When passed a key name and value, will add that key to the storage, or update that key's value if it already exists. ///
@@ -43,6 +69,18 @@ public async Task GetLength() public async Task SetItem(string? key, string? value) => await js.InvokeVoid("BitButil.storage.setItem", storageName, key, value); + /// + /// JSON-serializes and stores it under . + /// + [RequiresUnreferencedCode("JSON serialization may require types that cannot be statically analyzed.")] + [RequiresDynamicCode("JSON serialization may use reflection-based code paths that aren't AOT-safe; use a source generator for native AOT.")] + public Task SetItem<[DynamicallyAccessedMembers(JsonSerialized)] T>(string key, T? value, JsonSerializerOptions? options = null) + { + if (value is null) return SetItem(key, (string?)null); + if (value is string s) return SetItem(key, s); + return SetItem(key, JsonSerializer.Serialize(value, options)); + } + /// /// When passed a key name, will remove that key from the storage. ///
@@ -58,4 +96,25 @@ public async Task RemoveItem(string? key) ///
public async Task Clear() => await js.InvokeVoid("BitButil.storage.clear", storageName); + + /// + /// Subscribes to cross-tab storage events for this storage area + /// (localStorage or sessionStorage). The event only fires when another + /// tab/window of the same origin modifies the matching storage. + ///
+ /// window.storage + ///
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(StorageEvent))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(StorageListenersManager))] + public async Task SubscribeChanges(Action handler) + { + var id = StorageListenersManager.AddListener(handler, storageName); + await js.InvokeVoid("BitButil.storage.subscribe", StorageListenersManager.InvokeMethodName, id); + return new ButilSubscription(id, async () => + { + StorageListenersManager.RemoveListeners([id]); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.storage.unsubscribe", id); + }); + } } diff --git a/src/Butil/Bit.Butil/Publics/Storage/StorageEvent.cs b/src/Butil/Bit.Butil/Publics/Storage/StorageEvent.cs new file mode 100644 index 0000000000..d4a6439af1 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Storage/StorageEvent.cs @@ -0,0 +1,23 @@ +namespace Bit.Butil; + +/// +/// Mirrors StorageEvent. +/// Fires only when the modification happens in another tab/window of the same origin. +/// +public class StorageEvent +{ + /// The key that was added/removed/changed. null when storage.clear() was called. + public string? Key { get; set; } + + /// Previous value, or null when added or cleared. + public string? OldValue { get; set; } + + /// New value, or null when removed or cleared. + public string? NewValue { get; set; } + + /// URL of the document that triggered the event. + public string? Url { get; set; } + + /// "localStorage" or "sessionStorage". + public string StorageArea { get; set; } = string.Empty; +} diff --git a/src/Butil/Bit.Butil/Publics/StorageManager.cs b/src/Butil/Bit.Butil/Publics/StorageManager.cs new file mode 100644 index 0000000000..7c97c1c80c --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/StorageManager.cs @@ -0,0 +1,28 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps navigator.storage. +/// +public class StorageManager(IJSRuntime js) +{ + /// True when the runtime exposes navigator.storage. + public ValueTask IsSupported() => js.Invoke("BitButil.storageManager.isSupported"); + + /// + /// Reports an estimate of the storage quota and current usage for the origin. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(StorageEstimate))] + public ValueTask Estimate() => js.Invoke("BitButil.storageManager.estimate"); + + /// True when the origin's storage is persistent (won't be evicted under pressure). + public ValueTask Persisted() => js.Invoke("BitButil.storageManager.persisted"); + + /// + /// Asks the browser to make storage persistent. The user agent decides whether to grant. + /// + public ValueTask Persist() => js.Invoke("BitButil.storageManager.persist"); +} diff --git a/src/Butil/Bit.Butil/Publics/StorageManager/StorageEstimate.cs b/src/Butil/Bit.Butil/Publics/StorageManager/StorageEstimate.cs new file mode 100644 index 0000000000..817481401b --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/StorageManager/StorageEstimate.cs @@ -0,0 +1,11 @@ +namespace Bit.Butil; + +/// +/// Mirrors StorageManager.estimate(). +/// All values are in bytes; null when the runtime can't report them. +/// +public class StorageEstimate +{ + public long? Quota { get; set; } + public long? Usage { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/UserAgent.cs b/src/Butil/Bit.Butil/Publics/UserAgent.cs index dc0650754f..00b8cc87cd 100644 --- a/src/Butil/Bit.Butil/Publics/UserAgent.cs +++ b/src/Butil/Bit.Butil/Publics/UserAgent.cs @@ -17,4 +17,36 @@ public async ValueTask Extract(string? userAgentString = nu { return await js.Invoke("BitButil.userAgent.extract", userAgentString); } + + /// + /// True when the runtime exposes navigator.userAgentData (modern UA Client Hints). + /// + public ValueTask IsClientHintsSupported() + => js.Invoke("BitButil.userAgent.isClientHintsSupported"); + + /// + /// Low-entropy brands list from navigator.userAgentData.brands. Returns an empty array + /// on browsers that don't expose UA-CH. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(UserAgentBrand))] + public ValueTask GetBrands() + => js.Invoke("BitButil.userAgent.getBrands"); + + /// True when the user-agent identifies itself as a mobile device. + public ValueTask IsMobile() + => js.Invoke("BitButil.userAgent.isMobile"); + + /// The OS family the UA-CH layer reports — empty string when unsupported. + public ValueTask GetPlatform() + => js.Invoke("BitButil.userAgent.getPlatform"); + + /// + /// Requests high-entropy UA values. Callers must explicitly opt in to each hint + /// they need (e.g. "architecture", "platformVersion", "model") — see + /// getHighEntropyValues(). + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(HighEntropyUserAgent))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(UserAgentBrand))] + public ValueTask GetHighEntropyValues(params string[] hints) + => js.Invoke("BitButil.userAgent.getHighEntropyValues", (object)hints); } diff --git a/src/Butil/Bit.Butil/Publics/UserAgent/HighEntropyUserAgent.cs b/src/Butil/Bit.Butil/Publics/UserAgent/HighEntropyUserAgent.cs new file mode 100644 index 0000000000..0a17540163 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/UserAgent/HighEntropyUserAgent.cs @@ -0,0 +1,19 @@ +namespace Bit.Butil; + +/// +/// Result of . All fields are nullable because callers +/// can request a subset and the runtime may decline to provide some values. +/// +public class HighEntropyUserAgent +{ + public string? Architecture { get; set; } + public string? Bitness { get; set; } + public UserAgentBrand[]? Brands { get; set; } + public UserAgentBrand[]? FullVersionList { get; set; } + public bool? Mobile { get; set; } + public string? Model { get; set; } + public string? Platform { get; set; } + public string? PlatformVersion { get; set; } + public string? UaFullVersion { get; set; } + public bool? Wow64 { get; set; } +} diff --git a/src/Butil/Bit.Butil/Publics/UserAgent/UserAgentBrand.cs b/src/Butil/Bit.Butil/Publics/UserAgent/UserAgentBrand.cs new file mode 100644 index 0000000000..814abdfef9 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/UserAgent/UserAgentBrand.cs @@ -0,0 +1,8 @@ +namespace Bit.Butil; + +/// One entry of NavigatorUAData.brands. +public class UserAgentBrand +{ + public string Brand { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; +} diff --git a/src/Butil/Bit.Butil/Publics/VisualViewport.cs b/src/Butil/Bit.Butil/Publics/VisualViewport.cs index dbc3593c15..8bc3782396 100644 --- a/src/Butil/Bit.Butil/Publics/VisualViewport.cs +++ b/src/Butil/Bit.Butil/Publics/VisualViewport.cs @@ -104,6 +104,16 @@ public async ValueTask AddResize(Action handler) return listenerId; } + /// + /// Subscribe variant of returning an handle. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(VisualViewportListenersManager))] + public async ValueTask SubscribeResize(Action handler) + { + var id = await AddResize(handler); + return new ButilSubscription(id, () => RemoveResize(id)); + } + /// /// Fired when the visual viewport is resized. ///
@@ -165,6 +175,16 @@ public async ValueTask AddScroll(Action handler) return listenerId; } + /// + /// Subscribe variant of returning an handle. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(VisualViewportListenersManager))] + public async ValueTask SubscribeScroll(Action handler) + { + var id = await AddScroll(handler); + return new ButilSubscription(id, () => RemoveScroll(id)); + } + /// /// Fired when the visual viewport is scrolled. ///
diff --git a/src/Butil/Bit.Butil/Publics/WakeLock.cs b/src/Butil/Bit.Butil/Publics/WakeLock.cs new file mode 100644 index 0000000000..faa92e1352 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/WakeLock.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Screen Wake Lock API. +/// +/// +/// The browser will automatically release the wake lock when the page is hidden. +/// Re-acquire it on visibilitychange when the page becomes visible again. +/// +public class WakeLock(IJSRuntime js) : IAsyncDisposable +{ + private bool _heldByUs; + + /// True when the runtime exposes navigator.wakeLock. + public ValueTask IsSupported() => js.Invoke("BitButil.wakeLock.isSupported"); + + /// + /// Requests a screen wake lock. The lock is released either explicitly + /// ( / ) or when the user-agent decides + /// (typically when the page is hidden). + /// + /// True when the lock was acquired. + public async ValueTask Request() + { + var ok = await js.Invoke("BitButil.wakeLock.request"); + if (ok) _heldByUs = true; + return ok; + } + + /// Releases the most recently acquired lock if it is still active. + public async ValueTask Release() + { + if (_heldByUs is false) return; + _heldByUs = false; + await js.InvokeVoid("BitButil.wakeLock.release"); + } + + /// + /// Acquires a wake lock and keeps it alive across the page-visibility cycle by re-acquiring + /// it whenever the page becomes visible again. Browsers always release the lock when the + /// page is hidden — this helper restores it on resume. + /// + /// An that stops the auto-reacquire and releases the lock. + public async ValueTask RequestPersistent() + { + var token = Guid.NewGuid().ToString("N"); + await js.InvokeVoid("BitButil.wakeLock.persist", token); + return new PersistentLockHandle(js, token); + } + + public async ValueTask DisposeAsync() + { + try + { + await Release(); + } + catch (JSDisconnectedException) { } + GC.SuppressFinalize(this); + } + + private sealed class PersistentLockHandle(IJSRuntime js, string token) : IAsyncDisposable + { + private bool _disposed; + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try { await js.InvokeVoid("BitButil.wakeLock.unpersist", token); } + catch (JSDisconnectedException) { } + } + } +} diff --git a/src/Butil/Bit.Butil/Publics/WebAudio.cs b/src/Butil/Bit.Butil/Publics/WebAudio.cs new file mode 100644 index 0000000000..9a5396530b --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/WebAudio.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Lightweight wrapper over the +/// Web Audio API. +/// +/// +/// The wrapper deliberately exposes only the high-traffic operations (play a buffer, play a +/// tone, master gain). Build a richer node graph in JS and call into it via interop when you +/// need granular control. +/// +public class WebAudio(IJSRuntime js) +{ + /// True when the runtime exposes AudioContext. + public ValueTask IsSupported() => js.Invoke("BitButil.webAudio.isSupported"); + + /// + /// Resumes a suspended AudioContext. Mobile Safari requires this on the first user + /// interaction; calling it from a click/touch handler unblocks subsequent playback. + /// + public ValueTask Resume() => js.InvokeVoid("BitButil.webAudio.resume"); + + /// Suspends the shared AudioContext. + public ValueTask Suspend() => js.InvokeVoid("BitButil.webAudio.suspend"); + + /// Sets the master gain (in [0, 1]) applied to every Butil-managed playback. + public ValueTask SetMasterGain(double value) => js.InvokeVoid("BitButil.webAudio.setMasterGain", value); + + /// + /// Decodes and plays the given audio bytes. Returns a handle for stop/gain control. + /// + public async ValueTask PlayBuffer(byte[] data, double startGain = 1.0, bool loop = false) + { + var id = Guid.NewGuid(); + await js.InvokeVoid("BitButil.webAudio.playBuffer", id, data, startGain, loop); + return new AudioPlaybackHandle(js, id); + } + + /// + /// Plays a sine/triangle/square/sawtooth oscillator at the given frequency for + /// milliseconds. Set to 0 + /// for an open-ended tone you stop manually. + /// + public async ValueTask PlayTone(double frequency, + double durationMs = 0, + string waveform = "sine", + double startGain = 0.5) + { + var id = Guid.NewGuid(); + await js.InvokeVoid("BitButil.webAudio.playTone", id, frequency, durationMs, waveform, startGain); + return new AudioPlaybackHandle(js, id); + } +} diff --git a/src/Butil/Bit.Butil/Publics/WebAudio/AudioPlaybackHandle.cs b/src/Butil/Bit.Butil/Publics/WebAudio/AudioPlaybackHandle.cs new file mode 100644 index 0000000000..185dc9fb17 --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/WebAudio/AudioPlaybackHandle.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Handle to a one-shot Web Audio playback ( / +/// ). Dispose to stop early. +/// +public sealed class AudioPlaybackHandle : IAsyncDisposable +{ + private readonly IJSRuntime _js; + private readonly Guid _id; + private bool _disposed; + + internal AudioPlaybackHandle(IJSRuntime js, Guid id) { _js = js; _id = id; } + + /// The internal playback id. + public Guid Id => _id; + + /// Stops playback immediately. + public ValueTask Stop() => _js.InvokeVoid("BitButil.webAudio.stop", _id); + + /// Sets per-source gain in [0, 1]. + public ValueTask SetGain(double value) => _js.InvokeVoid("BitButil.webAudio.setGain", _id, value); + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try { await _js.InvokeVoid("BitButil.webAudio.stop", _id); } + catch (JSDisconnectedException) { } + } +} diff --git a/src/Butil/Bit.Butil/Publics/WebLocks.cs b/src/Butil/Bit.Butil/Publics/WebLocks.cs new file mode 100644 index 0000000000..4bbabfcf3d --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/WebLocks.cs @@ -0,0 +1,87 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Wraps the Web Locks API +/// (navigator.locks). +/// +/// +/// The native API uses a callback that holds the lock for the duration of its returned Promise. +/// We expose two ergonomic forms: +/// +/// +/// hands you an . Dispose to release the lock. +/// This matches typical .NET using patterns. +/// +/// +/// runs your callback while holding the lock — closer to the JS API. +/// +/// +/// +public class WebLocks(IJSRuntime js) +{ + /// True when the runtime exposes navigator.locks. + public ValueTask IsSupported() => js.Invoke("BitButil.webLocks.isSupported"); + + /// + /// Acquires the named lock. Dispose the returned handle to release. + /// + /// When true, returns a null handle immediately if the lock isn't available. + /// When true, steals the lock from the current holder. Use with care. + public async ValueTask Acquire(string name, + WebLockMode mode = WebLockMode.Exclusive, + bool ifAvailable = false, + bool steal = false, + CancellationToken cancellationToken = default) + { + var releaseToken = Guid.NewGuid().ToString("N"); + var modeStr = mode == WebLockMode.Shared ? "shared" : "exclusive"; + + var acquired = await js.Invoke("BitButil.webLocks.acquire", + cancellationToken, name, modeStr, ifAvailable, steal, releaseToken); + + if (!acquired) return null; + + return new WebLockHandle(js, releaseToken); + } + + /// + /// Runs while holding the named lock. Releases automatically. + /// + public async ValueTask Run(string name, Func action, + WebLockMode mode = WebLockMode.Exclusive, + bool ifAvailable = false, + bool steal = false, + CancellationToken cancellationToken = default) + { + var handle = await Acquire(name, mode, ifAvailable, steal, cancellationToken); + if (handle is null) return; // ifAvailable was true and the lock wasn't free + await using (handle) + { + await action(); + } + } + + /// Returns the current state of the lock manager. + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(WebLockSnapshot))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(WebLockInfo))] + public ValueTask Query() => js.Invoke("BitButil.webLocks.query"); + + private sealed class WebLockHandle(IJSRuntime js, string token) : IAsyncDisposable + { + private bool _disposed; + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + try { await js.InvokeVoid("BitButil.webLocks.release", token); } + catch (JSDisconnectedException) { } + } + } +} diff --git a/src/Butil/Bit.Butil/Publics/Window.cs b/src/Butil/Bit.Butil/Publics/Window.cs index 7917199b62..7bb32e307e 100644 --- a/src/Butil/Bit.Butil/Publics/Window.cs +++ b/src/Butil/Bit.Butil/Publics/Window.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -14,11 +16,38 @@ public class Window(IJSRuntime js) : IAsyncDisposable { private const string ElementName = "window"; + private readonly System.Collections.Concurrent.ConcurrentDictionary> _matchMediaHandlers = new(); + + private readonly System.Collections.Concurrent.ConcurrentDictionary<(Guid Id, string Event, bool UseCapture), byte> _listenerIds = new(); + public async Task AddEventListener(string domEvent, Action listener, bool useCapture = false) - => await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture); + { + var id = await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture); + _listenerIds.TryAdd((id, domEvent, useCapture), 0); + } public async Task RemoveEventListener(string domEvent, Action listener, bool useCapture = false) - => await DomEventDispatcher.RemoveEventListener(js, ElementName, domEvent, listener, useCapture); + { + var ids = await DomEventDispatcher.RemoveEventListener(js, ElementName, domEvent, listener, useCapture); + foreach (var id in ids) _listenerIds.TryRemove((id, domEvent, useCapture), out _); + } + + /// + /// Subscribe variant of returning an handle. + /// + public async Task SubscribeEvent(string domEvent, Action listener, bool useCapture = false) + { + var id = await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture); + var key = (id, domEvent, useCapture); + _listenerIds.TryAdd(key, 0); + + return new ButilSubscription(id, async () => + { + _listenerIds.TryRemove(key, out _); + if (OperatingSystem.IsBrowser() is false) return; + await DomEventDispatcher.RemoveEventListenerById(js, ElementName, domEvent, id, useCapture); + }); + } /// /// The beforeunload event is fired when the current window, contained document, and associated resources are about to be unloaded. @@ -29,6 +58,14 @@ public async Task RemoveEventListener(string domEvent, Action listener, bo public async Task AddBeforeUnload() => await js.InvokeVoid("BitButil.window.addBeforeUnload"); + /// + /// Same as but stores a confirmation message. Modern browsers + /// ignore the message text and show their own generic warning, but supplying a message + /// guarantees the prompt fires consistently across user-gesture vs auto-navigation cases. + /// + public Task AddBeforeUnload(string message) + => js.InvokeVoid("BitButil.window.addBeforeUnload", message).AsTask(); + /// /// The beforeunload event is fired when the current window, contained document, and associated resources are about to be unloaded. /// The document is still visible and the event is still cancelable at this point. @@ -38,6 +75,31 @@ public async Task AddBeforeUnload() public async Task RemoveBeforeUnload() => await js.InvokeVoid("BitButil.window.removeBeforeUnload"); + // ─── Page Lifecycle ───────────────────────────────────────────────────────── + + /// + /// Fires when the page is frozen by the browser (typically because it has been moved + /// to the back/forward cache). Use this to release expensive resources. + ///
+ /// freeze + ///
+ public Task SubscribeFreeze(Action handler) + { + Action bridge = _ => handler(); + return SubscribeEvent("freeze", bridge); + } + + /// + /// Fires when the page resumes from the back/forward cache. + ///
+ /// resume + ///
+ public Task SubscribeResume(Action handler) + { + Action bridge = _ => handler(); + return SubscribeEvent("resume", bridge); + } + /// /// Gets the height of the content area of the browser window in px including, if rendered, the horizontal scrollbar. ///
@@ -213,12 +275,34 @@ public async Task Focus() => await js.InvokeVoid("BitButil.window.focus"); /// - /// Returns the selection text representing the selected item(s). + /// Returns a snapshot of the current selection (selected text plus range metadata). + ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/Window/getSelection + ///
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(WindowSelection))] + public async Task GetSelection() + => await js.Invoke("BitButil.window.getSelection"); + + /// + /// Returns just the selected text (equivalent to window.getSelection().toString()). ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Window/getSelection ///
- public async Task GetSelection() - => await js.Invoke("BitButil.window.getSelection"); + public async Task GetSelectionText() + => await js.Invoke("BitButil.window.getSelectionText"); + + /// Removes any current selection. + public Task ClearSelection() => js.InvokeVoid("BitButil.window.clearSelection").AsTask(); + + /// + /// Selects every text node inside . Works on form-control inputs + /// too, falling back to HTMLInputElement.select(). + /// + public Task SelectElement(Microsoft.AspNetCore.Components.ElementReference element) + => js.InvokeVoid("BitButil.window.selectElement", element).AsTask(); + + /// Copies the current selection to the clipboard, returning true on success. + public Task CopySelection() => js.Invoke("BitButil.window.copySelection").AsTask(); /// /// Returns a MediaQueryList object representing the specified media query string. @@ -228,6 +312,69 @@ public async Task GetSelection() public async Task MatchMedia(string query) => await js.Invoke("BitButil.window.matchMedia", query); + /// + /// Subscribes to the change event of matchMedia(query). The handler fires whenever + /// the media query's evaluation flips (e.g. when the user toggles dark mode or rotates the device). + /// Use with the returned id to stop listening. + ///
+ /// https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList/change_event + ///
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaQueryListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaQueryList))] + public async Task SubscribeMatchMedia(string query, Action handler) + { + var listenerId = MediaQueryListenersManager.AddListener(handler); + _matchMediaHandlers.TryAdd(listenerId, handler); + + await js.InvokeVoid("BitButil.window.subscribeMatchMedia", + MediaQueryListenersManager.InvokeMethodName, + listenerId, + query); + + return listenerId; + } + + /// + /// Subscribe variant of + /// returning an handle. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaQueryListenersManager))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaQueryList))] + public async Task WatchMatchMedia(string query, Action handler) + { + var id = await SubscribeMatchMedia(query, handler); + return new ButilSubscription(id, () => UnsubscribeMatchMedia(id)); + } + + /// + /// Removes a previously registered match-media listener. + /// + public async ValueTask UnsubscribeMatchMedia(Guid id) + { + MediaQueryListenersManager.RemoveListeners([id]); + _matchMediaHandlers.TryRemove(id, out _); + if (OperatingSystem.IsBrowser() is false) return; + await js.InvokeVoid("BitButil.window.unsubscribeMatchMedia", new[] { id }); + } + + /// + /// Removes a previously registered match-media listener by handler reference. + /// + public async ValueTask UnsubscribeMatchMedia(Action handler) + { + var ids = MediaQueryListenersManager.RemoveListener(handler); + if (ids.Length == 0) return ids; + + foreach (var id in ids) _matchMediaHandlers.TryRemove(id, out _); + + if (OperatingSystem.IsBrowser()) + { + await js.InvokeVoid("BitButil.window.unsubscribeMatchMedia", ids); + } + + return ids; + } + /// /// Opens a new window. ///
@@ -310,6 +457,30 @@ protected virtual async ValueTask DisposeAsync(bool disposing) try { + if (_matchMediaHandlers.Count > 0) + { + var ids = _matchMediaHandlers.Keys.ToArray(); + _matchMediaHandlers.Clear(); + MediaQueryListenersManager.RemoveListeners(ids); + if (OperatingSystem.IsBrowser()) + { + await js.InvokeVoid("BitButil.window.unsubscribeMatchMedia", ids); + } + } + + if (_listenerIds.IsEmpty is false) + { + var snapshot = _listenerIds.Keys.ToArray(); + _listenerIds.Clear(); + if (OperatingSystem.IsBrowser()) + { + foreach (var (id, evt, useCapture) in snapshot) + { + await DomEventDispatcher.RemoveEventListenerById(js, ElementName, evt, id, useCapture); + } + } + } + await js.InvokeVoid("BitButil.window.dispose"); } catch (JSDisconnectedException) { } // we can ignore this exception here diff --git a/src/Butil/Bit.Butil/Publics/Window/WindowSelection.cs b/src/Butil/Bit.Butil/Publics/Window/WindowSelection.cs new file mode 100644 index 0000000000..3c7ce9998e --- /dev/null +++ b/src/Butil/Bit.Butil/Publics/Window/WindowSelection.cs @@ -0,0 +1,41 @@ +namespace Bit.Butil; + +/// +/// Represents the current text selection inside the window. Mirrors the most useful +/// fields of Selection. +/// +public class WindowSelection +{ + /// + /// The full selected text, equivalent to Selection.toString(). + /// + public string Text { get; set; } = string.Empty; + + /// + /// True when the anchor and focus are at the same position. + /// + /// + public bool IsCollapsed { get; set; } + + /// + /// The number of contiguous ranges in the selection (typically 0 or 1). + /// + /// + public int RangeCount { get; set; } + + /// + /// The selection type: None, Caret, or Range. + /// + /// + public string? Type { get; set; } + + /// + /// Offset of the anchor (selection start) inside its node. + /// + public int AnchorOffset { get; set; } + + /// + /// Offset of the focus (selection end) inside its node. + /// + public int FocusOffset { get; set; } +} diff --git a/src/Butil/Bit.Butil/Scripts/animation.ts b/src/Butil/Bit.Butil/Scripts/animation.ts new file mode 100644 index 0000000000..a881de2059 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/animation.ts @@ -0,0 +1,48 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _animations: { [id: string]: Animation } = {}; + + butil.animation = { + animate(id: string, element: HTMLElement, keyframes: Keyframe[], options: any) { + if (!element || typeof element.animate !== 'function') return; + // Map double.PositiveInfinity (sent as Infinity) → JS Infinity. JSON cannot represent + // it natively, so dotnet sends "Infinity" as a string; normalize defensively. + const iterations = options.iterations === 'Infinity' ? Infinity : options.iterations; + const animation = element.animate(keyframes, { + duration: options.duration, + delay: options.delay, + endDelay: options.endDelay, + iterations, + easing: options.easing, + direction: options.direction, + fill: options.fill, + composite: options.composite + }); + _animations[id] = animation; + }, + play(id: string) { _animations[id]?.play(); }, + pause(id: string) { _animations[id]?.pause(); }, + reverse(id: string) { _animations[id]?.reverse(); }, + cancel(id: string) { + const a = _animations[id]; + if (!a) return; + delete _animations[id]; + try { a.cancel(); } catch { /* already finished */ } + }, + finish(id: string) { + const a = _animations[id]; + if (!a) return; + try { a.finish(); } catch { /* fillMode "none" rejects this */ } + }, + async whenFinished(id: string) { + const a = _animations[id]; + if (!a?.finished) return; + try { await a.finished; } catch { /* canceled */ } + }, + setPlaybackRate(id: string, rate: number) { + const a = _animations[id]; + if (a) a.playbackRate = rate; + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/backgroundSync.ts b/src/Butil/Bit.Butil/Scripts/backgroundSync.ts new file mode 100644 index 0000000000..317390d3a3 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/backgroundSync.ts @@ -0,0 +1,44 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.backgroundSync = { + async isSupported() { + const reg = await window.navigator.serviceWorker?.getRegistration(); + return !!(reg && (reg as any).sync); + }, + async isPeriodicSupported() { + const reg = await window.navigator.serviceWorker?.getRegistration(); + return !!(reg && (reg as any).periodicSync); + }, + async register(tag: string) { + const reg: any = await window.navigator.serviceWorker?.getRegistration(); + if (!reg?.sync) return false; + try { await reg.sync.register(tag); return true; } + catch { return false; } + }, + async getTags() { + const reg: any = await window.navigator.serviceWorker?.getRegistration(); + if (!reg?.sync?.getTags) return []; + try { return await reg.sync.getTags(); } + catch { return []; } + }, + async registerPeriodic(tag: string, minInterval: number) { + const reg: any = await window.navigator.serviceWorker?.getRegistration(); + if (!reg?.periodicSync) return false; + try { await reg.periodicSync.register(tag, { minInterval }); return true; } + catch { return false; } + }, + async getPeriodicTags() { + const reg: any = await window.navigator.serviceWorker?.getRegistration(); + if (!reg?.periodicSync?.getTags) return []; + try { return await reg.periodicSync.getTags(); } + catch { return []; } + }, + async unregisterPeriodic(tag: string) { + const reg: any = await window.navigator.serviceWorker?.getRegistration(); + if (!reg?.periodicSync?.unregister) return false; + try { await reg.periodicSync.unregister(tag); return true; } + catch { return false; } + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/battery.ts b/src/Butil/Bit.Butil/Scripts/battery.ts new file mode 100644 index 0000000000..cc3fa37468 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/battery.ts @@ -0,0 +1,20 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.battery = { + isSupported() { return typeof (window.navigator as any).getBattery === 'function'; }, + async getStatus() { + const nav = window.navigator as any; + if (typeof nav.getBattery !== 'function') { + return { charging: true, chargingTime: 0, dischargingTime: Infinity, level: 1 }; + } + const b = await nav.getBattery(); + return { + charging: !!b.charging, + chargingTime: b.chargingTime, + dischargingTime: b.dischargingTime, + level: b.level + }; + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/broadcastChannel.ts b/src/Butil/Bit.Butil/Scripts/broadcastChannel.ts new file mode 100644 index 0000000000..971e8e18c0 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/broadcastChannel.ts @@ -0,0 +1,67 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + // Per-channel reference counting so multiple subscribers share one BroadcastChannel instance. + const _channels: { [name: string]: { ch: BroadcastChannel, subscribers: number } } = {}; + const _subscribers: { [id: string]: { name: string, onMessage: (e: MessageEvent) => void, onError: () => void } } = {}; + + butil.broadcastChannel = { + isSupported() { return 'BroadcastChannel' in window; }, + post, + subscribe, + unsubscribe + }; + + function getChannel(name: string) { + let entry = _channels[name]; + if (!entry) { + entry = { ch: new BroadcastChannel(name), subscribers: 0 }; + _channels[name] = entry; + } + return entry; + } + + function post(channelName: string, message: any) { + if (!('BroadcastChannel' in window)) return; + // Use a transient channel for fire-and-forget posts so we never accumulate stray channels + // when nobody is subscribing in this tab. + const entry = _channels[channelName]; + if (entry) { + entry.ch.postMessage(message); + return; + } + const ch = new BroadcastChannel(channelName); + try { ch.postMessage(message); } finally { ch.close(); } + } + + function subscribe(messageMethod: string, errorMethod: string, listenerId: string, channelName: string) { + if (!('BroadcastChannel' in window)) return; + const entry = getChannel(channelName); + const onMessage = (e: MessageEvent) => { + DotNet.invokeMethodAsync('Bit.Butil', messageMethod, listenerId, e.data ?? null); + }; + const onError = () => { + DotNet.invokeMethodAsync('Bit.Butil', errorMethod, listenerId); + }; + entry.ch.addEventListener('message', onMessage); + entry.ch.addEventListener('messageerror', onError); + entry.subscribers++; + + _subscribers[listenerId] = { name: channelName, onMessage, onError }; + } + + function unsubscribe(listenerId: string) { + const sub = _subscribers[listenerId]; + if (!sub) return; + delete _subscribers[listenerId]; + const entry = _channels[sub.name]; + if (!entry) return; + try { entry.ch.removeEventListener('message', sub.onMessage); } catch { /* ignore */ } + try { entry.ch.removeEventListener('messageerror', sub.onError); } catch { /* ignore */ } + entry.subscribers--; + if (entry.subscribers <= 0) { + try { entry.ch.close(); } catch { /* ignore */ } + delete _channels[sub.name]; + } + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/cacheStorage.ts b/src/Butil/Bit.Butil/Scripts/cacheStorage.ts new file mode 100644 index 0000000000..b73ce63a3e --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/cacheStorage.ts @@ -0,0 +1,99 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.cacheStorage = { + isSupported() { return 'caches' in window; }, + keys, + has, + delete: del, + add, + addAll, + putBytes, + putText, + match, + deleteEntry, + entryKeys + }; + + async function keys() { + if (!('caches' in window)) return []; + try { return await caches.keys(); } catch { return []; } + } + + async function has(name: string) { + if (!('caches' in window)) return false; + try { return await caches.has(name); } catch { return false; } + } + + async function del(name: string) { + if (!('caches' in window)) return false; + try { return await caches.delete(name); } catch { return false; } + } + + async function add(name: string, url: string) { + if (!('caches' in window)) return; + const cache = await caches.open(name); + try { await cache.add(url); } catch { /* network failure / 404 */ } + } + + async function addAll(name: string, urls: string[]) { + if (!('caches' in window) || !urls?.length) return; + const cache = await caches.open(name); + try { await cache.addAll(urls); } catch { /* one or more requests failed */ } + } + + async function putBytes(name: string, url: string, data: Uint8Array, contentType: string, status: number, statusText: string) { + if (!('caches' in window)) return; + const cache = await caches.open(name); + const body = butil.utils.arrayToBuffer(data); + const response = new Response(body, { + status, + statusText, + headers: { 'Content-Type': contentType || 'application/octet-stream' } + }); + await cache.put(url, response); + } + + async function putText(name: string, url: string, text: string, contentType: string, status: number, statusText: string) { + if (!('caches' in window)) return; + const cache = await caches.open(name); + const response = new Response(text ?? '', { + status, + statusText, + headers: { 'Content-Type': contentType || 'text/plain;charset=utf-8' } + }); + await cache.put(url, response); + } + + async function match(name: string, url: string) { + const empty = { found: false, status: 0, statusText: '', url: '', headers: {}, body: new Uint8Array() }; + if (!('caches' in window)) return empty; + const cache = await caches.open(name); + const response = await cache.match(url); + if (!response) return empty; + const buf = await response.arrayBuffer(); + const headers: any = {}; + response.headers.forEach((v, k) => { headers[k] = v; }); + return { + found: true, + status: response.status, + statusText: response.statusText, + url: response.url || url, + headers, + body: new Uint8Array(buf) + }; + } + + async function deleteEntry(name: string, url: string) { + if (!('caches' in window)) return false; + const cache = await caches.open(name); + try { return await cache.delete(url); } catch { return false; } + } + + async function entryKeys(name: string) { + if (!('caches' in window)) return []; + const cache = await caches.open(name); + const reqs = await cache.keys(); + return reqs.map(r => r.url); + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/contactPicker.ts b/src/Butil/Bit.Butil/Scripts/contactPicker.ts new file mode 100644 index 0000000000..0a6d4d1f9b --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/contactPicker.ts @@ -0,0 +1,41 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.contactPicker = { + isSupported() { return !!(window.navigator as any).contacts; }, + async getProperties() { + const c: any = (window.navigator as any).contacts; + if (!c?.getProperties) return []; + try { return await c.getProperties(); } + catch { return []; } + }, + async select(properties: string[], multiple: boolean) { + const c: any = (window.navigator as any).contacts; + if (!c?.select) return []; + try { + const list = await c.select(properties || ['name'], { multiple: !!multiple }); + return (list || []).map((entry: any) => ({ + name: entry.name ?? [], + email: entry.email ?? [], + tel: entry.tel ?? [], + // Addresses come back as ContactAddress objects — flatten to single-line strings. + address: (entry.address ?? []).map(stringifyAddress), + icon: (entry.icon ?? []).map((blob: any) => { + try { return URL.createObjectURL(blob); } catch { return ''; } + }).filter((u: string) => u.length > 0) + })); + } catch { + // Permission denied or no user gesture. + return []; + } + } + }; + + function stringifyAddress(a: any) { + if (!a) return ''; + const parts = [a.organization, a.recipient, + ...(a.addressLine ?? []), a.dependentLocality, a.city, a.region, + a.postalCode, a.country]; + return parts.filter((p: any) => !!p).join(', '); + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/cookieStore.ts b/src/Butil/Bit.Butil/Scripts/cookieStore.ts new file mode 100644 index 0000000000..7ab1bc7e2c --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/cookieStore.ts @@ -0,0 +1,54 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + function toItem(c: any) { + return { + name: c.name, + value: c.value, + domain: c.domain ?? null, + path: c.path ?? null, + // CookieStore exposes Unix epoch milliseconds; map to ISO 8601 for dotnet. + expires: typeof c.expires === 'number' ? new Date(c.expires).toISOString() : null, + secure: !!c.secure, + sameSite: c.sameSite ?? null, + partitioned: typeof c.partitioned === 'boolean' ? c.partitioned : null + }; + } + + function toInit(c: any) { + const init: any = { name: c.name, value: c.value }; + if (c.domain) init.domain = c.domain; + if (c.path) init.path = c.path; + if (c.expires) init.expires = Date.parse(c.expires); + if (typeof c.secure === 'boolean') init.secure = c.secure; + if (c.sameSite) init.sameSite = c.sameSite; + if (typeof c.partitioned === 'boolean') init.partitioned = c.partitioned; + return init; + } + + butil.cookieStore = { + isSupported() { return 'cookieStore' in window; }, + async getAll() { + const cs: any = (window as any).cookieStore; + if (!cs) return []; + const list = await cs.getAll(); + return list.map(toItem); + }, + async get(name: string) { + const cs: any = (window as any).cookieStore; + if (!cs) return null; + const c = await cs.get(name); + return c ? toItem(c) : null; + }, + async set(cookie: any) { + const cs: any = (window as any).cookieStore; + if (!cs) return; + await cs.set(toInit(cookie)); + }, + async delete(name: string) { + const cs: any = (window as any).cookieStore; + if (!cs) return; + await cs.delete(name); + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/crypto.ts b/src/Butil/Bit.Butil/Scripts/crypto.ts index 1b576a075e..b2813486bb 100644 --- a/src/Butil/Bit.Butil/Scripts/crypto.ts +++ b/src/Butil/Bit.Butil/Scripts/crypto.ts @@ -2,6 +2,142 @@ var BitButil = BitButil || {}; (function (butil: any) { butil.crypto = { + randomUUID() { + // Polyfill for older browsers / non-secure contexts. + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + }, + getRandomValues(length: number) { + const buf = new Uint8Array(length); + crypto.getRandomValues(buf); + return buf; + }, + async digest(algorithm: string, data: Uint8Array) { + const buf = await crypto.subtle.digest(algorithm, butil.utils.arrayToBuffer(data)); + return new Uint8Array(buf); + }, + async signHmac(algorithm: string, key: Uint8Array, data: Uint8Array) { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + butil.utils.arrayToBuffer(key), + { name: 'HMAC', hash: algorithm }, + false, + ['sign']); + const sig = await crypto.subtle.sign({ name: 'HMAC' }, cryptoKey, butil.utils.arrayToBuffer(data)); + return new Uint8Array(sig); + }, + async verifyHmac(algorithm: string, key: Uint8Array, signature: Uint8Array, data: Uint8Array) { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + butil.utils.arrayToBuffer(key), + { name: 'HMAC', hash: algorithm }, + false, + ['verify']); + return await crypto.subtle.verify( + { name: 'HMAC' }, + cryptoKey, + butil.utils.arrayToBuffer(signature), + butil.utils.arrayToBuffer(data)); + }, + async generateAesKey(bits: number) { + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: bits }, true, ['encrypt', 'decrypt']) as unknown as CryptoKey; + const raw = await crypto.subtle.exportKey('raw', key); + return new Uint8Array(raw); + }, + async generateHmacKey(algorithm: string, lengthBits: number | null) { + const params: any = { name: 'HMAC', hash: algorithm }; + if (lengthBits) params.length = lengthBits; + const key = await crypto.subtle.generateKey(params, true, ['sign', 'verify']) as unknown as CryptoKey; + const raw = await crypto.subtle.exportKey('raw', key); + return new Uint8Array(raw); + }, + async generateRsaKeyPair(modulusLengthBits: number, algorithm: string) { + const pair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: modulusLengthBits, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: algorithm + }, + true, + ['encrypt', 'decrypt']) as CryptoKeyPair; + const spki = await crypto.subtle.exportKey('spki', pair.publicKey); + const pkcs8 = await crypto.subtle.exportKey('pkcs8', pair.privateKey); + return { publicKey: new Uint8Array(spki), privateKey: new Uint8Array(pkcs8) }; + }, + async generateEcdsaKeyPair(curve: string) { + const pair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: curve }, + true, + ['sign', 'verify']) as CryptoKeyPair; + const spki = await crypto.subtle.exportKey('spki', pair.publicKey); + const pkcs8 = await crypto.subtle.exportKey('pkcs8', pair.privateKey); + return { publicKey: new Uint8Array(spki), privateKey: new Uint8Array(pkcs8), curve }; + }, + async derivePbkdf2(password: Uint8Array, salt: Uint8Array, iterations: number, outputLengthBits: number, algorithm: string) { + const baseKey = await crypto.subtle.importKey( + 'raw', + butil.utils.arrayToBuffer(password), + { name: 'PBKDF2' }, + false, + ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt: butil.utils.arrayToBuffer(salt), iterations, hash: algorithm }, + baseKey, + outputLengthBits); + return new Uint8Array(bits); + }, + async signRsaPss(privateKey: Uint8Array, data: Uint8Array, saltLength: number, algorithm: string) { + const key = await crypto.subtle.importKey( + 'pkcs8', + butil.utils.arrayToBuffer(privateKey), + { name: 'RSA-PSS', hash: algorithm }, + false, + ['sign']); + const sig = await crypto.subtle.sign({ name: 'RSA-PSS', saltLength }, key, butil.utils.arrayToBuffer(data)); + return new Uint8Array(sig); + }, + async verifyRsaPss(publicKey: Uint8Array, signature: Uint8Array, data: Uint8Array, saltLength: number, algorithm: string) { + const key = await crypto.subtle.importKey( + 'spki', + butil.utils.arrayToBuffer(publicKey), + { name: 'RSA-PSS', hash: algorithm }, + false, + ['verify']); + return await crypto.subtle.verify( + { name: 'RSA-PSS', saltLength }, + key, + butil.utils.arrayToBuffer(signature), + butil.utils.arrayToBuffer(data)); + }, + async signEcdsa(privateKey: Uint8Array, data: Uint8Array, curve: string, algorithm: string) { + const key = await crypto.subtle.importKey( + 'pkcs8', + butil.utils.arrayToBuffer(privateKey), + { name: 'ECDSA', namedCurve: curve }, + false, + ['sign']); + const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: algorithm }, key, butil.utils.arrayToBuffer(data)); + return new Uint8Array(sig); + }, + async verifyEcdsa(publicKey: Uint8Array, signature: Uint8Array, data: Uint8Array, curve: string, algorithm: string) { + const key = await crypto.subtle.importKey( + 'spki', + butil.utils.arrayToBuffer(publicKey), + { name: 'ECDSA', namedCurve: curve }, + false, + ['verify']); + return await crypto.subtle.verify( + { name: 'ECDSA', hash: algorithm }, + key, + butil.utils.arrayToBuffer(signature), + butil.utils.arrayToBuffer(data)); + }, encryptRsaOaep(algorithm, key, data, keyHash) { return endecryptRsaOaep(algorithm, key, data, keyHash, "encrypt") }, decryptRsaOaep(algorithm, key, data, keyHash) { return endecryptRsaOaep(algorithm, key, data, keyHash, "decrypt") }, @@ -15,58 +151,70 @@ var BitButil = BitButil || {}; decryptAesGcm(algorithm, key, data) { return endecryptAesGcm(algorithm, key, data, "decrypt") }, }; - async function endecryptRsaOaep(algorithm, key, data, keyHash, func) { - const cryptoAlgorithm = { - name: algorithm.name, - label: butil.utils.arrayToBuffer(algorithm.label) + async function endecryptRsaOaep(algorithm, key, data, keyHash, func: 'encrypt' | 'decrypt') { + const cryptoAlgorithm: any = { name: algorithm.name }; + if (algorithm.label) { + cryptoAlgorithm.label = butil.utils.arrayToBuffer(algorithm.label); } const keyAlgorithm = { name: "RSA-OAEP", hash: keyHash ?? "SHA-256" }; - return await endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func); + // RSA keys cannot be imported as "raw"; encrypt uses the public key (spki), + // decrypt uses the private key (pkcs8). + const keyFormat = func === 'encrypt' ? 'spki' : 'pkcs8'; + const keyUsages: KeyUsage[] = [func]; + + return await endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func, keyFormat, keyUsages); } - async function endecryptAesCtr(algorithm, key, data, func) { + async function endecryptAesCtr(algorithm, key, data, func: 'encrypt' | 'decrypt') { const cryptoAlgorithm = { name: algorithm.name, counter: butil.utils.arrayToBuffer(algorithm.counter), length: algorithm.length - } + }; const keyAlgorithm = { name: "AES-CTR" }; - return await endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func); + return await endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func, 'raw', [func]); } - async function endecryptAesCbc(algorithm, key, data, func) { + async function endecryptAesCbc(algorithm, key, data, func: 'encrypt' | 'decrypt') { const cryptoAlgorithm = { name: algorithm.name, iv: butil.utils.arrayToBuffer(algorithm.iv), - } + }; const keyAlgorithm = { name: "AES-CBC" }; - return await endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func); + return await endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func, 'raw', [func]); } - async function endecryptAesGcm(algorithm, key, data, func) { - const cryptoAlgorithm = { + async function endecryptAesGcm(algorithm, key, data, func: 'encrypt' | 'decrypt') { + const cryptoAlgorithm: any = { name: algorithm.name, iv: butil.utils.arrayToBuffer(algorithm.iv), - additionalData: butil.utils.arrayToBuffer(algorithm.additionalData), - tagLength: algorithm.tagLength, + }; + + // additionalData is optional in the spec; only forward when actually supplied. + if (algorithm.additionalData) { + cryptoAlgorithm.additionalData = butil.utils.arrayToBuffer(algorithm.additionalData); + } + if (typeof algorithm.tagLength === 'number') { + cryptoAlgorithm.tagLength = algorithm.tagLength; } const keyAlgorithm = { name: "AES-GCM" }; - return await endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func); + return await endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func, 'raw', [func]); } - async function endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func) { - const cryptoKey = await crypto.subtle.importKey("raw", butil.utils.arrayToBuffer(key), keyAlgorithm, false, ["encrypt", "decrypt"]); + async function endecrypt(cryptoAlgorithm, key, data, keyAlgorithm, func: 'encrypt' | 'decrypt', + keyFormat: 'raw' | 'pkcs8' | 'spki' = 'raw', keyUsages: KeyUsage[] = ['encrypt', 'decrypt']) { + const cryptoKey = await crypto.subtle.importKey(keyFormat, butil.utils.arrayToBuffer(key), keyAlgorithm, false, keyUsages); - const encryptedBuffer = await window.crypto.subtle[func](cryptoAlgorithm, cryptoKey, butil.utils.arrayToBuffer(data)); + const resultBuffer = await window.crypto.subtle[func](cryptoAlgorithm, cryptoKey, butil.utils.arrayToBuffer(data)); - return new Uint8Array(encryptedBuffer); + return new Uint8Array(resultBuffer); } }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Bit.Butil/Scripts/document.ts b/src/Butil/Bit.Butil/Scripts/document.ts index b351dbbaa2..ce3b5cbabc 100644 --- a/src/Butil/Bit.Butil/Scripts/document.ts +++ b/src/Butil/Bit.Butil/Scripts/document.ts @@ -15,6 +15,12 @@ var BitButil = BitButil || {}; URL() { return document.URL }, setTitle(value: string) { document.title = value }, exitFullscreen() { return document.exitFullscreen() }, - exitPointerLock() { return document.exitPointerLock() } + exitPointerLock() { return document.exitPointerLock() }, + visibilityState() { return document.visibilityState }, + hidden() { return document.hidden }, + hasFocus() { return document.hasFocus() }, + hasFullscreenElement() { return !!document.fullscreenElement; }, + hasPointerLockElement() { return !!document.pointerLockElement; }, + wasDiscarded() { return !!(document as any).wasDiscarded; } }; }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Bit.Butil/Scripts/element.ts b/src/Butil/Bit.Butil/Scripts/element.ts index 1f170c1897..c16e55c49a 100644 --- a/src/Butil/Bit.Butil/Scripts/element.ts +++ b/src/Butil/Bit.Butil/Scripts/element.ts @@ -1,6 +1,9 @@ var BitButil = BitButil || {}; (function (butil: any) { + // Element-scoped event handlers, indexed by listenerId so element teardown can find them. + const _elementHandlers: { [listenerId: string]: { element: HTMLElement, eventName: string, handler: any, options: any } } = {}; + butil.element = { blur(element: HTMLElement) { element.blur() }, getAttribute(element: HTMLElement, name: string) { return element.getAttribute(name) }, @@ -61,6 +64,8 @@ var BitButil = BitButil || {}; offsetWidth(element: HTMLElement) { return element.offsetWidth }, getTabIndex(element: HTMLElement) { return element.tabIndex }, setTabIndex(element: HTMLElement, value: number) { element.tabIndex = value }, + subscribeEvent, + unsubscribeEvent, }; function scroll(element: HTMLElement, options?: ScrollToOptions, x?: number, y?: number) { @@ -82,4 +87,26 @@ var BitButil = BitButil || {}; function scrollIntoView(element: HTMLElement, alignToTop?: boolean, options?: ScrollIntoViewOptions) { element.scrollIntoView(alignToTop ?? options); } + + function subscribeEvent(element: HTMLElement, elementId: string, eventName: string, methodName: string, + listenerId: string, argsMembers: string[], useCapture: boolean, + preventDefault: boolean, stopPropagation: boolean) { + if (!element) return; + const handler = (e: any) => { + preventDefault && e.preventDefault(); + stopPropagation && e.stopPropagation(); + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, butil.events.mapEvent(e, argsMembers)); + }; + _elementHandlers[listenerId] = { element, eventName, handler, options: useCapture }; + element.addEventListener(eventName, handler, useCapture); + } + + function unsubscribeEvent(elementId: string, eventName: string, listenerId: string, useCapture: boolean) { + const entry = _elementHandlers[listenerId]; + if (!entry) return; + delete _elementHandlers[listenerId]; + try { + entry.element.removeEventListener(entry.eventName, entry.handler, entry.options); + } catch { /* element may already be detached */ } + } }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Bit.Butil/Scripts/events.ts b/src/Butil/Bit.Butil/Scripts/events.ts index dda1e543b4..e45594d163 100644 --- a/src/Butil/Bit.Butil/Scripts/events.ts +++ b/src/Butil/Bit.Butil/Scripts/events.ts @@ -5,15 +5,60 @@ var BitButil = BitButil || {}; butil.events = { addEventListener, - removeEventListener + removeEventListener, + mapEvent }; + function mapTouchList(list: any): any[] { + if (!list) return []; + const out = []; + for (let i = 0; i < list.length; i++) { + const t = list[i]; + out.push({ + identifier: t.identifier, + clientX: t.clientX, + clientY: t.clientY, + pageX: t.pageX, + pageY: t.pageY, + screenX: t.screenX, + screenY: t.screenY, + radiusX: t.radiusX ?? 0, + radiusY: t.radiusY ?? 0, + rotationAngle: t.rotationAngle ?? 0, + force: t.force ?? 0 + }); + } + return out; + } + + function mapEvent(e: any, members: string[]) { + const out: any = {}; + for (const m of (members || [])) { + switch (m) { + case 'touches': + case 'targetTouches': + case 'changedTouches': + out[m] = mapTouchList(e[m]); + break; + case 'clipboardText': + out[m] = e.clipboardData?.getData?.('text/plain') ?? null; + break; + case 'relatedTarget': + // RelatedTarget is a DOM node — we can only safely send a stringy id. + out[m] = e.relatedTarget?.id ?? ''; + break; + default: + out[m] = e[m]; + } + } + return out; + } + function addEventListener(elementName, eventName, methodName, listenerId, argsMembers, options, preventDefault, stopPropagation) { - const argsMap = e => (argsMembers || []).reduce((pre, cur) => (pre[cur] = e[cur], pre), {}); const handler = e => { preventDefault && e.preventDefault(); stopPropagation && e.stopPropagation(); - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, argsMap(e)); + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, mapEvent(e, argsMembers)); }; _handlers[listenerId] = handler; diff --git a/src/Butil/Bit.Butil/Scripts/eyeDropper.ts b/src/Butil/Bit.Butil/Scripts/eyeDropper.ts new file mode 100644 index 0000000000..a70d0a819b --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/eyeDropper.ts @@ -0,0 +1,19 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.eyeDropper = { + isSupported() { return 'EyeDropper' in window; }, + async open() { + const W = window as any; + if (typeof W.EyeDropper !== 'function') return null; + try { + const dropper = new W.EyeDropper(); + const result = await dropper.open(); + return result?.sRGBHex ?? null; + } catch { + // User canceled or the call wasn't tied to a user gesture. + return null; + } + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/fetch.ts b/src/Butil/Bit.Butil/Scripts/fetch.ts new file mode 100644 index 0000000000..055ebd9b82 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/fetch.ts @@ -0,0 +1,113 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _controllers: { [id: string]: AbortController } = {}; + + butil.fetch = { + send, + start, + abort + }; + + function buildInit(req: any, controller: AbortController): RequestInit { + const headers = new Headers(); + if (req.headers) { + for (const k of Object.keys(req.headers)) headers.set(k, req.headers[k]); + } + const init: RequestInit = { + method: req.method || 'GET', + headers, + credentials: req.credentials || 'same-origin', + mode: req.mode || 'cors', + cache: req.cache || 'default', + redirect: req.redirect || 'follow', + signal: controller.signal + }; + if (req.body && req.body.length > 0) { + init.body = butil.utils.arrayToBuffer(req.body); + } + return init; + } + + function headersToObject(h: Headers) { + const out: any = {}; + h.forEach((v, k) => { out[k] = v; }); + return out; + } + + async function send(id: string, req: any, progressMethod: string, withProgress: boolean): Promise { + const controller = new AbortController(); + _controllers[id] = controller; + + try { + const resp = await fetch(req.url, buildInit(req, controller)); + const total = (() => { + const cl = resp.headers.get('content-length'); + return cl ? Number(cl) : null; + })(); + + let bytes: Uint8Array; + if (withProgress && resp.body && typeof (resp.body as any).getReader === 'function') { + const reader = (resp.body as any).getReader(); + const chunks: Uint8Array[] = []; + let loaded = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + loaded += value.byteLength; + DotNet.invokeMethodAsync('Bit.Butil', progressMethod, id, { loaded, total }); + } + bytes = new Uint8Array(loaded); + let offset = 0; + for (const c of chunks) { bytes.set(c, offset); offset += c.byteLength; } + } else { + const buf = await resp.arrayBuffer(); + bytes = new Uint8Array(buf); + if (withProgress) { + DotNet.invokeMethodAsync('Bit.Butil', progressMethod, id, { loaded: bytes.byteLength, total }); + } + } + + return { + ok: resp.ok, + status: resp.status, + statusText: resp.statusText, + url: resp.url, + headers: headersToObject(resp.headers), + body: bytes, + aborted: false, + error: null + }; + } catch (e: any) { + const aborted = e?.name === 'AbortError'; + return { + ok: false, + status: 0, + statusText: '', + url: req.url, + headers: {}, + body: new Uint8Array(), + aborted, + error: aborted ? null : (e?.message ?? String(e)) + }; + } finally { + delete _controllers[id]; + } + } + + function start(id: string, req: any) { + const controller = new AbortController(); + _controllers[id] = controller; + // Fire-and-forget: errors are silently swallowed because there's no consumer for the + // result. Use send() when you need the response. + fetch(req.url, buildInit(req, controller)).catch(() => { /* ignore */ }).finally(() => { delete _controllers[id]; }); + } + + function abort(id: string) { + const c = _controllers[id]; + if (!c) return; + delete _controllers[id]; + try { c.abort(); } catch { /* already aborted */ } + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/fileReader.ts b/src/Butil/Bit.Butil/Scripts/fileReader.ts new file mode 100644 index 0000000000..c38367b781 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/fileReader.ts @@ -0,0 +1,73 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.fileReader = { + getFileInfo, + getFileInfos, + readAsBytes, + readAsText, + readAsDataUrl, + clear + }; + + function info(f: File | null | undefined) { + if (!f) return null; + return { + name: f.name, + type: f.type ?? '', + size: f.size, + lastModified: f.lastModified ?? 0 + }; + } + + function file(input: HTMLInputElement, index: number): File | null { + return input?.files?.[index] ?? null; + } + + function getFileInfo(input: HTMLInputElement, index: number) { + return info(file(input, index)); + } + + function getFileInfos(input: HTMLInputElement) { + const list = input?.files; + if (!list) return []; + const out = []; + for (let i = 0; i < list.length; i++) out.push(info(list[i])); + return out; + } + + async function readAsBytes(input: HTMLInputElement, index: number) { + const f = file(input, index); + if (!f) return null; + const buf = await f.arrayBuffer(); + return new Uint8Array(buf); + } + + async function readAsText(input: HTMLInputElement, index: number, encoding: string) { + const f = file(input, index); + if (!f) return ''; + // Blob.text() forces UTF-8; fall back to a FileReader when a different encoding is requested. + if (!encoding || encoding.toLowerCase() === 'utf-8') return f.text(); + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve((reader.result as string) ?? ''); + reader.onerror = () => reject(reader.error); + reader.readAsText(f, encoding); + }); + } + + async function readAsDataUrl(input: HTMLInputElement, index: number) { + const f = file(input, index); + if (!f) return ''; + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve((reader.result as string) ?? ''); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(f); + }); + } + + function clear(input: HTMLInputElement) { + if (input) input.value = ''; + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/geolocation.ts b/src/Butil/Bit.Butil/Scripts/geolocation.ts new file mode 100644 index 0000000000..e4326db2a0 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/geolocation.ts @@ -0,0 +1,73 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + // Map of dotnet-watchId (string) -> browser numeric watchId (number). + const _watches: { [id: string]: number } = {}; + + butil.geolocation = { + isSupported() { return 'geolocation' in window.navigator; }, + getCurrentPosition, + watchPosition, + clearWatch + }; + + function toPosition(p: GeolocationPosition) { + const c = p.coords; + return { + timestamp: p.timestamp, + coords: { + latitude: c.latitude, + longitude: c.longitude, + accuracy: c.accuracy, + altitude: c.altitude, + altitudeAccuracy: c.altitudeAccuracy, + heading: c.heading, + speed: c.speed + } + }; + } + + function toJsOptions(options: any) { + if (!options) return undefined; + return { + enableHighAccuracy: !!options.enableHighAccuracy, + maximumAge: options.maximumAge, + timeout: options.timeout + }; + } + + function getCurrentPosition(options: any) { + return new Promise(resolve => { + if (!('geolocation' in window.navigator)) { + resolve({ position: null, errorCode: 2, errorMessage: 'Geolocation is not supported in this runtime.' }); + return; + } + + window.navigator.geolocation.getCurrentPosition( + p => resolve({ position: toPosition(p), errorCode: 0, errorMessage: null }), + err => resolve({ position: null, errorCode: err.code, errorMessage: err.message }), + toJsOptions(options)); + }); + } + + function watchPosition(positionMethod: string, errorMethod: string, listenerId: string, options: any) { + if (!('geolocation' in window.navigator)) { + DotNet.invokeMethodAsync('Bit.Butil', errorMethod, listenerId, 2, 'Geolocation is not supported in this runtime.'); + return; + } + + const watchId = window.navigator.geolocation.watchPosition( + p => DotNet.invokeMethodAsync('Bit.Butil', positionMethod, listenerId, toPosition(p)), + err => DotNet.invokeMethodAsync('Bit.Butil', errorMethod, listenerId, err.code, err.message), + toJsOptions(options)); + + _watches[listenerId] = watchId; + } + + function clearWatch(listenerId: string) { + const watchId = _watches[listenerId]; + if (watchId === undefined) return; + delete _watches[listenerId]; + window.navigator.geolocation.clearWatch(watchId); + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/idleDetector.ts b/src/Butil/Bit.Butil/Scripts/idleDetector.ts new file mode 100644 index 0000000000..1d13fcc380 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/idleDetector.ts @@ -0,0 +1,44 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _detectors: { [id: string]: { detector: any, controller: AbortController } } = {}; + + butil.idleDetector = { + isSupported() { return 'IdleDetector' in window; }, + async requestPermission() { + const ID: any = (window as any).IdleDetector; + if (!ID?.requestPermission) return 'unknown'; + try { return await ID.requestPermission(); } + catch { return 'denied'; } + }, + async start(methodName: string, listenerId: string, threshold: number) { + const ID: any = (window as any).IdleDetector; + if (!ID) return; + const controller = new AbortController(); + const detector = new ID(); + + const fire = () => { + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, { + userState: detector.userState ?? 'active', + screenState: detector.screenState ?? 'unlocked' + }); + }; + detector.addEventListener('change', fire); + + try { + await detector.start({ threshold: threshold * 1000, signal: controller.signal }); + _detectors[listenerId] = { detector, controller }; + // Emit an initial snapshot so the dotnet side knows the current state. + fire(); + } catch { + // Permission denied or aborted before start. + } + }, + stop(listenerId: string) { + const entry = _detectors[listenerId]; + if (!entry) return; + delete _detectors[listenerId]; + try { entry.controller.abort(); } catch { /* already aborted */ } + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/indexedDb.ts b/src/Butil/Bit.Butil/Scripts/indexedDb.ts new file mode 100644 index 0000000000..3c3947a035 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/indexedDb.ts @@ -0,0 +1,133 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _dbs: { [id: string]: IDBDatabase } = {}; + + butil.indexedDb = { + isSupported() { return 'indexedDB' in window; }, + open, + close, + deleteDatabase, + put, + add, + get, + getAll, + getAllKeys, + delete: del, + clear, + count, + getByIndex, + getAllByIndex + }; + + // ─── Lifecycle ────────────────────────────────────────────────────────────── + + function open(id: string, name: string, version: number, stores: any[]): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(name, version); + req.onupgradeneeded = () => { + const db = req.result; + for (const s of stores || []) { + if (!s?.name) continue; + let store: IDBObjectStore; + if (db.objectStoreNames.contains(s.name)) { + store = req.transaction!.objectStore(s.name); + } else { + const params: IDBObjectStoreParameters = {}; + if (s.keyPath) params.keyPath = s.keyPath; + if (s.autoIncrement) params.autoIncrement = true; + store = db.createObjectStore(s.name, params); + } + for (const idx of s.indexes || []) { + if (!idx?.name || !idx.keyPath) continue; + if (!store.indexNames.contains(idx.name)) { + store.createIndex(idx.name, idx.keyPath, { unique: !!idx.unique, multiEntry: !!idx.multiEntry }); + } + } + } + }; + req.onsuccess = () => { _dbs[id] = req.result; resolve(); }; + req.onerror = () => reject(req.error); + req.onblocked = () => reject(new Error('IndexedDB open is blocked by another tab.')); + }); + } + + function close(id: string) { + const db = _dbs[id]; + if (!db) return; + delete _dbs[id]; + try { db.close(); } catch { /* already closed */ } + } + + function deleteDatabase(name: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(name); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => reject(new Error('IndexedDB delete is blocked by another tab.')); + }); + } + + // ─── CRUD ─────────────────────────────────────────────────────────────────── + + function txStore(id: string, store: string, mode: IDBTransactionMode) { + const db = _dbs[id]; + if (!db) throw new Error('IndexedDB handle is not open.'); + return db.transaction(store, mode).objectStore(store); + } + + function awaitRequest(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } + + function put(id: string, store: string, value: any, key: any) { + return awaitRequest(key !== null && key !== undefined + ? txStore(id, store, 'readwrite').put(value, key) + : txStore(id, store, 'readwrite').put(value)); + } + + function add(id: string, store: string, value: any, key: any) { + return awaitRequest(key !== null && key !== undefined + ? txStore(id, store, 'readwrite').add(value, key) + : txStore(id, store, 'readwrite').add(value)); + } + + function get(id: string, store: string, key: any) { + return awaitRequest(txStore(id, store, 'readonly').get(key)).then(v => v ?? null); + } + + function getAll(id: string, store: string, count: number | null) { + const s = txStore(id, store, 'readonly'); + return awaitRequest(count != null ? s.getAll(undefined as any, count) : s.getAll()); + } + + function getAllKeys(id: string, store: string, count: number | null) { + const s = txStore(id, store, 'readonly'); + return awaitRequest(count != null ? s.getAllKeys(undefined as any, count) : s.getAllKeys()); + } + + function del(id: string, store: string, key: any) { + return awaitRequest(txStore(id, store, 'readwrite').delete(key)); + } + + function clear(id: string, store: string) { + return awaitRequest(txStore(id, store, 'readwrite').clear()); + } + + function count(id: string, store: string) { + return awaitRequest(txStore(id, store, 'readonly').count()); + } + + function getByIndex(id: string, store: string, indexName: string, key: any) { + const idx = txStore(id, store, 'readonly').index(indexName); + return awaitRequest(idx.get(key)).then(v => v ?? null); + } + + function getAllByIndex(id: string, store: string, indexName: string, key: any, c: number | null) { + const idx = txStore(id, store, 'readonly').index(indexName); + return awaitRequest(c != null ? idx.getAll(key, c) : idx.getAll(key)); + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/intersectionObserver.ts b/src/Butil/Bit.Butil/Scripts/intersectionObserver.ts new file mode 100644 index 0000000000..a15be917da --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/intersectionObserver.ts @@ -0,0 +1,46 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _observers: { [id: string]: IntersectionObserver } = {}; + + butil.intersectionObserver = { + observe, + unobserve + }; + + function toRect(r: DOMRectReadOnly | null) { + if (!r) return null; + return { x: r.x, y: r.y, width: r.width, height: r.height }; + } + + function observe(methodName: string, listenerId: string, element: HTMLElement, options: any) { + if (!element || !('IntersectionObserver' in window)) return; + + const init: IntersectionObserverInit = { + rootMargin: options?.rootMargin ?? undefined, + threshold: options?.thresholds && options.thresholds.length > 0 ? options.thresholds : 0 + }; + + const observer = new IntersectionObserver(entries => { + const payload = entries.map(e => ({ + isIntersecting: e.isIntersecting, + intersectionRatio: e.intersectionRatio, + time: e.time, + boundingClientRect: toRect(e.boundingClientRect), + intersectionRect: toRect(e.intersectionRect), + rootBounds: toRect(e.rootBounds) + })); + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + }, init); + + observer.observe(element); + _observers[listenerId] = observer; + } + + function unobserve(listenerId: string) { + const observer = _observers[listenerId]; + if (!observer) return; + delete _observers[listenerId]; + observer.disconnect(); + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/keyboard.ts b/src/Butil/Bit.Butil/Scripts/keyboard.ts index f6d3a0c5e8..8ac0b586d0 100644 --- a/src/Butil/Bit.Butil/Scripts/keyboard.ts +++ b/src/Butil/Bit.Butil/Scripts/keyboard.ts @@ -1,15 +1,17 @@ var BitButil = BitButil || {}; (function (butil: any) { - const _handlers = {}; + const _handlers: { [id: string]: { target: EventTarget, handler: any } } = {}; butil.keyboard = { add, + addOn, remove }; - function add(methodName, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat) { - const handler = e => { + function makeHandler(methodName: string, listenerId: string, code: string, alt: boolean, ctrl: boolean, + meta: boolean, shift: boolean, preventDefault: boolean, stopPropagation: boolean, repeat: boolean) { + return (e: KeyboardEvent) => { if (e.code !== code) return; if ((!alt && e.altKey) || (alt && !e.altKey)) return; @@ -24,17 +26,34 @@ var BitButil = BitButil || {}; DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId); }; + } - _handlers[listenerId] = handler; - + function add(methodName: string, listenerId: string, code: string, alt: boolean, ctrl: boolean, + meta: boolean, shift: boolean, preventDefault: boolean, stopPropagation: boolean, repeat: boolean) { + const handler = makeHandler(methodName, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat); + _handlers[listenerId] = { target: document, handler }; document.addEventListener('keydown', handler); } - function remove(ids) { + function addOn(methodName: string, listenerId: string, element: HTMLElement, code: string, + alt: boolean, ctrl: boolean, meta: boolean, shift: boolean, + preventDefault: boolean, stopPropagation: boolean, repeat: boolean) { + if (!element) { + // Fall back to document so callers don't lose the listener silently when the + // element ref isn't ready yet. + return add(methodName, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat); + } + const handler = makeHandler(methodName, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat); + _handlers[listenerId] = { target: element, handler }; + element.addEventListener('keydown', handler); + } + + function remove(ids: string[]) { ids.forEach(id => { - const handler = _handlers[id]; + const entry = _handlers[id]; + if (!entry) return; delete _handlers[id]; - document.removeEventListener('keydown', handler); + try { entry.target.removeEventListener('keydown', entry.handler); } catch { /* detached */ } }); } }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Bit.Butil/Scripts/mediaDevices.ts b/src/Butil/Bit.Butil/Scripts/mediaDevices.ts new file mode 100644 index 0000000000..ab06dff19a --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/mediaDevices.ts @@ -0,0 +1,54 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _streams: { [id: string]: MediaStream } = {}; + + butil.mediaDevices = { + isSupported() { return !!(window.navigator as any).mediaDevices; }, + async enumerate() { + const md = (window.navigator as any).mediaDevices; + if (!md?.enumerateDevices) return []; + try { + const list = await md.enumerateDevices(); + return list.map((d: any) => ({ + deviceId: d.deviceId, + kind: d.kind, + label: d.label, + groupId: d.groupId + })); + } catch { + return []; + } + }, + async getUserMedia(id: string, audio: boolean, video: boolean, audioConstraints: any, videoConstraints: any) { + const md = (window.navigator as any).mediaDevices; + if (!md?.getUserMedia) return false; + const constraints: MediaStreamConstraints = {}; + constraints.audio = audio ? (audioConstraints ?? true) : false; + constraints.video = video ? (videoConstraints ?? true) : false; + try { + const stream = await md.getUserMedia(constraints); + _streams[id] = stream; + return true; + } catch { + return false; + } + }, + attach(id: string, element: HTMLMediaElement) { + const stream = _streams[id]; + if (!stream || !element) return; + (element as any).srcObject = stream; + }, + setEnabled(id: string, enabled: boolean) { + const stream = _streams[id]; + if (!stream) return; + stream.getTracks().forEach(t => { t.enabled = enabled; }); + }, + stop(id: string) { + const stream = _streams[id]; + if (!stream) return; + delete _streams[id]; + try { stream.getTracks().forEach(t => t.stop()); } catch { /* ignore */ } + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/mutationObserver.ts b/src/Butil/Bit.Butil/Scripts/mutationObserver.ts new file mode 100644 index 0000000000..245d7645d5 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/mutationObserver.ts @@ -0,0 +1,49 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _observers: { [id: string]: MutationObserver } = {}; + + butil.mutationObserver = { + observe, + unobserve + }; + + function observe(methodName: string, listenerId: string, element: HTMLElement, options: any) { + if (!element || !('MutationObserver' in window)) return; + + const init: MutationObserverInit = { + childList: !!options?.childList, + attributes: !!options?.attributes, + characterData: !!options?.characterData, + subtree: !!options?.subtree, + attributeOldValue: !!options?.attributeOldValue, + characterDataOldValue: !!options?.characterDataOldValue + }; + if (options?.attributeFilter?.length) init.attributeFilter = options.attributeFilter; + + const observer = new MutationObserver(records => { + const payload = records.map(r => ({ + type: r.type, + targetTagName: (r.target as Element)?.tagName ?? '', + targetId: (r.target as Element)?.id || null, + attributeName: r.attributeName, + attributeNamespace: r.attributeNamespace, + oldValue: r.oldValue, + addedCount: r.addedNodes?.length ?? 0, + removedCount: r.removedNodes?.length ?? 0 + })); + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + }); + + try { observer.observe(element, init); } + catch { /* invalid options combo — silently ignore so dotnet sees no records */ } + _observers[listenerId] = observer; + } + + function unobserve(listenerId: string) { + const observer = _observers[listenerId]; + if (!observer) return; + delete _observers[listenerId]; + observer.disconnect(); + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/navigator.ts b/src/Butil/Bit.Butil/Scripts/navigator.ts index 3a56004fa8..640ec150a8 100644 --- a/src/Butil/Bit.Butil/Scripts/navigator.ts +++ b/src/Butil/Bit.Butil/Scripts/navigator.ts @@ -11,11 +11,30 @@ var BitButil = BitButil || {}; pdfViewerEnabled() { return window.navigator.pdfViewerEnabled }, userAgent() { return window.navigator.userAgent }, webdriver() { return window.navigator.webdriver }, - canShare() { return window.navigator.canShare() }, - clearAppBadge() { return window.navigator.clearAppBadge() }, - sendBeacon(url: string, data) { return window.navigator.sendBeacon(url, data) }, - setAppBadge(contents) { return window.navigator.setAppBadge(contents) }, + canShare(data?: ShareData) { return data ? window.navigator.canShare(data) : window.navigator.canShare() }, + clearAppBadge() { return (window.navigator as any).clearAppBadge?.() }, + sendBeacon(url: string, data?: any) { return window.navigator.sendBeacon(url, data ?? undefined) }, + setAppBadge(contents?: number) { return (window.navigator as any).setAppBadge?.(contents ?? undefined) }, share(data) { return window.navigator.share(data) }, + async shareFiles(title?: string, text?: string, url?: string, files?: any[]) { + if (typeof window.navigator.share !== 'function' || !files?.length) return false; + const fileObjects = files.map(f => new File([butil.utils.arrayToBuffer(f.data)], f.name, { type: f.mimeType || 'application/octet-stream' })); + const data: any = { files: fileObjects }; + if (title) data.title = title; + if (text) data.text = text; + if (url) data.url = url; + + // canShare is a quick gate: rejected sets cause share() to throw on some browsers. + if (typeof window.navigator.canShare === 'function' && !window.navigator.canShare(data)) return false; + + try { + await window.navigator.share(data); + return true; + } catch { + // AbortError when the user cancels, NotAllowedError if files were forbidden. + return false; + } + }, vibrate(pattern) { return window.navigator.vibrate(pattern) } }; }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Bit.Butil/Scripts/networkInformation.ts b/src/Butil/Bit.Butil/Scripts/networkInformation.ts new file mode 100644 index 0000000000..e6bc28fff7 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/networkInformation.ts @@ -0,0 +1,19 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.networkInformation = { + getStatus() { + const nav = window.navigator as any; + const c = nav.connection || nav.mozConnection || nav.webkitConnection || null; + return { + online: !!nav.onLine, + effectiveType: c?.effectiveType ?? null, + type: c?.type ?? null, + downlink: c?.downlink ?? null, + downlinkMax: c?.downlinkMax ?? null, + rtt: c?.rtt ?? null, + saveData: typeof c?.saveData === 'boolean' ? c.saveData : null + }; + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/nfc.ts b/src/Butil/Bit.Butil/Scripts/nfc.ts new file mode 100644 index 0000000000..91effc95b4 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/nfc.ts @@ -0,0 +1,88 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _readers: { [id: string]: { reader: any, controller: AbortController } } = {}; + + butil.nfc = { + isSupported() { return 'NDEFReader' in window; }, + scan, + stop, + writeText, + writeUrl + }; + + function decodeRecord(rec: any) { + const out: any = { + recordType: rec.recordType, + mediaType: rec.mediaType ?? null, + id: rec.id ?? null, + lang: rec.lang ?? null, + encoding: rec.encoding ?? null, + text: null, + data: null + }; + try { + if (rec.recordType === 'text' || rec.recordType === 'url' || rec.recordType === 'absolute-url') { + const decoder = new TextDecoder(rec.encoding || 'utf-8'); + out.text = decoder.decode(rec.data); + } else if (rec.data) { + out.data = new Uint8Array(rec.data.buffer || rec.data); + } + } catch { /* unsupported encoding — leave fields null */ } + return out; + } + + async function scan(id: string, readingMethod: string, errorMethod: string) { + const W = window as any; + if (typeof W.NDEFReader !== 'function') { + DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, 'NFC is not supported.'); + return; + } + const reader = new W.NDEFReader(); + const controller = new AbortController(); + reader.onreading = (event: any) => { + DotNet.invokeMethodAsync('Bit.Butil', readingMethod, id, { + serialNumber: event.serialNumber ?? '', + records: (event.message?.records ?? []).map(decodeRecord) + }); + }; + reader.onreadingerror = () => { + DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, 'reading-error'); + }; + try { await reader.scan({ signal: controller.signal }); _readers[id] = { reader, controller }; } + catch (e: any) { + DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, e?.message ?? String(e)); + } + } + + function stop(id: string) { + const entry = _readers[id]; + if (!entry) return; + delete _readers[id]; + try { entry.controller.abort(); } catch { /* already aborted */ } + } + + async function writeText(text: string, lang: string | null, recordId: string | null) { + const W = window as any; + if (typeof W.NDEFReader !== 'function') return false; + try { + const writer = new W.NDEFReader(); + await writer.write({ + records: [{ recordType: 'text', data: text, lang: lang ?? undefined, id: recordId ?? undefined }] + }); + return true; + } catch { return false; } + } + + async function writeUrl(url: string, recordId: string | null) { + const W = window as any; + if (typeof W.NDEFReader !== 'function') return false; + try { + const writer = new W.NDEFReader(); + await writer.write({ + records: [{ recordType: 'url', data: url, id: recordId ?? undefined }] + }); + return true; + } catch { return false; } + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/notification.ts b/src/Butil/Bit.Butil/Scripts/notification.ts index 524918b5eb..1906b495fa 100644 --- a/src/Butil/Bit.Butil/Scripts/notification.ts +++ b/src/Butil/Bit.Butil/Scripts/notification.ts @@ -1,11 +1,16 @@ var BitButil = BitButil || {}; (function (butil: any) { + const _tracked: { [id: string]: Notification } = {}; + butil.notification = { isSupported, getPermission, requestPermission, show, + showTracked, + close, + dispose }; function isSupported() { @@ -20,20 +25,60 @@ var BitButil = BitButil || {}; return await Notification.requestPermission(); } - function show(title: string, options?: NotificationOptions) { + function normalize(options?: NotificationOptions) { + if (!options) return options; for (const key in options) { - if (options.hasOwnProperty(key)) { - options[key] = options[key] === null ? undefined : options[key]; + if (Object.prototype.hasOwnProperty.call(options, key) && (options as any)[key] === null) { + (options as any)[key] = undefined; } } + return options; + } + function show(title: string, options?: NotificationOptions) { + normalize(options); try { - const notification = new Notification(title, options); + // tslint:disable-next-line:no-unused-expression + new Notification(title, options); } catch (e) { navigator.serviceWorker?.getRegistration().then(reg => { - reg.showNotification(title, options); + reg?.showNotification(title, options); }); } } + function showTracked(id: string, title: string, options: NotificationOptions | undefined, + clickMethod: string, showMethod: string, closeMethod: string, errorMethod: string) { + normalize(options); + try { + const n = new Notification(title, options); + _tracked[id] = n; + n.onclick = () => DotNet.invokeMethodAsync('Bit.Butil', clickMethod, id); + n.onshow = () => DotNet.invokeMethodAsync('Bit.Butil', showMethod, id); + n.onclose = () => DotNet.invokeMethodAsync('Bit.Butil', closeMethod, id); + n.onerror = () => DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id); + } catch { + // Service-worker fallback can't be tracked the same way (the toast is owned by the SW) + // — fire show + error so callers can detect graceful degradation. + navigator.serviceWorker?.getRegistration().then(reg => { + reg?.showNotification(title, options); + DotNet.invokeMethodAsync('Bit.Butil', showMethod, id); + }).catch(() => DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id)); + } + } + + function close(id: string) { + const n = _tracked[id]; + if (!n) return; + try { n.close(); } catch { /* already closed */ } + } + + function dispose(id: string) { + const n = _tracked[id]; + if (!n) return; + delete _tracked[id]; + n.onclick = null; n.onshow = null; n.onclose = null; n.onerror = null; + try { n.close(); } catch { /* already closed */ } + } + }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Bit.Butil/Scripts/objectUrls.ts b/src/Butil/Bit.Butil/Scripts/objectUrls.ts new file mode 100644 index 0000000000..3330ce714b --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/objectUrls.ts @@ -0,0 +1,14 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.objectUrls = { + create(data: Uint8Array, mimeType: string) { + const buf = butil.utils.arrayToBuffer(data); + const blob = new Blob([buf], { type: mimeType || 'application/octet-stream' }); + return URL.createObjectURL(blob); + }, + revoke(url: string) { + try { URL.revokeObjectURL(url); } catch { /* already revoked */ } + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/performance.ts b/src/Butil/Bit.Butil/Scripts/performance.ts new file mode 100644 index 0000000000..c3ca8d829c --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/performance.ts @@ -0,0 +1,59 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _perfObservers: { [id: string]: PerformanceObserver } = {}; + + butil.performance = { + now() { return performance.now(); }, + timeOrigin() { return performance.timeOrigin; }, + mark(name: string) { performance.mark(name); }, + measure(name: string, startMark?: string, endMark?: string) { + // measure() rejects undefined start/end; pass them only when set. + if (startMark && endMark) performance.measure(name, startMark, endMark); + else if (startMark) performance.measure(name, startMark); + else performance.measure(name); + }, + clearMarks(name?: string) { performance.clearMarks(name ?? undefined); }, + clearMeasures(name?: string) { performance.clearMeasures(name ?? undefined); }, + clearResourceTimings() { performance.clearResourceTimings(); }, + getEntries(name?: string, type?: string) { + let entries: PerformanceEntry[]; + if (name) entries = performance.getEntriesByName(name, type ?? undefined); + else if (type) entries = performance.getEntriesByType(type); + else entries = performance.getEntries(); + // toJSON exists on entries; map to plain objects so dotnet can deserialize. + return entries.map(e => (e as any).toJSON ? (e as any).toJSON() : e); + }, + memory() { + const m = (performance as any).memory; + if (!m) return { jsHeapSizeLimit: null, totalJsHeapSize: null, usedJsHeapSize: null }; + return { + jsHeapSizeLimit: m.jsHeapSizeLimit ?? null, + totalJsHeapSize: m.totalJSHeapSize ?? null, + usedJsHeapSize: m.usedJSHeapSize ?? null + }; + }, + observe(methodName: string, listenerId: string, entryTypes: string[], buffered: boolean) { + if (!('PerformanceObserver' in window) || !entryTypes?.length) return; + const observer = new PerformanceObserver(list => { + const payload = list.getEntries().map(e => (e as any).toJSON ? (e as any).toJSON() : e); + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + }); + try { + // observe() with a "type" + "buffered" can only handle one entry type at a time; + // loop so we register each one separately and merge their reports. + for (const t of entryTypes) { + try { observer.observe({ type: t, buffered }); } + catch { /* type isn't supported on this UA — skip silently */ } + } + } catch { /* observe() rejected the whole batch — fall through with no records */ } + _perfObservers[listenerId] = observer; + }, + disconnect(listenerId: string) { + const observer = _perfObservers[listenerId]; + if (!observer) return; + delete _perfObservers[listenerId]; + observer.disconnect(); + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/permissions.ts b/src/Butil/Bit.Butil/Scripts/permissions.ts new file mode 100644 index 0000000000..05a544b6ab --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/permissions.ts @@ -0,0 +1,18 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.permissions = { + isSupported() { return !!(window.navigator as any).permissions?.query; }, + async query(name: string) { + const perms: any = (window.navigator as any).permissions; + if (!perms || typeof perms.query !== 'function') return 'unknown'; + try { + const status = await perms.query({ name }); + return status?.state ?? 'unknown'; + } catch { + // The browser doesn't recognize this descriptor name. + return 'unknown'; + } + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/push.ts b/src/Butil/Bit.Butil/Scripts/push.ts new file mode 100644 index 0000000000..104852ae07 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/push.ts @@ -0,0 +1,60 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.push = { + isSupported() { + return 'serviceWorker' in window.navigator && 'PushManager' in window; + }, + async getSubscription() { return await readSubscription(); }, + async subscribe(applicationServerKey: string, userVisibleOnly: boolean) { + const reg = await window.navigator.serviceWorker?.getRegistration(); + if (!reg?.pushManager) return inactive(); + try { + const sub = await reg.pushManager.subscribe({ + applicationServerKey: urlBase64ToUint8Array(applicationServerKey), + userVisibleOnly: !!userVisibleOnly + }); + return toInfo(sub); + } catch { + return inactive(); + } + }, + async unsubscribe() { + const reg = await window.navigator.serviceWorker?.getRegistration(); + const sub = await reg?.pushManager?.getSubscription(); + if (!sub) return false; + try { return await sub.unsubscribe(); } catch { return false; } + } + }; + + function inactive() { + return { isActive: false, endpoint: '', expirationTime: null, p256dh: '', auth: '' }; + } + + async function readSubscription() { + const reg = await window.navigator.serviceWorker?.getRegistration(); + const sub = await reg?.pushManager?.getSubscription(); + return sub ? toInfo(sub) : inactive(); + } + + function toInfo(sub: PushSubscription) { + const json = sub.toJSON() as any; + return { + isActive: true, + endpoint: json.endpoint, + expirationTime: typeof json.expirationTime === 'number' ? json.expirationTime : null, + p256dh: json.keys?.p256dh ?? '', + auth: json.keys?.auth ?? '' + }; + } + + /** VAPID keys are base64url; PushManager.subscribe needs a Uint8Array. */ + function urlBase64ToUint8Array(value: string) { + const padding = '='.repeat((4 - value.length % 4) % 4); + const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + return bytes; + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/reporting.ts b/src/Butil/Bit.Butil/Scripts/reporting.ts new file mode 100644 index 0000000000..70a80411b0 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/reporting.ts @@ -0,0 +1,31 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _observers: { [id: string]: any } = {}; + + butil.reporting = { + isSupported() { return 'ReportingObserver' in window; }, + observe(methodName: string, listenerId: string, types: string[] | null, buffered: boolean) { + const W = window as any; + if (typeof W.ReportingObserver !== 'function') return; + const options: any = { buffered }; + if (types?.length) options.types = types; + const observer = new W.ReportingObserver((reports: any[]) => { + const payload = reports.map(r => ({ + type: r.type, + url: r.url, + body: r.body ?? null + })); + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + }, options); + try { observer.observe(); _observers[listenerId] = observer; } + catch { /* invalid options — silently ignore */ } + }, + disconnect(listenerId: string) { + const o = _observers[listenerId]; + if (!o) return; + delete _observers[listenerId]; + try { o.disconnect(); } catch { /* already disconnected */ } + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/resizeObserver.ts b/src/Butil/Bit.Butil/Scripts/resizeObserver.ts new file mode 100644 index 0000000000..2170ac0730 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/resizeObserver.ts @@ -0,0 +1,52 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _observers: { [id: string]: ResizeObserver } = {}; + + butil.resizeObserver = { + observe, + unobserve + }; + + function pickBox(entry: ResizeObserverEntry, prop: 'borderBoxSize' | 'contentBoxSize' | 'devicePixelContentBoxSize') { + const box = (entry as any)[prop]; + if (!box) return { inlineSize: 0, blockSize: 0 }; + // Older Safari delivered a single object instead of an array. + const first = Array.isArray(box) ? box[0] : box; + return { inlineSize: first?.inlineSize ?? 0, blockSize: first?.blockSize ?? 0 }; + } + + function observe(methodName: string, listenerId: string, element: HTMLElement, box: string) { + if (!element || !('ResizeObserver' in window)) return; + + const observer = new ResizeObserver(entries => { + const payload = entries.map(e => { + const r = e.contentRect; + const content = pickBox(e, 'contentBoxSize'); + const device = pickBox(e, 'devicePixelContentBoxSize'); + return { + contentRect: r ? { x: r.x, y: r.y, width: r.width, height: r.height } : null, + inlineSize: content.inlineSize, + blockSize: content.blockSize, + devicePixelInlineSize: device.inlineSize, + devicePixelBlockSize: device.blockSize, + }; + }); + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + }); + + try { + observer.observe(element, { box: box as ResizeObserverBoxOptions }); + } catch { + observer.observe(element); + } + _observers[listenerId] = observer; + } + + function unobserve(listenerId: string) { + const observer = _observers[listenerId]; + if (!observer) return; + delete _observers[listenerId]; + observer.disconnect(); + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/serviceWorker.ts b/src/Butil/Bit.Butil/Scripts/serviceWorker.ts new file mode 100644 index 0000000000..e740d38b0b --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/serviceWorker.ts @@ -0,0 +1,101 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _msgListeners: { [id: string]: (e: MessageEvent) => void } = {}; + const _ccListeners: { [id: string]: () => void } = {}; + + butil.serviceWorker = { + isSupported() { return 'serviceWorker' in window.navigator; }, + register, + getRegistration, + update, + unregister, + postMessage, + subscribeMessage, + unsubscribeMessage, + subscribeControllerChange, + unsubscribeControllerChange + }; + + function info(reg: ServiceWorkerRegistration | null | undefined) { + if (!reg) return { isRegistered: false, scope: '', activeState: null, installingState: null, waitingState: null, updateViaCache: null }; + return { + isRegistered: true, + scope: reg.scope ?? '', + activeState: reg.active?.state ?? null, + installingState: reg.installing?.state ?? null, + waitingState: reg.waiting?.state ?? null, + updateViaCache: (reg as any).updateViaCache ?? null + }; + } + + async function register(scriptUrl: string, scope: string | null, updateViaCache: string | null, moduleType: boolean) { + if (!('serviceWorker' in window.navigator)) return info(null); + try { + const opts: any = {}; + if (scope) opts.scope = scope; + if (updateViaCache) opts.updateViaCache = updateViaCache; + if (moduleType) opts.type = 'module'; + const reg = await window.navigator.serviceWorker.register(scriptUrl, opts); + return info(reg); + } catch { + return info(null); + } + } + + async function getRegistration(scope: string | null) { + if (!('serviceWorker' in window.navigator)) return info(null); + const reg = await window.navigator.serviceWorker.getRegistration(scope ?? undefined); + return info(reg); + } + + async function update(scope: string | null) { + const reg = await window.navigator.serviceWorker?.getRegistration(scope ?? undefined); + if (!reg) return; + try { await reg.update(); } catch { /* network failure / 404 — surface via subsequent getRegistration */ } + } + + async function unregister(scope: string | null) { + const reg = await window.navigator.serviceWorker?.getRegistration(scope ?? undefined); + if (!reg) return false; + try { return await reg.unregister(); } catch { return false; } + } + + function postMessage(message: any) { + const ctrl = window.navigator.serviceWorker?.controller; + if (!ctrl) return false; + try { ctrl.postMessage(message); return true; } catch { return false; } + } + + function subscribeMessage(methodName: string, listenerId: string) { + const sw = window.navigator.serviceWorker; + if (!sw) return; + const handler = (e: MessageEvent) => { + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, e.data ?? null); + }; + _msgListeners[listenerId] = handler; + sw.addEventListener('message', handler); + } + + function unsubscribeMessage(listenerId: string) { + const handler = _msgListeners[listenerId]; + if (!handler) return; + delete _msgListeners[listenerId]; + try { window.navigator.serviceWorker?.removeEventListener('message', handler); } catch { /* ignore */ } + } + + function subscribeControllerChange(methodName: string, listenerId: string) { + const sw = window.navigator.serviceWorker; + if (!sw) return; + const handler = () => { DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId); }; + _ccListeners[listenerId] = handler; + sw.addEventListener('controllerchange', handler); + } + + function unsubscribeControllerChange(listenerId: string) { + const handler = _ccListeners[listenerId]; + if (!handler) return; + delete _ccListeners[listenerId]; + try { window.navigator.serviceWorker?.removeEventListener('controllerchange', handler); } catch { /* ignore */ } + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/speech.ts b/src/Butil/Bit.Butil/Scripts/speech.ts new file mode 100644 index 0000000000..27c1e02f6e --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/speech.ts @@ -0,0 +1,37 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.speech = { + isSupported() { return 'speechSynthesis' in window; }, + getVoices() { + const synth = window.speechSynthesis; + if (!synth) return []; + return synth.getVoices().map(v => ({ + name: v.name, + lang: v.lang, + voiceUri: v.voiceURI, + default: v.default, + localService: v.localService + })); + }, + speak(utterance: any) { + const synth = window.speechSynthesis; + if (!synth) return; + const u = new SpeechSynthesisUtterance(utterance.text ?? ''); + if (utterance.lang) u.lang = utterance.lang; + if (typeof utterance.rate === 'number') u.rate = utterance.rate; + if (typeof utterance.pitch === 'number') u.pitch = utterance.pitch; + if (typeof utterance.volume === 'number') u.volume = utterance.volume; + if (utterance.voiceName) { + const match = synth.getVoices().find(v => v.name === utterance.voiceName); + if (match) u.voice = match; + } + synth.speak(u); + }, + cancel() { window.speechSynthesis?.cancel(); }, + pause() { window.speechSynthesis?.pause(); }, + resume() { window.speechSynthesis?.resume(); }, + isSpeaking() { return !!window.speechSynthesis?.speaking; }, + isPending() { return !!window.speechSynthesis?.pending; } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/speechRecognition.ts b/src/Butil/Bit.Butil/Scripts/speechRecognition.ts new file mode 100644 index 0000000000..384032b0e6 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/speechRecognition.ts @@ -0,0 +1,62 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + const _sessions: { [id: string]: any } = {}; + + butil.speechRecognition = { + isSupported() { + const W = window as any; + return !!(W.SpeechRecognition || W.webkitSpeechRecognition); + }, + start, + stop + }; + + function start(id: string, options: any, resultMethod: string, errorMethod: string, endMethod: string) { + const W = window as any; + const Ctor = W.SpeechRecognition || W.webkitSpeechRecognition; + if (!Ctor) { + DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, 'SpeechRecognition is not supported.'); + return; + } + const r = new Ctor(); + if (options.lang) r.lang = options.lang; + r.continuous = !!options.continuous; + r.interimResults = !!options.interimResults; + r.maxAlternatives = options.maxAlternatives ?? 1; + + r.onresult = (event: any) => { + for (let i = event.resultIndex; i < event.results.length; i++) { + const res = event.results[i]; + // We only forward the top alternative; callers wanting more should bump + // maxAlternatives and read each result via getEntries-style observation. + const top = res?.[0]; + if (!top) continue; + DotNet.invokeMethodAsync('Bit.Butil', resultMethod, id, { + transcript: top.transcript ?? '', + confidence: top.confidence ?? 0, + isFinal: !!res.isFinal + }); + } + }; + r.onerror = (event: any) => { + DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, event?.error ?? 'unknown'); + }; + r.onend = () => { + DotNet.invokeMethodAsync('Bit.Butil', endMethod, id); + delete _sessions[id]; + }; + + try { r.start(); _sessions[id] = r; } + catch (e: any) { + DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, e?.message ?? String(e)); + } + } + + function stop(id: string) { + const r = _sessions[id]; + if (!r) return; + delete _sessions[id]; + try { r.stop(); } catch { /* already stopped */ } + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/storage.ts b/src/Butil/Bit.Butil/Scripts/storage.ts index 8502e4033a..a5dc3e3538 100644 --- a/src/Butil/Bit.Butil/Scripts/storage.ts +++ b/src/Butil/Bit.Butil/Scripts/storage.ts @@ -1,12 +1,37 @@ var BitButil = BitButil || {}; (function (butil: any) { + const _handlers: { [id: string]: (e: StorageEvent) => void } = {}; + butil.storage = { length(storage: string) { return (window[storage] as Storage).length }, key(storage: string, index: number) { return (window[storage] as Storage).key(index) }, + containsKey(storage: string, key: string) { return (window[storage] as Storage).getItem(key) !== null }, getItem(storage: string, key: string) { return (window[storage] as Storage).getItem(key) }, setItem(storage: string, key: string, value: string) { (window[storage] as Storage).setItem(key, value) }, removeItem(storage: string, key: string) { (window[storage] as Storage).removeItem(key) }, clear(storage: string) { (window[storage] as Storage).clear() }, + subscribe(methodName: string, listenerId: string) { + const handler = (e: StorageEvent) => { + const area = e.storageArea === window.localStorage ? 'localStorage' + : e.storageArea === window.sessionStorage ? 'sessionStorage' + : ''; + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, { + key: e.key, + oldValue: e.oldValue, + newValue: e.newValue, + url: e.url, + storageArea: area + }); + }; + _handlers[listenerId] = handler; + window.addEventListener('storage', handler); + }, + unsubscribe(listenerId: string) { + const handler = _handlers[listenerId]; + if (!handler) return; + delete _handlers[listenerId]; + window.removeEventListener('storage', handler); + } }; }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Bit.Butil/Scripts/storageManager.ts b/src/Butil/Bit.Butil/Scripts/storageManager.ts new file mode 100644 index 0000000000..ff8f8e96b2 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/storageManager.ts @@ -0,0 +1,30 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + butil.storageManager = { + isSupported() { return !!(window.navigator as any).storage; }, + async estimate() { + const sm: any = (window.navigator as any).storage; + if (!sm?.estimate) return { quota: null, usage: null }; + try { + const e = await sm.estimate(); + return { + quota: typeof e.quota === 'number' ? e.quota : null, + usage: typeof e.usage === 'number' ? e.usage : null + }; + } catch { + return { quota: null, usage: null }; + } + }, + async persisted() { + const sm: any = (window.navigator as any).storage; + if (!sm?.persisted) return false; + try { return await sm.persisted(); } catch { return false; } + }, + async persist() { + const sm: any = (window.navigator as any).storage; + if (!sm?.persist) return false; + try { return await sm.persist(); } catch { return false; } + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/userAgent.ts b/src/Butil/Bit.Butil/Scripts/userAgent.ts index 10963a1576..e7090086b5 100644 --- a/src/Butil/Bit.Butil/Scripts/userAgent.ts +++ b/src/Butil/Bit.Butil/Scripts/userAgent.ts @@ -3,6 +3,40 @@ var BitButil = BitButil || {}; (function (butil: any) { butil.userAgent = { extract, + isClientHintsSupported() { return !!(window.navigator as any).userAgentData; }, + getBrands() { + const data = (window.navigator as any).userAgentData; + if (!data?.brands) return []; + return data.brands.map((b: any) => ({ brand: b.brand, version: b.version })); + }, + isMobile() { return !!(window.navigator as any).userAgentData?.mobile; }, + getPlatform() { return (window.navigator as any).userAgentData?.platform ?? ''; }, + async getHighEntropyValues(hints: string[]) { + const data = (window.navigator as any).userAgentData; + const empty = { + architecture: null, bitness: null, brands: null, fullVersionList: null, + mobile: null, model: null, platform: null, platformVersion: null, + uaFullVersion: null, wow64: null + }; + if (!data?.getHighEntropyValues) return empty; + try { + const v = await data.getHighEntropyValues(hints || []); + return { + architecture: v.architecture ?? null, + bitness: v.bitness ?? null, + brands: v.brands?.map((b: any) => ({ brand: b.brand, version: b.version })) ?? null, + fullVersionList: v.fullVersionList?.map((b: any) => ({ brand: b.brand, version: b.version })) ?? null, + mobile: typeof v.mobile === 'boolean' ? v.mobile : null, + model: v.model ?? null, + platform: v.platform ?? null, + platformVersion: v.platformVersion ?? null, + uaFullVersion: v.uaFullVersion ?? null, + wow64: typeof v.wow64 === 'boolean' ? v.wow64 : null + }; + } catch { + return empty; + } + } }; function extract(userAgentString?: string) { diff --git a/src/Butil/Bit.Butil/Scripts/utils.ts b/src/Butil/Bit.Butil/Scripts/utils.ts index 26f38ab794..a52d95649e 100644 --- a/src/Butil/Bit.Butil/Scripts/utils.ts +++ b/src/Butil/Bit.Butil/Scripts/utils.ts @@ -6,6 +6,9 @@ var BitButil = BitButil || {}; }; function arrayToBuffer(array: Uint8Array) { - return array?.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset) + if (!array) return undefined; + // Slice covers exactly the [byteOffset, byteOffset + byteLength) range so that + // a Uint8Array view over a larger buffer doesn't leak extra bytes. + return array.buffer.slice(array.byteOffset, array.byteOffset + array.byteLength); } }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Bit.Butil/Scripts/wakeLock.ts b/src/Butil/Bit.Butil/Scripts/wakeLock.ts new file mode 100644 index 0000000000..f4235f55a5 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/wakeLock.ts @@ -0,0 +1,57 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + let _sentinel: any = null; + const _persistent: { [token: string]: { sentinel: any, listener: () => void } } = {}; + + butil.wakeLock = { + isSupported() { return !!(window.navigator as any).wakeLock; }, + async request() { + const lock = (window.navigator as any).wakeLock; + if (!lock) return false; + try { + _sentinel = await lock.request('screen'); + _sentinel.addEventListener?.('release', () => { _sentinel = null; }); + return true; + } catch { + _sentinel = null; + return false; + } + }, + async release() { + if (!_sentinel) return; + try { await _sentinel.release(); } catch { /* already released */ } + _sentinel = null; + }, + async persist(token: string) { + const lockApi = (window.navigator as any).wakeLock; + if (!lockApi) return; + + const acquire = async () => { + if (document.visibilityState !== 'visible') return; + const entry = _persistent[token]; + // The sentinel may already be active and unreleased, in which case re-requesting + // would create a second one we can't track — bail out. + if (entry?.sentinel && !entry.sentinel.released) return; + try { + const sentinel = await lockApi.request('screen'); + if (_persistent[token]) _persistent[token].sentinel = sentinel; + } catch { /* permission denied or page not visible */ } + }; + + const listener = () => { acquire(); }; + _persistent[token] = { sentinel: null, listener }; + document.addEventListener('visibilitychange', listener); + await acquire(); + }, + async unpersist(token: string) { + const entry = _persistent[token]; + if (!entry) return; + delete _persistent[token]; + document.removeEventListener('visibilitychange', entry.listener); + if (entry.sentinel && !entry.sentinel.released) { + try { await entry.sentinel.release(); } catch { /* already released */ } + } + } + }; +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/webAudio.ts b/src/Butil/Bit.Butil/Scripts/webAudio.ts new file mode 100644 index 0000000000..7cf4446290 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/webAudio.ts @@ -0,0 +1,84 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + let _ctx: AudioContext | null = null; + let _master: GainNode | null = null; + const _nodes: { [id: string]: { source: AudioScheduledSourceNode, gain: GainNode } } = {}; + + butil.webAudio = { + isSupported() { return 'AudioContext' in window || 'webkitAudioContext' in (window as any); }, + resume() { return ensureCtx()?.resume(); }, + suspend() { return _ctx?.suspend(); }, + setMasterGain, + playBuffer, + playTone, + stop, + setGain + }; + + function ensureCtx(): AudioContext | null { + if (_ctx) return _ctx; + const Ctor: any = (window as any).AudioContext || (window as any).webkitAudioContext; + if (!Ctor) return null; + _ctx = new Ctor(); + if (!_ctx) return null; + _master = _ctx.createGain(); + _master.gain.value = 1; + _master.connect(_ctx.destination); + return _ctx; + } + + function setMasterGain(value: number) { + ensureCtx(); + if (_master) _master.gain.value = value; + } + + async function playBuffer(id: string, data: Uint8Array, startGain: number, loop: boolean) { + const ctx = ensureCtx(); + if (!ctx || !_master) return; + const buf = await ctx.decodeAudioData(butil.utils.arrayToBuffer(data)); + const source = ctx.createBufferSource(); + source.buffer = buf; + source.loop = !!loop; + const gain = ctx.createGain(); + gain.gain.value = startGain ?? 1; + source.connect(gain).connect(_master); + attach(id, source, gain); + try { source.start(); } catch { /* invalid state */ } + } + + function playTone(id: string, frequency: number, durationMs: number, waveform: string, startGain: number) { + const ctx = ensureCtx(); + if (!ctx || !_master) return; + const osc = ctx.createOscillator(); + osc.type = (waveform || 'sine') as OscillatorType; + osc.frequency.value = frequency; + const gain = ctx.createGain(); + gain.gain.value = startGain ?? 0.5; + osc.connect(gain).connect(_master); + attach(id, osc, gain); + try { + osc.start(); + if (durationMs && durationMs > 0) osc.stop(ctx.currentTime + durationMs / 1000); + } catch { /* invalid state */ } + } + + function attach(id: string, source: AudioScheduledSourceNode, gain: GainNode) { + _nodes[id] = { source, gain }; + source.addEventListener('ended', () => { delete _nodes[id]; }); + } + + function stop(id: string) { + const entry = _nodes[id]; + if (!entry) return; + delete _nodes[id]; + try { entry.source.stop(); } catch { /* already stopped */ } + try { entry.source.disconnect(); } catch { /* already disconnected */ } + try { entry.gain.disconnect(); } catch { /* already disconnected */ } + } + + function setGain(id: string, value: number) { + const entry = _nodes[id]; + if (entry) entry.gain.gain.value = value; + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/webLocks.ts b/src/Butil/Bit.Butil/Scripts/webLocks.ts new file mode 100644 index 0000000000..bbc3d473d0 --- /dev/null +++ b/src/Butil/Bit.Butil/Scripts/webLocks.ts @@ -0,0 +1,64 @@ +var BitButil = BitButil || {}; + +(function (butil: any) { + // Each acquired lock parks on a Promise that resolves only when dotnet calls release(token). + // The map of pending releases lives here so multiple locks per tab don't trip each other. + const _pending: { [token: string]: () => void } = {}; + + butil.webLocks = { + isSupported() { return !!(window.navigator as any).locks; }, + acquire, + release, + query + }; + + async function acquire(name: string, mode: 'exclusive' | 'shared', ifAvailable: boolean, steal: boolean, token: string) { + const lockManager = (window.navigator as any).locks; + if (!lockManager) return false; + + // Bridge the callback model into a deferred. We resolve `acquired` once we're inside the + // callback and resolve the *holder* promise when dotnet releases. + let acquiredResolve: (v: boolean) => void; + const acquiredPromise = new Promise(r => { acquiredResolve = r; }); + + const holder = new Promise(resolve => { + _pending[token] = resolve; + }); + + const options: any = { mode }; + if (ifAvailable) options.ifAvailable = true; + if (steal) options.steal = true; + + // Fire the request without awaiting the outer call; the JS callback receiving null + // means the lock wasn't acquired (only happens with ifAvailable: true). + lockManager.request(name, options, (lock: any) => { + if (!lock) { + acquiredResolve(false); + delete _pending[token]; + return; + } + acquiredResolve(true); + return holder; + }).catch(() => { + acquiredResolve(false); + delete _pending[token]; + }); + + return acquiredPromise; + } + + function release(token: string) { + const r = _pending[token]; + if (!r) return; + delete _pending[token]; + r(); + } + + async function query() { + const lockManager = (window.navigator as any).locks; + if (!lockManager?.query) return { held: [], pending: [] }; + const snap = await lockManager.query(); + const map = (arr: any[]) => (arr || []).map(l => ({ name: l.name, mode: l.mode, clientId: l.clientId })); + return { held: map(snap.held), pending: map(snap.pending) }; + } +}(BitButil)); diff --git a/src/Butil/Bit.Butil/Scripts/window.ts b/src/Butil/Bit.Butil/Scripts/window.ts index addf5fb5b6..8d3f6e6aa9 100644 --- a/src/Butil/Bit.Butil/Scripts/window.ts +++ b/src/Butil/Bit.Butil/Scripts/window.ts @@ -2,6 +2,7 @@ var BitButil = BitButil || {}; (function (butil: any) { let _refs = {}; + const _mediaQueryHandlers: { [id: string]: { mql: MediaQueryList, handler: (e: MediaQueryListEvent) => void } } = {}; butil.window = { addBeforeUnload, @@ -27,8 +28,32 @@ var BitButil = BitButil || {}; confirm(message?: string) { return window.confirm(message) }, find, focus() { window.focus() }, - getSelection() { return window.getSelection().toString() }, // TODO: needs to be improved and not only return the toString + getSelection, + getSelectionText() { return window.getSelection()?.toString() ?? '' }, + clearSelection() { window.getSelection()?.removeAllRanges(); }, + selectElement(element: HTMLElement) { + if (!element) return; + // Inputs/textareas have their own select(), and trying to wrap them in a Range fails. + if (typeof (element as any).select === 'function' && (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { + (element as HTMLInputElement).select(); + return; + } + const sel = window.getSelection(); + if (!sel) return; + sel.removeAllRanges(); + const range = document.createRange(); + try { range.selectNodeContents(element); sel.addRange(range); } + catch { /* element may not be in the DOM */ } + }, + async copySelection() { + const text = window.getSelection()?.toString() ?? ''; + if (!text) return false; + try { await navigator.clipboard.writeText(text); return true; } + catch { return false; } + }, matchMedia, + subscribeMatchMedia, + unsubscribeMatchMedia, open, print() { window.print() }, prompt(message?: string, defaultValue?: string) { return window.prompt(message, defaultValue) }, @@ -38,11 +63,14 @@ var BitButil = BitButil || {}; dispose }; - function addBeforeUnload() { + function addBeforeUnload(message?: string) { window.onbeforeunload = e => { e.preventDefault(); - e.returnValue = true; - return true; + // Modern browsers ignore the returnValue/message text and show their own copy, + // but legacy and some embedded webviews still honor it. + const msg = typeof message === 'string' && message.length > 0 ? message : true; + (e as any).returnValue = msg; + return msg; }; } @@ -68,6 +96,19 @@ var BitButil = BitButil || {}; return (window as any).find(text, caseSensitive, backward, wrapAround, wholeWord, searchInFrame); } + function getSelection() { + const sel = window.getSelection(); + if (!sel) return null; + return { + text: sel.toString(), + isCollapsed: sel.isCollapsed, + rangeCount: sel.rangeCount, + type: (sel as any).type ?? null, + anchorOffset: sel.anchorOffset, + focusOffset: sel.focusOffset + }; + } + function matchMedia(query: string) { const media = window.matchMedia(query); return { @@ -76,6 +117,35 @@ var BitButil = BitButil || {}; }; } + function subscribeMatchMedia(methodName: string, listenerId: string, query: string) { + const mql = window.matchMedia(query); + const handler = (e: MediaQueryListEvent) => { + DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, { matches: e.matches, media: e.media }); + }; + + // addEventListener is supported on MediaQueryList in all evergreen browsers; older + // Safari only exposes the legacy addListener variant. + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', handler); + } else { + (mql as any).addListener(handler); + } + _mediaQueryHandlers[listenerId] = { mql, handler }; + } + + function unsubscribeMatchMedia(ids: string[]) { + ids.forEach(id => { + const entry = _mediaQueryHandlers[id]; + if (!entry) return; + delete _mediaQueryHandlers[id]; + if (typeof entry.mql.removeEventListener === 'function') { + entry.mql.removeEventListener('change', entry.handler); + } else { + (entry.mql as any).removeListener(entry.handler); + } + }); + } + function open(id: string, url?: string, target?: string, windowFeatures?: string) { const ref = window.open(url, target, windowFeatures); if (!ref) return; @@ -101,5 +171,14 @@ var BitButil = BitButil || {}; function dispose() { _refs = {}; + for (const id of Object.keys(_mediaQueryHandlers)) { + const entry = _mediaQueryHandlers[id]; + if (typeof entry.mql.removeEventListener === 'function') { + entry.mql.removeEventListener('change', entry.handler); + } else { + (entry.mql as any).removeListener(entry.handler); + } + delete _mediaQueryHandlers[id]; + } } }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ClipboardPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ClipboardPage.razor index af32859298..0a7224fad8 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ClipboardPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ClipboardPage.razor @@ -1,75 +1,63 @@ @page "/clipboard" -@inject Bit.Butil.Console console @inject Bit.Butil.Clipboard clipboard Clipboard Samples -

Clipboard

+ -
+
 @@inject Bit.Butil.Clipboard clipboard
 
-@@code {
-    ...
-    await clipboard.WriteText("new clipboard text");
-    ...
-}
+await clipboard.WriteText("new clipboard text");
+var text = await clipboard.ReadText();
 
-
-
- -

Open the DevTools' console and start clicking on buttons

- -
-
- - - -
-
-
-
- - -
-
- - -
-
-
-
- - - -
-
-
-
- - -
-
- - -
-
+
+ +
+ Text to copy + +
+
+ + +
+
+ + +
+ Item content + +
+
+ + +
+
+
+ + @code { + private DemoConsole output = default!; private string newClipText; private string newText; private async Task ReadText() { var text = await clipboard.ReadText(); - await console.Log("Clipboard.ReadText =", $"\"{text}\""); + await output.Success("ReadText →", $"\"{text}\""); } private async Task WriteText() { await clipboard.WriteText(newClipText ?? string.Empty); - await console.Log("Clipboard.WriteText =", $"\"{newClipText}\""); + await output.Log("WriteText →", $"\"{newClipText}\""); } private async Task Read() @@ -77,15 +65,15 @@ var items = await clipboard.Read(); foreach (var item in items) { - await console.Log("Clipboard.Read=", $"\"{item.MimeType}\",", System.Text.Encoding.UTF8.GetString(item.Data)); + await output.Success("Read →", $"\"{item.MimeType}\":", System.Text.Encoding.UTF8.GetString(item.Data)); } } public async Task Write() { - var data = System.Text.Encoding.UTF8.GetBytes(newText); + var data = System.Text.Encoding.UTF8.GetBytes(newText ?? string.Empty); var item = new ClipboardItem() { MimeType = "text/plain", Data = data }; await clipboard.Write([item]); - await console.Log("Clipboard.Write=", $"\"{item.MimeType}\",", System.Text.Encoding.UTF8.GetString(item.Data)); + await output.Log("Write →", $"\"{item.MimeType}\":", System.Text.Encoding.UTF8.GetString(item.Data)); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ConsolePage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ConsolePage.razor index 2e9c49c045..11ea180e9e 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ConsolePage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ConsolePage.razor @@ -3,130 +3,85 @@ Console Samples -

Console

+ -
+
 @@inject Bit.Butil.Console console
 
-@@code {
-    ...
-    console.Log("This is a test log");
-    ...
-}
+await console.Log("This is a test log");
+await console.Table(new { Name = "Value", Value = value });
 
-
-
- -

Open the DevTools' console and start clicking on buttons

- -
-
- -Value: - - -
-
-
-
- - - -
-
-
-
- - - -
-
-
-
- - - -
-
-
-
- - - -
-
-
-
- - -  - - -
-
-
-
- - -
-
- -
-
- -
-
- -
-
- - -
-
-
-
- - - -
-
-
-
- - -  - - -
-
-
-
- - -  - -  - -  - - -
-
-
-
- - - -
-
-
-
- - - -
-
+ +
+ Value + +
+
+ +
+ +
+ + + + + +
+
+ + +
+ + + + +
+
+ + +
+ + +
+
+ + +
+ + + + +
+
+ + + + + + +
+ + + +
+
+ + + + +
@code { private string value = "Test"; @@ -146,4 +101,4 @@ await console.GroupEnd(); await console.Log("Back to the outer level"); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CookiePage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CookiePage.razor index 8217c068cc..76a97b40d5 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CookiePage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CookiePage.razor @@ -1,69 +1,61 @@ @page "/cookie" -@inject Bit.Butil.Console console @inject Bit.Butil.Cookie cookie Cookie Samples -

Cookie

+ -
+
 @@inject Bit.Butil.Cookie cookie
 
-@@code {
-    ...
-    await cookie.Remove("cookie-name");
-    ...
-}
+await cookie.Set(new ButilCookie { Name = "theme", Value = "dark" });
+var theme = await cookie.Get("theme");
+await cookie.Remove("theme");
 
-
-
- -

Open the DevTools' console and click on buttons

- -
-
- - -
-
-
-Name -
- -
-
- -
-
-
-Name -
- -
-Value -
- -
-
- - -
-
-
-
- -Name -
- -
-
- - -
-
+
+ +
+ Name + +
+
+ Value + +
+ +
+ + +
+ Name + +
+
+ + +
+
+ + +
+ Name + +
+ +
+
+ + @code { + private DemoConsole output = default!; private string getCookieName = ""; private string setCookieName = ""; private string setCookieValue = ""; @@ -71,22 +63,35 @@ private async Task GetAllCookies() { - await console.Log("All cookies =", string.Join("; ", await cookie.GetAll())); + var all = await cookie.GetAll(); + if (all.Length == 0) + { + await output.Warn("No cookies found."); + return; + } + await output.Success("All cookies →", string.Join("; ", all)); } private async Task GetCookie() { - await console.Log("GetCookie =", await cookie.Get(getCookieName)); + var result = await cookie.Get(getCookieName); + if (result is null) + { + await output.Warn($"Cookie \"{getCookieName}\" was not found."); + return; + } + await output.Success($"Get(\"{getCookieName}\") →", result); } private async Task SetCookie() { await cookie.Set(new ButilCookie { Name = setCookieName, Value = setCookieValue }); - await console.Log("SetCookie =", await cookie.Get(setCookieName)); + await output.Log($"Set(\"{setCookieName}\") →", await cookie.Get(setCookieName)); } private async Task RemoveCookie() { await cookie.Remove(removeCookieName); + await output.Log($"Removed \"{removeCookieName}\"."); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CryptoPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CryptoPage.razor index 361d039fe3..98af24f628 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CryptoPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CryptoPage.razor @@ -1,46 +1,55 @@ @page "/crypto" -@inject Bit.Butil.Console console @inject Bit.Butil.Crypto crypto Crypto Samples -

Crypto

+ -
+
 @@inject Bit.Butil.Crypto crypto
 
-@@code {
-    ...
-    await crypto.Encrypt(...);
-    ...
-}
+var cipher = await crypto.Encrypt(CryptoAlgorithm.AesCbc, key, data, iv: iv);
+var plain  = await crypto.Decrypt(CryptoAlgorithm.AesCbc, key, cipher, iv: iv);
 
-
-
+
+ +
+ Plain text + +
+ + +
-

Open the DevTools' console and start clicking

+
+ Cipher (hex) + +
+ -
-
+ @if (!string.IsNullOrEmpty(outputText)) + { +

+ Decrypted: @outputText +

+ } +
+
- -
-
- -
-
-
- -
-
- + @code { + private DemoConsole output = default!; private string inputText; private byte[] iv = new byte[16]; private byte[] key = new byte[16]; private byte[] encryptedBytes; + private string cipherHex; private string outputText; protected override void OnInitialized() @@ -51,19 +60,30 @@ private async Task Encrypt() { - await console.Log("Start encrypting..."); + if (string.IsNullOrEmpty(inputText)) + { + await output.Warn("Enter some text to encrypt first."); + return; + } + + await output.Info("Encrypting..."); var textAsUtf8Bytes = System.Text.Encoding.UTF8.GetBytes(inputText); encryptedBytes = await crypto.Encrypt(CryptoAlgorithm.AesCbc, key, textAsUtf8Bytes, iv: iv); - await console.Log("Encrypted bytes:", encryptedBytes); - await console.Log("Encrypted text:", BitConverter.ToString(encryptedBytes)); + cipherHex = BitConverter.ToString(encryptedBytes); + await output.Success("Encrypted →", cipherHex); } private async Task Decrypt() { - await console.Log("Start decrypting..."); + if (encryptedBytes is null) + { + await output.Warn("Encrypt something first."); + return; + } + + await output.Info("Decrypting..."); var decryptedBytes = await crypto.Decrypt(CryptoAlgorithm.AesCbc, key, encryptedBytes, iv: iv); - await console.Log("Decrypted bytes:", decryptedBytes); outputText = System.Text.Encoding.UTF8.GetString(decryptedBytes); - await console.Log("Decrypted text:", outputText); + await output.Success("Decrypted →", outputText); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/DocumentPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/DocumentPage.razor index 6d2a6c698e..2145dae52c 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/DocumentPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/DocumentPage.razor @@ -1,101 +1,84 @@ @page "/document" @implements IDisposable -@inject Bit.Butil.Console console @inject Bit.Butil.Document document Document Samples -

Document

+ -
+
 @@inject Bit.Butil.Document document
 
-@@code {
-    ...
-    await document.AddEventListener(ButilEvents.Click, args => { ... });
-    ...
-    await document.SetTitle("New shinny title");
-    ...
-}
+await document.AddEventListener(ButilEvents.Click, args => { ... });
+await document.SetTitle("New shiny title");
 
-
-
- -

Open the DevTools' console and start clicking

- -
-
- - -  - -
-
-
Is Registered: @isRegistered
- -
-
-
- - -  - -
-
- -  - -
-
- -  - - -
-
-
-
- - - -
-
- -
-
- - -
-
-
-
- - - -
-
- -
-
- - -
-
-
-
- - -
-
- -  - - -
-
+
+ +
+ + + Registered: @isRegistered +
+
+ + +
+ + + + + + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+ Title + +
+
+ + +
+
+
+ + @code { + private DemoConsole output = default!; private bool isRegistered = false; private Action _handler = default!; @@ -108,7 +91,7 @@ protected override void OnInitialized() { - _handler = (ButilMouseEventArgs arg) => _ = console.Log("Click from C# = X:", arg.ClientX, "Y:", arg.ClientY); + _handler = (ButilMouseEventArgs arg) => _ = output.Info("Click from C# → X:", arg.ClientX, "Y:", arg.ClientY); base.OnInitialized(); } @@ -127,17 +110,17 @@ private async Task GetCharacterSet() { - await console.Log("document.characterSet =", await document.GetCharacterSet()); + await output.Success("document.characterSet →", await document.GetCharacterSet()); } private async Task GetCompatMode() { - await console.Log("document.compatMode =", await document.GetCompatMode()); + await output.Success("document.compatMode →", await document.GetCompatMode()); } private async Task GetContentType() { - await console.Log("document.contentType =", await document.GetContentType()); + await output.Success("document.contentType →", await document.GetContentType()); } private async Task GetReferrer() @@ -146,52 +129,55 @@ if (string.IsNullOrWhiteSpace(res) is false) { - await console.Log("The user came from:", await document.GetReferrer()); + await output.Success("The user came from →", res); } else { - await console.Log("The user landed directly on this page"); + await output.Info("The user landed directly on this page"); } } private async Task GetUrl() { - await console.Log("document.URL =", await document.GetUrl()); + await output.Success("document.URL →", await document.GetUrl()); } private async Task GetDocumentURI() { - await console.Log("document.documentURI =", await document.GetDocumentURI()); + await output.Success("document.documentURI →", await document.GetDocumentURI()); } private async Task SetDesignMode() { await document.SetDesignMode(isDesignModeOn ? DesignMode.On : DesignMode.Off); + await output.Log("document.designMode set to", isDesignModeOn ? "On" : "Off"); } private async Task GetDesignMode() { - await console.Log("document.designMode =", await document.GetDesignMode()); + await output.Success("document.designMode →", await document.GetDesignMode()); } private async Task SetDocumentDir() { await document.SetDir(isDocumentDirRtl ? DocumentDir.Rtl : DocumentDir.Ltr); + await output.Log("document.dir set to", isDocumentDirRtl ? "Rtl" : "Ltr"); } private async Task GetDocumentDir() { - await console.Log("document.dir =", await document.GetDir()); + await output.Success("document.dir →", await document.GetDir()); } private async Task SetTitle() { await document.SetTitle(documentTitle); + await output.Log("document.title set to", $"\"{documentTitle}\""); } private async Task GetTitle() { - await console.Log("document.title =", await document.GetTitle()); + await output.Success("document.title →", await document.GetTitle()); } public void Dispose() @@ -201,4 +187,4 @@ _ = document.RemoveEventListener(ButilEvents.Click, _handler); } } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EObserversPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EObserversPage.razor new file mode 100644 index 0000000000..83c2865948 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EObserversPage.razor @@ -0,0 +1,214 @@ +@page "/e2e-observers" +@implements IAsyncDisposable +@inject Bit.Butil.Performance performance +@inject Bit.Butil.StorageManager storageManager +@inject Bit.Butil.NetworkInformation networkInformation +@inject Bit.Butil.BroadcastChannel broadcastChannel +@inject Bit.Butil.IndexedDb indexedDb +@inject IJSRuntime js + +@* + Second deterministic harness, focused on async/observer-based APIs. Same contract as /e2e: + every control has a stable id and results land in #status. +*@ + +Butil E2E Observers + +

Butil E2E Observers

+ +
@_status
+ +
+

Performance

+ + +
+ +
+

Storage Manager + Network

+ + +
+ +
+

Observers

+
target
+ + + + +
+ +
+

BroadcastChannel

+ + +
+ +
+

IndexedDB

+ +
+ +@code { + private string _status = "ready"; + private ElementReference _target; + + private ButilSubscription? _intersectionSub; + private ButilSubscription? _resizeSub; + private ButilSubscription? _mutationSub; + private ButilSubscription? _perfObserverSub; + private ButilSubscription? _broadcastSub; + + private int _resizeWidth = 120; + + private void Set(string value) { _status = value; StateHasChanged(); } + + // ─── Performance ───────────────────────────────────────────────────────────── + private async Task PerfMarkMeasure() + { + await performance.Mark("butil-e2e-a"); + await performance.Mark("butil-e2e-b"); + await performance.Measure("butil-e2e-measure", "butil-e2e-a", "butil-e2e-b"); + var entries = await performance.GetEntries("butil-e2e-measure", "measure"); + Set($"perf:measure:{entries.Length > 0}"); + } + + private async Task PerfObserver() + { + var tcs = new TaskCompletionSource(); + _perfObserverSub = await performance.SubscribeObserver(["mark"], entries => + { + if (entries.Length > 0) tcs.TrySetResult(true); + }, buffered: false); + + await performance.Mark("butil-e2e-observed-mark"); + + var fired = await WaitFor(tcs, 5000); + Set($"perf:observer:{fired}"); + } + + // ─── Storage Manager + Network ─────────────────────────────────────────────── + private async Task StorageEstimate() + { + var est = await storageManager.Estimate(); + // Quota is reported on every evergreen browser; usage may be 0 on a clean origin. + Set($"storage:estimate:{est.Quota.HasValue}"); + } + + private async Task NetworkStatus() + { + var status = await networkInformation.GetStatus(); + Set($"network:online:{status.Online}"); + } + + // ─── Observers ─────────────────────────────────────────────────────────────── + private async Task ObserveIntersection() + { + var tcs = new TaskCompletionSource(); + _intersectionSub = await _target.ObserveIntersection(js, entries => + { + if (entries.Length > 0) tcs.TrySetResult(true); + }); + // The target is on-screen, so the observer fires an initial entry immediately. + var fired = await WaitFor(tcs, 5000); + Set($"intersection:{fired}"); + } + + private async Task ObserveResize() + { + var tcs = new TaskCompletionSource(); + _resizeSub = await _target.ObserveResize(js, entries => + { + if (entries.Length > 0) tcs.TrySetResult(true); + }); + var fired = await WaitFor(tcs, 5000); + Set($"resize:observed:{fired}"); + } + + private async Task TriggerResize() + { + _resizeWidth += 40; + await _target.SetAttribute("style", $"width:{_resizeWidth}px;height:40px;border:1px solid #888;"); + Set($"resize:triggered:{_resizeWidth}"); + } + + private async Task ObserveMutation() + { + var tcs = new TaskCompletionSource(); + _mutationSub = await _target.ObserveMutations(js, records => + { + if (records.Length > 0) tcs.TrySetResult(true); + }, new MutationObserverOptions { Attributes = true }); + + // Mutate an attribute to trigger the observer. + await _target.SetAttribute("data-butil", Guid.NewGuid().ToString("N")); + + var fired = await WaitFor(tcs, 5000); + Set($"mutation:{fired}"); + } + + // ─── BroadcastChannel ──────────────────────────────────────────────────────── + private async Task BroadcastSubscribe() + { + _broadcastReceived = new TaskCompletionSource(); + _broadcastSub = await broadcastChannel.Subscribe("butil-e2e-channel", msg => + { + _broadcastReceived?.TrySetResult(msg.GetProperty("text").GetString() ?? ""); + }); + Set("broadcast:subscribed"); + } + + private TaskCompletionSource? _broadcastReceived; + + private async Task BroadcastPost() + { + // Post from a second BroadcastChannel in JS land so the subscriber (which doesn't receive + // its own messages) actually fires. + await js.InvokeVoidAsync("eval", + "(function(){const c=new BroadcastChannel('butil-e2e-channel');c.postMessage({text:'pong'});c.close();})()"); + + var received = _broadcastReceived is null ? "" : await WaitForValue(_broadcastReceived, 5000); + Set($"broadcast:received:{received}"); + } + + // ─── IndexedDB ─────────────────────────────────────────────────────────────── + private async Task IndexedDbRoundTrip() + { + await using var db = await indexedDb.Open("butil-e2e-db", 1, + [ + new IndexedDbStoreSchema { Name = "items", KeyPath = "id" } + ]); + + await db.Put("items", new IdbItem { Id = "k1", Value = "stored" }); + var item = await db.Get("items", "k1"); + Set($"idb:get:{item?.Value}"); + } + + private static async Task WaitFor(TaskCompletionSource tcs, int timeoutMs) + { + var completed = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); + return completed == tcs.Task && tcs.Task.Result; + } + + private static async Task WaitForValue(TaskCompletionSource tcs, int timeoutMs) + { + var completed = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs)); + return completed == tcs.Task ? tcs.Task.Result : ""; + } + + public class IdbItem + { + public string Id { get; set; } = ""; + public string Value { get; set; } = ""; + } + + public async ValueTask DisposeAsync() + { + if (_intersectionSub is not null) await _intersectionSub.DisposeAsync(); + if (_resizeSub is not null) await _resizeSub.DisposeAsync(); + if (_mutationSub is not null) await _mutationSub.DisposeAsync(); + if (_perfObserverSub is not null) await _perfObserverSub.DisposeAsync(); + if (_broadcastSub is not null) await _broadcastSub.DisposeAsync(); + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EPage.razor new file mode 100644 index 0000000000..b1b060d260 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EPage.razor @@ -0,0 +1,182 @@ +@page "/e2e" +@inject Bit.Butil.LocalStorage localStorage +@inject Bit.Butil.SessionStorage sessionStorage +@inject Bit.Butil.Cookie cookie +@inject Bit.Butil.Crypto crypto +@inject Bit.Butil.Performance performance +@inject Bit.Butil.Document document +@inject Bit.Butil.Window window +@inject Bit.Butil.History history +@inject Bit.Butil.Location location + +@* + Deterministic end-to-end harness for Playwright. Every interactive element exposes + a stable id and outputs are written to a single status element. The page deliberately + avoids APIs that trigger permission prompts so the suite can run headless without flakes. +*@ + +Butil E2E Harness + +

Butil E2E

+ +
@_status
+ +
+

Storage

+ + + + + + + + +
+ +
+

Cookie

+ + + +
+ +
+

Crypto

+ + + + +
+ +
+

Performance + Window + Document + History

+ + + + + +
+ +@code { + private const string TestCookieName = "butil_e2e"; + private const string TestCookieValue = "v=1; b=hello world & again"; + private string _status = "ready"; + + private void Set(string value) { _status = value; StateHasChanged(); } + + // ─── Storage ──────────────────────────────────────────────────────────────── + private async Task LocalStorageSet() + { + await localStorage.SetItem("butil-e2e-key", "butil-e2e-value"); + Set("ls:set"); + } + private async Task LocalStorageGet() + { + var v = await localStorage.GetItem("butil-e2e-key"); + Set($"ls:get:{v}"); + } + private async Task LocalStorageTypedSet() + { + await localStorage.SetItem("butil-e2e-typed", new SamplePayload(42, "answer")); + Set("ls:typed-set"); + } + private async Task LocalStorageTypedGet() + { + var v = await localStorage.GetItem("butil-e2e-typed"); + Set($"ls:typed-get:{v?.Number}/{v?.Label}"); + } + private async Task SessionStorageSet() + { + await sessionStorage.SetItem("butil-e2e-skey", "butil-e2e-svalue"); + Set("ss:set"); + } + private async Task SessionStorageGet() + { + var v = await sessionStorage.GetItem("butil-e2e-skey"); + Set($"ss:get:{v}"); + } + private async Task LocalStorageClear() { await localStorage.Clear(); Set("ls:clear"); } + private async Task SessionStorageClear() { await sessionStorage.Clear(); Set("ss:clear"); } + + // ─── Cookie ───────────────────────────────────────────────────────────────── + private async Task CookieSet() + { + await cookie.Set(new ButilCookie { Name = TestCookieName, Value = TestCookieValue, Path = "/" }); + Set("cookie:set"); + } + private async Task CookieGet() + { + var c = await cookie.Get(TestCookieName); + Set($"cookie:get:{c?.Value}"); + } + private async Task CookieRemove() + { + await cookie.Remove(new ButilCookie { Name = TestCookieName, Path = "/" }); + var c = await cookie.Get(TestCookieName); + Set($"cookie:removed:{c is null}"); + } + + // ─── Crypto ───────────────────────────────────────────────────────────────── + private async Task CryptoUuid() + { + var id = await crypto.RandomUuid(); + Set($"crypto:uuid:{id}"); + } + private async Task CryptoRand() + { + var bytes = await crypto.GetRandomValues(32); + Set($"crypto:rand:{bytes.Length}"); + } + private async Task CryptoDigestSha256() + { + var bytes = System.Text.Encoding.UTF8.GetBytes("hello"); + var hash = await crypto.Digest(CryptoKeyHash.Sha256, bytes); + Set($"crypto:sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"); + } + private async Task CryptoAesRoundTrip() + { + var key = await crypto.GenerateAesKey(256); + var iv = await crypto.GetRandomValues(12); + var data = System.Text.Encoding.UTF8.GetBytes("payload"); + var cipher = await crypto.Encrypt(CryptoAlgorithm.AesGcm, key, data, iv); + var plain = await crypto.Decrypt(CryptoAlgorithm.AesGcm, key, cipher, iv); + var matches = plain is not null && System.Text.Encoding.UTF8.GetString(plain) == "payload"; + Set($"crypto:aes-gcm:{matches}"); + } + + // ─── Misc ────────────────────────────────────────────────────────────────── + private async Task PerfNow() + { + var n = await performance.Now(); + Set($"perf:now:{n > 0}"); + } + private async Task WindowBase64() + { + var encoded = await window.Btoa("butil"); + var decoded = await window.Atob(encoded); + Set($"window:b64:{encoded}/{decoded}"); + } + private async Task DocSetTitle() + { + await document.SetTitle("butil-e2e-title"); + var t = await document.GetTitle(); + Set($"doc:title:{t}"); + } + private async Task LocationHref() + { + var href = await location.GetHref(); + Set($"loc:href:{href.Contains("/e2e")}"); + } + private async Task HistoryPush() + { + await history.PushState(new SamplePayload(1, "marker"), "/e2e?marked"); + var len = await history.GetLength(); + Set($"history:len:{len > 0}"); + } + + public class SamplePayload(int number, string label) + { + public int Number { get; set; } = number; + public string Label { get; set; } = label; + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ElementPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ElementPage.razor index 4228060ca5..ce441bd918 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ElementPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ElementPage.razor @@ -1,442 +1,290 @@ @page "/element" -@inject Bit.Butil.Console console Element Samples -

Element

+ -
+
 @@code {
-    ...
     var rect = await elementRef.GetBoundingClientRect();
-    ...
+    await elementRef.ScrollIntoView();
 }
 
-
-
- -

Open the DevTools' console and start clicking on buttons

- -
-
- -
+
+
Target element (click it to capture a pointer id):
-
Element
+
Element
-
-
-
- - -
-
- -  - -
-
- -  - -
-
- -  - - -
-
-
-
- -X: -
-
-Y: -
-
- - -
-
-
-
- -X: -
-
-Y: -
-
- - -
-
-
-
- - - -
-
-
-
- - - -
-
-
-
- -@if (pointerId != 0) -{ - - - -
-
- - - -
-} -else -{ -
*Click/Tap on the element first*
-} - -
-
-
- - - -
-
-
-
- - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -  - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -  - -
-
- -  - - -
-
-
-
- - - -
-
-
-
- - -
-
- -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- +
+ +
+ Attribute name + +
+
+ + + + + + +
+
+ + +
+ + + +
+
+ + + +
+ +
+ + + @if (pointerId != 0) + { +
+ + + + +
+ } + else + { +

👆 Click/tap the target element above first to capture a pointer id.

+ } +
+ + +
+
+ + +
+ + + + AccessKey +
+
+ + + + ClassName +
+
+ + + + Id +
+
+ + + + InnerHtml +
+
+ + + + OuterHtml +
+
+ + + + InnerText +
+
+ + +
Client
+
+ + + + +
+
Scroll
+
+ + + + +
+
Offset
+
+ + + + +
+
+ + +
+
+ + +
+ + + + + ContentEditable +
+
+ + + + EnterKeyHint +
+
+ + + + Hidden +
+
+ + + + + Dir +
+
+ + + + + Inert +
+
+ + + + TabIndex +
+
+ + + + +
- -
-
- -  - - -
-
-
-
- - - -
-
- -
-
- - -
-
-
-
- - - -
-
- -  - - -
-
-
-
- - -  - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - - -
-
+ @code { + private DemoConsole output = default!; + private ElementReference elementRef = default!; private bool isDirRtl; - private bool isInert; - private int tabIndex; - private int pointerId; private float scrollX = 0; private float scrollY = 100; - private float scrollByX = 0; private float scrollByY = -156; private string attributeName = "style"; - private string accessKey = "TestKey"; - private string className = "element-class"; - private string idName = "target"; - private string innerHtml = "
Element
"; - private string outerHtml = "
Element
"; - private string innerText = "Element"; private ContentEditable selectedContentEditable = ContentEditable.True; - private EnterKeyHint selectedEnterKeyHint = EnterKeyHint.Done; - private Hidden selectedHidden = Hidden.True; - private async Task GetAttribute() { - await console.Log("Element Attribute:", await elementRef.GetAttribute(attributeName)); + await output.Success("GetAttribute →", await elementRef.GetAttribute(attributeName)); } private async Task GetAttributeNames() { var attributeNames = await elementRef.GetAttributeNames(); - foreach (var name in attributeNames) { var value = await elementRef.GetAttribute(name); - await console.Log(name, value); + await output.Success(name, "→", value); } } private async Task GetBoundingClientRect() { - await console.Log("Element BoundingClientRect:", await elementRef.GetBoundingClientRect()); + await output.Success("BoundingClientRect →", await elementRef.GetBoundingClientRect()); } private async Task HasAttribute() { - await console.Log($"Element HasAttribute ({attributeName}):", await elementRef.HasAttribute(attributeName)); + await output.Success($"HasAttribute(\"{attributeName}\") →", await elementRef.HasAttribute(attributeName)); } private async Task HasAttributes() { - await console.Log("Element HasAttributes:", await elementRef.HasAttributes()); + await output.Success("HasAttributes →", await elementRef.HasAttributes()); } private async Task ToggleAttribute() { - await console.Log($"Element ToggleAttribute ({attributeName}):", await elementRef.ToggleAttribute(attributeName, true)); + await output.Success($"ToggleAttribute(\"{attributeName}\") →", await elementRef.ToggleAttribute(attributeName, true)); } private async Task RemoveAttribute() { var hasAttribute = await elementRef.HasAttribute(attributeName); - await elementRef.RemoveAttribute(attributeName); - if (hasAttribute) { - await console.Log($"Element {attributeName} attribute has been removed"); + await output.Log($"Attribute \"{attributeName}\" removed"); } } @@ -447,48 +295,43 @@ else private async Task HasPointerCapture() { - var isPointerCaptured = await elementRef.HasPointerCapture(pointerId); - await console.Log($"Element HasPointerCapture (id: {pointerId}):", isPointerCaptured); + await output.Success($"HasPointerCapture({pointerId}) →", await elementRef.HasPointerCapture(pointerId)); } private async Task SetPointerCapture() { await elementRef.SetPointerCapture(pointerId); - var isPointerCaptured = await elementRef.HasPointerCapture(pointerId); - - if (isPointerCaptured) + if (await elementRef.HasPointerCapture(pointerId)) { - await console.Log($"Element Pointer (id: {pointerId}) has been captured"); + await output.Log($"Pointer {pointerId} captured"); } } private async Task ReleasePointerCapture() { await elementRef.ReleasePointerCapture(pointerId); - var isPointerCaptured = await elementRef.HasPointerCapture(pointerId); - - if (isPointerCaptured) + if (await elementRef.HasPointerCapture(pointerId) is false) { - await console.Log($"Element Pointer with (id: {pointerId}) capture has been released"); + await output.Log($"Pointer {pointerId} capture released"); } } private async Task RequestPointerLock() { await elementRef.RequestPointerLock(); - await console.Log("Pointer has been locked"); + await output.Log("Pointer locked"); } private async Task Matches() { - await console.Log("Element matches id=\"target\"?", await elementRef.Matches("#target")); + await output.Success("Matches(\"#target\") →", await elementRef.Matches("#target")); } private async Task RequestFullScreen() { var options = new FullScreenOptions() { NavigationUI = FullScreenNavigationUI.Show }; await elementRef.RequestFullScreen(options); - await console.Log("Element has been fullscreened"); + await output.Log("Element is fullscreen"); } private async Task Scroll() @@ -511,215 +354,127 @@ else Block = ScrollLogicalPosition.Center, Behavior = ScrollBehavior.Smooth }; - await elementRef.ScrollIntoView(scrollOptions); } private async Task SetAccessKey() { await elementRef.SetAccessKey(accessKey); - await console.Log($"Element AccessKey has been successfuly set to \"{accessKey}\""); + await output.Log($"AccessKey set to \"{accessKey}\""); } - private async Task GetAccessKey() - { - await console.Log("Element AccessKey:", await elementRef.GetAccessKey()); - } + private async Task GetAccessKey() => await output.Success("AccessKey →", await elementRef.GetAccessKey()); private async Task SetClassName() { await elementRef.SetClassName(className); - await console.Log($"Element class name has been successfuly set to \"{className}\""); - } - - private async Task GetClassName() - { - await console.Log("Element AccessKey:", await elementRef.GetClassName()); - } - - private async Task GetClientHeight() - { - await console.Log("Element ClientHeight:", await elementRef.GetClientHeight()); + await output.Log($"ClassName set to \"{className}\""); } - private async Task GetClientLeft() - { - await console.Log("Element ClientLeft:", await elementRef.GetClientLeft()); - } + private async Task GetClassName() => await output.Success("ClassName →", await elementRef.GetClassName()); - private async Task GetClientTop() - { - await console.Log("Element ClientTop:", await elementRef.GetClientTop()); - } - - private async Task GetClientWidth() - { - await console.Log("Element ClientWidth:", await elementRef.GetClientWidth()); - } + private async Task GetClientHeight() => await output.Success("ClientHeight →", await elementRef.GetClientHeight()); + private async Task GetClientLeft() => await output.Success("ClientLeft →", await elementRef.GetClientLeft()); + private async Task GetClientTop() => await output.Success("ClientTop →", await elementRef.GetClientTop()); + private async Task GetClientWidth() => await output.Success("ClientWidth →", await elementRef.GetClientWidth()); private async Task SetId() { await elementRef.SetId(idName); - await console.Log($"Element Id has been successfuly set to \"{idName}\""); + await output.Log($"Id set to \"{idName}\""); } - private async Task GetId() - { - await console.Log("Element Id:", await elementRef.GetId()); - } + private async Task GetId() => await output.Success("Id →", await elementRef.GetId()); private async Task SetInnerHtml() { await elementRef.SetInnerHtml(innerHtml); - await console.Log($"Element InnerHtml has been successfuly set to \"{innerHtml}\""); + await output.Log("InnerHtml set"); } - private async Task GetInnerHtml() - { - await console.Log("Element InnerHtml:", await elementRef.GetInnerHtml()); - } + private async Task GetInnerHtml() => await output.Success("InnerHtml →", await elementRef.GetInnerHtml()); private async Task SetOuterHtml() { await elementRef.SetOuterHtml(outerHtml); - await console.Log($"Element OuterHtml has been successfuly set to \"{outerHtml}\""); + await output.Log("OuterHtml set"); } - private async Task GetOuterHtml() - { - await console.Log("Element OuterHtml:", await elementRef.GetOuterHtml()); - } + private async Task GetOuterHtml() => await output.Success("OuterHtml →", await elementRef.GetOuterHtml()); private async Task SetInnerText() { await elementRef.SetInnerText(innerText); - await console.Log("Element InnerText has been successfuly set"); + await output.Log("InnerText set"); } - private async Task GetInnerText() - { - await console.Log("Element InnerText:", await elementRef.GetInnerText()); - } + private async Task GetInnerText() => await output.Success("InnerText →", await elementRef.GetInnerText()); - private async Task GetScrollHeight() - { - await console.Log("Element ScrollHeight:", await elementRef.GetScrollHeight()); - } + private async Task GetScrollHeight() => await output.Success("ScrollHeight →", await elementRef.GetScrollHeight()); + private async Task GetScrollLeft() => await output.Success("ScrollLeft →", await elementRef.GetScrollLeft()); + private async Task GetScrollTop() => await output.Success("ScrollTop →", await elementRef.GetScrollTop()); + private async Task GetScrollWidth() => await output.Success("ScrollWidth →", await elementRef.GetScrollWidth()); - private async Task GetScrollLeft() - { - await console.Log("Element ScrollLeft:", await elementRef.GetScrollLeft()); - } - - private async Task GetScrollTop() - { - await console.Log("Element ScrollTop:", await elementRef.GetScrollTop()); - } - - private async Task GetScrollWidth() - { - await console.Log("Element ScrollWidth:", await elementRef.GetScrollWidth()); - } - - private async Task GetTagName() - { - await console.Log("Element TagName:", await elementRef.GetTagName()); - } + private async Task GetTagName() => await output.Success("TagName →", await elementRef.GetTagName()); - private async Task IsContentEditable() - { - await console.Log("Element IsContentEditable:", await elementRef.IsContentEditable()); - } + private async Task IsContentEditable() => await output.Success("IsContentEditable →", await elementRef.IsContentEditable()); private async Task SetContentEditable() { await elementRef.SetContentEditable(selectedContentEditable); - await console.Log($"Element ContentEditable has been successfuly set to \"{selectedContentEditable}\""); + await output.Log($"ContentEditable set to \"{selectedContentEditable}\""); } - private async Task GetContentEditable() - { - await console.Log("Element ContentEditable:", await elementRef.GetContentEditable()); - } + private async Task GetContentEditable() => await output.Success("ContentEditable →", await elementRef.GetContentEditable()); private async Task SetEnterKeyHint() { await elementRef.SetEnterKeyHint(selectedEnterKeyHint); - await console.Log($"Element EnterKeyHint has been successfuly set to \"{selectedEnterKeyHint}\""); + await output.Log($"EnterKeyHint set to \"{selectedEnterKeyHint}\""); } - private async Task GetEnterKeyHint() - { - await console.Log("Element EnterKeyHint:", await elementRef.GetEnterKeyHint()); - } + private async Task GetEnterKeyHint() => await output.Success("EnterKeyHint →", await elementRef.GetEnterKeyHint()); private async Task SetHidden() { await elementRef.SetHidden(selectedHidden); - await console.Log($"Element Hidden has been successfuly set to \"{selectedHidden}\""); + await output.Log($"Hidden set to \"{selectedHidden}\""); } - private async Task GetHidden() - { - await console.Log("Element Hidden:", (await elementRef.GetHidden()).ToString()); - } + private async Task GetHidden() => await output.Success("Hidden →", (await elementRef.GetHidden()).ToString()); private async Task SetDir() { await elementRef.SetDir(isDirRtl ? ElementDir.Rtl : ElementDir.Ltr); - await console.Log($"Element Dir has been successfuly set to \"{(isDirRtl ? "Rtl" : "Ltr")}\""); + await output.Log($"Dir set to \"{(isDirRtl ? "Rtl" : "Ltr")}\""); } - private async Task GetDir() - { - await console.Log("Element Dir:", await elementRef.GetDir()); - } + private async Task GetDir() => await output.Success("Dir →", await elementRef.GetDir()); private async Task SetInert() { await elementRef.SetInert(isInert); - await console.Log($"Element Inert has been successfuly set to \"{isInert}\""); + await output.Log($"Inert set to \"{isInert}\""); } - private async Task GetInert() - { - await console.Log("Element Inert:", await elementRef.GetInert()); - } + private async Task GetInert() => await output.Success("Inert →", await elementRef.GetInert()); - private async Task GetOffsetHeight() - { - await console.Log("Element OffsetHeight:", await elementRef.GetOffsetHeight()); - } - - private async Task GetOffsetLeft() - { - await console.Log("Element OffsetLeft:", await elementRef.GetOffsetLeft()); - } - - private async Task GetOffsetTop() - { - await console.Log("Element OffsetTop:", await elementRef.GetOffsetTop()); - } - - private async Task GetOffsetWidth() - { - await console.Log("Element OffsetWidth:", await elementRef.GetOffsetWidth()); - } + private async Task GetOffsetHeight() => await output.Success("OffsetHeight →", await elementRef.GetOffsetHeight()); + private async Task GetOffsetLeft() => await output.Success("OffsetLeft →", await elementRef.GetOffsetLeft()); + private async Task GetOffsetTop() => await output.Success("OffsetTop →", await elementRef.GetOffsetTop()); + private async Task GetOffsetWidth() => await output.Success("OffsetWidth →", await elementRef.GetOffsetWidth()); private async Task SetTabIndex() { await elementRef.SetTabIndex(tabIndex); - await console.Log($"Element TabIndex has been successfuly set to \"{tabIndex}\""); + await output.Log($"TabIndex set to \"{tabIndex}\""); } - private async Task GetTabIndex() - { - await console.Log("Element TabIndex:", await elementRef.GetTabIndex()); - } + private async Task GetTabIndex() => await output.Success("TabIndex →", await elementRef.GetTabIndex()); private async Task Remove() { await elementRef.Remove(); - await console.Log("Element has been Removed"); + await output.Warn("Element removed from the DOM"); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/HistoryPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/HistoryPage.razor index 08da49ffc4..facf25496c 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/HistoryPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/HistoryPage.razor @@ -1,87 +1,72 @@ @page "/history" @implements IAsyncDisposable -@inject Bit.Butil.Console console @inject Bit.Butil.History history History Samples -

History

+ -
+
 @@inject Bit.Butil.History history
 
-@@code {
-    ...
-    await history.GoBack();
-    ...
-}
+await history.GoBack();
+await history.PushState(url: "/window");
+await history.AddPopState(state => { ... });
 
-
-
- -

Open the DevTools' console and start clicking on buttons

- -
-
- - -  - -
-
- -  - -  - - -
-
-
-
- - - -
-
-
-
- - - -
-
- -
-
- - -
-
-
-
- - - -
-
-
-
- - -  - - -
-
+
+ +
+ + + + + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + @code { + private DemoConsole output = default!; private bool isScrollRestorationManual; protected override async Task OnInitializedAsync() { - await history.AddPopState(obj => _ = console.Log("Popped state:", obj)); + await history.AddPopState(obj => _ = output.Info("Popped state →", obj)); await base.OnInitializedAsync(); } @@ -103,38 +88,39 @@ private async Task GetLength() { - var length = await history.GetLength(); - await console.Log("History length", length); + await output.Success("History length →", await history.GetLength()); } private async Task SetScrollRestoration() { await history.SetScrollRestoration(isScrollRestorationManual ? ScrollRestoration.Manual : ScrollRestoration.Auto); + await output.Log("ScrollRestoration set to", isScrollRestorationManual ? "Manual" : "Auto"); } private async Task GetScrollRestoration() { - await console.Log("history.scrollRestoration =", (await history.GetScrollRestoration()).ToString()); + await output.Success("history.scrollRestoration →", (await history.GetScrollRestoration()).ToString()); } private async Task GetState() { - var state = await history.GetState(); - await console.Log("History state", state); + await output.Success("History state →", await history.GetState()); } private async Task PushState() { await history.PushState(url: "/window"); + await output.Log("PushState → /window"); } private async Task ReplaceState() { await history.ReplaceState(url: "/document"); + await output.Log("ReplaceState → /document"); } public async ValueTask DisposeAsync() { await history.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/Index.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/Index.razor index 30be6a0406..b282757b50 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/Index.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/Index.razor @@ -1,5 +1,50 @@ @page "/" -Index +Bit.Butil Demo -

Hello Butil users!

+
+ B +

Bit.Butil

+

+ A thin, strongly-typed .NET wrapper over the browser's built-in JavaScript APIs. + Call the Clipboard, Crypto, Storage, History, Notification and dozens of other Web + APIs straight from C# — no JS interop boilerplate. Pick an API below to try it live. +

+
+ +
+ @foreach (var tile in Tiles) + { + + @tile.Icon + @tile.Name +
@tile.Desc
+
+ } +
+ +@code { + private record Tile(string Name, string Href, string Icon, string Desc); + + private static readonly Tile[] Tiles = + [ + new("Clipboard", "clipboard", "📋", "Read & write the system clipboard"), + new("Cookie", "cookie", "🍪", "Get, set and remove cookies"), + new("Storage", "storage", "💾", "Local & session storage"), + new("Crypto", "crypto", "🔐", "Encrypt, hash & random values"), + new("Console", "console", "🖨️", "Log, group, time & trace"), + new("Document", "document", "📄", "Title, dir, events & more"), + new("Element", "element", "🧱", "Attributes, scroll & layout"), + new("Window", "window", "🪟", "Alerts, scroll, base64 & find"), + new("Navigator", "navigator", "🧭", "Device, share & vibrate"), + new("UserAgent", "userAgent", "🪪", "Parse the user-agent string"), + new("Screen", "screen", "🖥️", "Screen size & color depth"), + new("ScreenOrientation", "screenOrientation", "🔄", "Read & lock orientation"), + new("VisualViewport", "visualViewport", "🔍", "Viewport offset & scale"), + new("History", "history", "🕘", "Navigate & manage state"), + new("Location", "location", "📍", "Read & change the URL"), + new("Keyboard", "keyboard", "⌨️", "Global keyboard shortcuts"), + new("Notification", "notification", "🔔", "Show system notifications"), + new("WebAuthn", "webauthn", "🔑", "Passkey credentials"), + ]; +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor index e76234cb71..83507244dd 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor @@ -1,41 +1,49 @@ @page "/keyboard" @implements IAsyncDisposable -@inject Bit.Butil.Console console @inject Bit.Butil.Keyboard keyboard Keyboard Samples -

Keyboard

+ -
+
 @@inject Bit.Butil.Keyboard keyboard
 
-@@code {
-    ...
-    await keyboard.Add(ButilKeyCodes.F10, args => { ... }, , ButilModifiers.Alt | ButilModifiers.Ctrl);
-    ...
-}
+await keyboard.Add(ButilKeyCodes.F5, () => { ... });
+await keyboard.Add(ButilKeyCodes.F10, () => { ... }, ButilModifiers.Alt | ButilModifiers.Ctrl);
 
-
-
- -

Open the DevTools' console and start pressing F5 or Ctrl+Alt+F10

- -
-
+ +
+ F5 + — logs a message +
+
+ Ctrl + Alt + F10 + — logs a message +
+

Tip: F5 normally reloads the page — Bit.Butil intercepts it here so the handler runs instead.

+
+ + @code { + private DemoConsole output = default!; + protected override async Task OnInitializedAsync() { - await keyboard.Add(ButilKeyCodes.F5, () => _ = console.Log("F5 is pressed!")); - await keyboard.Add(ButilKeyCodes.F10, () => _ = console.Log("Ctrl+Alt+F10 is pressed!"), ButilModifiers.Alt | ButilModifiers.Ctrl); + await keyboard.Add(ButilKeyCodes.F5, () => _ = output.Success("F5 was pressed!")); + await keyboard.Add(ButilKeyCodes.F10, () => _ = output.Success("Ctrl+Alt+F10 was pressed!"), ButilModifiers.Alt | ButilModifiers.Ctrl); - base.OnInitialized(); + await base.OnInitializedAsync(); } public async ValueTask DisposeAsync() { await keyboard.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/LocationPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/LocationPage.razor index b9ad5f7c56..a3d945ab0f 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/LocationPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/LocationPage.razor @@ -1,282 +1,124 @@ @page "/location" -@inject Bit.Butil.Console console @inject Bit.Butil.Location location Location Samples -

Location

+ -
+
 @@inject Bit.Butil.Location location
 
-@@code {
-    ...
-    await location.Reload();
-    ...
-}
+var href = await location.GetHref();
+await location.SetHash("#section-2");
+await location.Reload();
 
-
-
- -

Open the DevTools' console and click on buttons

- -
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - -
-
- -  - - -
-
-
-
- - - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - - -
-
- +

⚠️ Setting Href, Host, Pathname, Assign or Replace will navigate away from this page.

+ +
+ + @foreach (var part in Parts) + { +
+ + + +
+ } +
+ + + + + + +
+ + +
+
+ + +
+ +
+
+ + @code { - private string newHref; - - private string newProtocol; - - private string newHost; - - private string newHostName; - - private string newPort; - - private string newPathName; - - private string newSearch; - - private string newHash; + private DemoConsole output = default!; private string newAssign; - private string newReplace; + private List Parts = default!; - private async Task SetHref() - { - await location.SetHref(newHref); - await console.Log("location.href =", newHref); - } - - private async Task GetHref() + protected override void OnInitialized() { - await console.Log("location.href =", await location.GetHref()); + Parts = + [ + new("href", v => location.SetHref(v), () => location.GetHref(), v => Log("href", v)), + new("protocol", v => location.SetProtocol(v), () => location.GetProtocol(), v => Log("protocol", v)), + new("host", v => location.SetHost(v), () => location.GetHost(), v => Log("host", v)), + new("hostname", v => location.SetHostname(v), () => location.GetHostname(), v => Log("hostname", v)), + new("port", v => location.SetPort(v), () => location.GetPort(), v => Log("port", v)), + new("pathname", v => location.SetPathname(v), () => location.GetPathname(), v => Log("pathname", v)), + new("search", v => location.SetSearch(v), () => location.GetSearch(), v => Log("search", v)), + new("hash", v => location.SetHash(v), () => location.GetHash(), v => Log("hash", v)), + ]; } - private async Task SetProtocol() - { - await location.SetProtocol(newProtocol); - await console.Log("location.protocol =", newProtocol); - } + private Task Log(string name, string value) => output.Success($"location.{name} →", value); - private async Task GetProtocol() - { - await console.Log("location.protocol =", await location.GetProtocol()); - } - - private async Task SetHost() - { - await location.SetHost(newHost); - await console.Log("location.host =", newHost); - } - - private async Task GetHost() - { - await console.Log("location.host =", await location.GetHost()); - } - - private async Task SetHostName() - { - await location.SetHostname(newHostName); - await console.Log("location.hostname =", newHostName); - } - - private async Task GetHostName() - { - await console.Log("location.hostname =", await location.GetHostname()); - } - - private async Task SetPort() - { - await location.SetPort(newPort); - await console.Log("location.port =", newPort); - } - - private async Task GetPort() - { - await console.Log("location.port =", await location.GetPort()); - } - - private async Task SetPathName() - { - await location.SetPathname(newPathName); - await console.Log("location.pathname =", newPathName); - } - - private async Task GetPathName() + private async Task GetOrigin() { - await console.Log("location.pathname =", await location.GetPathname()); + await output.Success("location.origin →", await location.GetOrigin()); } - private async Task SetSearch() + private async Task SetAssign() { - await location.SetSearch(newSearch); - await console.Log("location.search =", newSearch); + await location.Assign(newAssign); + await output.Log("Assign →", newAssign); } - private async Task GetSearch() + private async Task SetReplace() { - await console.Log("location.search =", await location.GetSearch()); + await location.Replace(newReplace); + await output.Log("Replace →", newReplace); } - private async Task SetHash() + private class UrlPart { - await location.SetHash(newHash); - await console.Log("location.hash =", newHash); - } + public string Name { get; } + public string Value { get; set; } = ""; + private readonly Func _set; + private readonly Func> _get; + private readonly Func _log; - private async Task GetHash() - { - await console.Log("location.hash =", await location.GetHash()); - } + public UrlPart(string name, Func set, Func> get, Func log) + { + Name = name; + _set = set; + _get = get; + _log = log; + } - private async Task GetOrigin() - { - await console.Log("location.origin =", await location.GetOrigin()); - } - - private async Task SetAssign() - { - await location.Assign(newAssign); - await console.Log("location.assign =", newAssign); - } + public async Task Set(string value) + { + await _set(value); + await _log(value); + } - private async Task SetReplace() - { - await location.Replace(newReplace); - await console.Log("location.replace =", newReplace); + public async Task Get() + { + await _log(await _get()); + } } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NavigatorPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NavigatorPage.razor index d76654c3a8..b3b4231d63 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NavigatorPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NavigatorPage.razor @@ -1,179 +1,79 @@ @page "/navigator" -@inject Bit.Butil.Console console @inject Bit.Butil.Navigator navigator Navigator Samples -

Navigator

+ -
+
 @@inject Bit.Butil.Navigator navigator
 
-@@code {
-    ...
-    var userAgent = await navigator.GetUserAgent();
-    ...
-}
+var userAgent = await navigator.GetUserAgent();
+await navigator.Share(new ShareData { title = "Bit.Butil", url = "https://bitplatform.dev" });
 
-
-
- -

Open the DevTools' console and start clicking on buttons

- -
-
- - - -
-
-
Device memory is: 2 ^ @deviceMemory
- -
-
-
- - -
-
-
Hardware concurrency is: @hardwareConcurrency
- -
-
-
- - -
-
-
Language: @language
- -
-
-
- - -
-
-
Languages: @languages
- -
-
-
- - -
-
-
Max touch points: @maxTouchPoints
- -
-
-
- - -
-
-
Is OnLine: @isOnLine
- -
-
-
- - -
-
-
Is Pdf viewer enabled: @isPdfViewerEnabled
- -
-
-
- - -
-
-
User agent: @userAgent
- -
-
-
- - -
-
-
Is web driver: @isWebDriver
- -
-
-
- - -
-
-
Can share: @canShare
- -
-
-
- - - -
-
-
-
- - - -
-
-
-
- - - -
-
-
-
- - -
- -
- -
- - -
-
-
-
- - - -
-
+
+ +
+ + + + + + +
+
+ + +
+ + + + +
+
+ + +
+ Title + +
+
+ Text + +
+
+ URL + +
+ +
+ + +
+ + + +
+
+ + + + +
+ + @code { - private string deviceMemory; - - private string hardwareConcurrency; - - private string language; - - private string languages; - - private string maxTouchPoints; - - private string isOnLine; - - private string isPdfViewerEnabled; - - private string userAgent; - - private string isWebDriver; - - private string canShare; + private DemoConsole output = default!; private string textValue; private string titleValue; @@ -181,65 +81,55 @@ private int[] sosPattern = [100, 30, 100, 30, 100, 30, 200, 30, 200, 30, 200, 30, 100, 30, 100, 30, 100]; - private async Task GetDeviceMemory() { var result = await navigator.GetDeviceMemory(); - deviceMemory = result.ToString(); + await output.Success("DeviceMemory → 2 ^", result); } private async Task GetHardwareConcurrency() { - var result = await navigator.GetHardwareConcurrency(); - hardwareConcurrency = result.ToString(); + await output.Success("HardwareConcurrency →", await navigator.GetHardwareConcurrency()); } private async Task GetLanguage() { - var result = await navigator.GetLanguage(); - language = result.ToString(); + await output.Success("Language →", await navigator.GetLanguage()); } private async Task GetLanguages() { - var result = await navigator.GetLanguages(); - languages = string.Join(",", result); + await output.Success("Languages →", string.Join(", ", await navigator.GetLanguages())); } private async Task GetMaxTouchPoints() { - var result = await navigator.GetMaxTouchPoints(); - maxTouchPoints = result.ToString(); + await output.Success("MaxTouchPoints →", await navigator.GetMaxTouchPoints()); } private async Task GetIsOnLine() { - var result = await navigator.IsOnLine(); - isOnLine = result.ToString(); + await output.Success("IsOnLine →", await navigator.IsOnLine()); } private async Task GetIsPdfViewerEnabled() { - var result = await navigator.IsPdfViewerEnabled(); - isPdfViewerEnabled = result.ToString(); + await output.Success("IsPdfViewerEnabled →", await navigator.IsPdfViewerEnabled()); } private async Task GetUserAgent() { - var result = await navigator.GetUserAgent(); - userAgent = result.ToString(); + await output.Success("UserAgent →", await navigator.GetUserAgent()); } private async Task GetCanShare() { - var result = await navigator.CanShare(); - canShare = result.ToString(); + await output.Success("CanShare →", await navigator.CanShare()); } private async Task GetIsWebDriver() { - var result = await navigator.IsWebDriver(); - isWebDriver = result.ToString(); + await output.Success("IsWebDriver →", await navigator.IsWebDriver()); } private async Task Share() @@ -252,5 +142,30 @@ }; await navigator.Share(shareData); + await output.Log("Share invoked"); + } + + private async Task SetBadge() + { + await navigator.SetAppBadge(); + await output.Log("AppBadge set"); + } + + private async Task ClearBadge() + { + await navigator.ClearAppBadge(); + await output.Log("AppBadge cleared"); } -} \ No newline at end of file + + private async Task SendBeacon() + { + await navigator.SendBeacon("/_butil-demo-beacon"); + await output.Log("Beacon sent"); + } + + private async Task Vibrate() + { + await navigator.Vibrate(sosPattern); + await output.Log("Vibrate triggered"); + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NotificationPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NotificationPage.razor index 6846ba9e21..cae44dff1d 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NotificationPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NotificationPage.razor @@ -1,70 +1,69 @@ @page "/notification" -@inject Bit.Butil.Console console @inject Bit.Butil.Notification notification Notification Samples -

Notification

+ -
+
 @@inject Bit.Butil.Notification notification
 
-@@code {
-    ...
-    await notification.Show("title", new() { body: "this is body" });
-    ...
-}
+var permission = await notification.RequestPermission();
+await notification.Show("title", new() { Body = "this is the body" });
 
-
-
- -

Open the DevTools' console and start clicking on buttons

- -
-
- - - -
-
-
-
- - - -
-
-
-
- - -
- +
+ + + + + @code { + private DemoConsole output = default!; private string notifTitle; private string notifBody; + private string permissionStatus = "unknown"; private async Task GetPermission() { var permission = await notification.GetPermission(); - await console.Log("Notification permission:", permission.ToString()); + permissionStatus = permission.ToString(); + await output.Success("Notification permission →", permissionStatus); } private async Task RequestPermission() { var permission = await notification.RequestPermission(); - await console.Log("Notification permission:", permission.ToString()); + permissionStatus = permission.ToString(); + await output.Success("Notification permission →", permissionStatus); } private async Task Show() { await notification.Show(notifTitle, new() { Body = notifBody }); + await output.Log("Show →", $"\"{notifTitle}\""); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ScreenOrientationPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ScreenOrientationPage.razor index 5ce0fa3141..c14568e7c3 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ScreenOrientationPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ScreenOrientationPage.razor @@ -1,91 +1,87 @@ @page "/screenOrientation" @implements IAsyncDisposable -@inject Bit.Butil.Console console @inject Bit.Butil.ScreenOrientation screenOrientation ScreenOrientation Samples -

ScreenOrientation

+ -
+
 @@inject Bit.Butil.ScreenOrientation screenOrientation
 
-@@code {
-    ...
-    var angle = await screenOrientation.GetAngle();
-    ...
-}
+var angle = await screenOrientation.GetAngle();
+await screenOrientation.Lock(OrientationLockType.Portrait);
 
-
-
- -

Open the DevTools' console and start clicking

- -
-
- - - -
-
-
-
- - - -
-
-
-
- - -
-
- - -
-
-
-
- - - -
-
+
+ +
+ + +
+
+ + +
+ Lock type + +
+
+ + +
+
+
+ + @code { + private DemoConsole output = default!; private OrientationLockType selectedOrientationLockType; private async Task GetOrientationType() { var type = await screenOrientation.GetOrientationType(); - await console.Log("OrientationType:", type.ToString()); + await output.Success("OrientationType →", type.ToString()); } private async Task GetAngle() { var angle = await screenOrientation.GetAngle(); - await console.Log("Angle:", angle); + await output.Success("Angle →", angle); } private async Task SetOrientationLockType() { - await screenOrientation.Lock(selectedOrientationLockType); + try + { + await screenOrientation.Lock(selectedOrientationLockType); + await output.Log("Locked to", selectedOrientationLockType.ToString()); + } + catch (Exception ex) + { + await output.Error("Lock failed →", ex.Message); + } } private async Task UnlockOrientation() { await screenOrientation.Unlock(); + await output.Log("Orientation unlocked"); } public async ValueTask DisposeAsync() { await screenOrientation.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ScreenPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ScreenPage.razor index d7cf7b0673..a169c3c4f3 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ScreenPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ScreenPage.razor @@ -1,120 +1,97 @@ @page "/screen" @implements IAsyncDisposable -@inject Bit.Butil.Console console @inject Bit.Butil.Screen screen Screen Samples -

Screen

+ -
+
 @@inject Bit.Butil.Screen screen
 
-@@code {
-    ...
-    var screenWidth = await screen.GetWidth();
-    ...
-}
+var width  = await screen.GetWidth();
+var height = await screen.GetHeight();
 
-
-
- -

Open the DevTools' console and click on buttons

- -
-
- - -  - - -
-
-
-
- - -  - - -
-
-
-
- - -  - - -
-
-
-
- - - -
-
+
+ +
+ + + + +
+
+ + +
+ + + +
+
+
+ + @code { + private DemoConsole output = default!; + protected override async Task OnInitializedAsync() { await screen.AddChange(() => _ = GetScreenDimensions()); - base.OnInitialized(); + await base.OnInitializedAsync(); } private async Task GetScreenDimensions() { var width = await screen.GetWidth(); var height = await screen.GetHeight(); - await console.Log("Screen dimensions =", width, "x", height); + await output.Info("Screen changed →", width, "x", height); } private async Task GetAvailableHeight() { - var availHeight = await screen.GetAvailableHeight(); - await console.Log("screen.availHeight =", availHeight); + await output.Success("screen.availHeight →", await screen.GetAvailableHeight()); } private async Task GetAvailableWidth() { - var availWidth = await screen.GetAvailableWidth(); - await console.Log("screen.availWidth =", availWidth); + await output.Success("screen.availWidth →", await screen.GetAvailableWidth()); } private async Task GetHeight() { - var height = await screen.GetHeight(); - await console.Log("screen.height =", height); + await output.Success("screen.height →", await screen.GetHeight()); } private async Task GetWidth() { - var width = await screen.GetWidth(); - await console.Log("screen.height =", width); + await output.Success("screen.width →", await screen.GetWidth()); } private async Task GetColorDepth() { - var colorDepth = await screen.GetColorDepth(); - await console.Log("screen.colorDepth =", colorDepth); + await output.Success("screen.colorDepth →", await screen.GetColorDepth()); } private async Task GetPixelDepth() { - var pixelDepth = await screen.GetPixelDepth(); - await console.Log("screen.pixelDepth =", pixelDepth); + await output.Success("screen.pixelDepth →", await screen.GetPixelDepth()); } private async Task GetIsExtended() { - var isExtended = await screen.IsExtended(); - await console.Log("screen.isExtented =", isExtended); + await output.Success("screen.isExtended →", await screen.IsExtended()); } public async ValueTask DisposeAsync() { await screen.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/StoragePage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/StoragePage.razor index 685e35a7e0..29c7f3a912 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/StoragePage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/StoragePage.razor @@ -1,120 +1,99 @@ @page "/storage" -@inject Bit.Butil.Console console @inject Bit.Butil.LocalStorage localStorage @inject Bit.Butil.SessionStorage sessionStorage Storage Samples -

Storage

+ -
+
 @@inject Bit.Butil.LocalStorage localStorage
 @@inject Bit.Butil.SessionStorage sessionStorage
 
-@@code {
-    ...
-    await localStorage.SetItem("my-key", "my-value");
-    ...
-    await sessionStorage.SetItem("my-key2", "my-value2");
-    ...
-}
+await localStorage.SetItem("my-key", "my-value");
+await sessionStorage.SetItem("my-key2", "my-value2");
 
-
-
- -

Open the DevTools' console and click on buttons

- -
- -
LocalStorage
- -
-
-
-key index -
- -
-
- -
-
-
-key -
- -
-
- -
-
-
-key -
- -
-
-value -
- -
-
- - -
-
-
- -
SessionStorage
- -
-
-
-key index -
- -
-
- -
-
-
-key -
- -
-
- -
-
-
-key -
- -
-
-value -
- -
-
- - -
-
-
-
- - -
-
- - -
-
+
+ +
Write
+
+ Key + +
+
+ Value + +
+ + +
+ +
Read
+
+ Key + +
+
+ Key index + +
+
+ + + +
+
+ + +
Write
+
+ Key + +
+
+ Value + +
+ + +
+ +
Read
+
+ Key + +
+
+ Key index + +
+
+ + + +
+
+ + +
+ + +
+
+
+ + @code { + private DemoConsole output = default!; + private int keyIndex; private string getItemKey = ""; private string setItemKey = ""; @@ -123,28 +102,27 @@ private async Task GetLength() { var length = await localStorage.GetLength(); - await console.Log("localStorage.length =", length); + await output.Success("localStorage.length →", length); } private async Task GetKey() { var key = await localStorage.GetKey(keyIndex); - await console.Log("localStorage.key =", key); + await output.Success($"localStorage.key({keyIndex}) →", key); } private async Task GetItem() { var value = await localStorage.GetItem(getItemKey); - await console.Log($"localStorage.getItem({getItemKey}) =", value); + await output.Success($"localStorage.getItem(\"{getItemKey}\") →", value); } private async Task SetItem() { await localStorage.SetItem(setItemKey, setItemValue); - await console.Log($"localStorage.setItem({setItemKey}, {setItemValue})"); + await output.Log($"localStorage.setItem(\"{setItemKey}\", \"{setItemValue}\")"); } - private int keyIndex2; private string getItemKey2 = ""; private string setItemKey2 = ""; @@ -153,24 +131,36 @@ private async Task GetLength2() { var length = await sessionStorage.GetLength(); - await console.Log("sessionStorage.length =", length); + await output.Success("sessionStorage.length →", length); } private async Task GetKey2() { var key = await sessionStorage.GetKey(keyIndex2); - await console.Log("localStorage.key =", key); + await output.Success($"sessionStorage.key({keyIndex2}) →", key); } private async Task GetItem2() { var value = await sessionStorage.GetItem(getItemKey2); - await console.Log($"sessionStorage.getItem({getItemKey2}) =", value); + await output.Success($"sessionStorage.getItem(\"{getItemKey2}\") →", value); } private async Task SetItem2() { await sessionStorage.SetItem(setItemKey2, setItemValue2); - await console.Log($"sessionStorage.setItem({setItemKey2}, {setItemValue2})"); + await output.Log($"sessionStorage.setItem(\"{setItemKey2}\", \"{setItemValue2}\")"); + } + + private async Task ClearLocal() + { + await localStorage.Clear(); + await output.Warn("localStorage cleared"); } -} \ No newline at end of file + + private async Task ClearSession() + { + await sessionStorage.Clear(); + await output.Warn("sessionStorage cleared"); + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor index 49c2b40a88..1d24755db5 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor @@ -1,41 +1,38 @@ @page "/userAgent" -@inject Bit.Butil.Console console @inject Bit.Butil.UserAgent userAgent UserAgent Samples -

UserAgent

+ -
+
 @@inject Bit.Butil.UserAgent userAgent
 
-@@code {
-    ...
-    var userAgentProps = await userAgent.Extract();
-    ...
-}
+var props = await userAgent.Extract();          // current browser
+var props = await userAgent.Extract(uaString);  // a specific string
 
-
-
- -

Open the DevTools' console and start clicking on buttons

- -
-
+ +
+ User-agent string (optional) + +
+ +
- - - -
-
+ @code { + private DemoConsole output = default!; private string? userAgentString; private async Task Extract() { - var userAgentProps = await userAgent.Extract(userAgentString); - await console.Log("UserAgent properties:", userAgentProps); + var props = await userAgent.Extract(userAgentString); + await output.Success("UserAgent properties →", props); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/VisualViewportPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/VisualViewportPage.razor index bfb62fdfc6..7ee1f65022 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/VisualViewportPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/VisualViewportPage.razor @@ -1,122 +1,90 @@ @page "/visualViewport" @implements IAsyncDisposable -@inject Bit.Butil.Console console @inject Bit.Butil.VisualViewport visualViewport VisualViewport Samples -

VisualViewport

+ -
+
 @@inject Bit.Butil.VisualViewport visualViewport
 
-@@code {
-    ...
-    var offsetTop = await visualViewport.GetOffsetTop();
-    ...
-}
+var scale     = await visualViewport.GetScale();
+var offsetTop = await visualViewport.GetOffsetTop();
 
-
-
- -

Open the DevTools' console and start clicking

- -
-
- - - -
-
-
-
- - - -
-
-
-
- - - -
-
-
-
- - - -
-
-
-
- - - -
-
-
-
- - - -
-
-
-
- - - -
-
+
+ +
+ + +
+
+ + +
+ + +
+
+ + +
+ + + +
+
+
+ + @code { + private DemoConsole output = default!; + private async Task GetOffsetLeft() { - var offsetLeft = await visualViewport.GetOffsetLeft(); - await console.Log("OffsetLeft:", offsetLeft); + await output.Success("OffsetLeft →", await visualViewport.GetOffsetLeft()); } private async Task GetOffsetTop() { - var offsetTop = await visualViewport.GetOffsetTop(); - await console.Log("OffsetTop:", offsetTop); + await output.Success("OffsetTop →", await visualViewport.GetOffsetTop()); } private async Task GetPageLeft() { - var pageLeft = await visualViewport.GetPageLeft(); - await console.Log("PageLeft:", pageLeft); + await output.Success("PageLeft →", await visualViewport.GetPageLeft()); } private async Task GetPageTop() { - var pageTop = await visualViewport.GetPageTop(); - await console.Log("PageTop:", pageTop); + await output.Success("PageTop →", await visualViewport.GetPageTop()); } private async Task GetWidth() { - var width = await visualViewport.GetWidth(); - await console.Log("Width:", width); + await output.Success("Width →", await visualViewport.GetWidth()); } private async Task GetHeight() { - var height = await visualViewport.GetHeight(); - await console.Log("Height:", height); + await output.Success("Height →", await visualViewport.GetHeight()); } private async Task GetScale() { - var scale = await visualViewport.GetScale(); - await console.Log("Scale:", scale); + await output.Success("Scale →", await visualViewport.GetScale()); } public async ValueTask DisposeAsync() { await visualViewport.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WebAuthnPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WebAuthnPage.razor index ec7814407a..0d5b83b3c0 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WebAuthnPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WebAuthnPage.razor @@ -1,69 +1,78 @@ @page "/webauthn" -@inject Bit.Butil.Console console @inject Bit.Butil.WebAuthn webAuthn WebAuthn Samples -

WebAuthn

+ -
+
 @@inject Bit.Butil.WebAuthn webAuthn
 
-@@code {
-    ...
-    webAuthn.CreateCredential({ ... });
-    ...
-    webAuthn.GetCredential({ ... });
-    ...
-}
+var created = await webAuthn.CreateCredential(new { challenge = "...", rp = ..., user = ... });
+var got     = await webAuthn.GetCredential(new { challenge = "..." });
 
-
-
- -

Open the DevTools' console and start clicking on buttons

- -
-
+
+ + + - + + + -
-
+ + + +
- - -
-
- - + @code { + private DemoConsole output = default!; + private async Task Create() { - var result = await webAuthn.CreateCredential(new + try { - challenge = "testChallenge", - rp = new { name = "testRp" }, - user = new { id = "userId", name = "testUser", displayName = "testUser" }, - pubKeyCredParams = new object[] { new { alg = -7, type = "public-key" } } - }); - - await console.Log("id:", result.GetProperty("id")); - await console.Log("Create result:", result); + var result = await webAuthn.CreateCredential(new + { + challenge = "testChallenge", + rp = new { name = "testRp" }, + user = new { id = "userId", name = "testUser", displayName = "testUser" }, + pubKeyCredParams = new object[] { new { alg = -7, type = "public-key" } } + }); + + await output.Success("Created credential id →", result.GetProperty("id")); + } + catch (Exception ex) + { + await output.Error("Create failed →", ex.Message); + } } private async Task Get() { - var result = await webAuthn.GetCredential(new { challenge = "test" }); - - await console.Log("id:", result.GetProperty("id")); - await console.Log("Get result:", result); + try + { + var result = await webAuthn.GetCredential(new { challenge = "test" }); + await output.Success("Got credential id →", result.GetProperty("id")); + } + catch (Exception ex) + { + await output.Error("Get failed →", ex.Message); + } } private async Task Verify() { var result = await webAuthn.Verify(); - await console.Log("Verify result:", result); + await output.Success("Verify result →", result); } } diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WindowPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WindowPage.razor index 87ae249771..ceca84f72a 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WindowPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WindowPage.razor @@ -1,268 +1,154 @@ @page "/window" @implements IDisposable -@inject Bit.Butil.Console console @inject Bit.Butil.Window window Window Samples -

Window

+ -
+
 @@inject Bit.Butil.Window window
 
-@@code {
-    ...
-    await window.AddEventListener(ButilEvents.KeyDown, args => { ... });
-    ...
-    await window.Alert("Alert from C#");
-    ...
-}
+await window.AddEventListener(ButilEvents.KeyDown, args => { ... });
+await window.Alert("Alert from C#");
 
-
-
- -

Open the DevTools' console and start pressing keys or clicking on buttons

- -
-
- - -  - -
-
-
Is KeyDown Registered? @isKeyDownRegistered
- -
-
-
- - -  - -
-
-
Is BeforeUnload Registered? @isBeforeUnloadRegistered
- -
-
-
- - -  - - -
-
-
-
- - -  - - -
-
-
-
- - -  - - -
-
-
-
- - -  - - -
-
-
-
- - -  - -  - - -
-
-
-
- - - -
-
- - -
-
-
-
- - -
-
- -
-
-
Encoded text is: @btoaText
- -
-
- - -
-
- -
-
-
Decoded text is: @atobText
- -
-
-
- - -
-
- - -
- - -
- - -
- - -
- - -
-
- - -
-
-
-
- - -
-
-
Is context secured? @contextSecureStatus
- -
-
-
- - -
-
- - -
-
-
- - -
-
-
Current window name is: @currentWindowName
- -
-
-
- - -
-
-
Origin is: @origin
- -
-
-
- -
Select/Highlight a text on this window
-
- -
-
-
Selected text is: @selectedText
- -
-
-
- - -
-
-
Is media query matches 'max-width: 600px'? @mediaQueryMatchStatus
- -
-
-
- -X: -
-
-Y: -
-
- - -
-
-
-
- -X: -
-
-Y: -
-
- - -
-
+
+ +
+ + + Registered: @isKeyDownRegistered +
+
+ + +
+ + + Registered: @isBeforeUnloadRegistered +
+
+ + +
+ + + + + + + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+ Search text + +
+
+
+
+
+
+ +
+ + +
+ + + +
+
+ + +
+
+ + +
+ + +
+

Highlight some text on this page, then click GetSelection.

+
+ + +
+ + + +
+
+ + + +
+
+
+ + @code { + private DemoConsole output = default!; + private bool isKeyDownRegistered; private bool isBeforeUnloadRegistered; - private string contextSecureStatus; - private string btoaValue; private string atobValue; - private string btoaText; - private string atobText; - private string searchText; - private bool isCaseSensitive; - private bool isBackward; - private bool isWrapAround; - private bool isWholeWord; - private bool isSearchInFrame; + private bool isCaseSensitive; + private bool isBackward; + private bool isWrapAround; + private bool isWholeWord; + private bool isSearchInFrame; private string windowName; - private string currentWindowName; - - private string origin; - - private string selectedText; - - private string mediaQueryMatchStatus; private float scrollY = 0; private float scrollX = 0; @@ -274,7 +160,7 @@ Y: protected override void OnInitialized() { - _handler = (ButilKeyboardEventArgs arg) => _ = console.Log("KeyDown from C#:", arg.Code); + _handler = (ButilKeyboardEventArgs arg) => _ = output.Info("KeyDown from C# →", arg.Code); base.OnInitialized(); } @@ -303,136 +189,91 @@ Y: isBeforeUnloadRegistered = false; } - private async Task GetInnerHeight() - { - await console.Log("Window InnerHeight =", await window.GetInnerHeight()); - } - - private async Task GetInnerWidth() - { - await console.Log("Window InnerWidth =", await window.GetInnerWidth()); - } - - private async Task GetOuterHeight() - { - await console.Log("Window OuterHeight =", await window.GetOuterHeight()); - } + private async Task GetInnerHeight() => await output.Success("InnerHeight →", await window.GetInnerHeight()); + private async Task GetInnerWidth() => await output.Success("InnerWidth →", await window.GetInnerWidth()); + private async Task GetOuterHeight() => await output.Success("OuterHeight →", await window.GetOuterHeight()); + private async Task GetOuterWidth() => await output.Success("OuterWidth →", await window.GetOuterWidth()); + private async Task GetScreenX() => await output.Success("ScreenX →", await window.GetScreenX()); + private async Task GetScreenY() => await output.Success("ScreenY →", await window.GetScreenY()); + private async Task GetScrollX() => await output.Success("ScrollX →", await window.GetScrollX()); + private async Task GetScrollY() => await output.Success("ScrollY →", await window.GetScrollY()); - private async Task GetOuterWidth() - { - await console.Log("Window OuterWidth =", await window.GetOuterWidth()); - } - - private async Task GetScreenX() - { - await console.Log("Window ScreenX =", await window.GetScreenX()); - } - - private async Task GetScreenY() - { - await console.Log("Window ScreenY =", await window.GetScreenY()); - } - - private async Task GetScrollX() - { - await console.Log("Window ScrollX =", await window.GetScrollX()); - } - - private async Task GetScrollY() - { - await console.Log("Window ScrollY =", await window.GetScrollY()); - } - - private async Task ShowAlert() - { - await window.Alert("Alert from C#"); - } - - private async Task ShowConfirm() - { - await window.Confirm("Confirm from C#"); - } - - private async Task ShowPrompt() - { - await window.Prompt("Prompt from C#", string.Empty); - } + private async Task ShowAlert() => await window.Alert("Alert from C#"); + private async Task ShowConfirm() => await output.Success("Confirm →", await window.Confirm("Confirm from C#")); + private async Task ShowPrompt() => await output.Success("Prompt →", await window.Prompt("Prompt from C#", string.Empty)); private List windowIds = []; private async Task OpenWindow() { var windowFeatures = new WindowFeatures() { Popup = true, Width = 848, Height = 568 }; windowIds.Add(await window.Open("/document", "_blank", windowFeatures)); + await output.Log("Opened a popup window"); } private async Task CloseWindow() { - if (windowIds.Count == 0) return; + if (windowIds.Count == 0) + { + await output.Warn("No window to close"); + return; + } var id = windowIds[^1]; await window.Close(id); windowIds.Remove(id); + await output.Log("Closed the popup window"); } - private async Task OpenPrint() - { - await window.Print(); - } + private async Task OpenPrint() => await window.Print(); private async Task EncodeData() { - var res = await window.Btoa(btoaValue); - btoaText = res; + var res = await window.Btoa(btoaValue ?? string.Empty); + await output.Success("Btoa →", res); } private async Task DecodeData() { - var res = await window.Atob(atobValue); - atobText = res; + var res = await window.Atob(atobValue ?? string.Empty); + await output.Success("Atob →", res); } private async Task Find() { - await window.Find(searchText, - isCaseSensitive, - isBackward, - isWrapAround, - isWholeWord, - isSearchInFrame); + var found = await window.Find(searchText, isCaseSensitive, isBackward, isWrapAround, isWholeWord, isSearchInFrame); + await output.Success($"Find(\"{searchText}\") →", found); } private async Task CheckContextIsSecured() { - var res = await window.IsSecureContext(); - contextSecureStatus = res.ToString(); + await output.Success("IsSecureContext →", await window.IsSecureContext()); } private async Task SetWindowName() { await window.SetName(windowName); + await output.Log("window.name set to", $"\"{windowName}\""); } private async Task GetWindowName() { - var res = await window.GetName(); - currentWindowName = res; + await output.Success("window.name →", await window.GetName()); } private async Task GetOrigin() { - var res = await window.GetOrigin(); - origin = res; + await output.Success("window.origin →", await window.GetOrigin()); } private async Task GetSelection() { var res = await window.GetSelection(); - selectedText = res; + await output.Success("Selected text →", $"\"{res?.Text ?? string.Empty}\""); } private async Task GetMatchMedia() { var res = await window.MatchMedia("(max-width: 600px)"); - mediaQueryMatchStatus = res.Matches.ToString(); + await output.Success("matchMedia(max-width: 600px) →", res.Matches); } private async Task Scroll() @@ -454,4 +295,4 @@ Y: _ = window.RemoveEventListener(ButilEvents.KeyDown, _handler); } } -} \ No newline at end of file +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoCard.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoCard.razor new file mode 100644 index 0000000000..9c0f0b656c --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoCard.razor @@ -0,0 +1,21 @@ +
+
+ @Title + @if (!string.IsNullOrWhiteSpace(Api)) + { + @Api + } +
+ @if (!string.IsNullOrWhiteSpace(Description)) + { +

@Description

+ } + @ChildContent +
+ +@code { + [Parameter] public string Title { get; set; } = ""; + [Parameter] public string? Api { get; set; } + [Parameter] public string? Description { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoConsole.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoConsole.razor new file mode 100644 index 0000000000..2ed043866f --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoConsole.razor @@ -0,0 +1,79 @@ +@inject Bit.Butil.Console console + +
+
+ + @Title + +
+
+ @if (_lines.Count == 0) + { +
@Placeholder
+ } + else + { + @foreach (var line in _lines) + { +
+ @line.Timestamp + @line.Message +
+ } + } +
+
+ +@code { + [Parameter] public string Title { get; set; } = "output"; + [Parameter] public string Placeholder { get; set; } = "Results will appear here when you interact with the samples."; + + /// When true, every entry is also mirrored to the browser DevTools console. + [Parameter] public bool MirrorToDevTools { get; set; } = true; + + private readonly List _lines = new(); + + public Task Log(params object?[] values) => Write("log", values); + public Task Info(params object?[] values) => Write("info", values); + public Task Success(params object?[] values) => Write("success", values); + public Task Warn(params object?[] values) => Write("warn", values); + public Task Error(params object?[] values) => Write("error", values); + + private async Task Write(string kind, object?[] values) + { + var message = string.Join(" ", values.Select(Format)); + _lines.Insert(0, new LogLine(kind, message, DateTime.Now.ToString("HH:mm:ss"))); + if (_lines.Count > 100) + { + _lines.RemoveAt(_lines.Count - 1); + } + + StateHasChanged(); + + if (MirrorToDevTools) + { + switch (kind) + { + case "warn": await console.Warn(values); break; + case "error": await console.Error(values); break; + case "info": await console.Info(values); break; + default: await console.Log(values); break; + } + } + } + + public void Clear() + { + _lines.Clear(); + StateHasChanged(); + } + + private static string Format(object? value) => value switch + { + null => "null", + string s => s, + _ => value.ToString() ?? "" + }; + + private record LogLine(string Kind, string Message, string Timestamp); +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/Header.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/Header.razor deleted file mode 100644 index 97e51ead4e..0000000000 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/Header.razor +++ /dev/null @@ -1,21 +0,0 @@ - -
\ No newline at end of file diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/Header.razor.css b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/Header.razor.css deleted file mode 100644 index a6e1c45f80..0000000000 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/Header.razor.css +++ /dev/null @@ -1,2 +0,0 @@ -.container { -} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/MainLayout.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/MainLayout.razor index e1db014d17..7c7ac1cd35 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/MainLayout.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/MainLayout.razor @@ -1,11 +1,24 @@ @inherits LayoutComponentBase
-
- -
-
- @Body -
-
-
\ No newline at end of file + + +
+
+ + Bit.Butil Demo +
+ +
+
+ @Body +
+
+
+ + +@code { + private NavMenu? _navMenu; + + private void ToggleNav() => _navMenu?.Toggle(); +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/NavMenu.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/NavMenu.razor new file mode 100644 index 0000000000..0047ed9be2 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/NavMenu.razor @@ -0,0 +1,92 @@ +@if (_open) +{ +
+} + + + +@code { + private bool _open; + private string _filter = ""; + + public void Toggle() { _open = !_open; StateHasChanged(); } + public void Close() { _open = false; StateHasChanged(); } + + private bool Match(NavItem item) => + string.IsNullOrWhiteSpace(_filter) || + item.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase); + + private record NavItem(string Name, string Href, string Icon); + private record NavGroup(string Label, NavItem[] Items); + + private static readonly NavGroup[] Groups = + [ + new("Storage & State", + [ + new("Clipboard", "clipboard", "📋"), + new("Cookie", "cookie", "🍪"), + new("Storage", "storage", "💾"), + ]), + new("Browser & Device", + [ + new("Navigator", "navigator", "🧭"), + new("UserAgent", "userAgent", "🪪"), + new("Screen", "screen", "🖥️"), + new("ScreenOrientation", "screenOrientation", "🔄"), + new("VisualViewport", "visualViewport", "🔍"), + ]), + new("DOM", + [ + new("Document", "document", "📄"), + new("Element", "element", "🧱"), + new("Window", "window", "🪟"), + ]), + new("Navigation", + [ + new("History", "history", "🕘"), + new("Location", "location", "📍"), + ]), + new("Utilities", + [ + new("Console", "console", "🖨️"), + new("Crypto", "crypto", "🔐"), + new("Keyboard", "keyboard", "⌨️"), + new("Notification", "notification", "🔔"), + new("WebAuthn", "webauthn", "🔑"), + ]), + new("Testing", + [ + new("E2E", "e2e", "🧪"), + new("E2E Observers", "e2e-observers", "👁️"), + ]), + ]; +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/PageHeader.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/PageHeader.razor new file mode 100644 index 0000000000..e3eb268124 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/PageHeader.razor @@ -0,0 +1,21 @@ + + +@code { + [Parameter] public string Category { get; set; } = "Bit.Butil"; + [Parameter] public string Title { get; set; } = ""; + [Parameter] public string? Lead { get; set; } + [Parameter] public string? MdnUrl { get; set; } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/wwwroot/app.css b/src/Butil/Demo/Bit.Butil.Demo.Core/wwwroot/app.css new file mode 100644 index 0000000000..01b648ce69 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/wwwroot/app.css @@ -0,0 +1,685 @@ +/* ============================================================ + Bit.Butil Demo - Design System + A modern, cohesive theme shared by the Web and MAUI demos. + ============================================================ */ + +:root { + --bit-bg: #0f1226; + --bit-bg-soft: #161a36; + --bit-surface: #ffffff; + --bit-surface-2: #f6f7fb; + --bit-surface-3: #eef0f7; + --bit-border: #e3e6f0; + --bit-text: #1c2030; + --bit-text-soft: #5b6072; + --bit-text-faint: #8a90a3; + + --bit-primary: #6c5ce7; + --bit-primary-strong: #5a4bd6; + --bit-primary-soft: #eceaff; + --bit-accent: #00b894; + --bit-danger: #e74c64; + --bit-warning: #f6a609; + + --bit-radius: 14px; + --bit-radius-sm: 9px; + --bit-shadow: 0 1px 2px rgba(16, 18, 38, .06), 0 8px 24px rgba(16, 18, 38, .06); + --bit-shadow-sm: 0 1px 2px rgba(16, 18, 38, .08); + + --bit-sidebar-w: 264px; + --bit-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --bit-mono: "Cascadia Code", "Fira Code", Consolas, "SF Mono", Menlo, monospace; +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; +} + +body { + font-family: var(--bit-font); + color: var(--bit-text); + background: + radial-gradient(1200px 600px at 100% -10%, rgba(108, 92, 231, .10), transparent 60%), + radial-gradient(1000px 500px at -10% 110%, rgba(0, 184, 148, .08), transparent 55%), + var(--bit-surface-2); + -webkit-font-smoothing: antialiased; + line-height: 1.55; +} + +a { + color: var(--bit-primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* ---------- App shell ---------- */ +.page { + display: flex; + min-height: 100vh; +} + +.app-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +main { + flex: 1; + min-width: 0; +} + +article { + max-width: 960px; + margin: 0 auto; + padding: 2rem 2rem 5rem; +} + +/* ---------- Typography ---------- */ +h1 { + font-size: 2rem; + font-weight: 800; + letter-spacing: -.02em; + margin: 0 0 .25rem; +} + +h3 { + font-size: 1.05rem; + font-weight: 700; + margin: 0 0 .75rem; +} + +h5 { + font-size: .8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--bit-text-faint); + margin: 0 0 .75rem; +} + +/* ---------- Code snippet block ---------- */ +pre { + font-family: var(--bit-mono); + font-size: .85rem; + line-height: 1.6; + color: #d7d9ff; + background: var(--bit-bg); + border: 1px solid #2a2f55; + border-radius: var(--bit-radius); + padding: 1.1rem 1.25rem; + overflow: auto; + margin: 0; + box-shadow: var(--bit-shadow); +} + +/* ---------- Buttons ---------- */ +button { + font-family: inherit; + font-size: .875rem; + font-weight: 600; + color: #fff; + background: linear-gradient(180deg, var(--bit-primary), var(--bit-primary-strong)); + border: 1px solid transparent; + border-radius: var(--bit-radius-sm); + padding: .5rem .95rem; + cursor: pointer; + transition: transform .06s ease, box-shadow .15s ease, filter .15s ease; + box-shadow: var(--bit-shadow-sm); +} + +button:hover { + filter: brightness(1.05); + box-shadow: 0 4px 14px rgba(108, 92, 231, .3); +} + +button:active { + transform: translateY(1px); +} + +button:focus-visible { + outline: 3px solid var(--bit-primary-soft); + outline-offset: 1px; +} + +/* secondary button variant */ +button.secondary, +button.ghost { + color: var(--bit-primary); + background: var(--bit-surface); + border: 1px solid var(--bit-border); + box-shadow: none; +} + +button.secondary:hover, +button.ghost:hover { + background: var(--bit-primary-soft); + box-shadow: none; +} + +button.danger { + background: linear-gradient(180deg, #f06582, var(--bit-danger)); +} + +/* ---------- Inputs ---------- */ +input:not([type="checkbox"]):not([type="radio"]), +textarea, +select { + font-family: inherit; + font-size: .9rem; + color: var(--bit-text); + background: var(--bit-surface); + border: 1px solid var(--bit-border); + border-radius: var(--bit-radius-sm); + padding: .55rem .7rem; + width: 100%; + max-width: 340px; + transition: border-color .15s ease, box-shadow .15s ease; +} + +textarea { + min-height: 84px; + resize: vertical; +} + +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--bit-primary); + box-shadow: 0 0 0 3px var(--bit-primary-soft); +} + +input[type="checkbox"] { + width: 1.05rem; + height: 1.05rem; + accent-color: var(--bit-primary); + vertical-align: middle; + cursor: pointer; +} + +label { + color: var(--bit-text-soft); + font-size: .9rem; +} + +/* ---------- Divider ---------- */ +hr { + border: none; + border-top: 1px solid var(--bit-border); + margin: 1.25rem 0; +} + +/* ---------- Sidebar navigation ---------- */ +.sidebar { + width: var(--bit-sidebar-w); + flex-shrink: 0; + background: linear-gradient(180deg, var(--bit-bg), var(--bit-bg-soft)); + color: #cfd2f2; + display: flex; + flex-direction: column; + position: sticky; + top: 0; + align-self: flex-start; + height: 100vh; + overflow-y: auto; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: .7rem; + padding: 1.4rem 1.4rem 1rem; +} + +.sidebar-brand .logo { + width: 38px; + height: 38px; + border-radius: 11px; + background: linear-gradient(135deg, var(--bit-primary), var(--bit-accent)); + display: grid; + place-items: center; + font-weight: 800; + color: #fff; + font-size: 1.05rem; + box-shadow: 0 6px 18px rgba(108, 92, 231, .45); +} + +.sidebar-brand .title { + font-weight: 800; + font-size: 1.1rem; + color: #fff; + letter-spacing: -.01em; +} + +.sidebar-brand .subtitle { + font-size: .72rem; + color: #8a90c4; + letter-spacing: .04em; +} + +.sidebar-search { + padding: 0 1rem .75rem; +} + +.sidebar-search input { + width: 100%; + max-width: none; + background: rgba(255, 255, 255, .06); + border: 1px solid rgba(255, 255, 255, .1); + color: #e8eaff; + font-size: .82rem; +} + +.sidebar-search input::placeholder { + color: #7f86b8; +} + +.sidebar-search input:focus { + border-color: var(--bit-primary); + box-shadow: 0 0 0 3px rgba(108, 92, 231, .25); +} + +.sidebar-nav { + padding: .25rem .75rem 1.5rem; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-nav .group-label { + font-size: .68rem; + text-transform: uppercase; + letter-spacing: .1em; + color: #6f76ad; + padding: 1rem .75rem .4rem; + font-weight: 700; +} + +.sidebar-nav a { + display: flex; + align-items: center; + gap: .6rem; + padding: .5rem .75rem; + border-radius: 9px; + color: #c2c6ee; + font-size: .88rem; + font-weight: 500; + transition: background .12s ease, color .12s ease; +} + +.sidebar-nav a:hover { + background: rgba(255, 255, 255, .06); + color: #fff; + text-decoration: none; +} + +.sidebar-nav a.active { + background: linear-gradient(90deg, rgba(108, 92, 231, .35), rgba(108, 92, 231, .12)); + color: #fff; + box-shadow: inset 3px 0 0 var(--bit-primary); +} + +.sidebar-nav a .ico { + font-size: 1rem; + width: 1.2rem; + text-align: center; + opacity: .9; +} + +/* ---------- Top bar (mobile) ---------- */ +.topbar { + display: none; + align-items: center; + gap: .75rem; + padding: .85rem 1rem; + background: var(--bit-bg); + color: #fff; + position: sticky; + top: 0; + z-index: 20; +} + +.topbar .menu-btn { + background: rgba(255, 255, 255, .1); + border: none; + box-shadow: none; + padding: .4rem .65rem; +} + +/* ---------- Page header ---------- */ +.page-header { + margin-bottom: 1.5rem; +} + +.page-header .eyebrow { + display: inline-block; + font-size: .72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .1em; + color: var(--bit-primary); + background: var(--bit-primary-soft); + padding: .25rem .6rem; + border-radius: 999px; + margin-bottom: .6rem; +} + +.page-header p.lead { + color: var(--bit-text-soft); + font-size: 1rem; + margin: .25rem 0 0; + max-width: 70ch; +} + +.page-header .mdn-link { + display: inline-flex; + align-items: center; + gap: .35rem; + font-size: .82rem; + margin-top: .75rem; + font-weight: 600; +} + +/* ---------- Cards ---------- */ +.card { + background: var(--bit-surface); + border: 1px solid var(--bit-border); + border-radius: var(--bit-radius); + padding: 1.25rem 1.35rem; + margin-bottom: 1.1rem; + box-shadow: var(--bit-shadow); +} + +.card > .card-title { + display: flex; + align-items: center; + gap: .55rem; + font-size: 1rem; + font-weight: 700; + margin: 0 0 .35rem; +} + +.card > .card-desc { + color: var(--bit-text-soft); + font-size: .88rem; + margin: 0 0 1rem; +} + +.card .card-title .badge { + font-size: .68rem; + font-weight: 700; + color: var(--bit-text-faint); + background: var(--bit-surface-3); + border-radius: 6px; + padding: .1rem .45rem; + font-family: var(--bit-mono); +} + +/* ---------- Control rows ---------- */ +.controls { + display: flex; + flex-wrap: wrap; + gap: .6rem; + align-items: center; +} + +.field { + display: flex; + flex-direction: column; + gap: .3rem; + margin-bottom: .85rem; +} + +.field > .field-label { + font-size: .78rem; + font-weight: 600; + color: var(--bit-text-soft); +} + +.inline-field { + display: flex; + align-items: center; + gap: .5rem; + flex-wrap: wrap; +} + +.check-row { + display: flex; + align-items: center; + gap: .5rem; + margin-bottom: .5rem; +} + +/* ---------- Inline output console ---------- */ +.demo-console { + margin-top: 1.5rem; + border: 1px solid #2a2f55; + border-radius: var(--bit-radius); + background: var(--bit-bg); + overflow: hidden; + box-shadow: var(--bit-shadow); +} + +.demo-console-head { + display: flex; + align-items: center; + gap: .6rem; + padding: .6rem .9rem; + background: rgba(255, 255, 255, .03); + border-bottom: 1px solid #2a2f55; +} + +.demo-console-head .dots { + display: flex; + gap: .35rem; +} + +.demo-console-head .dots i { + width: 11px; + height: 11px; + border-radius: 50%; + display: inline-block; +} + +.demo-console-head .dots i:nth-child(1) { background: #ff5f57; } +.demo-console-head .dots i:nth-child(2) { background: #febc2e; } +.demo-console-head .dots i:nth-child(3) { background: #28c840; } + +.demo-console-head .label { + color: #8a90c4; + font-size: .78rem; + font-family: var(--bit-mono); + font-weight: 600; +} + +.demo-console-head .clear-btn { + margin-left: auto; + background: transparent; + border: 1px solid #3a3f66; + color: #9aa0d0; + box-shadow: none; + padding: .25rem .6rem; + font-size: .72rem; +} + +.demo-console-head .clear-btn:hover { + background: rgba(255, 255, 255, .06); + box-shadow: none; + filter: none; +} + +.demo-console-body { + font-family: var(--bit-mono); + font-size: .82rem; + color: #d7d9ff; + padding: .85rem 1rem; + max-height: 280px; + overflow-y: auto; + margin: 0; +} + +.demo-console-body .empty { + color: #6f76ad; + font-style: italic; +} + +.demo-console-body .line { + display: flex; + gap: .6rem; + padding: .15rem 0; + border-bottom: 1px solid rgba(255, 255, 255, .04); + white-space: pre-wrap; + word-break: break-word; +} + +.demo-console-body .line .ts { + color: #6f76ad; + flex-shrink: 0; +} + +.demo-console-body .line .msg { color: #e8eaff; } +.demo-console-body .line.info .msg { color: #6cc5ff; } +.demo-console-body .line.success .msg { color: #4ce0a8; } +.demo-console-body .line.warn .msg { color: #ffd166; } +.demo-console-body .line.error .msg { color: #ff8095; } + +/* ---------- Landing page ---------- */ +.hero { + text-align: center; + padding: 2rem 0 1rem; +} + +.hero .logo-lg { + width: 76px; + height: 76px; + border-radius: 20px; + background: linear-gradient(135deg, var(--bit-primary), var(--bit-accent)); + display: inline-grid; + place-items: center; + font-weight: 800; + color: #fff; + font-size: 2rem; + box-shadow: 0 12px 30px rgba(108, 92, 231, .4); + margin-bottom: 1rem; +} + +.hero h1 { + font-size: 2.6rem; +} + +.hero p { + color: var(--bit-text-soft); + font-size: 1.1rem; + max-width: 60ch; + margin: .5rem auto 0; +} + +.tile-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; + margin-top: 2rem; +} + +.tile { + background: var(--bit-surface); + border: 1px solid var(--bit-border); + border-radius: var(--bit-radius); + padding: 1.1rem; + box-shadow: var(--bit-shadow); + transition: transform .12s ease, box-shadow .15s ease, border-color .15s ease; + display: block; + color: inherit; +} + +.tile:hover { + transform: translateY(-3px); + border-color: var(--bit-primary); + box-shadow: 0 12px 28px rgba(108, 92, 231, .18); + text-decoration: none; +} + +.tile .tile-ico { + font-size: 1.6rem; + margin-bottom: .5rem; + display: block; +} + +.tile .tile-name { + font-weight: 700; + color: var(--bit-text); +} + +.tile .tile-desc { + font-size: .8rem; + color: var(--bit-text-soft); + margin-top: .15rem; +} + +/* ---------- Helpers ---------- */ +.result-pill { + display: inline-flex; + align-items: center; + gap: .4rem; + background: var(--bit-surface-3); + border-radius: 999px; + padding: .3rem .75rem; + font-size: .82rem; + color: var(--bit-text-soft); + font-family: var(--bit-mono); +} + +.result-pill b { + color: var(--bit-text); +} + +.muted { + color: var(--bit-text-faint); + font-size: .85rem; +} + +.stack { + display: flex; + flex-direction: column; + gap: .85rem; +} + +/* ---------- Responsive ---------- */ +@media (max-width: 860px) { + .sidebar { + position: fixed; + z-index: 30; + transform: translateX(-100%); + transition: transform .2s ease; + box-shadow: 0 0 40px rgba(0, 0, 0, .4); + } + + .sidebar.open { + transform: translateX(0); + } + + .topbar { + display: flex; + } + + article { + padding: 1.25rem 1.1rem 4rem; + } + + .scrim { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .45); + z-index: 25; + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Maui/wwwroot/index.html b/src/Butil/Demo/Bit.Butil.Demo.Maui/wwwroot/index.html index 8dde0bdcc6..0938596924 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Maui/wwwroot/index.html +++ b/src/Butil/Demo/Bit.Butil.Demo.Maui/wwwroot/index.html @@ -5,6 +5,7 @@ Bit.Butil.Demo + diff --git a/src/Butil/Demo/Bit.Butil.Demo.Web/wwwroot/index.html b/src/Butil/Demo/Bit.Butil.Demo.Web/wwwroot/index.html index 02b28b4087..2629f40b5e 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Web/wwwroot/index.html +++ b/src/Butil/Demo/Bit.Butil.Demo.Web/wwwroot/index.html @@ -5,6 +5,7 @@ Bit.Butil.Demo + diff --git a/src/Butil/tests/Bit.Butil.E2ETests/Bit.Butil.E2ETests.csproj b/src/Butil/tests/Bit.Butil.E2ETests/Bit.Butil.E2ETests.csproj new file mode 100644 index 0000000000..fe04e2505a --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/Bit.Butil.E2ETests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + latest + + + true + Exe + true + + + + + + + + + + + diff --git a/src/Butil/tests/Bit.Butil.E2ETests/BroadcastAndIndexedDbTests.cs b/src/Butil/tests/Bit.Butil.E2ETests/BroadcastAndIndexedDbTests.cs new file mode 100644 index 0000000000..c2c4e5a548 --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/BroadcastAndIndexedDbTests.cs @@ -0,0 +1,21 @@ +using Bit.Butil.E2ETests.Infrastructure; +using NUnit.Framework; + +namespace Bit.Butil.E2ETests; + +[Parallelizable(ParallelScope.Self)] +public class BroadcastAndIndexedDbTests : ButilObserversPageTest +{ + [Test] + public async Task BroadcastChannel_Subscriber_Receives_A_Posted_Message() + { + await ClickAndExpectAsync("broadcast-subscribe", "broadcast:subscribed"); + await ClickAndExpectAsync("broadcast-post", "broadcast:received:pong"); + } + + [Test] + public async Task IndexedDb_Open_Put_Get_Roundtrips() + { + await ClickAndExpectAsync("idb-roundtrip", "idb:get:stored"); + } +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/CookieTests.cs b/src/Butil/tests/Bit.Butil.E2ETests/CookieTests.cs new file mode 100644 index 0000000000..a31e1d7309 --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/CookieTests.cs @@ -0,0 +1,25 @@ +using Bit.Butil.E2ETests.Infrastructure; +using NUnit.Framework; + +namespace Bit.Butil.E2ETests; + +[Parallelizable(ParallelScope.Self)] +public class CookieTests : ButilPageTest +{ + [Test] + public async Task Cookie_Set_Get_Survives_Reserved_Characters_Roundtrip() + { + // Pre-clean: removing twice doesn't break anything if the cookie isn't there yet. + await ClickAndExpectAsync("cookie-remove", "cookie:removed:"); + + await ClickAndExpectAsync("cookie-set", "cookie:set"); + await ClickAndExpectAsync("cookie-get", "cookie:get:v=1; b=hello world & again"); + } + + [Test] + public async Task Cookie_Remove_Deletes_The_Entry() + { + await ClickAndExpectAsync("cookie-set", "cookie:set"); + await ClickAndExpectAsync("cookie-remove", "cookie:removed:True"); + } +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/CryptoTests.cs b/src/Butil/tests/Bit.Butil.E2ETests/CryptoTests.cs new file mode 100644 index 0000000000..e75fb17b4f --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/CryptoTests.cs @@ -0,0 +1,41 @@ +using System.Text.RegularExpressions; +using Bit.Butil.E2ETests.Infrastructure; +using Microsoft.Playwright; +using NUnit.Framework; + +namespace Bit.Butil.E2ETests; + +[Parallelizable(ParallelScope.Self)] +public class CryptoTests : ButilPageTest +{ + [Test] + public async Task RandomUuid_Returns_Valid_V4_Guid() + { + await ClickAndExpectAsync("crypto-uuid", "crypto:uuid:"); + var status = await CurrentStatusAsync(); + var guid = status["crypto:uuid:".Length..]; + Assert.That(guid, Has.Length.EqualTo(36)); + Assert.That(Regex.IsMatch(guid, "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")); + } + + [Test] + public async Task GetRandomValues_Returns_Requested_Length() + { + await ClickAndExpectAsync("crypto-rand", "crypto:rand:32"); + } + + [Test] + public async Task Digest_Sha256_Matches_Known_Hello_Hash() + { + // The well-known SHA-256("hello") digest. Pinning it ensures Butil's byte→hex pipeline + // doesn't drift in either direction. + await ClickAndExpectAsync("crypto-digest", + "crypto:sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"); + } + + [Test] + public async Task AesGcm_Roundtrip_Recovers_Plaintext() + { + await ClickAndExpectAsync("crypto-roundtrip", "crypto:aes-gcm:True"); + } +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilHarnessTestBase.cs b/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilHarnessTestBase.cs new file mode 100644 index 0000000000..b7cd6dc1b5 --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilHarnessTestBase.cs @@ -0,0 +1,80 @@ +using Microsoft.Playwright; +using NUnit.Framework; + +namespace Bit.Butil.E2ETests.Infrastructure; +/// +/// Self-managing Playwright base class. Unlike Microsoft.Playwright.NUnit.PageTest this +/// doesn't depend on runsettings being threaded through the test host (which doesn't happen +/// reliably under the Microsoft.Testing.Platform runner). Instead it reads configuration from +/// environment variables so the same binary runs against bundled Chromium, a system Chrome +/// channel, or an explicit executable path: +/// +/// BUTIL_E2E_CHANNEL — e.g. chrome / msedge (uses an installed browser). +/// BUTIL_E2E_EXECUTABLE — full path to a chromium-family executable. +/// BUTIL_E2E_HEADED — set to 1 to watch the run. +/// +/// +public abstract class ButilHarnessTestBase +{ + private IPlaywright _playwright = default!; + private IBrowser _browser = default!; + private IBrowserContext _context = default!; + + protected IPage Page { get; private set; } = default!; + + /// The harness route a derived fixture drives, e.g. "/e2e". + protected abstract string HarnessRoute { get; } + + [SetUp] + public async Task SetUp() + { + _playwright = await Playwright.CreateAsync(); + + var launchOptions = new BrowserTypeLaunchOptions + { + Headless = Environment.GetEnvironmentVariable("BUTIL_E2E_HEADED") != "1" + }; + + var channel = Environment.GetEnvironmentVariable("BUTIL_E2E_CHANNEL"); + if (!string.IsNullOrWhiteSpace(channel)) launchOptions.Channel = channel; + + var executable = Environment.GetEnvironmentVariable("BUTIL_E2E_EXECUTABLE"); + if (!string.IsNullOrWhiteSpace(executable)) launchOptions.ExecutablePath = executable; + + _browser = await _playwright.Chromium.LaunchAsync(launchOptions); + _context = await _browser.NewContextAsync(new() + { + BaseURL = DemoServerFixture.BaseUrl, + IgnoreHTTPSErrors = true, + ViewportSize = new() { Width = 1280, Height = 720 } + }); + Page = await _context.NewPageAsync(); + + await Page.GotoAsync(HarnessRoute, new() { Timeout = 60_000 }); + await Assertions.Expect(Page.Locator("#status")).ToHaveTextAsync("ready", new() { Timeout = 60_000 }); + } + + [TearDown] + public async Task TearDown() + { + try + { + if (_context is not null) await _context.CloseAsync(); + if (_browser is not null) await _browser.CloseAsync(); + } + finally + { + _playwright?.Dispose(); + } + } + + /// Clicks an element by id and waits for #status to contain the expected prefix. + protected async Task ClickAndExpectAsync(string id, string statusPrefix, int timeoutMs = 15_000) + { + await Page.Locator($"#{id}").ClickAsync(); + await Assertions.Expect(Page.Locator("#status")).ToContainTextAsync(statusPrefix, new() { Timeout = timeoutMs }); + } + + protected async Task CurrentStatusAsync() + => (await Page.Locator("#status").TextContentAsync())?.Trim() ?? string.Empty; +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilObserversPageTest.cs b/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilObserversPageTest.cs new file mode 100644 index 0000000000..e3f4a3786d --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilObserversPageTest.cs @@ -0,0 +1,7 @@ +namespace Bit.Butil.E2ETests.Infrastructure; + +/// Drives the deterministic /e2e-observers harness page. +public abstract class ButilObserversPageTest : ButilHarnessTestBase +{ + protected override string HarnessRoute => "/e2e-observers"; +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilPageTest.cs b/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilPageTest.cs new file mode 100644 index 0000000000..ea574e3c14 --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/ButilPageTest.cs @@ -0,0 +1,7 @@ +namespace Bit.Butil.E2ETests.Infrastructure; + +/// Drives the deterministic /e2e harness page. +public abstract class ButilPageTest : ButilHarnessTestBase +{ + protected override string HarnessRoute => "/e2e"; +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/DemoServerFixture.cs b/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/DemoServerFixture.cs new file mode 100644 index 0000000000..cc8851a4de --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/Infrastructure/DemoServerFixture.cs @@ -0,0 +1,134 @@ +using System.Diagnostics; +using System.Net.Http; +using NUnit.Framework; + +// NOTE: deliberately in the assembly's root test namespace (not .Infrastructure) so the +// [SetUpFixture] applies to every test in the assembly regardless of their namespace. +namespace Bit.Butil.E2ETests; + +/// +/// Boots Bit.Butil.Demo.Web as a child process for the duration of the test session and +/// exposes the URL test fixtures should hit. Reuses an externally-running server when +/// BUTIL_E2E_BASE_URL is set so CI can hand-roll the boot if it wants. +/// +[SetUpFixture] +public class DemoServerFixture +{ + public static string BaseUrl { get; private set; } = string.Empty; + + private Process? _process; + + [OneTimeSetUp] + public async Task GlobalSetup() + { + var external = Environment.GetEnvironmentVariable("BUTIL_E2E_BASE_URL"); + if (!string.IsNullOrWhiteSpace(external)) + { + BaseUrl = external.TrimEnd('/'); + await WaitForReady(BaseUrl); + return; + } + + // Reserve a TCP port up-front so we can pass it to the dev server explicitly. Avoids + // collisions with the developer's ambient `dotnet run` on 5040. + var port = GetFreePort(); + BaseUrl = $"http://127.0.0.1:{port}"; + + var demoCsproj = LocateDemoCsproj(); + + _process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + ArgumentList = + { + "run", + "--no-launch-profile", + "--project", demoCsproj, + "--", "--urls", BaseUrl + }, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + } + }; + + // Drain output so the child's stdout buffer never fills up and stalls the server. + _process.OutputDataReceived += (_, e) => { if (e.Data is not null) TestContext.Progress.WriteLine(e.Data); }; + _process.ErrorDataReceived += (_, e) => { if (e.Data is not null) TestContext.Progress.WriteLine(e.Data); }; + + if (!_process.Start()) + throw new InvalidOperationException("Failed to start the Bit.Butil demo process."); + + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + + await WaitForReady(BaseUrl); + } + + [OneTimeTearDown] + public void GlobalTeardown() + { + if (_process is null || _process.HasExited) return; + try + { + _process.Kill(entireProcessTree: true); + _process.WaitForExit(5000); + } + catch { /* best-effort cleanup */ } + finally + { + _process.Dispose(); + _process = null; + } + } + + private static int GetFreePort() + { + // Bind to port 0 then immediately close; the OS hands us a free ephemeral port. + var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static string LocateDemoCsproj() + { + // Walk up from the test bin directory until we find the demo csproj. This makes the + // suite robust to the test runner's chosen working directory (CLI vs IDE differ). + var dir = AppContext.BaseDirectory; + for (var i = 0; i < 10 && dir is not null; i++) + { + var candidate = Path.Combine(dir, "Demo", "Bit.Butil.Demo.Web", "Bit.Butil.Demo.Web.csproj"); + if (File.Exists(candidate)) return candidate; + // Also handle running from tests/Bit.Butil.E2ETests/bin/Debug/... + candidate = Path.Combine(dir, "..", "..", "..", "..", "..", "Demo", "Bit.Butil.Demo.Web", "Bit.Butil.Demo.Web.csproj"); + if (File.Exists(candidate)) return Path.GetFullPath(candidate); + dir = Path.GetDirectoryName(dir); + } + throw new InvalidOperationException("Could not locate Bit.Butil.Demo.Web.csproj relative to the test binaries."); + } + + private static async Task WaitForReady(string baseUrl) + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; + var deadline = DateTime.UtcNow.AddSeconds(180); + while (DateTime.UtcNow < deadline) + { + try + { + using var resp = await http.GetAsync(baseUrl + "/"); + if (resp.IsSuccessStatusCode) return; + } + catch + { + // Server not ready yet; retry. + } + await Task.Delay(500); + } + throw new TimeoutException($"Demo app at {baseUrl} did not become ready within the deadline."); + } +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/ObserverTests.cs b/src/Butil/tests/Bit.Butil.E2ETests/ObserverTests.cs new file mode 100644 index 0000000000..33419971da --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/ObserverTests.cs @@ -0,0 +1,34 @@ +using Bit.Butil.E2ETests.Infrastructure; +using NUnit.Framework; + +namespace Bit.Butil.E2ETests; + +[Parallelizable(ParallelScope.Self)] +public class ObserverTests : ButilObserversPageTest +{ + [Test] + public async Task IntersectionObserver_Fires_For_OnScreen_Target() + { + await ClickAndExpectAsync("intersection-observe", "intersection:True"); + } + + [Test] + public async Task ResizeObserver_Fires_On_Initial_Observe() + { + // ResizeObserver delivers an initial entry on observe, so we don't even need to resize. + await ClickAndExpectAsync("resize-observe", "resize:observed:True"); + } + + [Test] + public async Task ResizeObserver_Trigger_Changes_Target_Width() + { + await Page.Locator("#resize-observe").ClickAsync(); + await ClickAndExpectAsync("resize-trigger", "resize:triggered:"); + } + + [Test] + public async Task MutationObserver_Fires_On_Attribute_Change() + { + await ClickAndExpectAsync("mutation-observe", "mutation:True"); + } +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/PerformanceAndPlatformTests.cs b/src/Butil/tests/Bit.Butil.E2ETests/PerformanceAndPlatformTests.cs new file mode 100644 index 0000000000..563b29375d --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/PerformanceAndPlatformTests.cs @@ -0,0 +1,32 @@ +using Bit.Butil.E2ETests.Infrastructure; +using NUnit.Framework; + +namespace Bit.Butil.E2ETests; + +[Parallelizable(ParallelScope.Self)] +public class PerformanceAndPlatformTests : ButilObserversPageTest +{ + [Test] + public async Task Performance_Mark_And_Measure_Produces_An_Entry() + { + await ClickAndExpectAsync("perf-mark-measure", "perf:measure:True"); + } + + [Test] + public async Task PerformanceObserver_Reports_A_Mark() + { + await ClickAndExpectAsync("perf-observer", "perf:observer:True"); + } + + [Test] + public async Task StorageManager_Estimate_Reports_A_Quota() + { + await ClickAndExpectAsync("storage-estimate", "storage:estimate:True"); + } + + [Test] + public async Task NetworkInformation_Reports_Online() + { + await ClickAndExpectAsync("network-status", "network:online:True"); + } +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/README.md b/src/Butil/tests/Bit.Butil.E2ETests/README.md new file mode 100644 index 0000000000..e6b6c4dd5c --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/README.md @@ -0,0 +1,58 @@ +# Bit.Butil end-to-end tests + +NUnit + Microsoft.Playwright suite that boots `Bit.Butil.Demo.Web` (Blazor WASM) as a child process and exercises two deterministic harness pages. Uses the **Microsoft.Testing.Platform** runner (mandated by the repo `global.json`) via NUnit's MTP runner. + +## First-time setup + +```powershell +# Restore + build. +dotnet build .\Bit.Butil.E2ETests.csproj + +# Install the Playwright-managed Chromium (skip if you'll use a system browser, see below). +pwsh .\bin\Debug\net10.0\playwright.ps1 install chromium +``` + +## Running + +```powershell +dotnet test .\Bit.Butil.E2ETests.csproj +``` + +### Browser selection (environment variables) + +The test base class manages Playwright directly and reads configuration from env vars (this is more reliable than runsettings under the MTP runner): + +| Variable | Effect | +| --- | --- | +| `BUTIL_E2E_CHANNEL` | Launch an installed browser channel instead of the bundled Chromium — e.g. `chrome`, `msedge`. Handy when the Playwright download is blocked. | +| `BUTIL_E2E_EXECUTABLE` | Full path to a chromium-family executable. | +| `BUTIL_E2E_HEADED` | Set to `1` to watch the run in a visible window. | +| `BUTIL_E2E_BASE_URL` | Point at an already-running demo server (e.g. `https://localhost:5041`) instead of auto-launching one. | + +Example — run against a system Chrome with no Playwright download: + +```powershell +$env:BUTIL_E2E_CHANNEL = "chrome" +dotnet test .\Bit.Butil.E2ETests.csproj +``` + +## Layout + +* `Infrastructure/DemoServerFixture.cs` — assembly-level `[SetUpFixture]` that starts the demo on a free TCP port and tears it down at the end of the run. +* `Infrastructure/ButilHarnessTestBase.cs` — self-managing Playwright base class (launch + context + page) that reads the env vars above. +* `Infrastructure/ButilPageTest.cs` / `ButilObserversPageTest.cs` — thin bases pinning each harness route. +* `*Tests.cs` — narrowly-scoped tests grouped by Butil surface. +* `ci/bit.ci.Butil.e2e.yml` — ready-to-merge GitHub Actions workflow (copy into `.github/workflows/`). + +## Harness pages + +Two deterministic pages live in `Bit.Butil.Demo.Core/Pages`: + +* `/e2e` — storage, cookie, crypto, performance.now, window base64, document title, location, history. +* `/e2e-observers` — PerformanceObserver, StorageManager, NetworkInformation, IntersectionObserver, ResizeObserver, MutationObserver, BroadcastChannel, IndexedDB. + +Both expose stable element ids and funnel results through a single `#status` element so test selectors stay simple. + +## Why a custom harness page? + +Real Butil pages (`/clipboard`, `/notification`, …) trigger permission prompts that can't be granted reliably in headless. The harness pages only exercise APIs that work with no user gesture and expose outputs through one stable `#status` element so the test selectors don't have to chase per-feature DOM. diff --git a/src/Butil/tests/Bit.Butil.E2ETests/StorageTests.cs b/src/Butil/tests/Bit.Butil.E2ETests/StorageTests.cs new file mode 100644 index 0000000000..4dd3f54c03 --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/StorageTests.cs @@ -0,0 +1,32 @@ +using Bit.Butil.E2ETests.Infrastructure; +using NUnit.Framework; + +namespace Bit.Butil.E2ETests; + +[Parallelizable(ParallelScope.Self)] +public class StorageTests : ButilPageTest +{ + [Test] + public async Task LocalStorage_RoundTrips_StringValues() + { + await ClickAndExpectAsync("ls-clear", "ls:clear"); + await ClickAndExpectAsync("ls-set", "ls:set"); + await ClickAndExpectAsync("ls-get", "ls:get:butil-e2e-value"); + } + + [Test] + public async Task LocalStorage_RoundTrips_TypedPayload_ViaJsonGenerics() + { + await ClickAndExpectAsync("ls-clear", "ls:clear"); + await ClickAndExpectAsync("ls-typed-set", "ls:typed-set"); + await ClickAndExpectAsync("ls-typed-get", "ls:typed-get:42/answer"); + } + + [Test] + public async Task SessionStorage_RoundTrips_StringValues() + { + await ClickAndExpectAsync("ss-clear", "ss:clear"); + await ClickAndExpectAsync("ss-set", "ss:set"); + await ClickAndExpectAsync("ss-get", "ss:get:butil-e2e-svalue"); + } +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/WindowDocumentHistoryTests.cs b/src/Butil/tests/Bit.Butil.E2ETests/WindowDocumentHistoryTests.cs new file mode 100644 index 0000000000..78c602e601 --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/WindowDocumentHistoryTests.cs @@ -0,0 +1,39 @@ +using Bit.Butil.E2ETests.Infrastructure; +using NUnit.Framework; + +namespace Bit.Butil.E2ETests; + +[Parallelizable(ParallelScope.Self)] +public class WindowDocumentHistoryTests : ButilPageTest +{ + [Test] + public async Task Performance_Now_Returns_PositiveValue() + { + await ClickAndExpectAsync("perf-now", "perf:now:True"); + } + + [Test] + public async Task Window_Btoa_Atob_Roundtrip() + { + // "butil" base64-encoded is "YnV0aWw=". + await ClickAndExpectAsync("window-base64", "window:b64:YnV0aWw=/butil"); + } + + [Test] + public async Task Document_SetTitle_Then_GetTitle_Roundtrips() + { + await ClickAndExpectAsync("doc-title", "doc:title:butil-e2e-title"); + } + + [Test] + public async Task Location_GetHref_Reports_The_Current_Page() + { + await ClickAndExpectAsync("loc-href", "loc:href:True"); + } + + [Test] + public async Task History_PushState_Increments_Length() + { + await ClickAndExpectAsync("history-state", "history:len:True"); + } +} diff --git a/src/Butil/tests/Bit.Butil.E2ETests/ci/bit.ci.Butil.e2e.yml b/src/Butil/tests/Bit.Butil.E2ETests/ci/bit.ci.Butil.e2e.yml new file mode 100644 index 0000000000..bf1c768745 --- /dev/null +++ b/src/Butil/tests/Bit.Butil.E2ETests/ci/bit.ci.Butil.e2e.yml @@ -0,0 +1,65 @@ +# Ready-to-merge GitHub Actions workflow for the Bit.Butil E2E suite. +# +# This file lives inside the test project because the repo's `.github/workflows` +# folder is outside this package's tree. To enable it, copy this file to +# .github/workflows/bit.ci.Butil.e2e.yml +# (it's self-contained and won't clash with the existing bit.ci.Butil.yml build job). + +name: bit platform CI - Butil E2E + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/Butil/**' + +env: + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: true + +jobs: + e2e-Butil: + if: startsWith(github.event.pull_request.title, 'Prerelease') != true && startsWith(github.event.pull_request.title, 'Release') != true && startsWith(github.event.pull_request.title, 'Version') != true + name: Bit.Butil E2E (Playwright) + runs-on: ubuntu-24.04 + + steps: + - name: Checkout source code + uses: actions/checkout@v6 + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v5 + with: + global-json-file: src/global.json + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: dotnet workload restore + run: cd src/Butil && dotnet workload restore + + # Build the Butil library first so the static web asset (bit-butil.js) is generated + # and the Node/TypeScript pipeline runs once before the demo references it. + - name: Build Bit.Butil (net10.0) + run: dotnet build src/Butil/Bit.Butil/Bit.Butil.csproj -c Release -f net10.0 + + - name: Build E2E test project + run: dotnet build src/Butil/tests/Bit.Butil.E2ETests/Bit.Butil.E2ETests.csproj -c Release + + - name: Install Playwright browsers (Chromium) + run: pwsh src/Butil/tests/Bit.Butil.E2ETests/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + + - name: Run E2E tests + run: > + dotnet test src/Butil/tests/Bit.Butil.E2ETests/Bit.Butil.E2ETests.csproj + -c Release + --no-build + --report-trx --report-trx-filename butil-e2e.trx + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: butil-e2e-results + path: '**/butil-e2e.trx' + if-no-files-found: ignore From 452dfc748475c22b9b8ce36ef6fb3b7aa779b5fd Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 2 Jun 2026 23:10:32 +0330 Subject: [PATCH 02/10] resolve review comments --- src/Butil/Bit.Butil/Publics/Fetch.cs | 3 ++- .../MutationObserver/MutationRecord.cs | 2 +- .../Shared/DemoConsole.razor | 23 +++++++++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/Butil/Bit.Butil/Publics/Fetch.cs b/src/Butil/Bit.Butil/Publics/Fetch.cs index 724687e506..edb73ab443 100644 --- a/src/Butil/Bit.Butil/Publics/Fetch.cs +++ b/src/Butil/Bit.Butil/Publics/Fetch.cs @@ -36,7 +36,8 @@ public async Task Send(FetchRequest request, ? cancellationToken.Register(static state => { var (j, rid) = ((IJSRuntime, Guid))state!; - _ = j.InvokeVoid("BitButil.fetch.abort", rid); + try { _ = j.InvokeVoid("BitButil.fetch.abort", rid); } + catch (JSDisconnectedException) { } }, (js, id)) : default; diff --git a/src/Butil/Bit.Butil/Publics/MutationObserver/MutationRecord.cs b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationRecord.cs index 62bad7f294..d3935db0f5 100644 --- a/src/Butil/Bit.Butil/Publics/MutationObserver/MutationRecord.cs +++ b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationRecord.cs @@ -1,7 +1,7 @@ namespace Bit.Butil; /// -/// Mirrors a subset of MutationRecord. +/// Mirrors a subset of MutationRecord. /// DOM nodes can't cross interop, so they're flattened to lightweight summaries. /// public class MutationRecord diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoConsole.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoConsole.razor index 2ed043866f..8675062ecd 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoConsole.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/DemoConsole.razor @@ -1,3 +1,4 @@ +@using System.Text.Json @inject Bit.Butil.Console console
@@ -68,12 +69,24 @@ StateHasChanged(); } - private static string Format(object? value) => value switch + private static string Format(object? value) { - null => "null", - string s => s, - _ => value.ToString() ?? "" - }; + if (value is null) return "null"; + if (value is string s) return s; + + var type = value.GetType(); + if (type.IsPrimitive || value is decimal or char) return value.ToString() ?? ""; + if (type.IsEnum) return value.ToString() ?? ""; + + try + { + return JsonSerializer.Serialize(value); + } + catch + { + return value.ToString() ?? ""; + } + } private record LogLine(string Kind, string Message, string Timestamp); } From 24850e9a8ad7762f29879c23ed2355c08e8f49a0 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Thu, 4 Jun 2026 12:45:13 +0330 Subject: [PATCH 03/10] improve page titles --- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ClipboardPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ConsolePage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CookiePage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CryptoPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/DocumentPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EObserversPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ElementPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/HistoryPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/Index.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/LocationPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NavigatorPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/NotificationPage.razor | 2 +- .../Demo/Bit.Butil.Demo.Core/Pages/ScreenOrientationPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ScreenPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/StoragePage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor | 2 +- .../Demo/Bit.Butil.Demo.Core/Pages/VisualViewportPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WebAuthnPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Pages/WindowPage.razor | 2 +- src/Butil/Demo/Bit.Butil.Demo.Core/Routes.razor | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ClipboardPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ClipboardPage.razor index 0a7224fad8..cd147a6481 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ClipboardPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ClipboardPage.razor @@ -1,7 +1,7 @@ @page "/clipboard" @inject Bit.Butil.Clipboard clipboard -Clipboard Samples +Butil - Clipboard Samples Console Samples +Butil - Console Samples Cookie Samples +Butil - Cookie Samples Crypto Samples +Butil - Crypto Samples Document Samples +Butil - Document Samples Butil E2E Observers +Butil - E2E Observers

Butil E2E Observers

diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EPage.razor index b1b060d260..c8b84a846f 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/E2EPage.razor @@ -15,7 +15,7 @@ avoids APIs that trigger permission prompts so the suite can run headless without flakes. *@ -Butil E2E Harness +Butil - E2E Harness

Butil E2E

diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ElementPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ElementPage.razor index ce441bd918..56c9bcdbe3 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ElementPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ElementPage.razor @@ -1,6 +1,6 @@ @page "/element" -Element Samples +Butil - Element Samples History Samples +Butil - History Samples Bit.Butil Demo +Butil - Demo
B diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor index 83507244dd..cc193753d4 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor @@ -2,7 +2,7 @@ @implements IAsyncDisposable @inject Bit.Butil.Keyboard keyboard -Keyboard Samples +Butil - Keyboard Samples Location Samples +Butil - Location Samples Navigator Samples +Butil - Navigator Samples Notification Samples +Butil - Notification Samples ScreenOrientation Samples +Butil - ScreenOrientation Samples Screen Samples +Butil - Screen Samples Storage Samples +Butil - Storage Samples UserAgent Samples +Butil - UserAgent Samples VisualViewport Samples +Butil - VisualViewport Samples WebAuthn Samples +Butil - WebAuthn Samples Window Samples +Butil - Window Samples - Not found + Butil - Not found

Sorry, there's nothing at this address.

From 8ef7bcc171c23b415fd7597ade2026c78bf46847 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Thu, 4 Jun 2026 13:51:06 +0330 Subject: [PATCH 04/10] resolve review comments II --- src/Butil/Bit.Butil/Publics/Battery/BatteryStatus.cs | 8 ++++---- src/Butil/Bit.Butil/Publics/Fetch.cs | 6 +++--- src/Butil/Bit.Butil/Publics/FileReader.cs | 6 +++--- src/Butil/Bit.Butil/Publics/ObjectUrls.cs | 4 ++-- src/Butil/Bit.Butil/Scripts/battery.ts | 10 +++++++--- .../Demo/Bit.Butil.Demo.Core/Shared/MainLayout.razor | 2 +- .../Demo/Bit.Butil.Demo.Core/Shared/PageHeader.razor | 2 +- 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/Butil/Bit.Butil/Publics/Battery/BatteryStatus.cs b/src/Butil/Bit.Butil/Publics/Battery/BatteryStatus.cs index a2767ed7d2..54959328f2 100644 --- a/src/Butil/Bit.Butil/Publics/Battery/BatteryStatus.cs +++ b/src/Butil/Bit.Butil/Publics/Battery/BatteryStatus.cs @@ -8,11 +8,11 @@ public class BatteryStatus /// True if the device is currently charging. public bool Charging { get; set; } - /// Seconds remaining until fully charged. when unknown. - public double ChargingTime { get; set; } + /// Seconds remaining until fully charged, or when unknown. + public double? ChargingTime { get; set; } - /// Seconds remaining until discharged. when unknown. - public double DischargingTime { get; set; } + /// Seconds remaining until discharged, or when unknown. + public double? DischargingTime { get; set; } /// Battery level, in [0, 1]. public double Level { get; set; } diff --git a/src/Butil/Bit.Butil/Publics/Fetch.cs b/src/Butil/Bit.Butil/Publics/Fetch.cs index edb73ab443..c6050b6216 100644 --- a/src/Butil/Bit.Butil/Publics/Fetch.cs +++ b/src/Butil/Bit.Butil/Publics/Fetch.cs @@ -55,9 +55,9 @@ public async Task Send(FetchRequest request, } /// - /// Starts the request and immediately returns an . Await - /// won't give you the response — use this when you only - /// need fire-and-forget abort control. For typical use prefer . + /// Starts the request and immediately returns an abort handle. + /// This does not return the response payload — use for that. Prefer + /// unless you only need fire-and-forget abort control. /// [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchRequest))] public async Task Start(FetchRequest request) diff --git a/src/Butil/Bit.Butil/Publics/FileReader.cs b/src/Butil/Bit.Butil/Publics/FileReader.cs index 65d93f0be5..36c6a2b3f9 100644 --- a/src/Butil/Bit.Butil/Publics/FileReader.cs +++ b/src/Butil/Bit.Butil/Publics/FileReader.cs @@ -24,9 +24,9 @@ public class FileReader(IJSRuntime js) public ValueTask GetFileInfos(ElementReference inputElement) => js.Invoke("BitButil.fileReader.getFileInfos", inputElement); - /// Reads a single file as raw bytes. - public ValueTask ReadAsBytes(ElementReference inputElement, int index = 0) - => js.Invoke("BitButil.fileReader.readAsBytes", inputElement, index); + /// Reads a single file as raw bytes, or when no file exists at . + public ValueTask ReadAsBytes(ElementReference inputElement, int index = 0) + => js.Invoke("BitButil.fileReader.readAsBytes", inputElement, index); /// Reads a single file as UTF-8 text. Pass a different when the source is non-UTF-8. public ValueTask ReadAsText(ElementReference inputElement, int index = 0, string encoding = "utf-8") diff --git a/src/Butil/Bit.Butil/Publics/ObjectUrls.cs b/src/Butil/Bit.Butil/Publics/ObjectUrls.cs index 15a04f4bfd..656d6f250f 100644 --- a/src/Butil/Bit.Butil/Publics/ObjectUrls.cs +++ b/src/Butil/Bit.Butil/Publics/ObjectUrls.cs @@ -10,8 +10,8 @@ namespace Bit.Butil; ///
/// /// Object URLs leak memory if not revoked. The instance tracks every URL it creates so -/// disposal automatically revokes outstanding ones. Use when you want -/// the URL to outlive disposal and call yourself. +/// disposal automatically revokes outstanding ones. Call +/// with track: false when you want the URL to outlive disposal and call yourself. /// public class ObjectUrls(IJSRuntime js) : IAsyncDisposable { diff --git a/src/Butil/Bit.Butil/Scripts/battery.ts b/src/Butil/Bit.Butil/Scripts/battery.ts index cc3fa37468..d2b958ff48 100644 --- a/src/Butil/Bit.Butil/Scripts/battery.ts +++ b/src/Butil/Bit.Butil/Scripts/battery.ts @@ -1,18 +1,22 @@ var BitButil = BitButil || {}; (function (butil: any) { + function batterySeconds(value: number) { + return Number.isFinite(value) ? value : null; + } + butil.battery = { isSupported() { return typeof (window.navigator as any).getBattery === 'function'; }, async getStatus() { const nav = window.navigator as any; if (typeof nav.getBattery !== 'function') { - return { charging: true, chargingTime: 0, dischargingTime: Infinity, level: 1 }; + return { charging: true, chargingTime: 0, dischargingTime: null, level: 1 }; } const b = await nav.getBattery(); return { charging: !!b.charging, - chargingTime: b.chargingTime, - dischargingTime: b.dischargingTime, + chargingTime: batterySeconds(b.chargingTime), + dischargingTime: batterySeconds(b.dischargingTime), level: b.level }; } diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/MainLayout.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/MainLayout.razor index 7c7ac1cd35..6656b96d73 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/MainLayout.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/MainLayout.razor @@ -5,7 +5,7 @@
- + Bit.Butil Demo
diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/PageHeader.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/PageHeader.razor index e3eb268124..54e2f2b3c4 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/PageHeader.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/PageHeader.razor @@ -7,7 +7,7 @@ } @if (!string.IsNullOrWhiteSpace(MdnUrl)) { - + 📖 Read the MDN reference } From 98b719a8c3092bf649c1fc351e38927ac05405c7 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Fri, 5 Jun 2026 22:53:59 +0330 Subject: [PATCH 05/10] resolve review comments III --- src/Butil/Bit.Butil/Publics/MediaDevices.cs | 6 +++--- src/Butil/Bit.Butil/Publics/MediaDevices/MediaDeviceInfo.cs | 2 +- src/Butil/Bit.Butil/Publics/Permissions.cs | 4 ++-- .../Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Butil/Bit.Butil/Publics/MediaDevices.cs b/src/Butil/Bit.Butil/Publics/MediaDevices.cs index 46041ccab6..5891ee3578 100644 --- a/src/Butil/Bit.Butil/Publics/MediaDevices.cs +++ b/src/Butil/Bit.Butil/Publics/MediaDevices.cs @@ -17,9 +17,9 @@ public class MediaDevices(IJSRuntime js) /// Lists all input/output media devices. Labels may be empty strings until the user has /// granted permission to a matching input. ///
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaDeviceInfoItem))] - public ValueTask EnumerateDevices() - => js.Invoke("BitButil.mediaDevices.enumerate"); + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaDeviceInfo))] + public ValueTask EnumerateDevices() + => js.Invoke("BitButil.mediaDevices.enumerate"); /// /// Requests audio and/or video access from the user. Returns a diff --git a/src/Butil/Bit.Butil/Publics/MediaDevices/MediaDeviceInfo.cs b/src/Butil/Bit.Butil/Publics/MediaDevices/MediaDeviceInfo.cs index 88fbf653bc..2c0756a668 100644 --- a/src/Butil/Bit.Butil/Publics/MediaDevices/MediaDeviceInfo.cs +++ b/src/Butil/Bit.Butil/Publics/MediaDevices/MediaDeviceInfo.cs @@ -3,7 +3,7 @@ namespace Bit.Butil; /// /// Mirrors MediaDeviceInfo. /// -public class MediaDeviceInfoItem +public class MediaDeviceInfo { public string DeviceId { get; set; } = string.Empty; diff --git a/src/Butil/Bit.Butil/Publics/Permissions.cs b/src/Butil/Bit.Butil/Publics/Permissions.cs index 3299a39581..9b1434ac23 100644 --- a/src/Butil/Bit.Butil/Publics/Permissions.cs +++ b/src/Butil/Bit.Butil/Publics/Permissions.cs @@ -9,8 +9,8 @@ namespace Bit.Butil; public class Permissions(IJSRuntime js) { /// True when the runtime exposes navigator.permissions. - public async ValueTask IsSupported() - => await js.Invoke("BitButil.permissions.isSupported"); + public ValueTask IsSupported() + => js.Invoke("BitButil.permissions.isSupported"); /// /// Returns the current state for a given permission descriptor name. diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor index 7085581c55..308d305493 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor @@ -12,7 +12,7 @@ @@inject Bit.Butil.UserAgent userAgent var props = await userAgent.Extract(); // current browser -var props = await userAgent.Extract(uaString); // a specific string +var props2 = await userAgent.Extract(uaString); // a specific string Date: Sat, 6 Jun 2026 21:34:28 +0330 Subject: [PATCH 06/10] add missing demos --- .../Pages/CookiePage.razor | 56 ++++ .../Pages/CryptoPage.razor | 61 ++++ .../Pages/DevicePage.razor | 166 +++++++++++ .../Bit.Butil.Demo.Core/Pages/FetchPage.razor | 141 ++++++++++ .../Bit.Butil.Demo.Core/Pages/FilesPage.razor | 156 +++++++++++ .../Pages/GeolocationPage.razor | 93 ++++++ .../Bit.Butil.Demo.Core/Pages/Index.razor | 8 + .../Pages/MediaDevicesPage.razor | 127 +++++++++ .../Pages/ObserversPage.razor | 265 ++++++++++++++++++ .../Pages/PermissionsPage.razor | 81 ++++++ .../Pages/SpeechPage.razor | 176 ++++++++++++ .../Pages/UserAgentPage.razor | 56 +++- .../Pages/VisualViewportPage.razor | 56 ++++ .../Bit.Butil.Demo.Core/Shared/NavMenu.razor | 8 + .../Demo/Bit.Butil.Demo.Core/_Imports.razor | 1 + 15 files changed, 1443 insertions(+), 8 deletions(-) create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/DevicePage.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/FetchPage.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/FilesPage.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/GeolocationPage.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/MediaDevicesPage.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ObserversPage.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/PermissionsPage.razor create mode 100644 src/Butil/Demo/Bit.Butil.Demo.Core/Pages/SpeechPage.razor diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CookiePage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CookiePage.razor index cc35a95127..94dd7c3cbb 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CookiePage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CookiePage.razor @@ -1,5 +1,6 @@ @page "/cookie" @inject Bit.Butil.Cookie cookie +@inject Bit.Butil.CookieStore cookieStore Butil - Cookie Samples @@ -50,6 +51,27 @@ await cookie.Remove("theme"); + + +
+ + +
+
+ Name + +
+
+ Value + +
+
+ + + +
+
@@ -60,6 +82,8 @@ await cookie.Remove("theme"); private string setCookieName = ""; private string setCookieValue = ""; private string removeCookieName = ""; + private string _csName = "butil-demo"; + private string _csValue = "from-cookie-store"; private async Task GetAllCookies() { @@ -94,4 +118,36 @@ await cookie.Remove("theme"); await cookie.Remove(removeCookieName); await output.Log($"Removed \"{removeCookieName}\"."); } + + private async Task CookieStoreSupported() + { + await output.Success("CookieStore.IsSupported →", await cookieStore.IsSupported()); + } + + private async Task CookieStoreGetAll() + { + var all = await cookieStore.GetAll(); + await output.Success("CookieStore.GetAll →", all.Length == 0 ? "(none)" : all); + } + + private async Task CookieStoreSet() + { + await cookieStore.Set(new CookieStoreItem { Name = _csName, Value = _csValue, SameSite = "lax" }); + await output.Log($"CookieStore.Set(\"{_csName}\")"); + } + + private async Task CookieStoreGet() + { + var item = await cookieStore.Get(_csName); + if (item is null) + await output.Warn($"CookieStore.Get(\"{_csName}\") → not found"); + else + await output.Success($"CookieStore.Get(\"{_csName}\") →", item); + } + + private async Task CookieStoreDelete() + { + await cookieStore.Delete(_csName); + await output.Log($"CookieStore.Delete(\"{_csName}\")"); + } } diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CryptoPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CryptoPage.razor index 9ad86069c8..184bf746ed 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CryptoPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/CryptoPage.razor @@ -39,6 +39,26 @@ var plain = await crypto.Decrypt(CryptoAlgorithm.AesCbc, key, cipher, iv: iv);

} + + +
+ + +
+
+ + +
+ Message + +
+
+ + +
+
@@ -51,6 +71,7 @@ var plain = await crypto.Decrypt(CryptoAlgorithm.AesCbc, key, cipher, iv: iv); private byte[] encryptedBytes; private string cipherHex; private string outputText; + private string _hashInput = "Bit.Butil"; protected override void OnInitialized() { @@ -86,4 +107,44 @@ var plain = await crypto.Decrypt(CryptoAlgorithm.AesCbc, key, cipher, iv: iv); outputText = System.Text.Encoding.UTF8.GetString(decryptedBytes); await output.Success("Decrypted →", outputText); } + + private async Task RandomUuid() + { + var uuid = await crypto.RandomUuid(); + await output.Success("RandomUuid →", uuid); + } + + private async Task RandomBytes() + { + var bytes = await crypto.GetRandomValues(16); + await output.Success("GetRandomValues →", BitConverter.ToString(bytes)); + } + + private async Task DigestSha256() + { + if (string.IsNullOrEmpty(_hashInput)) + { + await output.Warn("Enter a message to hash."); + return; + } + + var data = System.Text.Encoding.UTF8.GetBytes(_hashInput); + var hash = await crypto.Digest(CryptoKeyHash.Sha256, data); + await output.Success("Digest (SHA-256) →", BitConverter.ToString(hash)); + } + + private async Task HmacSignVerify() + { + if (string.IsNullOrEmpty(_hashInput)) + { + await output.Warn("Enter a message to sign."); + return; + } + + var data = System.Text.Encoding.UTF8.GetBytes(_hashInput); + var key = await crypto.GenerateHmacKey(CryptoKeyHash.Sha256); + var signature = await crypto.SignHmac(CryptoKeyHash.Sha256, key, data); + var valid = await crypto.VerifyHmac(CryptoKeyHash.Sha256, key, signature, data); + await output.Success("HMAC verify →", valid); + } } diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/DevicePage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/DevicePage.razor new file mode 100644 index 0000000000..f552749b73 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/DevicePage.razor @@ -0,0 +1,166 @@ +@page "/device" +@implements IAsyncDisposable +@inject Bit.Butil.Battery battery +@inject Bit.Butil.NetworkInformation networkInformation +@inject Bit.Butil.WakeLock wakeLock +@inject Bit.Butil.IdleDetector idleDetector + +Butil - Device Sensors Samples + + + +
+@@inject Bit.Butil.Battery battery
+@@inject Bit.Butil.NetworkInformation networkInformation
+@@inject Bit.Butil.WakeLock wakeLock
+@@inject Bit.Butil.IdleDetector idleDetector
+
+var level = await battery.GetStatus();
+var net   = await networkInformation.GetStatus();
+
+ +
+ +
+ + +
+
+ + + + + + +
+ + + +
+
+ + +
+ Threshold (seconds, min 60) + +
+
+ + + +
+
+
+ + + +@code { + private DemoConsole output = default!; + private int _idleThreshold = 60; + private ButilSubscription? _idleSub; + private IAsyncDisposable? _persistentWakeLock; + + private async Task BatterySupported() + { + await output.Success("Battery.IsSupported →", await battery.IsSupported()); + } + + private async Task BatteryStatus() + { + var status = await battery.GetStatus(); + await output.Success("Battery →", + $"level={status.Level:P0}, charging={status.Charging}"); + } + + private async Task NetworkStatus() + { + var status = await networkInformation.GetStatus(); + await output.Success("Network →", + $"online={status.Online}, type={status.EffectiveType ?? status.Type ?? "n/a"}, downlink={status.Downlink}, rtt={status.Rtt}"); + } + + private async Task AcquireWakeLock() + { + if (!await wakeLock.IsSupported()) + { + await output.Warn("Wake lock is not supported."); + return; + } + + var ok = await wakeLock.Request(); + await output.Log(ok ? "Wake lock acquired." : "Wake lock request failed."); + } + + private async Task ReleaseWakeLock() + { + await wakeLock.Release(); + await output.Log("Wake lock released."); + } + + private async Task AcquirePersistentWakeLock() + { + if (!await wakeLock.IsSupported()) + { + await output.Warn("Wake lock is not supported."); + return; + } + + if (_persistentWakeLock is not null) + await _persistentWakeLock.DisposeAsync(); + + _persistentWakeLock = await wakeLock.RequestPersistent(); + await output.Log("Persistent wake lock started (auto re-acquire on visibility)."); + } + + private async Task RequestIdlePermission() + { + if (!await idleDetector.IsSupported()) + { + await output.Warn("IdleDetector is not supported."); + return; + } + + var state = await idleDetector.RequestPermission(); + await output.Success("RequestPermission →", state); + } + + private async Task StartIdleWatch() + { + await StopIdleWatch(); + + if (!await idleDetector.IsSupported()) + { + await output.Warn("IdleDetector is not supported."); + return; + } + + _idleSub = await idleDetector.Start(_idleThreshold, state => + _ = output.Log($"Idle → user={state.UserState}, screen={state.ScreenState}")); + + await output.Log("Idle watch started."); + } + + private async Task StopIdleWatch() + { + if (_idleSub is null) return; + await _idleSub.DisposeAsync(); + _idleSub = null; + await output.Log("Idle watch stopped."); + } + + public async ValueTask DisposeAsync() + { + if (_idleSub is not null) + await _idleSub.DisposeAsync(); + if (_persistentWakeLock is not null) + await _persistentWakeLock.DisposeAsync(); + await wakeLock.DisposeAsync(); + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/FetchPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/FetchPage.razor new file mode 100644 index 0000000000..849a06cc34 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/FetchPage.razor @@ -0,0 +1,141 @@ +@page "/fetch" +@inject Bit.Butil.Fetch fetch + +Butil - Fetch Samples + + + +
+@@inject Bit.Butil.Fetch fetch
+
+var response = await fetch.Send(new FetchRequest { Url = "https://…" },
+    onProgress: p => { /* bytes received */ });
+
+ +
+ +
+ URL + +
+ + @if (_lastBodyPreview is not null) + { +
@_lastBodyPreview
+ } +
+ + +
+ + +
+ @if (_progressText is not null) + { +

@_progressText

+ } +
+
+ + + +@code { + private DemoConsole output = default!; + + private string _url = "https://jsonplaceholder.typicode.com/todos/1"; + private string? _lastBodyPreview; + private string? _progressText; + private bool _busy; + private CancellationTokenSource? _cts; + + private async Task SendGet() + { + if (string.IsNullOrWhiteSpace(_url)) + { + await output.Warn("Enter a URL first."); + return; + } + + _busy = true; + _lastBodyPreview = null; + try + { + var response = await fetch.Send(new FetchRequest { Url = _url.Trim() }); + await LogResponse(response); + _lastBodyPreview = PreviewBody(response.Body); + } + catch (Exception ex) + { + await output.Error(ex.Message); + } + finally + { + _busy = false; + } + } + + private async Task SendWithProgress() + { + _busy = true; + _progressText = null; + _cts = new CancellationTokenSource(); + + try + { + var response = await fetch.Send( + new FetchRequest { Url = "https://jsonplaceholder.typicode.com/photos" }, + onProgress: p => + { + _progressText = p.Total.HasValue + ? $"Received {p.Loaded} / {p.Total} bytes" + : $"Received {p.Loaded} bytes"; + InvokeAsync(StateHasChanged); + }, + cancellationToken: _cts.Token); + + await LogResponse(response); + _progressText = response.Aborted ? "Aborted" : "Complete"; + } + catch (OperationCanceledException) + { + await output.Warn("Request cancelled."); + _progressText = "Cancelled"; + } + catch (Exception ex) + { + await output.Error(ex.Message); + } + finally + { + _cts.Dispose(); + _cts = null; + _busy = false; + } + } + + private void AbortRequest() => _cts?.Cancel(); + + private async Task LogResponse(FetchResponse response) + { + if (response.Error is not null) + { + await output.Error(response.Error); + return; + } + + await output.Success($"Status {response.Status} {response.StatusText} →", + $"{response.Body.Length} bytes from {response.Url}"); + } + + private static string PreviewBody(byte[] body) + { + if (body.Length == 0) return "(empty body)"; + var text = System.Text.Encoding.UTF8.GetString(body); + return text.Length > 500 ? text[..500] + "…" : text; + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/FilesPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/FilesPage.razor new file mode 100644 index 0000000000..e6fa43da2d --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/FilesPage.razor @@ -0,0 +1,156 @@ +@page "/files" +@inject Bit.Butil.FileReader fileReader +@inject Bit.Butil.ObjectUrls objectUrls + +Butil - FileReader & ObjectUrls Samples + + + +
+@@inject Bit.Butil.FileReader fileReader
+@@inject Bit.Butil.ObjectUrls objectUrls
+
+var text = await fileReader.ReadAsText(inputElement);
+var url  = await objectUrls.Create(bytes, "image/png");
+
+ +
+ +
+ File input + +
+
+ + + + + +
+
+ + +
+ Text to blob + +
+
+ + +
+ @if (_objectUrl is not null) + { +

+ Open blob URL +

+ } + @if (_imagePreviewUrl is not null) + { + Preview + } +
+
+ + + +@code { + private DemoConsole output = default!; + private ElementReference _fileInput; + + private string _blobText = "Hello from Bit.Butil ObjectUrls!"; + private string? _objectUrl; + private string? _imagePreviewUrl; + + private async Task OnFilesChanged() => await ReadInfo(); + + private async Task ReadInfo() + { + var infos = await fileReader.GetFileInfos(_fileInput); + if (infos.Length == 0) + { + await output.Warn("No file selected."); + return; + } + + foreach (var info in infos) + { + await output.Success($"{info.Name} →", $"{info.Size} bytes, {info.Type}"); + } + } + + private async Task ReadText() + { + var text = await fileReader.ReadAsText(_fileInput); + if (string.IsNullOrEmpty(text)) + { + await output.Warn("No file or empty content."); + return; + } + + var preview = text.Length > 300 ? text[..300] + "…" : text; + await output.Success("ReadAsText →", preview); + } + + private async Task ReadBytes() + { + var bytes = await fileReader.ReadAsBytes(_fileInput); + if (bytes is null) + { + await output.Warn("No file selected."); + return; + } + + await output.Success("ReadAsBytes →", $"{bytes.Length} bytes"); + } + + private async Task ReadDataUrl() + { + var dataUrl = await fileReader.ReadAsDataUrl(_fileInput); + if (string.IsNullOrEmpty(dataUrl)) + { + await output.Warn("No file selected."); + return; + } + + _imagePreviewUrl = dataUrl.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase) + ? dataUrl + : null; + + var preview = dataUrl.Length > 120 ? dataUrl[..120] + "…" : dataUrl; + await output.Success("ReadAsDataUrl →", preview); + } + + private async Task ClearInput() + { + await fileReader.Clear(_fileInput); + _imagePreviewUrl = null; + await output.Log("Input cleared."); + } + + private async Task CreateObjectUrl() + { + await RevokeObjectUrlInternal(); + + var bytes = System.Text.Encoding.UTF8.GetBytes(_blobText); + _objectUrl = await objectUrls.Create(bytes, "text/plain"); + await output.Success("Create →", _objectUrl); + } + + private async Task RevokeObjectUrl() + { + await RevokeObjectUrlInternal(); + await output.Log("Object URL revoked."); + } + + private async Task RevokeObjectUrlInternal() + { + if (_objectUrl is null) return; + await objectUrls.Revoke(_objectUrl); + _objectUrl = null; + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/GeolocationPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/GeolocationPage.razor new file mode 100644 index 0000000000..7c485bd815 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/GeolocationPage.razor @@ -0,0 +1,93 @@ +@page "/geolocation" +@implements IAsyncDisposable +@inject Bit.Butil.Geolocation geolocation + +Butil - Geolocation Samples + + + +
+@@inject Bit.Butil.Geolocation geolocation
+
+var pos = await geolocation.GetCurrentPosition();
+await using var sub = await geolocation.SubscribeWatch(p => { /* … */ });
+
+ +
+ + + + + + + + + +
+ + +
+
+
+ + + +@code { + private DemoConsole output = default!; + private ButilSubscription? _watchSub; + + private async Task CheckSupported() + { + await output.Success("IsSupported →", await geolocation.IsSupported()); + } + + private async Task GetCurrent() + { + try + { + var pos = await geolocation.GetCurrentPosition(); + await LogPosition("GetCurrentPosition", pos); + } + catch (GeolocationException ex) + { + await output.Error(ex.Code, "→", ex.Message); + } + } + + private async Task StartWatch() + { + await StopWatch(); + + _watchSub = await geolocation.SubscribeWatch( + onPosition: pos => _ = LogPosition("Watch", pos), + onError: ex => _ = output.Error(ex.Code, "→", ex.Message)); + + await output.Log("Watch started."); + } + + private async Task StopWatch() + { + if (_watchSub is null) return; + await _watchSub.DisposeAsync(); + _watchSub = null; + await output.Log("Watch stopped."); + } + + private async Task LogPosition(string source, GeolocationPosition pos) + { + var c = pos.Coords; + await output.Success($"{source} →", + $"lat={c.Latitude:F6}, lng={c.Longitude:F6}, accuracy={c.Accuracy:F1}m"); + } + + public async ValueTask DisposeAsync() + { + await geolocation.DisposeAsync(); + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/Index.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/Index.razor index b1b3676029..f37b6c1481 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/Index.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/Index.razor @@ -32,12 +32,20 @@ new("Cookie", "cookie", "🍪", "Get, set and remove cookies"), new("Storage", "storage", "💾", "Local & session storage"), new("Crypto", "crypto", "🔐", "Encrypt, hash & random values"), + new("Fetch", "fetch", "🌐", "HTTP fetch with progress & abort"), + new("Files", "files", "📁", "FileReader & object URLs"), + new("Speech", "speech", "🗣️", "Text-to-speech & recognition"), new("Console", "console", "🖨️", "Log, group, time & trace"), new("Document", "document", "📄", "Title, dir, events & more"), new("Element", "element", "🧱", "Attributes, scroll & layout"), new("Window", "window", "🪟", "Alerts, scroll, base64 & find"), + new("Observers", "observers", "👁️", "Intersection, resize & mutation"), new("Navigator", "navigator", "🧭", "Device, share & vibrate"), new("UserAgent", "userAgent", "🪪", "Parse the user-agent string"), + new("Permissions", "permissions", "✅", "Query browser permission state"), + new("MediaDevices", "mediaDevices", "🎥", "Cameras, mics & getUserMedia"), + new("Geolocation", "geolocation", "📡", "Current position & watch"), + new("Device Sensors", "device", "🔋", "Battery, network & wake lock"), new("Screen", "screen", "🖥️", "Screen size & color depth"), new("ScreenOrientation", "screenOrientation", "🔄", "Read & lock orientation"), new("VisualViewport", "visualViewport", "🔍", "Viewport offset & scale"), diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/MediaDevicesPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/MediaDevicesPage.razor new file mode 100644 index 0000000000..b6e5347245 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/MediaDevicesPage.razor @@ -0,0 +1,127 @@ +@page "/mediaDevices" +@implements IAsyncDisposable +@inject Bit.Butil.MediaDevices mediaDevices + +Butil - MediaDevices Samples + + + +
+@@inject Bit.Butil.MediaDevices mediaDevices
+
+var devices = await mediaDevices.EnumerateDevices();
+var stream  = await mediaDevices.GetUserMedia(video: true);
+await stream.AttachTo(videoElement);
+
+ +
+ +
+ + +
+
+ + +
+ + +
+
+ + + +
+ +
+
+ + + +@code { + private DemoConsole output = default!; + private ElementReference _preview; + private MediaStreamHandle? _stream; + private bool _requestAudio = true; + private bool _requestVideo = true; + private bool _enabled = true; + + private async Task CheckSupported() + { + await output.Success("IsSupported →", await mediaDevices.IsSupported()); + } + + private async Task Enumerate() + { + var devices = await mediaDevices.EnumerateDevices(); + if (devices.Length == 0) + { + await output.Warn("No media devices reported."); + return; + } + + foreach (var device in devices) + { + var label = string.IsNullOrEmpty(device.Label) + ? device.DeviceId[..Math.Min(8, device.DeviceId.Length)] + "…" + : device.Label; + await output.Log($"{device.Kind}: {label}"); + } + } + + private async Task StartStream() + { + await StopStream(); + + if (!_requestAudio && !_requestVideo) + { + await output.Warn("Enable at least audio or video."); + return; + } + + _stream = await mediaDevices.GetUserMedia(_requestAudio, _requestVideo); + if (_stream is null) + { + await output.Warn("GetUserMedia returned null — permission denied or no device available."); + return; + } + + await _stream.AttachTo(_preview); + _enabled = true; + await output.Success("Stream started →", _stream.Id); + await Enumerate(); + } + + private async Task ToggleEnabled() + { + if (_stream is null) + { + await output.Warn("Start a stream first."); + return; + } + + _enabled = !_enabled; + await _stream.SetEnabled(_enabled); + await output.Log($"Tracks enabled → {_enabled}"); + } + + private async Task StopStream() + { + if (_stream is null) return; + await _stream.DisposeAsync(); + _stream = null; + await output.Log("Stream stopped."); + } + + public async ValueTask DisposeAsync() + { + if (_stream is not null) + await _stream.DisposeAsync(); + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ObserversPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ObserversPage.razor new file mode 100644 index 0000000000..c799f92843 --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/ObserversPage.razor @@ -0,0 +1,265 @@ +@page "/observers" +@implements IAsyncDisposable +@inject Bit.Butil.Performance performance +@inject Bit.Butil.StorageManager storageManager +@inject Bit.Butil.NetworkInformation networkInformation +@inject Bit.Butil.BroadcastChannel broadcastChannel +@inject Bit.Butil.IndexedDb indexedDb +@inject IJSRuntime js + +Butil - Observers & Async APIs Samples + + + +
+await using var sub = await element.ObserveIntersection(js, entries => { /* … */ });
+await using var db  = await indexedDb.Open("my-db", stores: [ … ]);
+
+ +
+ +
+ target +
+
+ + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + + +
+
+ + + + +
+ + + +@code { + private DemoConsole output = default!; + private ElementReference _target; + private int _targetWidth = 120; + + private ButilSubscription? _intersectionSub; + private ButilSubscription? _resizeSub; + private ButilSubscription? _mutationSub; + private ButilSubscription? _perfObserverSub; + private ButilSubscription? _broadcastSub; + + private async Task StartIntersection() + { + await StopIntersection(); + _intersectionSub = await _target.ObserveIntersection(js, entries => + { + if (entries.Length > 0) + _ = output.Log($"Intersection → ratio={entries[0].IntersectionRatio:F2}, visible={entries[0].IsIntersecting}"); + }); + await output.Log("Intersection observer started."); + } + + private async Task StopIntersection() + { + if (_intersectionSub is null) return; + await _intersectionSub.DisposeAsync(); + _intersectionSub = null; + } + + private async Task StartResize() + { + await StopResize(); + _resizeSub = await _target.ObserveResize(js, entries => + { + if (entries.Length > 0) + { + var rect = entries[0].ContentRect; + _ = output.Log($"Resize → {rect.Width:F0}×{rect.Height:F0}"); + } + }); + await output.Log("Resize observer started."); + } + + private async Task TriggerResize() + { + _targetWidth += 40; + await _target.SetAttribute("style", + $"width:{_targetWidth}px;height:48px;border:2px solid var(--accent,#6366f1);border-radius:6px;display:flex;align-items:center;justify-content:center"); + await output.Log($"Target width → {_targetWidth}px"); + } + + private async Task StopResize() + { + if (_resizeSub is null) return; + await _resizeSub.DisposeAsync(); + _resizeSub = null; + } + + private async Task StartMutation() + { + await StopMutation(); + _mutationSub = await _target.ObserveMutations(js, records => + { + foreach (var record in records) + _ = output.Log($"Mutation → {record.Type} on {record.TargetId ?? "target"}"); + }, new MutationObserverOptions { Attributes = true }); + await output.Log("Mutation observer started."); + } + + private async Task MutateTarget() + { + await _target.SetAttribute("data-butil", Guid.NewGuid().ToString("N")[..8]); + } + + private async Task StopMutation() + { + if (_mutationSub is null) return; + await _mutationSub.DisposeAsync(); + _mutationSub = null; + } + + private async Task PerfMarkMeasure() + { + await performance.Mark("butil-demo-a"); + await Task.Delay(50); + await performance.Mark("butil-demo-b"); + await performance.Measure("butil-demo-measure", "butil-demo-a", "butil-demo-b"); + var entries = await performance.GetEntries("butil-demo-measure", "measure"); + await output.Success("Measure →", entries.Length > 0 ? entries[0].ToString() : "(no entry)"); + } + + private async Task PerfObserver() + { + await StopPerfObserver(); + _perfObserverSub = await performance.SubscribeObserver(["mark"], entries => + { + if (entries.Length > 0) + _ = output.Log($"PerfObserver → {entries[0]}"); + }, buffered: false); + + await performance.Mark("butil-demo-observed"); + await output.Log("PerformanceObserver subscribed — mark created."); + } + + private async Task StopPerfObserver() + { + if (_perfObserverSub is null) return; + await _perfObserverSub.DisposeAsync(); + _perfObserverSub = null; + } + + private async Task StorageEstimate() + { + var est = await storageManager.Estimate(); + await output.Success("Storage estimate →", + $"quota={est.Quota}, usage={est.Usage}"); + } + + private async Task NetworkStatus() + { + var status = await networkInformation.GetStatus(); + await output.Success("Network →", status); + } + + private async Task BroadcastSubscribe() + { + await StopBroadcast(); + _broadcastSub = await broadcastChannel.Subscribe("butil-demo-channel", msg => + _ = output.Success("Broadcast received →", msg.ToString())); + await output.Log("Subscribed to butil-demo-channel."); + } + + private async Task BroadcastPostLocal() + { + await broadcastChannel.Post("butil-demo-channel", new { text = "hello from this tab", at = DateTime.Now }); + await output.Log("Posted (same tab won't receive its own message)."); + } + + private async Task BroadcastPostExternal() + { + await js.InvokeVoidAsync("eval", + "(function(){const c=new BroadcastChannel('butil-demo-channel');c.postMessage({text:'hello from simulated tab'});c.close();})()"); + await output.Log("Posted via simulated second channel."); + } + + private async Task StopBroadcast() + { + if (_broadcastSub is null) return; + await _broadcastSub.DisposeAsync(); + _broadcastSub = null; + } + + private async Task IndexedDbRoundTrip() + { + await using var db = await indexedDb.Open("butil-demo-db", 1, + [ + new IndexedDbStoreSchema { Name = "items", KeyPath = "id" } + ]); + + var item = new DemoIdbItem { Id = "demo-1", Value = "stored at " + DateTime.Now.ToString("HH:mm:ss") }; + await db.Put("items", item); + var read = await db.Get("items", "demo-1"); + await output.Success("IndexedDB round-trip →", read?.Value ?? "(null)"); + } + + public async ValueTask DisposeAsync() + { + if (_intersectionSub is not null) await _intersectionSub.DisposeAsync(); + if (_resizeSub is not null) await _resizeSub.DisposeAsync(); + if (_mutationSub is not null) await _mutationSub.DisposeAsync(); + if (_perfObserverSub is not null) await _perfObserverSub.DisposeAsync(); + if (_broadcastSub is not null) await _broadcastSub.DisposeAsync(); + } + + private class DemoIdbItem + { + public string Id { get; set; } = ""; + public string Value { get; set; } = ""; + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/PermissionsPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/PermissionsPage.razor new file mode 100644 index 0000000000..20ff870e7e --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/PermissionsPage.razor @@ -0,0 +1,81 @@ +@page "/permissions" +@inject Bit.Butil.Permissions permissions + +Butil - Permissions Samples + + + +
+@@inject Bit.Butil.Permissions permissions
+
+var state = await permissions.Query("geolocation");
+
+ +
+ + + + + +
+ Permission name + +
+
+ + +
+
+
+ + + +@code { + private DemoConsole output = default!; + private string _selectedPermission = "geolocation"; + + private static readonly string[] _permissionNames = + [ + "geolocation", + "notifications", + "camera", + "microphone", + "clipboard-read", + "clipboard-write", + "push", + "midi", + "background-sync", + "persistent-storage", + ]; + + private async Task CheckSupported() + { + await output.Success("IsSupported →", await permissions.IsSupported()); + } + + private async Task QuerySelected() + { + var state = await permissions.Query(_selectedPermission); + await output.Success($"Query(\"{_selectedPermission}\") →", state); + } + + private async Task QueryAll() + { + await output.Info("Querying common permissions…"); + foreach (var name in _permissionNames) + { + var state = await permissions.Query(name); + await output.Log($"{name} → {state}"); + } + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/SpeechPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/SpeechPage.razor new file mode 100644 index 0000000000..6dad971fbd --- /dev/null +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/SpeechPage.razor @@ -0,0 +1,176 @@ +@page "/speech" +@implements IAsyncDisposable +@inject Bit.Butil.SpeechSynthesis speechSynthesis +@inject Bit.Butil.SpeechRecognition speechRecognition + +Butil - Speech Samples + + + +
+@@inject Bit.Butil.SpeechSynthesis speechSynthesis
+@@inject Bit.Butil.SpeechRecognition speechRecognition
+
+await speechSynthesis.Speak("Hello from Butil!");
+await using var rec = await speechRecognition.Start(options, onResult: r => { /* … */ });
+
+ +
+ +
+ Text + +
+
+ Voice + +
+
+ + + + + +
+
+ + +
+ + +
+ @if (!string.IsNullOrWhiteSpace(_transcript)) + { +

@_transcript

+ } +
+
+ + + +@code { + private DemoConsole output = default!; + private SpeechVoice[] _voices = []; + private string _speakText = "Hello from Bit.Butil speech synthesis!"; + private string? _voiceName; + private string _transcript = ""; + private bool _recognizing; + private IAsyncDisposable? _recognitionHandle; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + await LoadVoices(); + } + + private async Task LoadVoices() + { + if (!await speechSynthesis.IsSupported()) + { + await output.Warn("Speech synthesis is not supported in this browser."); + return; + } + + _voices = await speechSynthesis.GetVoices(); + await output.Success("Voices →", _voices.Length); + StateHasChanged(); + } + + private async Task Speak() + { + if (string.IsNullOrWhiteSpace(_speakText)) + { + await output.Warn("Enter text to speak."); + return; + } + + await speechSynthesis.Speak(new SpeechUtterance + { + Text = _speakText, + VoiceName = string.IsNullOrWhiteSpace(_voiceName) ? null : _voiceName, + Rate = 1, + Pitch = 1, + }); + await output.Log("Speak queued."); + } + + private async Task PauseSpeech() + { + await speechSynthesis.Pause(); + await output.Log($"Paused (speaking={await speechSynthesis.IsSpeaking()})"); + } + + private async Task ResumeSpeech() + { + await speechSynthesis.Resume(); + await output.Log("Resumed."); + } + + private async Task CancelSpeech() + { + await speechSynthesis.Cancel(); + await output.Log("Cancelled."); + } + + private async Task StartRecognition() + { + if (!await speechRecognition.IsSupported()) + { + await output.Warn("Speech recognition is not supported in this browser."); + return; + } + + await StopRecognitionInternal(); + + _transcript = ""; + _recognizing = true; + + _recognitionHandle = await speechRecognition.Start( + new SpeechRecognitionOptions { Continuous = true, InterimResults = true }, + onResult: result => + { + _transcript = result.IsFinal + ? $"Final: {result.Transcript} ({result.Confidence:P0})" + : $"Interim: {result.Transcript}"; + InvokeAsync(StateHasChanged); + }, + onError: err => _ = output.Error("Recognition error →", err), + onEnd: () => + { + _recognizing = false; + InvokeAsync(StateHasChanged); + }); + + await output.Log("Listening…"); + } + + private async Task StopRecognition() + { + await StopRecognitionInternal(); + await output.Log("Recognition stopped."); + } + + private async Task StopRecognitionInternal() + { + if (_recognitionHandle is null) return; + await _recognitionHandle.DisposeAsync(); + _recognitionHandle = null; + _recognizing = false; + } + + public async ValueTask DisposeAsync() + { + await StopRecognitionInternal(); + } +} diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor index 308d305493..68e3fa66af 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/UserAgentPage.razor @@ -15,14 +15,27 @@ var props = await userAgent.Extract(); // current browser var props2 = await userAgent.Extract(uaString); // a specific string - -
- User-agent string (optional) - -
- -
+
+ +
+ User-agent string (optional) + +
+ +
+ + +
+ + + + + +
+
+
@@ -35,4 +48,31 @@ var props2 = await userAgent.Extract(uaString); // a specific string var props = await userAgent.Extract(userAgentString); await output.Success("UserAgent properties →", props); } + + private async Task ClientHintsSupported() + { + await output.Success("IsClientHintsSupported →", await userAgent.IsClientHintsSupported()); + } + + private async Task GetBrands() + { + var brands = await userAgent.GetBrands(); + await output.Success("GetBrands →", brands.Length == 0 ? "(empty — UA-CH unsupported)" : brands); + } + + private async Task IsMobile() + { + await output.Success("IsMobile →", await userAgent.IsMobile()); + } + + private async Task GetPlatform() + { + await output.Success("GetPlatform →", await userAgent.GetPlatform()); + } + + private async Task GetHighEntropy() + { + var values = await userAgent.GetHighEntropyValues("architecture", "platformVersion", "model", "uaFullVersion"); + await output.Success("GetHighEntropyValues →", values); + } } diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/VisualViewportPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/VisualViewportPage.razor index f544ca9217..2b55b15485 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/VisualViewportPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/VisualViewportPage.razor @@ -41,12 +41,23 @@ var offsetTop = await visualViewport.GetOffsetTop(); + + +
+ + + +
+
@code { private DemoConsole output = default!; + private ButilSubscription? _resizeSub; + private ButilSubscription? _scrollSub; private async Task GetOffsetLeft() { @@ -83,8 +94,53 @@ var offsetTop = await visualViewport.GetOffsetTop(); await output.Success("Scale →", await visualViewport.GetScale()); } + private async Task SubscribeResize() + { + await UnsubscribeResize(); + _resizeSub = await visualViewport.SubscribeResize(async () => + { + var w = await visualViewport.GetWidth(); + var h = await visualViewport.GetHeight(); + await output.Log($"Resize → {w:F0}×{h:F0}, scale={await visualViewport.GetScale():F2}"); + }); + await output.Log("Subscribed to resize."); + } + + private async Task SubscribeScroll() + { + await UnsubscribeScroll(); + _scrollSub = await visualViewport.SubscribeScroll(async () => + { + await output.Log($"Scroll → offset=({await visualViewport.GetOffsetLeft():F0}, {await visualViewport.GetOffsetTop():F0})"); + }); + await output.Log("Subscribed to scroll."); + } + + private async Task UnsubscribeAll() + { + await UnsubscribeResize(); + await UnsubscribeScroll(); + await output.Log("All subscriptions removed."); + } + + private async Task UnsubscribeResize() + { + if (_resizeSub is null) return; + await _resizeSub.DisposeAsync(); + _resizeSub = null; + } + + private async Task UnsubscribeScroll() + { + if (_scrollSub is null) return; + await _scrollSub.DisposeAsync(); + _scrollSub = null; + } + public async ValueTask DisposeAsync() { + await UnsubscribeResize(); + await UnsubscribeScroll(); await visualViewport.DisposeAsync(); } } diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/NavMenu.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/NavMenu.razor index 0047ed9be2..7c747d6dd1 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/NavMenu.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Shared/NavMenu.razor @@ -60,6 +60,10 @@ [ new("Navigator", "navigator", "🧭"), new("UserAgent", "userAgent", "🪪"), + new("Permissions", "permissions", "✅"), + new("MediaDevices", "mediaDevices", "🎥"), + new("Geolocation", "geolocation", "📡"), + new("Device Sensors", "device", "🔋"), new("Screen", "screen", "🖥️"), new("ScreenOrientation", "screenOrientation", "🔄"), new("VisualViewport", "visualViewport", "🔍"), @@ -69,6 +73,7 @@ new("Document", "document", "📄"), new("Element", "element", "🧱"), new("Window", "window", "🪟"), + new("Observers", "observers", "👁️"), ]), new("Navigation", [ @@ -79,6 +84,9 @@ [ new("Console", "console", "🖨️"), new("Crypto", "crypto", "🔐"), + new("Fetch", "fetch", "🌐"), + new("Files", "files", "📁"), + new("Speech", "speech", "🗣️"), new("Keyboard", "keyboard", "⌨️"), new("Notification", "notification", "🔔"), new("WebAuthn", "webauthn", "🔑"), diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/_Imports.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/_Imports.razor index cb7d46743c..5486cfae97 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/_Imports.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/_Imports.razor @@ -3,6 +3,7 @@ @using System.Net.Http.Json @using System.Reflection @using System.Runtime.Loader +@using System.Threading @using Microsoft.JSInterop @using Microsoft.Extensions.Logging @using Microsoft.AspNetCore.Components From 323520e005824a45449dddbe4488335496056c79 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Sat, 6 Jun 2026 22:09:02 +0330 Subject: [PATCH 07/10] resolve review comments IV --- src/Butil/Bit.Butil/Scripts/geolocation.ts | 4 ++-- .../Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor | 9 ++++++--- src/Butil/tests/Bit.Butil.E2ETests/CryptoTests.cs | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Butil/Bit.Butil/Scripts/geolocation.ts b/src/Butil/Bit.Butil/Scripts/geolocation.ts index e4326db2a0..dba9f649cc 100644 --- a/src/Butil/Bit.Butil/Scripts/geolocation.ts +++ b/src/Butil/Bit.Butil/Scripts/geolocation.ts @@ -39,7 +39,7 @@ var BitButil = BitButil || {}; function getCurrentPosition(options: any) { return new Promise(resolve => { if (!('geolocation' in window.navigator)) { - resolve({ position: null, errorCode: 2, errorMessage: 'Geolocation is not supported in this runtime.' }); + resolve({ position: null, errorCode: 0, errorMessage: 'Geolocation is not supported in this runtime.' }); return; } @@ -52,7 +52,7 @@ var BitButil = BitButil || {}; function watchPosition(positionMethod: string, errorMethod: string, listenerId: string, options: any) { if (!('geolocation' in window.navigator)) { - DotNet.invokeMethodAsync('Bit.Butil', errorMethod, listenerId, 2, 'Geolocation is not supported in this runtime.'); + DotNet.invokeMethodAsync('Bit.Butil', errorMethod, listenerId, 0, 'Geolocation is not supported in this runtime.'); return; } diff --git a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor index cc193753d4..1bd4a32ba6 100644 --- a/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor +++ b/src/Butil/Demo/Bit.Butil.Demo.Core/Pages/KeyboardPage.razor @@ -33,17 +33,20 @@ await keyboard.Add(ButilKeyCodes.F10, () => { ... }, ButilModifiers.Alt | ButilM @code { private DemoConsole output = default!; + private Guid _f5Id; + private Guid _f10Id; protected override async Task OnInitializedAsync() { - await keyboard.Add(ButilKeyCodes.F5, () => _ = output.Success("F5 was pressed!")); - await keyboard.Add(ButilKeyCodes.F10, () => _ = output.Success("Ctrl+Alt+F10 was pressed!"), ButilModifiers.Alt | ButilModifiers.Ctrl); + _f5Id = await keyboard.Add(ButilKeyCodes.F5, () => _ = output.Success("F5 was pressed!")); + _f10Id = await keyboard.Add(ButilKeyCodes.F10, () => _ = output.Success("Ctrl+Alt+F10 was pressed!"), ButilModifiers.Alt | ButilModifiers.Ctrl); await base.OnInitializedAsync(); } public async ValueTask DisposeAsync() { - await keyboard.DisposeAsync(); + await keyboard.Remove(_f5Id); + await keyboard.Remove(_f10Id); } } diff --git a/src/Butil/tests/Bit.Butil.E2ETests/CryptoTests.cs b/src/Butil/tests/Bit.Butil.E2ETests/CryptoTests.cs index e75fb17b4f..e3e50801f3 100644 --- a/src/Butil/tests/Bit.Butil.E2ETests/CryptoTests.cs +++ b/src/Butil/tests/Bit.Butil.E2ETests/CryptoTests.cs @@ -15,7 +15,7 @@ public async Task RandomUuid_Returns_Valid_V4_Guid() var status = await CurrentStatusAsync(); var guid = status["crypto:uuid:".Length..]; Assert.That(guid, Has.Length.EqualTo(36)); - Assert.That(Regex.IsMatch(guid, "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")); + Assert.That(Regex.IsMatch(guid, "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$")); } [Test] From 73f0f2e51ae2764dcfa5589802f3164188c0c715 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 8 Jun 2026 22:03:41 +0330 Subject: [PATCH 08/10] resolve local review comments --- src/Butil/Bit.Butil/Bit.Butil.csproj | 27 ++- src/Butil/Bit.Butil/BitButil.cs | 10 +- .../Extensions/InternalJSRuntimeExtensions.cs | 99 ++++++++- .../Extensions/JSRuntimeExtensions.cs | 12 +- .../BroadcastChannelListenersManager.cs | 40 ---- .../DomClipboardEventListenersManager.cs | 44 ---- .../DomCompositionEventListenersManager.cs | 44 ---- .../Events/DomDragEventListenersManager.cs | 44 ---- .../Internals/Events/DomEventDispatcher.cs | 191 ------------------ .../Events/DomEventListenersManager.cs | 49 ----- .../Internals/Events/DomEventsInterop.cs | 152 ++++++++++++++ .../Events/DomFocusEventListenersManager.cs | 44 ---- .../Events/DomInputEventListenersManager.cs | 44 ---- .../DomKeyboardEventListenersManager.cs | 49 ----- .../Events/DomMouseEventListenersManager.cs | 49 ----- .../Events/DomPointerEventListenersManager.cs | 44 ---- .../Events/DomTouchEventListenersManager.cs | 44 ---- .../Events/DomWheelEventListenersManager.cs | 44 ---- .../Fetch/FetchProgressListenersManager.cs | 22 -- .../GeolocationListenersManager.cs | 57 ------ .../History/HistoryListenersManager.cs | 53 ----- .../IdleDetectorListenersManager.cs | 27 --- .../IntersectionObserverInterop.cs | 26 +++ .../IntersectionObserverListenersManager.cs | 27 --- .../Internals/JsInterops/EventsJsInterop.cs | 2 + .../Keyboard/KeyboardListenersManager.cs | 53 ----- .../MutationObserverInterop.cs | 26 +++ .../MutationObserverListenersManager.cs | 27 --- .../Internals/Nfc/NdefListenersManager.cs | 40 ---- .../NotificationListenersManager.cs | 56 ----- .../PerformanceObserverListenersManager.cs | 28 --- .../Reporting/ReportingListenersManager.cs | 27 --- .../ResizeObserver/ResizeObserverInterop.cs | 26 +++ .../ResizeObserverListenersManager.cs | 27 --- .../Screen/ScreenListenersManager.cs | 53 ----- .../ScreenOrientationListenersManager.cs | 53 ----- .../ServiceWorkerListenersManager.cs | 43 ---- .../SpeechRecognitionListenersManager.cs | 48 ----- .../Storage/StorageListenersManager.cs | 47 ----- .../VisualViewportListenersManager.cs | 53 ----- .../Window/MediaQueryListenersManager.cs | 51 ----- .../Bit.Butil/Publics/BroadcastChannel.cs | 59 ++++-- src/Butil/Bit.Butil/Publics/Console.cs | 62 +++--- src/Butil/Bit.Butil/Publics/Cookie.cs | 18 +- src/Butil/Bit.Butil/Publics/Document.cs | 30 ++- .../ElementReferenceEventExtensions.cs | 99 ++------- .../Publics/Events/ButilMouseEventArgs.cs | 5 +- src/Butil/Bit.Butil/Publics/Fetch.cs | 37 +++- src/Butil/Bit.Butil/Publics/Geolocation.cs | 72 +++++-- src/Butil/Bit.Butil/Publics/History.cs | 43 +++- src/Butil/Bit.Butil/Publics/IdleDetector.cs | 57 +++++- .../IntersectionObserverExtensions.cs | 11 +- src/Butil/Bit.Butil/Publics/Keyboard.cs | 55 +++-- src/Butil/Bit.Butil/Publics/Location.cs | 40 ++-- .../MutationObserverExtensions.cs | 11 +- src/Butil/Bit.Butil/Publics/Nfc.cs | 75 ++++++- src/Butil/Bit.Butil/Publics/Notification.cs | 72 +++++-- .../Notification/NotificationHandle.cs | 5 +- src/Butil/Bit.Butil/Publics/Performance.cs | 56 ++++- src/Butil/Bit.Butil/Publics/Reporting.cs | 55 ++++- .../ResizeObserverExtensions.cs | 11 +- src/Butil/Bit.Butil/Publics/Screen.cs | 44 +++- .../Bit.Butil/Publics/ScreenOrientation.cs | 45 ++++- src/Butil/Bit.Butil/Publics/ServiceWorker.cs | 74 +++++-- .../Bit.Butil/Publics/SpeechRecognition.cs | 95 +++++++-- .../Bit.Butil/Publics/Storage/ButilStorage.cs | 71 +++++-- src/Butil/Bit.Butil/Publics/VisualViewport.cs | 64 ++++-- src/Butil/Bit.Butil/Publics/Window.cs | 74 ++++--- .../Bit.Butil/Scripts/broadcastChannel.ts | 6 +- src/Butil/Bit.Butil/Scripts/element.ts | 4 +- src/Butil/Bit.Butil/Scripts/events.ts | 29 ++- src/Butil/Bit.Butil/Scripts/fetch.ts | 6 +- src/Butil/Bit.Butil/Scripts/geolocation.ts | 8 +- src/Butil/Bit.Butil/Scripts/history.ts | 4 +- src/Butil/Bit.Butil/Scripts/idleDetector.ts | 4 +- .../Bit.Butil/Scripts/intersectionObserver.ts | 4 +- src/Butil/Bit.Butil/Scripts/keyboard.ts | 14 +- .../Bit.Butil/Scripts/mutationObserver.ts | 4 +- src/Butil/Bit.Butil/Scripts/nfc.ts | 10 +- src/Butil/Bit.Butil/Scripts/notification.ts | 15 +- src/Butil/Bit.Butil/Scripts/performance.ts | 4 +- src/Butil/Bit.Butil/Scripts/reporting.ts | 4 +- src/Butil/Bit.Butil/Scripts/resizeObserver.ts | 4 +- src/Butil/Bit.Butil/Scripts/screen.ts | 4 +- .../Bit.Butil/Scripts/screenOrientation.ts | 4 +- src/Butil/Bit.Butil/Scripts/serviceWorker.ts | 8 +- .../Bit.Butil/Scripts/speechRecognition.ts | 12 +- src/Butil/Bit.Butil/Scripts/storage.ts | 4 +- src/Butil/Bit.Butil/Scripts/visualViewport.ts | 8 +- src/Butil/Bit.Butil/Scripts/window.ts | 4 +- 90 files changed, 1396 insertions(+), 2010 deletions(-) delete mode 100644 src/Butil/Bit.Butil/Internals/BroadcastChannel/BroadcastChannelListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomClipboardEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomCompositionEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomDragEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomEventListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/Events/DomEventsInterop.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomFocusEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomInputEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomKeyboardEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomMouseEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomPointerEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomTouchEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Events/DomWheelEventListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Fetch/FetchProgressListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Geolocation/GeolocationListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/History/HistoryListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/IdleDetector/IdleDetectorListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverInterop.cs delete mode 100644 src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Keyboard/KeyboardListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverInterop.cs delete mode 100644 src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Nfc/NdefListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Notification/NotificationListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Performance/PerformanceObserverListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Reporting/ReportingListenersManager.cs create mode 100644 src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverInterop.cs delete mode 100644 src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Screen/ScreenListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/ScreenOrientation/ScreenOrientationListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/ServiceWorker/ServiceWorkerListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/SpeechRecognition/SpeechRecognitionListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Storage/StorageListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/VisualViewport/VisualViewportListenersManager.cs delete mode 100644 src/Butil/Bit.Butil/Internals/Window/MediaQueryListenersManager.cs diff --git a/src/Butil/Bit.Butil/Bit.Butil.csproj b/src/Butil/Bit.Butil/Bit.Butil.csproj index 87c0b5b8ec..27f6963807 100644 --- a/src/Butil/Bit.Butil/Bit.Butil.csproj +++ b/src/Butil/Bit.Butil/Bit.Butil.csproj @@ -5,8 +5,13 @@ net10.0;net9.0;net8.0 true - - BeforeBuildTasks; + + + BuildButilJavaScript; $(ResolveStaticWebAssetsInputsDependsOn) $(NoWarn);IL2026 @@ -31,10 +36,20 @@ - - - - + + + + + diff --git a/src/Butil/Bit.Butil/BitButil.cs b/src/Butil/Bit.Butil/BitButil.cs index ecafce281d..7275cca3bb 100644 --- a/src/Butil/Bit.Butil/BitButil.cs +++ b/src/Butil/Bit.Butil/BitButil.cs @@ -62,7 +62,13 @@ public static IServiceCollection AddBitButilServices(this IServiceCollection ser internal static bool FastInvokeEnabled { get; private set; } /// - /// Enables the use of the fast APIs globally when available (Invoke methods of IJSInProcessRuntime). + /// Enables the synchronous in-process ("fast") invoke path for the APIs that opt into it. + ///
+ /// Only APIs backed by synchronous JavaScript functions (for example , + /// , , and ) + /// use this path; everything that wraps an asynchronous (Promise-returning) browser API always runs + /// asynchronously regardless of this setting, so enabling it can't break those calls. + /// Only effective on Blazor WebAssembly (where an is available). ///
public static void UseFastInvoke() { @@ -70,7 +76,7 @@ public static void UseFastInvoke() } /// - /// Disables the use of the fast APIs globally when available (Invoke methods of IJSInProcessRuntime). + /// Disables the synchronous in-process ("fast") invoke path; all calls run asynchronously. /// public static void UseNormalInvoke() { diff --git a/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs b/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs index 13aec55147..621bf7e070 100644 --- a/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs +++ b/src/Butil/Bit.Butil/Extensions/InternalJSRuntimeExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using System.Diagnostics.CodeAnalysis; @@ -9,23 +10,59 @@ namespace Bit.Butil; internal static class InternalJSRuntimeExtensions { + /// + /// Invokes a void JavaScript function through the safe async path. + /// + /// + /// During static SSR / pre-render (when no real JS runtime is available) this is a no-op: + /// it returns a completed without calling into JS, so callers don't + /// have to special-case prerender. See . + /// internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifier, params object?[]? args) { return InvokeVoid(jsRuntime, identifier, CancellationToken.None, args); } - internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args) + internal static async ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args) { + // This method must stay async: the CancellationTokenSource's internal timer is what + // enforces the timeout, and it must remain alive (undisposed) until the JS call + // completes. Returning the ValueTask from a non-async method would dispose the CTS + // immediately, cancelling its timer and silently defeating the timeout. using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout); var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None; - return InvokeVoid(jsRuntime, identifier, cancellationToken, args); + await InvokeVoid(jsRuntime, identifier, cancellationToken, args); } internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object?[]? args) { if (jsRuntime.IsJsRuntimeInvalid()) return default; + // Always the safe async path. The synchronous in-process ("fast") path is only valid + // for JS functions that are synchronous; using it for a Promise-returning function + // either throws on deserialization or silently fires-and-forgets. Callers that know + // their JS function is synchronous opt in via InvokeVoidFast. + return jsRuntime.InvokeVoidAsync(identifier, cancellationToken, args); + } + + /// + /// Opt-in fast invoke for VOID calls. Honors and, + /// when running under an (Blazor WebAssembly), calls the + /// JS function synchronously. + ///
+ /// IMPORTANT: only use this for JS functions that are genuinely synchronous (no Promise). + /// Using it for an async JS function loses awaiting and error propagation. + ///
+ internal static ValueTask InvokeVoidFast(this IJSRuntime jsRuntime, string identifier, params object?[]? args) + { + return InvokeVoidFast(jsRuntime, identifier, CancellationToken.None, args); + } + + internal static ValueTask InvokeVoidFast(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object?[]? args) + { + if (jsRuntime.IsJsRuntimeInvalid()) return default; + return BitButil.FastInvokeEnabled ? jsRuntime.FastInvokeVoidAsync(identifier, cancellationToken, args) : jsRuntime.InvokeVoidAsync(identifier, cancellationToken, args); @@ -33,20 +70,60 @@ internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifie + /// + /// Invokes a value-returning JavaScript function through the safe async path. + /// + /// + /// The deserialized result, or default() during static SSR / + /// pre-render when no JS runtime is available. + /// + /// + /// IMPORTANT: because prerender returns default (e.g. null, false, 0) + /// instead of throwing, a caller can't distinguish a genuine value from "the runtime wasn't + /// available". Code that branches on the result should treat the prerender pass accordingly + /// (for example, by deferring the read to OnAfterRender). See . + /// internal static ValueTask Invoke<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, params object?[]? args) { return Invoke(jsRuntime, identifier, CancellationToken.None, args); } - internal static ValueTask Invoke<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args) + internal static async ValueTask Invoke<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args) { + // Async on purpose — see the note on the InvokeVoid timeout overload: the CTS timer + // must outlive the call, which only happens if we await inside the using scope. using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout); var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None; - return Invoke(jsRuntime, identifier, cancellationToken, args); + return await Invoke(jsRuntime, identifier, cancellationToken, args); } internal static ValueTask Invoke<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object?[]? args) + { + // Prerender/SSR: no runtime, so hand back default(TValue) rather than throwing. Callers + // can't tell this apart from a real default — documented on the params-based overload. + if (jsRuntime.IsJsRuntimeInvalid()) return default; + + // Always the safe async path — see the note on InvokeVoid. Callers whose JS function is + // synchronous opt in via InvokeFast. + return jsRuntime.InvokeAsync(identifier, cancellationToken, args); + } + + /// + /// Opt-in fast invoke for value-returning calls. Honors + /// and, when running under an (Blazor WebAssembly), calls the + /// JS function synchronously. + ///
+ /// IMPORTANT: only use this for JS functions that are genuinely synchronous (no Promise). + /// Invoking a Promise-returning function this way throws when the result can't be deserialized + /// to . + ///
+ internal static ValueTask InvokeFast<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, params object?[]? args) + { + return InvokeFast(jsRuntime, identifier, CancellationToken.None, args); + } + + internal static ValueTask InvokeFast<[DynamicallyAccessedMembers(JsonSerialized)] TValue>(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object?[]? args) { if (jsRuntime.IsJsRuntimeInvalid()) return default; @@ -67,13 +144,21 @@ internal static ValueTask InvokeVoid(this IJSRuntime jsRuntime, string identifie /// UnsupportedJavaScriptRuntime type used during static SSR / pre-render — /// and let actual disconnect surface as at /// the call site, which callers already catch. + ///
+ /// This runs on every JS call, so the (type-based, therefore stable) verdict is cached per + /// runtime to avoid repeating the GetType().Name comparison and its + /// string allocation on the hot path. /// + private static readonly ConcurrentDictionary UnsupportedRuntimeCache = new(); + internal static bool IsJsRuntimeInvalid(this IJSRuntime? jsRuntime) { if (jsRuntime is null) return true; - // During pre-rendering ASP.NET injects an UnsupportedJavaScriptRuntime that - // throws on every call. We special-case it to keep prerender silent. - return jsRuntime.GetType().Name == "UnsupportedJavaScriptRuntime"; + return UnsupportedRuntimeCache.GetOrAdd(jsRuntime.GetType(), + // During pre-rendering ASP.NET injects an UnsupportedJavaScriptRuntime that throws on + // every call. We special-case it by type name (the documented sentinel) to keep + // prerender silent. + static type => type.Name == "UnsupportedJavaScriptRuntime"); } } diff --git a/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs b/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs index 07fb3c29d1..486721c55b 100644 --- a/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs +++ b/src/Butil/Bit.Butil/Extensions/JSRuntimeExtensions.cs @@ -34,12 +34,15 @@ public static class JSRuntimeExtensions /// JSON-serializable arguments. /// An instance of obtained by JSON-deserializing the return value. [RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed.")] - public static ValueTask FastInvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TResult>(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args) + public static async ValueTask FastInvokeAsync<[DynamicallyAccessedMembers(JsonSerialized)] TResult>(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args) { + // Must be async: the CancellationTokenSource's timer enforces the timeout and has to + // remain alive until the call finishes. Disposing it synchronously (as a non-async + // method would) cancels the timer and defeats the timeout. using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout); var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None; - return FastInvokeAsync(jsRuntime, identifier, cancellationToken, args); + return await FastInvokeAsync(jsRuntime, identifier, cancellationToken, args); } /// @@ -88,12 +91,13 @@ public static ValueTask FastInvokeVoidAsync(this IJSRuntime jsRuntime, string id /// The duration after which to cancel the async operation. Overrides default timeouts (). /// JSON-serializable arguments. [RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed.")] - public static ValueTask FastInvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args) + public static async ValueTask FastInvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object?[]? args) { + // Async on purpose — the CTS timer must outlive the call for the timeout to fire. using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout); var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None; - return FastInvokeVoidAsync(jsRuntime, identifier, cancellationToken, args); + await FastInvokeVoidAsync(jsRuntime, identifier, cancellationToken, args); } /// diff --git a/src/Butil/Bit.Butil/Internals/BroadcastChannel/BroadcastChannelListenersManager.cs b/src/Butil/Bit.Butil/Internals/BroadcastChannel/BroadcastChannelListenersManager.cs deleted file mode 100644 index a15a5da59a..0000000000 --- a/src/Butil/Bit.Butil/Internals/BroadcastChannel/BroadcastChannelListenersManager.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class BroadcastChannelListenersManager -{ - internal const string MessageMethodName = "InvokeBroadcastChannelMessage"; - internal const string ErrorMethodName = "InvokeBroadcastChannelError"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid AddListener(Action? onMessage, Action? onError) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { OnMessage = onMessage, OnError = onError }); - return id; - } - - internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(MessageMethodName)] - public static void InvokeMessage(Guid id, System.Text.Json.JsonElement data) - { - if (Listeners.TryGetValue(id, out var listener)) listener.OnMessage?.Invoke(data); - } - - [JSInvokable(ErrorMethodName)] - public static void InvokeError(Guid id) - { - if (Listeners.TryGetValue(id, out var listener)) listener.OnError?.Invoke(); - } - - private class Listener - { - public Action? OnMessage { get; set; } - public Action? OnError { get; set; } - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomClipboardEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomClipboardEventListenersManager.cs deleted file mode 100644 index 5e5670c1a1..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomClipboardEventListenersManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomClipboardEventListenersManager -{ - internal const string InvokeMethodName = "InvokeClipboardEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var toRemove = Listeners - .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) - .ToArray(); - - return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); - } - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilClipboardEventArgs args) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomCompositionEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomCompositionEventListenersManager.cs deleted file mode 100644 index 8dc661e2c6..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomCompositionEventListenersManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomCompositionEventListenersManager -{ - internal const string InvokeMethodName = "InvokeCompositionEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var toRemove = Listeners - .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) - .ToArray(); - - return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); - } - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilCompositionEventArgs args) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomDragEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomDragEventListenersManager.cs deleted file mode 100644 index aa547e0631..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomDragEventListenersManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomDragEventListenersManager -{ - internal const string InvokeMethodName = "InvokeDragEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var toRemove = Listeners - .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) - .ToArray(); - - return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); - } - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilDragEventArgs args) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs b/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs deleted file mode 100644 index 02c912f88e..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomEventDispatcher.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Threading.Tasks; -using System.Diagnostics.CodeAnalysis; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -internal static class DomEventDispatcher -{ - private static readonly object FalseUseCapture = false; - private static readonly object TrueUseCapture = true; - - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilMouseEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilKeyboardEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilPointerEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilWheelEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilTouchEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilTouchPoint))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilFocusEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilInputEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilDragEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilClipboardEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilCompositionEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomMouseEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomKeyboardEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomPointerEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomWheelEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomTouchEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomFocusEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomInputEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomDragEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomClipboardEventListenersManager))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DomCompositionEventListenersManager))] - internal static async Task AddEventListener(IJSRuntime js, - string elementName, - string domEvent, - Action listener, - bool useCapture = false, - bool preventDefault = false, - bool stopPropagation = false) - { - var argType = typeof(T); - var eventType = DomEventArgs.TypeOf(domEvent); - - if (argType != eventType) - throw new InvalidOperationException($"Invalid listener type ({argType}) for this dom event type ({eventType})"); - - string[] args = []; - var methodName = ""; - var id = Guid.NewGuid(); - var options = useCapture ? TrueUseCapture : FalseUseCapture; - - if (argType == typeof(ButilKeyboardEventArgs)) - { - args = ButilKeyboardEventArgs.EventArgsMembers; - methodName = DomKeyboardEventListenersManager.InvokeMethodName; - id = DomKeyboardEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else if (argType == typeof(ButilMouseEventArgs)) - { - args = ButilMouseEventArgs.EventArgsMembers; - methodName = DomMouseEventListenersManager.InvokeMethodName; - id = DomMouseEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else if (argType == typeof(ButilPointerEventArgs)) - { - args = ButilPointerEventArgs.EventArgsMembers; - methodName = DomPointerEventListenersManager.InvokeMethodName; - id = DomPointerEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else if (argType == typeof(ButilWheelEventArgs)) - { - args = ButilWheelEventArgs.EventArgsMembers; - methodName = DomWheelEventListenersManager.InvokeMethodName; - id = DomWheelEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else if (argType == typeof(ButilTouchEventArgs)) - { - args = ButilTouchEventArgs.EventArgsMembers; - methodName = DomTouchEventListenersManager.InvokeMethodName; - id = DomTouchEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else if (argType == typeof(ButilFocusEventArgs)) - { - args = ButilFocusEventArgs.EventArgsMembers; - methodName = DomFocusEventListenersManager.InvokeMethodName; - id = DomFocusEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else if (argType == typeof(ButilInputEventArgs)) - { - args = ButilInputEventArgs.EventArgsMembers; - methodName = DomInputEventListenersManager.InvokeMethodName; - id = DomInputEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else if (argType == typeof(ButilDragEventArgs)) - { - args = ButilDragEventArgs.EventArgsMembers; - methodName = DomDragEventListenersManager.InvokeMethodName; - id = DomDragEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else if (argType == typeof(ButilClipboardEventArgs)) - { - args = ButilClipboardEventArgs.EventArgsMembers; - methodName = DomClipboardEventListenersManager.InvokeMethodName; - id = DomClipboardEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else if (argType == typeof(ButilCompositionEventArgs)) - { - args = ButilCompositionEventArgs.EventArgsMembers; - methodName = DomCompositionEventListenersManager.InvokeMethodName; - id = DomCompositionEventListenersManager.SetListener((listener as Action)!, elementName, options); - } - else - { - methodName = DomEventListenersManager.InvokeMethodName; - var action = listener as Action; - id = DomEventListenersManager.SetListener(action!, elementName, options); - } - - await js.AddEventListener(elementName, domEvent, methodName, id, args, options, preventDefault, stopPropagation); - - return id; - } - - internal static async Task RemoveEventListener(IJSRuntime js, - string elementName, - string domEvent, - Action listener, - bool useCapture = false) - { - var argType = typeof(T); - var eventType = DomEventArgs.TypeOf(domEvent); - - if (argType != eventType) - throw new InvalidOperationException($"Invalid listener type ({argType}) for this dom event type ({eventType})"); - - Guid[] ids; - var options = useCapture ? TrueUseCapture : FalseUseCapture; - - if (argType == typeof(ButilKeyboardEventArgs)) - ids = DomKeyboardEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else if (argType == typeof(ButilMouseEventArgs)) - ids = DomMouseEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else if (argType == typeof(ButilPointerEventArgs)) - ids = DomPointerEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else if (argType == typeof(ButilWheelEventArgs)) - ids = DomWheelEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else if (argType == typeof(ButilTouchEventArgs)) - ids = DomTouchEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else if (argType == typeof(ButilFocusEventArgs)) - ids = DomFocusEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else if (argType == typeof(ButilInputEventArgs)) - ids = DomInputEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else if (argType == typeof(ButilDragEventArgs)) - ids = DomDragEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else if (argType == typeof(ButilClipboardEventArgs)) - ids = DomClipboardEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else if (argType == typeof(ButilCompositionEventArgs)) - ids = DomCompositionEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - else - ids = DomEventListenersManager.RemoveListener((listener as Action)!, elementName, options); - - await js.RemoveEventListener(elementName, domEvent, ids, options); - - return ids; - } - - /// - /// Detaches a single listener by id, regardless of which typed manager owns it. - /// Used by when the original delegate isn't available. - /// - internal static async Task RemoveEventListenerById(IJSRuntime js, string elementName, string domEvent, Guid id, bool useCapture = false) - { - // Try every typed store; the one that owns the id will succeed. - DomKeyboardEventListenersManager.RemoveById(id); - DomMouseEventListenersManager.RemoveById(id); - DomPointerEventListenersManager.RemoveById(id); - DomWheelEventListenersManager.RemoveById(id); - DomTouchEventListenersManager.RemoveById(id); - DomFocusEventListenersManager.RemoveById(id); - DomInputEventListenersManager.RemoveById(id); - DomDragEventListenersManager.RemoveById(id); - DomClipboardEventListenersManager.RemoveById(id); - DomCompositionEventListenersManager.RemoveById(id); - DomEventListenersManager.RemoveById(id); - - var options = useCapture ? TrueUseCapture : FalseUseCapture; - await js.RemoveEventListener(elementName, domEvent, [id], options); - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomEventListenersManager.cs deleted file mode 100644 index 6ddefa766b..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomEventListenersManager.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Linq; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomEventListenersManager -{ - internal const string InvokeMethodName = "InvokeDomEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var listenersToRemove = Listeners.Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options).ToArray(); - - return listenersToRemove.Select(l => - { - Listeners.TryRemove(l.Key, out _); - return l.Key; - }).ToArray(); - } - - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, object args) - { - Listeners.TryGetValue(id, out Listener? listener); - listener?.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomEventsInterop.cs b/src/Butil/Bit.Butil/Internals/Events/DomEventsInterop.cs new file mode 100644 index 0000000000..e3f8269f98 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/Events/DomEventsInterop.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Per-instance DOM event dispatcher. Replaces the old static Dom*EventListenersManager set: +/// all listener state lives on this instance and the typed callbacks +/// are reached through a per-instance . That keeps listeners +/// isolated per Blazor circuit / WASM app and releases them (no leak) when the owner is disposed. +/// +internal sealed class DomEventsInterop : IDisposable +{ + private readonly ConcurrentDictionary _listeners = new(); + + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilMouseEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilKeyboardEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilPointerEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilWheelEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilTouchEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilTouchPoint))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilFocusEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilInputEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilDragEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilClipboardEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ButilCompositionEventArgs))] + internal async Task AddEventListener(IJSRuntime js, + string elementName, + string domEvent, + Action listener, + bool useCapture = false, + bool preventDefault = false, + bool stopPropagation = false) + { + var argType = typeof(T); + var eventType = DomEventArgs.TypeOf(domEvent); + + if (argType != eventType) + throw new InvalidOperationException($"Invalid listener type ({argType}) for this dom event type ({eventType})"); + + var (methodName, members) = Resolve(argType); + var options = useCapture; + var id = Guid.NewGuid(); + _listeners.TryAdd(id, new Entry { Action = listener, ArgType = argType, Element = elementName, UseCapture = useCapture }); + + await js.AddEventListener(elementName, domEvent, methodName, DotNetRef, id, members, options, preventDefault, stopPropagation); + + return id; + } + + internal async Task RemoveEventListener(IJSRuntime js, + string elementName, + string domEvent, + Action listener, + bool useCapture = false) + { + var argType = typeof(T); + var eventType = DomEventArgs.TypeOf(domEvent); + + if (argType != eventType) + throw new InvalidOperationException($"Invalid listener type ({argType}) for this dom event type ({eventType})"); + + var ids = _listeners + .Where(l => Equals(l.Value.Action, listener) && l.Value.Element == elementName && l.Value.UseCapture == useCapture) + .Select(l => l.Key) + .ToArray(); + + foreach (var id in ids) _listeners.TryRemove(id, out _); + + await js.RemoveEventListener(elementName, domEvent, ids, (object)useCapture); + + return ids; + } + + /// Detaches a single listener by id (used by subscription handles). + internal async Task RemoveEventListenerById(IJSRuntime js, string elementName, string domEvent, Guid id, bool useCapture = false) + { + _listeners.TryRemove(id, out _); + await js.RemoveEventListener(elementName, domEvent, [id], (object)useCapture); + } + + /// + /// Registers a listener without performing the window/document JS wiring. Used by the + /// element-scoped path, which drives its own JS subscription but still needs the typed + /// callback routing and the per-instance reference. + /// + internal (Guid Id, string MethodName, string[] Members, DotNetObjectReference Ref) Register( + Action listener, string element, bool useCapture) + { + var (methodName, members) = Resolve(typeof(T)); + var id = Guid.NewGuid(); + _listeners.TryAdd(id, new Entry { Action = listener, ArgType = typeof(T), Element = element, UseCapture = useCapture }); + return (id, methodName, members, DotNetRef); + } + + internal void Unregister(Guid id) => _listeners.TryRemove(id, out _); + + private void Dispatch(Guid id, T args) + { + if (_listeners.TryGetValue(id, out var entry) && entry.Action is Action action) + action.Invoke(args); + } + + [JSInvokable("InvokeMouseEvent")] public void InvokeMouseEvent(Guid id, ButilMouseEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokeKeyboardEvent")] public void InvokeKeyboardEvent(Guid id, ButilKeyboardEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokePointerEvent")] public void InvokePointerEvent(Guid id, ButilPointerEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokeWheelEvent")] public void InvokeWheelEvent(Guid id, ButilWheelEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokeTouchEvent")] public void InvokeTouchEvent(Guid id, ButilTouchEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokeFocusEvent")] public void InvokeFocusEvent(Guid id, ButilFocusEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokeInputEvent")] public void InvokeInputEvent(Guid id, ButilInputEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokeDragEvent")] public void InvokeDragEvent(Guid id, ButilDragEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokeClipboardEvent")] public void InvokeClipboardEvent(Guid id, ButilClipboardEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokeCompositionEvent")] public void InvokeCompositionEvent(Guid id, ButilCompositionEventArgs args) => Dispatch(id, args); + [JSInvokable("InvokeDomEvent")] public void InvokeDomEvent(Guid id, object args) => Dispatch(id, args); + + private static (string MethodName, string[] Members) Resolve(Type argType) + { + if (argType == typeof(ButilKeyboardEventArgs)) return ("InvokeKeyboardEvent", ButilKeyboardEventArgs.EventArgsMembers); + if (argType == typeof(ButilMouseEventArgs)) return ("InvokeMouseEvent", ButilMouseEventArgs.EventArgsMembers); + if (argType == typeof(ButilPointerEventArgs)) return ("InvokePointerEvent", ButilPointerEventArgs.EventArgsMembers); + if (argType == typeof(ButilWheelEventArgs)) return ("InvokeWheelEvent", ButilWheelEventArgs.EventArgsMembers); + if (argType == typeof(ButilTouchEventArgs)) return ("InvokeTouchEvent", ButilTouchEventArgs.EventArgsMembers); + if (argType == typeof(ButilFocusEventArgs)) return ("InvokeFocusEvent", ButilFocusEventArgs.EventArgsMembers); + if (argType == typeof(ButilInputEventArgs)) return ("InvokeInputEvent", ButilInputEventArgs.EventArgsMembers); + if (argType == typeof(ButilDragEventArgs)) return ("InvokeDragEvent", ButilDragEventArgs.EventArgsMembers); + if (argType == typeof(ButilClipboardEventArgs)) return ("InvokeClipboardEvent", ButilClipboardEventArgs.EventArgsMembers); + if (argType == typeof(ButilCompositionEventArgs)) return ("InvokeCompositionEvent", ButilCompositionEventArgs.EventArgsMembers); + return ("InvokeDomEvent", []); + } + + public void Dispose() + { + _listeners.Clear(); + _dotNetRef?.Dispose(); + _dotNetRef = null; + } + + private sealed class Entry + { + public object Action { get; set; } = default!; + public Type ArgType { get; set; } = default!; + public string Element { get; set; } = string.Empty; + public bool UseCapture { get; set; } + } +} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomFocusEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomFocusEventListenersManager.cs deleted file mode 100644 index 381d0f46a6..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomFocusEventListenersManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomFocusEventListenersManager -{ - internal const string InvokeMethodName = "InvokeFocusEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var toRemove = Listeners - .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) - .ToArray(); - - return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); - } - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilFocusEventArgs args) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomInputEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomInputEventListenersManager.cs deleted file mode 100644 index 0bd2677de8..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomInputEventListenersManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomInputEventListenersManager -{ - internal const string InvokeMethodName = "InvokeInputEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var toRemove = Listeners - .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) - .ToArray(); - - return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); - } - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilInputEventArgs args) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomKeyboardEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomKeyboardEventListenersManager.cs deleted file mode 100644 index 468865a9df..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomKeyboardEventListenersManager.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Linq; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomKeyboardEventListenersManager -{ - internal const string InvokeMethodName = "InvokeKeyboardEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var listenersToRemove = Listeners.Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options).ToArray(); - - return listenersToRemove.Select(l => - { - Listeners.TryRemove(l.Key, out _); - return l.Key; - }).ToArray(); - } - - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilKeyboardEventArgs args) - { - Listeners.TryGetValue(id, out Listener? listener); - listener?.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomMouseEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomMouseEventListenersManager.cs deleted file mode 100644 index f19fed62f1..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomMouseEventListenersManager.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Linq; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomMouseEventListenersManager -{ - internal const string InvokeMethodName = "InvokeMouseEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var listenersToRemove = Listeners.Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options).ToArray(); - - return listenersToRemove.Select(l => - { - Listeners.TryRemove(l.Key, out _); - return l.Key; - }).ToArray(); - } - - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilMouseEventArgs args) - { - Listeners.TryGetValue(id, out Listener? listener); - listener?.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomPointerEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomPointerEventListenersManager.cs deleted file mode 100644 index e61235e944..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomPointerEventListenersManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomPointerEventListenersManager -{ - internal const string InvokeMethodName = "InvokePointerEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var toRemove = Listeners - .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) - .ToArray(); - - return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); - } - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilPointerEventArgs args) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomTouchEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomTouchEventListenersManager.cs deleted file mode 100644 index 19c647aedb..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomTouchEventListenersManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomTouchEventListenersManager -{ - internal const string InvokeMethodName = "InvokeTouchEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var toRemove = Listeners - .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) - .ToArray(); - - return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); - } - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilTouchEventArgs args) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Events/DomWheelEventListenersManager.cs b/src/Butil/Bit.Butil/Internals/Events/DomWheelEventListenersManager.cs deleted file mode 100644 index 53bc5c8a50..0000000000 --- a/src/Butil/Bit.Butil/Internals/Events/DomWheelEventListenersManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class DomWheelEventListenersManager -{ - internal const string InvokeMethodName = "InvokeWheelEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid SetListener(Action action, string element, object options) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action, Element = element, Options = options }); - return id; - } - - internal static Guid[] RemoveListener(Action action, string element, object options) - { - var toRemove = Listeners - .Where(l => l.Value.Action == action && l.Value.Element == element && l.Value.Options == options) - .ToArray(); - - return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); - } - internal static void RemoveById(Guid id) => Listeners.TryRemove(id, out _); - - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ButilWheelEventArgs args) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Action.Invoke(args); - } - - private class Listener - { - public string Element { get; set; } = string.Empty; - public object Options { get; set; } = default!; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Fetch/FetchProgressListenersManager.cs b/src/Butil/Bit.Butil/Internals/Fetch/FetchProgressListenersManager.cs deleted file mode 100644 index 5bd98d10fa..0000000000 --- a/src/Butil/Bit.Butil/Internals/Fetch/FetchProgressListenersManager.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class FetchProgressListenersManager -{ - internal const string InvokeMethodName = "InvokeFetchProgress"; - - private static readonly ConcurrentDictionary> Listeners = []; - - internal static void AddListener(Guid id, Action action) => Listeners[id] = action; - - internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, FetchProgress progress) - { - if (Listeners.TryGetValue(id, out var l)) l.Invoke(progress); - } -} diff --git a/src/Butil/Bit.Butil/Internals/Geolocation/GeolocationListenersManager.cs b/src/Butil/Bit.Butil/Internals/Geolocation/GeolocationListenersManager.cs deleted file mode 100644 index be1ed6bdc2..0000000000 --- a/src/Butil/Bit.Butil/Internals/Geolocation/GeolocationListenersManager.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class GeolocationListenersManager -{ - internal const string PositionMethodName = "InvokeGeolocationPosition"; - internal const string ErrorMethodName = "InvokeGeolocationError"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid AddListener(Action? onPosition, Action? onError) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { OnPosition = onPosition, OnError = onError }); - return id; - } - - internal static void RemoveListeners(Guid[] ids) - { - foreach (var id in ids) Listeners.TryRemove(id, out _); - } - - [JSInvokable(PositionMethodName)] - public static void InvokePosition(Guid id, GeolocationPosition position) - { - if (Listeners.TryGetValue(id, out var listener)) - { - listener.OnPosition?.Invoke(position); - } - } - - [JSInvokable(ErrorMethodName)] - public static void InvokeError(Guid id, int code, string message) - { - if (Listeners.TryGetValue(id, out var listener)) - { - var enumCode = code switch - { - 1 => GeolocationErrorCode.PermissionDenied, - 2 => GeolocationErrorCode.PositionUnavailable, - 3 => GeolocationErrorCode.Timeout, - _ => GeolocationErrorCode.Unknown, - }; - listener.OnError?.Invoke(new GeolocationException(enumCode, message)); - } - } - - private class Listener - { - public Action? OnPosition { get; set; } - public Action? OnError { get; set; } - } -} diff --git a/src/Butil/Bit.Butil/Internals/History/HistoryListenersManager.cs b/src/Butil/Bit.Butil/Internals/History/HistoryListenersManager.cs deleted file mode 100644 index b336a25518..0000000000 --- a/src/Butil/Bit.Butil/Internals/History/HistoryListenersManager.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Linq; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class HistoryListenersManager -{ - internal const string InvokeMethodName = "InvokeHistoryPopState"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - - Listeners.TryAdd(id, new Listener { Action = action }); - - return id; - } - - internal static Guid[] RemoveListener(Action action) - { - var listenersToRemove = Listeners.Where(l => l.Value.Action == action).ToArray(); - - return listenersToRemove.Select(l => - { - Listeners.TryRemove(l.Key, out _); - return l.Key; - }).ToArray(); - } - - internal static void RemoveListeners(Guid[] ids) - { - foreach (var id in ids) - { - Listeners.TryRemove(id, out _); - } - } - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, object state) - { - Listeners.TryGetValue(id, out Listener? listener); - listener?.Action.Invoke(state); - } - - private class Listener - { - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/IdleDetector/IdleDetectorListenersManager.cs b/src/Butil/Bit.Butil/Internals/IdleDetector/IdleDetectorListenersManager.cs deleted file mode 100644 index 27409071b5..0000000000 --- a/src/Butil/Bit.Butil/Internals/IdleDetector/IdleDetectorListenersManager.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class IdleDetectorListenersManager -{ - internal const string InvokeMethodName = "InvokeIdleDetector"; - - private static readonly ConcurrentDictionary> Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, action); - return id; - } - - internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, IdleState state) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(state); - } -} diff --git a/src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverInterop.cs b/src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverInterop.cs new file mode 100644 index 0000000000..be188140a4 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverInterop.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Per-subscription host for a single IntersectionObserver. The callback is reached through +/// a per-instance (no static state), and the reference is +/// released when the subscription is disposed. +/// +internal sealed class IntersectionObserverInterop(Action handler) : IDisposable +{ + internal const string InvokeMethodName = nameof(InvokeIntersection); + + private DotNetObjectReference? _dotNetRef; + internal DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + [JSInvokable(InvokeMethodName)] + public void InvokeIntersection(Guid id, IntersectionObserverEntry[] entries) => handler(entries); + + public void Dispose() + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } +} diff --git a/src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverListenersManager.cs b/src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverListenersManager.cs deleted file mode 100644 index ead564c9b6..0000000000 --- a/src/Butil/Bit.Butil/Internals/IntersectionObserver/IntersectionObserverListenersManager.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class IntersectionObserverListenersManager -{ - internal const string InvokeMethodName = "InvokeIntersectionObserver"; - - private static readonly ConcurrentDictionary> Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, action); - return id; - } - - internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, IntersectionObserverEntry[] entries) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(entries); - } -} diff --git a/src/Butil/Bit.Butil/Internals/JsInterops/EventsJsInterop.cs b/src/Butil/Bit.Butil/Internals/JsInterops/EventsJsInterop.cs index 0ae7fe259d..c1d0d5bbff 100644 --- a/src/Butil/Bit.Butil/Internals/JsInterops/EventsJsInterop.cs +++ b/src/Butil/Bit.Butil/Internals/JsInterops/EventsJsInterop.cs @@ -10,6 +10,7 @@ internal static async Task AddEventListener(this IJSRuntime js, string elementName, string eventName, string methodName, + object dotNetRef, Guid listenerId, string[] argsMembers, object? options = null, @@ -19,6 +20,7 @@ internal static async Task AddEventListener(this IJSRuntime js, elementName, eventName, methodName, + dotNetRef, listenerId, argsMembers, options, diff --git a/src/Butil/Bit.Butil/Internals/Keyboard/KeyboardListenersManager.cs b/src/Butil/Bit.Butil/Internals/Keyboard/KeyboardListenersManager.cs deleted file mode 100644 index b777f4488f..0000000000 --- a/src/Butil/Bit.Butil/Internals/Keyboard/KeyboardListenersManager.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Linq; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class KeyboardListenersManager -{ - internal const string InvokeMethodName = "InvokeKeyboard"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - - Listeners.TryAdd(id, new Listener { Action = action }); - - return id; - } - - internal static Guid[] RemoveListener(Action action) - { - var listenersToRemove = Listeners.Where(l => l.Value.Action == action).ToArray(); - - return listenersToRemove.Select(l => - { - Listeners.TryRemove(l.Key, out _); - return l.Key; - }).ToArray(); - } - - internal static void RemoveListeners(Guid[] ids) - { - foreach (var id in ids) - { - Listeners.TryRemove(id, out _); - } - } - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id) - { - Listeners.TryGetValue(id, out Listener? listener); - listener?.Action.Invoke(); - } - - private class Listener - { - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverInterop.cs b/src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverInterop.cs new file mode 100644 index 0000000000..766f57a8c4 --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverInterop.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Per-subscription host for a single MutationObserver. The callback is reached through a +/// per-instance (no static state), and the reference is +/// released when the subscription is disposed. +/// +internal sealed class MutationObserverInterop(Action handler) : IDisposable +{ + internal const string InvokeMethodName = nameof(InvokeMutation); + + private DotNetObjectReference? _dotNetRef; + internal DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + [JSInvokable(InvokeMethodName)] + public void InvokeMutation(Guid id, MutationRecord[] records) => handler(records); + + public void Dispose() + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } +} diff --git a/src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverListenersManager.cs b/src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverListenersManager.cs deleted file mode 100644 index 59ee1acd39..0000000000 --- a/src/Butil/Bit.Butil/Internals/MutationObserver/MutationObserverListenersManager.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class MutationObserverListenersManager -{ - internal const string InvokeMethodName = "InvokeMutationObserver"; - - private static readonly ConcurrentDictionary> Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, action); - return id; - } - - internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, MutationRecord[] records) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(records); - } -} diff --git a/src/Butil/Bit.Butil/Internals/Nfc/NdefListenersManager.cs b/src/Butil/Bit.Butil/Internals/Nfc/NdefListenersManager.cs deleted file mode 100644 index 4f90eb0336..0000000000 --- a/src/Butil/Bit.Butil/Internals/Nfc/NdefListenersManager.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class NdefListenersManager -{ - internal const string ReadingMethodName = "InvokeNdefReading"; - internal const string ErrorMethodName = "InvokeNdefError"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid Add(Listener listener) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, listener); - return id; - } - - internal static void Remove(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(ReadingMethodName)] - public static void InvokeReading(Guid id, NdefMessage message) - { - if (Listeners.TryGetValue(id, out var l)) l.OnReading?.Invoke(message); - } - - [JSInvokable(ErrorMethodName)] - public static void InvokeError(Guid id, string message) - { - if (Listeners.TryGetValue(id, out var l)) l.OnError?.Invoke(message); - } - - internal class Listener - { - public Action? OnReading { get; set; } - public Action? OnError { get; set; } - } -} diff --git a/src/Butil/Bit.Butil/Internals/Notification/NotificationListenersManager.cs b/src/Butil/Bit.Butil/Internals/Notification/NotificationListenersManager.cs deleted file mode 100644 index f0d36bfaf6..0000000000 --- a/src/Butil/Bit.Butil/Internals/Notification/NotificationListenersManager.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class NotificationListenersManager -{ - internal const string ClickMethodName = "InvokeNotificationClick"; - internal const string ShowMethodName = "InvokeNotificationShow"; - internal const string CloseMethodName = "InvokeNotificationClose"; - internal const string ErrorMethodName = "InvokeNotificationError"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid Add(Listener listener) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, listener); - return id; - } - - internal static void Remove(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(ClickMethodName)] - public static void InvokeClick(Guid id) - { - if (Listeners.TryGetValue(id, out var l)) l.OnClick?.Invoke(); - } - - [JSInvokable(ShowMethodName)] - public static void InvokeShow(Guid id) - { - if (Listeners.TryGetValue(id, out var l)) l.OnShow?.Invoke(); - } - - [JSInvokable(CloseMethodName)] - public static void InvokeClose(Guid id) - { - if (Listeners.TryGetValue(id, out var l)) l.OnClose?.Invoke(); - } - - [JSInvokable(ErrorMethodName)] - public static void InvokeError(Guid id) - { - if (Listeners.TryGetValue(id, out var l)) l.OnError?.Invoke(); - } - - internal class Listener - { - public Action? OnClick { get; set; } - public Action? OnShow { get; set; } - public Action? OnClose { get; set; } - public Action? OnError { get; set; } - } -} diff --git a/src/Butil/Bit.Butil/Internals/Performance/PerformanceObserverListenersManager.cs b/src/Butil/Bit.Butil/Internals/Performance/PerformanceObserverListenersManager.cs deleted file mode 100644 index cf96338528..0000000000 --- a/src/Butil/Bit.Butil/Internals/Performance/PerformanceObserverListenersManager.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Text.Json; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class PerformanceObserverListenersManager -{ - internal const string InvokeMethodName = "InvokePerformanceObserver"; - - private static readonly ConcurrentDictionary> Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, action); - return id; - } - - internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, JsonElement[] entries) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(entries); - } -} diff --git a/src/Butil/Bit.Butil/Internals/Reporting/ReportingListenersManager.cs b/src/Butil/Bit.Butil/Internals/Reporting/ReportingListenersManager.cs deleted file mode 100644 index 67698524ce..0000000000 --- a/src/Butil/Bit.Butil/Internals/Reporting/ReportingListenersManager.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class ReportingListenersManager -{ - internal const string InvokeMethodName = "InvokeBrowserReport"; - - private static readonly ConcurrentDictionary> Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, action); - return id; - } - - internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, BrowserReport[] reports) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(reports); - } -} diff --git a/src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverInterop.cs b/src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverInterop.cs new file mode 100644 index 0000000000..5e6931a9dc --- /dev/null +++ b/src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverInterop.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.JSInterop; + +namespace Bit.Butil; + +/// +/// Per-subscription host for a single ResizeObserver. The callback is reached through a +/// per-instance (no static state), and the reference is +/// released when the subscription is disposed. +/// +internal sealed class ResizeObserverInterop(Action handler) : IDisposable +{ + internal const string InvokeMethodName = nameof(InvokeResize); + + private DotNetObjectReference? _dotNetRef; + internal DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + [JSInvokable(InvokeMethodName)] + public void InvokeResize(Guid id, ResizeObserverEntry[] entries) => handler(entries); + + public void Dispose() + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } +} diff --git a/src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverListenersManager.cs b/src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverListenersManager.cs deleted file mode 100644 index 9d69dc7e94..0000000000 --- a/src/Butil/Bit.Butil/Internals/ResizeObserver/ResizeObserverListenersManager.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class ResizeObserverListenersManager -{ - internal const string InvokeMethodName = "InvokeResizeObserver"; - - private static readonly ConcurrentDictionary> Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, action); - return id; - } - - internal static void RemoveListener(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, ResizeObserverEntry[] entries) - { - if (Listeners.TryGetValue(id, out var listener)) listener.Invoke(entries); - } -} diff --git a/src/Butil/Bit.Butil/Internals/Screen/ScreenListenersManager.cs b/src/Butil/Bit.Butil/Internals/Screen/ScreenListenersManager.cs deleted file mode 100644 index bc663d2dc3..0000000000 --- a/src/Butil/Bit.Butil/Internals/Screen/ScreenListenersManager.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Linq; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class ScreenListenersManager -{ - internal const string InvokeMethodName = "InvokeScreenChange"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - - Listeners.TryAdd(id, new Listener { Action = action }); - - return id; - } - - internal static Guid[] RemoveListener(Action action) - { - var listenersToRemove = Listeners.Where(l => l.Value.Action == action).ToArray(); - - return listenersToRemove.Select(l => - { - Listeners.TryRemove(l.Key, out _); - return l.Key; - }).ToArray(); - } - - internal static void RemoveListeners(Guid[] ids) - { - foreach (var id in ids) - { - Listeners.TryRemove(id, out _); - } - } - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id) - { - Listeners.TryGetValue(id, out Listener? listener); - listener?.Action.Invoke(); - } - - private class Listener - { - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/ScreenOrientation/ScreenOrientationListenersManager.cs b/src/Butil/Bit.Butil/Internals/ScreenOrientation/ScreenOrientationListenersManager.cs deleted file mode 100644 index fa5e2da2ac..0000000000 --- a/src/Butil/Bit.Butil/Internals/ScreenOrientation/ScreenOrientationListenersManager.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Linq; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class ScreenOrientationListenersManager -{ - internal const string InvokeMethodName = "InvokeScreenOrientation"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - - Listeners.TryAdd(id, new Listener { Action = action }); - - return id; - } - - internal static Guid[] RemoveListener(Action action) - { - var listenersToRemove = Listeners.Where(l => l.Value.Action == action).ToArray(); - - return listenersToRemove.Select(l => - { - Listeners.TryRemove(l.Key, out _); - return l.Key; - }).ToArray(); - } - - internal static void RemoveListeners(Guid[] ids) - { - foreach (var id in ids) - { - Listeners.TryRemove(id, out _); - } - } - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, OrientationState state) - { - Listeners.TryGetValue(id, out Listener? listener); - listener?.Action.Invoke(state); - } - - private class Listener - { - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/ServiceWorker/ServiceWorkerListenersManager.cs b/src/Butil/Bit.Butil/Internals/ServiceWorker/ServiceWorkerListenersManager.cs deleted file mode 100644 index 877414fe23..0000000000 --- a/src/Butil/Bit.Butil/Internals/ServiceWorker/ServiceWorkerListenersManager.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Text.Json; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class ServiceWorkerListenersManager -{ - internal const string MessageMethodName = "InvokeServiceWorkerMessage"; - internal const string ControllerChangeMethodName = "InvokeServiceWorkerControllerChange"; - - private static readonly ConcurrentDictionary> MessageListeners = []; - private static readonly ConcurrentDictionary ControllerChangeListeners = []; - - internal static Guid AddMessageListener(Action action) - { - var id = Guid.NewGuid(); - MessageListeners.TryAdd(id, action); - return id; - } - internal static void RemoveMessageListener(Guid id) => MessageListeners.TryRemove(id, out _); - - internal static Guid AddControllerChangeListener(Action action) - { - var id = Guid.NewGuid(); - ControllerChangeListeners.TryAdd(id, action); - return id; - } - internal static void RemoveControllerChangeListener(Guid id) => ControllerChangeListeners.TryRemove(id, out _); - - [JSInvokable(MessageMethodName)] - public static void InvokeMessage(Guid id, JsonElement data) - { - if (MessageListeners.TryGetValue(id, out var l)) l.Invoke(data); - } - - [JSInvokable(ControllerChangeMethodName)] - public static void InvokeControllerChange(Guid id) - { - if (ControllerChangeListeners.TryGetValue(id, out var l)) l.Invoke(); - } -} diff --git a/src/Butil/Bit.Butil/Internals/SpeechRecognition/SpeechRecognitionListenersManager.cs b/src/Butil/Bit.Butil/Internals/SpeechRecognition/SpeechRecognitionListenersManager.cs deleted file mode 100644 index eaba5deb72..0000000000 --- a/src/Butil/Bit.Butil/Internals/SpeechRecognition/SpeechRecognitionListenersManager.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class SpeechRecognitionListenersManager -{ - internal const string ResultMethodName = "InvokeSpeechRecognitionResult"; - internal const string ErrorMethodName = "InvokeSpeechRecognitionError"; - internal const string EndMethodName = "InvokeSpeechRecognitionEnd"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid Add(Listener listener) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, listener); - return id; - } - - internal static void Remove(Guid id) => Listeners.TryRemove(id, out _); - - [JSInvokable(ResultMethodName)] - public static void InvokeResult(Guid id, SpeechRecognitionResult result) - { - if (Listeners.TryGetValue(id, out var l)) l.OnResult?.Invoke(result); - } - - [JSInvokable(ErrorMethodName)] - public static void InvokeError(Guid id, string message) - { - if (Listeners.TryGetValue(id, out var l)) l.OnError?.Invoke(message); - } - - [JSInvokable(EndMethodName)] - public static void InvokeEnd(Guid id) - { - if (Listeners.TryGetValue(id, out var l)) l.OnEnd?.Invoke(); - } - - internal class Listener - { - public Action? OnResult { get; set; } - public Action? OnError { get; set; } - public Action? OnEnd { get; set; } - } -} diff --git a/src/Butil/Bit.Butil/Internals/Storage/StorageListenersManager.cs b/src/Butil/Bit.Butil/Internals/Storage/StorageListenersManager.cs deleted file mode 100644 index 93d07063c4..0000000000 --- a/src/Butil/Bit.Butil/Internals/Storage/StorageListenersManager.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class StorageListenersManager -{ - internal const string InvokeMethodName = "InvokeStorageEvent"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid AddListener(Action action, string area) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action, Area = area }); - return id; - } - - internal static Guid[] RemoveListener(Action action) - { - var toRemove = Listeners.Where(l => l.Value.Action == action).ToArray(); - return toRemove.Select(l => { Listeners.TryRemove(l.Key, out _); return l.Key; }).ToArray(); - } - - internal static void RemoveListeners(Guid[] ids) - { - foreach (var id in ids) Listeners.TryRemove(id, out _); - } - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, StorageEvent evt) - { - if (Listeners.TryGetValue(id, out var listener) && - (string.IsNullOrEmpty(listener.Area) || string.Equals(listener.Area, evt.StorageArea, StringComparison.Ordinal))) - { - listener.Action.Invoke(evt); - } - } - - private class Listener - { - public string Area { get; set; } = string.Empty; - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/VisualViewport/VisualViewportListenersManager.cs b/src/Butil/Bit.Butil/Internals/VisualViewport/VisualViewportListenersManager.cs deleted file mode 100644 index 9f7580fb7a..0000000000 --- a/src/Butil/Bit.Butil/Internals/VisualViewport/VisualViewportListenersManager.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Linq; -using System.Collections.Concurrent; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class VisualViewportListenersManager -{ - internal const string InvokeMethodName = "InvokeVisualViewport"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - - Listeners.TryAdd(id, new Listener { Action = action }); - - return id; - } - - internal static Guid[] RemoveListener(Action action) - { - var listenersToRemove = Listeners.Where(l => l.Value.Action == action).ToArray(); - - return listenersToRemove.Select(l => - { - Listeners.TryRemove(l.Key, out _); - return l.Key; - }).ToArray(); - } - - internal static void RemoveListeners(Guid[] ids) - { - foreach (var id in ids) - { - Listeners.TryRemove(id, out _); - } - } - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id) - { - Listeners.TryGetValue(id, out Listener? listener); - listener?.Action.Invoke(); - } - - private class Listener - { - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Internals/Window/MediaQueryListenersManager.cs b/src/Butil/Bit.Butil/Internals/Window/MediaQueryListenersManager.cs deleted file mode 100644 index 539114cf74..0000000000 --- a/src/Butil/Bit.Butil/Internals/Window/MediaQueryListenersManager.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.JSInterop; - -namespace Bit.Butil; - -public static class MediaQueryListenersManager -{ - internal const string InvokeMethodName = "InvokeMediaQueryChange"; - - private static readonly ConcurrentDictionary Listeners = []; - - internal static Guid AddListener(Action action) - { - var id = Guid.NewGuid(); - Listeners.TryAdd(id, new Listener { Action = action }); - return id; - } - - internal static Guid[] RemoveListener(Action action) - { - var toRemove = Listeners.Where(l => l.Value.Action == action).ToArray(); - - return toRemove.Select(l => - { - Listeners.TryRemove(l.Key, out _); - return l.Key; - }).ToArray(); - } - - internal static void RemoveListeners(Guid[] ids) - { - foreach (var id in ids) - { - Listeners.TryRemove(id, out _); - } - } - - [JSInvokable(InvokeMethodName)] - public static void Invoke(Guid id, MediaQueryList state) - { - Listeners.TryGetValue(id, out Listener? listener); - listener?.Action.Invoke(state); - } - - private class Listener - { - public Action Action { get; set; } = default!; - } -} diff --git a/src/Butil/Bit.Butil/Publics/BroadcastChannel.cs b/src/Butil/Bit.Butil/Publics/BroadcastChannel.cs index 62721abde3..8476a10b3a 100644 --- a/src/Butil/Bit.Butil/Publics/BroadcastChannel.cs +++ b/src/Butil/Bit.Butil/Publics/BroadcastChannel.cs @@ -20,11 +20,36 @@ namespace Bit.Butil; /// public class BroadcastChannel(IJSRuntime js) : IAsyncDisposable { - private readonly ConcurrentDictionary _subscriptions = new(); + internal const string MessageMethodName = nameof(InvokeBroadcastChannelMessage); + internal const string ErrorMethodName = nameof(InvokeBroadcastChannelError); + + private readonly ConcurrentDictionary _subscriptions = new(); + + // Per-instance callback reference (see Keyboard): subscriptions are isolated per circuit / WASM + // app and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); /// True when the runtime exposes BroadcastChannel. public ValueTask IsSupported() => js.Invoke("BitButil.broadcastChannel.isSupported"); + /// + /// Invoked from JS for each channel message. Public + so it can + /// be dispatched through the per-instance . + /// + [JSInvokable(MessageMethodName)] + public void InvokeBroadcastChannelMessage(Guid id, JsonElement data) + { + if (_subscriptions.TryGetValue(id, out var listener)) listener.OnMessage?.Invoke(data); + } + + /// Invoked from JS on a channel messageerror. See . + [JSInvokable(ErrorMethodName)] + public void InvokeBroadcastChannelError(Guid id) + { + if (_subscriptions.TryGetValue(id, out var listener)) listener.OnError?.Invoke(); + } + /// /// Sends to every other listener on /// in the same origin (the sender does not receive its own message — that's the spec). @@ -39,7 +64,8 @@ public class BroadcastChannel(IJSRuntime js) : IAsyncDisposable /// so callers can deserialize into whatever shape they expect. /// Use the returned to detach. /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BroadcastChannelListenersManager))] + [DynamicDependency(nameof(InvokeBroadcastChannelMessage), typeof(BroadcastChannel))] + [DynamicDependency(nameof(InvokeBroadcastChannelError), typeof(BroadcastChannel))] public async Task Subscribe(string channelName, Action? onMessage, Action? onError = null) @@ -47,20 +73,14 @@ public async Task Subscribe(string channelName, if (onMessage is null && onError is null) throw new ArgumentException("At least one of onMessage or onError must be provided."); - var id = BroadcastChannelListenersManager.AddListener(onMessage, onError); - _subscriptions.TryAdd(id, channelName); + var id = Guid.NewGuid(); + _subscriptions.TryAdd(id, new Listener { OnMessage = onMessage, OnError = onError }); - await js.InvokeVoid("BitButil.broadcastChannel.subscribe", - BroadcastChannelListenersManager.MessageMethodName, - BroadcastChannelListenersManager.ErrorMethodName, - id, - channelName); + await js.InvokeVoid("BitButil.broadcastChannel.subscribe", DotNetRef, id, channelName); return new ButilSubscription(id, async () => { - BroadcastChannelListenersManager.RemoveListener(id); _subscriptions.TryRemove(id, out _); - if (OperatingSystem.IsBrowser() is false) return; await js.InvokeVoid("BitButil.broadcastChannel.unsubscribe", id); }); } @@ -75,15 +95,22 @@ public async ValueTask DisposeAsync() _subscriptions.Clear(); foreach (var id in ids) { - BroadcastChannelListenersManager.RemoveListener(id); - if (OperatingSystem.IsBrowser()) - { - await js.InvokeVoid("BitButil.broadcastChannel.unsubscribe", id); - } + await js.InvokeVoid("BitButil.broadcastChannel.unsubscribe", id); } } } catch (JSDisconnectedException) { } + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } GC.SuppressFinalize(this); } + + private class Listener + { + public Action? OnMessage { get; set; } + public Action? OnError { get; set; } + } } diff --git a/src/Butil/Bit.Butil/Publics/Console.cs b/src/Butil/Bit.Butil/Publics/Console.cs index bb49c27a74..e6b0739c07 100644 --- a/src/Butil/Bit.Butil/Publics/Console.cs +++ b/src/Butil/Bit.Butil/Publics/Console.cs @@ -11,7 +11,7 @@ public class Console(IJSRuntime js) /// https://developer.mozilla.org/en-US/docs/Web/API/console/assert_static /// public async Task Assert(bool? condition, params object?[]? args) - => await js.InvokeVoid("BitButil.console.assert", [condition, .. args]); + => await js.InvokeVoidFast("BitButil.console.assert", [condition, .. args ?? []]); /// /// Clear the console. @@ -19,7 +19,7 @@ public async Task Assert(bool? condition, params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/clear_static /// public async Task Clear() - => await js.InvokeVoid("BitButil.console.clear"); + => await js.InvokeVoidFast("BitButil.console.clear"); /// /// Log the number of times this line has been called with the given label. @@ -27,8 +27,8 @@ public async Task Clear() /// https://developer.mozilla.org/en-US/docs/Web/API/console/count_static /// public async Task Count(string? label = null) - => await (label is null ? js.InvokeVoid("BitButil.console.count") - : js.InvokeVoid("BitButil.console.count", label)); + => await (label is null ? js.InvokeVoidFast("BitButil.console.count") + : js.InvokeVoidFast("BitButil.console.count", label)); /// /// Resets the value of the counter with the given label. @@ -36,8 +36,8 @@ public async Task Count(string? label = null) /// https://developer.mozilla.org/en-US/docs/Web/API/console/countreset_static /// public async Task CountReset(string? label = null) - => await (label is null ? js.InvokeVoid("BitButil.console.countReset") - : js.InvokeVoid("BitButil.console.countReset", label)); + => await (label is null ? js.InvokeVoidFast("BitButil.console.countReset") + : js.InvokeVoidFast("BitButil.console.countReset", label)); /// /// Outputs a message to the console with the log level debug. @@ -45,7 +45,7 @@ public async Task CountReset(string? label = null) /// https://developer.mozilla.org/en-US/docs/Web/API/console/debug_static /// public async Task Debug(params object?[]? args) - => await js.InvokeVoid("BitButil.console.debug", args); + => await js.InvokeVoidFast("BitButil.console.debug", args); /// /// Displays an interactive listing of the properties of a specified JavaScript object. @@ -54,7 +54,7 @@ public async Task Debug(params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/dir_static /// public async Task Dir(object? item, object? options = null) - => await js.InvokeVoid("BitButil.console.dir", item, options); + => await js.InvokeVoidFast("BitButil.console.dir", item, options); /// /// Displays an XML/HTML Element representation of the specified object if possible @@ -63,7 +63,7 @@ public async Task Dir(object? item, object? options = null) /// https://developer.mozilla.org/en-US/docs/Web/API/console/dirxml_static /// public async Task Dirxml(params object?[]? args) - => await js.InvokeVoid("BitButil.console.dirxml", args); + => await js.InvokeVoidFast("BitButil.console.dirxml", args); /// /// Outputs an error message. You may use string substitution and additional arguments with this method. @@ -71,7 +71,7 @@ public async Task Dirxml(params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/error_static /// public async Task Error(params object?[]? args) - => await js.InvokeVoid("BitButil.console.error", args); + => await js.InvokeVoidFast("BitButil.console.error", args); /// /// Creates a new inline group, indenting all following output by another level. @@ -80,7 +80,7 @@ public async Task Error(params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/group_static /// public async Task Group(params object?[]? args) - => await js.InvokeVoid("BitButil.console.group", args); + => await js.InvokeVoidFast("BitButil.console.group", args); /// /// Creates a new inline group, indenting all following output by another level. However, unlike console.group() @@ -90,7 +90,7 @@ public async Task Group(params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/groupcollapsed_static /// public async Task GroupCollapsed(params object?[]? args) - => await js.InvokeVoid("BitButil.console.groupCollapsed", args); + => await js.InvokeVoidFast("BitButil.console.groupCollapsed", args); /// /// Exits the current inline group. @@ -98,7 +98,7 @@ public async Task GroupCollapsed(params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/groupend_static /// public async Task GroupEnd() - => await js.InvokeVoid("BitButil.console.groupEnd"); + => await js.InvokeVoidFast("BitButil.console.groupEnd"); /// /// Informative logging of information. You may use string substitution and additional arguments with this method. @@ -106,7 +106,7 @@ public async Task GroupEnd() /// https://developer.mozilla.org/en-US/docs/Web/API/console/info_static /// public async Task Info(params object?[]? args) - => await js.InvokeVoid("BitButil.console.info", args); + => await js.InvokeVoidFast("BitButil.console.info", args); /// /// For general output of logging information. You may use string substitution and additional arguments with this method. @@ -114,7 +114,7 @@ public async Task Info(params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/log_static /// public async Task Log(params object?[]? args) - => await js.InvokeVoid("BitButil.console.log", args); + => await js.InvokeVoidFast("BitButil.console.log", args); /// /// Starts the browser's built-in profiler (for example, the Firefox performance tool). @@ -123,8 +123,8 @@ public async Task Log(params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/profile_static /// public async Task Profile(string? name = null) - => await (name is null ? js.InvokeVoid("BitButil.console.profile") - : js.InvokeVoid("BitButil.console.profile", name)); + => await (name is null ? js.InvokeVoidFast("BitButil.console.profile") + : js.InvokeVoidFast("BitButil.console.profile", name)); /// /// Stops the profiler. You can see the resulting profile in the browser's performance tool @@ -133,8 +133,8 @@ public async Task Profile(string? name = null) /// https://developer.mozilla.org/en-US/docs/Web/API/console/profileend_static /// public async Task ProfileEnd(string? name = null) - => await (name is null ? js.InvokeVoid("BitButil.console.profileEnd") - : js.InvokeVoid("BitButil.console.profileEnd", name)); + => await (name is null ? js.InvokeVoidFast("BitButil.console.profileEnd") + : js.InvokeVoidFast("BitButil.console.profileEnd", name)); /// /// Displays tabular data as a table. @@ -142,8 +142,8 @@ public async Task ProfileEnd(string? name = null) /// https://developer.mozilla.org/en-US/docs/Web/API/console/table_static /// public async Task Table(object? data, object? properties = null) - => await (properties is null ? js.InvokeVoid("BitButil.console.table", data) - : js.InvokeVoid("BitButil.console.table", data, properties)); + => await (properties is null ? js.InvokeVoidFast("BitButil.console.table", data) + : js.InvokeVoidFast("BitButil.console.table", data, properties)); /// /// Starts a timer with a name specified as an input parameter. Up to 10,000 simultaneous timers can run on a given page. @@ -151,8 +151,8 @@ public async Task Table(object? data, object? properties = null) /// https://developer.mozilla.org/en-US/docs/Web/API/console/time_static /// public async Task Time(string? label = null) - => await (label is null ? js.InvokeVoid("BitButil.console.time") - : js.InvokeVoid("BitButil.console.time", label)); + => await (label is null ? js.InvokeVoidFast("BitButil.console.time") + : js.InvokeVoidFast("BitButil.console.time", label)); /// /// Stops the specified timer and logs the elapsed time in milliseconds since it started. @@ -160,8 +160,8 @@ public async Task Time(string? label = null) /// https://developer.mozilla.org/en-US/docs/Web/API/console/timeend_static /// public async Task TimeEnd(string? label = null) - => await (label is null ? js.InvokeVoid("BitButil.console.timeEnd") - : js.InvokeVoid("BitButil.console.timeEnd", label)); + => await (label is null ? js.InvokeVoidFast("BitButil.console.timeEnd") + : js.InvokeVoidFast("BitButil.console.timeEnd", label)); /// /// Logs the value of the specified timer to the console. @@ -169,8 +169,8 @@ public async Task TimeEnd(string? label = null) /// https://developer.mozilla.org/en-US/docs/Web/API/console/timelog_static /// public async Task TimeLog(string? label = null, params object?[]? args) - => await (label is null ? js.InvokeVoid("BitButil.console.timeLog") - : js.InvokeVoid("BitButil.console.timeLog", [label, .. args])); + => await (label is null ? js.InvokeVoidFast("BitButil.console.timeLog") + : js.InvokeVoidFast("BitButil.console.timeLog", [label, .. args ?? []])); /// /// Adds a marker to the browser performance tool's timeline. @@ -178,8 +178,8 @@ public async Task TimeLog(string? label = null, params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/timestamp_static /// public async Task TimeStamp(string? label = null) - => await (label is null ? js.InvokeVoid("BitButil.console.timeStamp") - : js.InvokeVoid("BitButil.console.timeStamp", label)); + => await (label is null ? js.InvokeVoidFast("BitButil.console.timeStamp") + : js.InvokeVoidFast("BitButil.console.timeStamp", label)); /// /// Outputs a stack trace. @@ -187,7 +187,7 @@ public async Task TimeStamp(string? label = null) /// https://developer.mozilla.org/en-US/docs/Web/API/console/trace_static /// public async Task Trace(params object?[]? args) - => await js.InvokeVoid("BitButil.console.trace", args); + => await js.InvokeVoidFast("BitButil.console.trace", args); /// /// Outputs a warning message. @@ -195,5 +195,5 @@ public async Task Trace(params object?[]? args) /// https://developer.mozilla.org/en-US/docs/Web/API/console/warn_static /// public async Task Warn(params object?[]? args) - => await js.InvokeVoid("BitButil.console.warn", args); + => await js.InvokeVoidFast("BitButil.console.warn", args); } diff --git a/src/Butil/Bit.Butil/Publics/Cookie.cs b/src/Butil/Bit.Butil/Publics/Cookie.cs index f9b2d103b4..5683bfb4b4 100644 --- a/src/Butil/Bit.Butil/Publics/Cookie.cs +++ b/src/Butil/Bit.Butil/Publics/Cookie.cs @@ -16,9 +16,19 @@ public class Cookie(IJSRuntime js) /// /// Gets all cookies registered on the current document. /// + /// + /// The browser's document.cookie API exposes only name=value pairs, so each + /// returned has only its and + /// populated. Attributes such as , + /// , , , + /// , and + /// are never returned by the browser and will be at their + /// default values regardless of how the cookie was originally set. HttpOnly cookies are not + /// visible at all. + /// public async Task GetAll() { - var raw = await js.Invoke("BitButil.cookie.get"); + var raw = await js.InvokeFast("BitButil.cookie.get"); if (string.IsNullOrWhiteSpace(raw)) return []; @@ -32,6 +42,10 @@ public async Task GetAll() /// /// Returns a cookie by providing the cookie name. /// + /// + /// Only and are populated; see + /// for why the other attributes can't be read back from the browser. + /// public async Task Get(string name) { var allCookies = await GetAll(); @@ -70,5 +84,5 @@ public async Task Remove(ButilCookie cookie) /// Sets a cookie. /// public async Task Set(ButilCookie cookie) - => await js.InvokeVoid("BitButil.cookie.set", cookie.ToString()); + => await js.InvokeVoidFast("BitButil.cookie.set", cookie.ToString()); } diff --git a/src/Butil/Bit.Butil/Publics/Document.cs b/src/Butil/Bit.Butil/Publics/Document.cs index dd2c00303e..7dd8e1463c 100644 --- a/src/Butil/Bit.Butil/Publics/Document.cs +++ b/src/Butil/Bit.Butil/Publics/Document.cs @@ -13,6 +13,10 @@ public class Document(IJSRuntime js) : IAsyncDisposable // Track listener ids registered through this *instance* so dispose actually drains them. private readonly ConcurrentDictionary<(Guid Id, string Event, bool UseCapture), byte> _listenerIds = new(); + // Per-instance DOM event dispatcher: listeners are isolated per circuit / WASM app and released + // on disposal — no static state, no cross-circuit leak. + private readonly DomEventsInterop _events = new(); + public async Task AddEventListener( string domEvent, Action listener, @@ -20,13 +24,22 @@ public async Task AddEventListener( bool preventDefault = false, bool stopPropagation = false) { - var id = await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation); + var id = await _events.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation); _listenerIds.TryAdd((id, domEvent, useCapture), 0); } + /// + /// Removes a listener previously added with . + /// + /// + /// Listeners are matched by delegate identity, so you must pass the very same + /// instance that was registered. A newly-created lambda will not + /// match and nothing will be removed. For lambdas, prefer , + /// which returns a disposable you can dispose to detach. + /// public async Task RemoveEventListener(string domEvent, Action listener, bool useCapture = false) { - var ids = await DomEventDispatcher.RemoveEventListener(js, ElementName, domEvent, listener, useCapture); + var ids = await _events.RemoveEventListener(js, ElementName, domEvent, listener, useCapture); foreach (var id in ids) _listenerIds.TryRemove((id, domEvent, useCapture), out _); } @@ -41,15 +54,14 @@ public async Task SubscribeEvent( bool preventDefault = false, bool stopPropagation = false) { - var id = await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation); + var id = await _events.AddEventListener(js, ElementName, domEvent, listener, useCapture, preventDefault, stopPropagation); var key = (id, domEvent, useCapture); _listenerIds.TryAdd(key, 0); return new ButilSubscription(id, async () => { _listenerIds.TryRemove(key, out _); - if (OperatingSystem.IsBrowser() is false) return; - await DomEventDispatcher.RemoveEventListenerById(js, ElementName, domEvent, id, useCapture); + await _events.RemoveEventListenerById(js, ElementName, domEvent, id, useCapture); }); } @@ -325,15 +337,17 @@ protected virtual async ValueTask DisposeAsync(bool disposing) var snapshot = _listenerIds.Keys.ToArray(); _listenerIds.Clear(); - if (OperatingSystem.IsBrowser() is false) return; - try { foreach (var (id, evt, useCapture) in snapshot) { - await DomEventDispatcher.RemoveEventListenerById(js, ElementName, evt, id, useCapture); + await _events.RemoveEventListenerById(js, ElementName, evt, id, useCapture); } } catch (JSDisconnectedException) { } // we can ignore this exception here + finally + { + _events.Dispose(); + } } } diff --git a/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs b/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs index 1d052a4711..254694e676 100644 --- a/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs +++ b/src/Butil/Bit.Butil/Publics/Element/ElementReferenceEventExtensions.cs @@ -11,9 +11,10 @@ namespace Bit.Butil; /// component without hand-rolling Add/Remove pairs. /// /// -/// Internally this routes through the same per-element JS plumbing used by Document and Window, -/// so all the typed event-arg classes (, , etc.) -/// are available with no extra wiring. +/// Each subscription owns a per-subscription (there is no +/// long-lived service instance to host it, since these are extension methods). The reference — and +/// therefore all captured component state — is released when the returned subscription is disposed, +/// so there is no static state and no cross-circuit bleed. /// public static class ElementReferenceEventExtensions { @@ -36,17 +37,17 @@ public static async Task SubscribeEvent( // Each element gets a generated id so the JS side can target it directly. var elementId = Guid.NewGuid().ToString("N"); - var members = ResolveMembers(argType); - var methodName = ResolveMethodName(argType); - var listenerId = RegisterListener(argType, listener, elementId, useCapture); + var host = new DomEventsInterop(); + var (listenerId, methodName, members, dotNetRef) = host.Register(listener, elementId, useCapture); - var options = useCapture; + object options = useCapture; await js.InvokeVoid("BitButil.element.subscribeEvent", element, elementId, domEvent, methodName, + dotNetRef, listenerId, members, options, @@ -55,81 +56,15 @@ await js.InvokeVoid("BitButil.element.subscribeEvent", return new ButilSubscription(listenerId, async () => { - UnregisterListener(argType, listenerId); - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.element.unsubscribeEvent", elementId, domEvent, listenerId, options); + host.Unregister(listenerId); + try + { + await js.InvokeVoid("BitButil.element.unsubscribeEvent", elementId, domEvent, listenerId, options); + } + finally + { + host.Dispose(); + } }); } - - private static string[] ResolveMembers(Type argType) - { - if (argType == typeof(ButilKeyboardEventArgs)) return ButilKeyboardEventArgs.EventArgsMembers; - if (argType == typeof(ButilMouseEventArgs)) return ButilMouseEventArgs.EventArgsMembers; - if (argType == typeof(ButilPointerEventArgs)) return ButilPointerEventArgs.EventArgsMembers; - if (argType == typeof(ButilWheelEventArgs)) return ButilWheelEventArgs.EventArgsMembers; - if (argType == typeof(ButilTouchEventArgs)) return ButilTouchEventArgs.EventArgsMembers; - if (argType == typeof(ButilFocusEventArgs)) return ButilFocusEventArgs.EventArgsMembers; - if (argType == typeof(ButilInputEventArgs)) return ButilInputEventArgs.EventArgsMembers; - if (argType == typeof(ButilDragEventArgs)) return ButilDragEventArgs.EventArgsMembers; - if (argType == typeof(ButilClipboardEventArgs)) return ButilClipboardEventArgs.EventArgsMembers; - if (argType == typeof(ButilCompositionEventArgs)) return ButilCompositionEventArgs.EventArgsMembers; - return []; - } - - private static string ResolveMethodName(Type argType) - { - if (argType == typeof(ButilKeyboardEventArgs)) return DomKeyboardEventListenersManager.InvokeMethodName; - if (argType == typeof(ButilMouseEventArgs)) return DomMouseEventListenersManager.InvokeMethodName; - if (argType == typeof(ButilPointerEventArgs)) return DomPointerEventListenersManager.InvokeMethodName; - if (argType == typeof(ButilWheelEventArgs)) return DomWheelEventListenersManager.InvokeMethodName; - if (argType == typeof(ButilTouchEventArgs)) return DomTouchEventListenersManager.InvokeMethodName; - if (argType == typeof(ButilFocusEventArgs)) return DomFocusEventListenersManager.InvokeMethodName; - if (argType == typeof(ButilInputEventArgs)) return DomInputEventListenersManager.InvokeMethodName; - if (argType == typeof(ButilDragEventArgs)) return DomDragEventListenersManager.InvokeMethodName; - if (argType == typeof(ButilClipboardEventArgs)) return DomClipboardEventListenersManager.InvokeMethodName; - if (argType == typeof(ButilCompositionEventArgs)) return DomCompositionEventListenersManager.InvokeMethodName; - return DomEventListenersManager.InvokeMethodName; - } - - private static Guid RegisterListener(Type argType, Action listener, string elementId, bool useCapture) - { - // The existing element-scoped store key is the elementId — we reuse the same managers. - object options = useCapture; - if (argType == typeof(ButilKeyboardEventArgs)) - return DomKeyboardEventListenersManager.SetListener((listener as Action)!, elementId, options); - if (argType == typeof(ButilMouseEventArgs)) - return DomMouseEventListenersManager.SetListener((listener as Action)!, elementId, options); - if (argType == typeof(ButilPointerEventArgs)) - return DomPointerEventListenersManager.SetListener((listener as Action)!, elementId, options); - if (argType == typeof(ButilWheelEventArgs)) - return DomWheelEventListenersManager.SetListener((listener as Action)!, elementId, options); - if (argType == typeof(ButilTouchEventArgs)) - return DomTouchEventListenersManager.SetListener((listener as Action)!, elementId, options); - if (argType == typeof(ButilFocusEventArgs)) - return DomFocusEventListenersManager.SetListener((listener as Action)!, elementId, options); - if (argType == typeof(ButilInputEventArgs)) - return DomInputEventListenersManager.SetListener((listener as Action)!, elementId, options); - if (argType == typeof(ButilDragEventArgs)) - return DomDragEventListenersManager.SetListener((listener as Action)!, elementId, options); - if (argType == typeof(ButilClipboardEventArgs)) - return DomClipboardEventListenersManager.SetListener((listener as Action)!, elementId, options); - if (argType == typeof(ButilCompositionEventArgs)) - return DomCompositionEventListenersManager.SetListener((listener as Action)!, elementId, options); - return DomEventListenersManager.SetListener((listener as Action)!, elementId, options); - } - - private static void UnregisterListener(Type argType, Guid id) - { - if (argType == typeof(ButilKeyboardEventArgs)) { DomKeyboardEventListenersManager.RemoveById(id); return; } - if (argType == typeof(ButilMouseEventArgs)) { DomMouseEventListenersManager.RemoveById(id); return; } - if (argType == typeof(ButilPointerEventArgs)) { DomPointerEventListenersManager.RemoveById(id); return; } - if (argType == typeof(ButilWheelEventArgs)) { DomWheelEventListenersManager.RemoveById(id); return; } - if (argType == typeof(ButilTouchEventArgs)) { DomTouchEventListenersManager.RemoveById(id); return; } - if (argType == typeof(ButilFocusEventArgs)) { DomFocusEventListenersManager.RemoveById(id); return; } - if (argType == typeof(ButilInputEventArgs)) { DomInputEventListenersManager.RemoveById(id); return; } - if (argType == typeof(ButilDragEventArgs)) { DomDragEventListenersManager.RemoveById(id); return; } - if (argType == typeof(ButilClipboardEventArgs)) { DomClipboardEventListenersManager.RemoveById(id); return; } - if (argType == typeof(ButilCompositionEventArgs)) { DomCompositionEventListenersManager.RemoveById(id); return; } - DomEventListenersManager.RemoveById(id); - } } diff --git a/src/Butil/Bit.Butil/Publics/Events/ButilMouseEventArgs.cs b/src/Butil/Bit.Butil/Publics/Events/ButilMouseEventArgs.cs index f5a0b6557d..51574593af 100644 --- a/src/Butil/Bit.Butil/Publics/Events/ButilMouseEventArgs.cs +++ b/src/Butil/Bit.Butil/Publics/Events/ButilMouseEventArgs.cs @@ -89,7 +89,10 @@ public class ButilMouseEventArgs : EventArgs public double PageY { get; set; } /// - /// The secondary target for the event, if there is one. + /// The id of the secondary target for the event (for example the element the pointer + /// is entering/leaving), or an empty string when there is no related target or the + /// related element has no id. The underlying DOM node can't be marshaled across JS + /// interop, so only its id is surfaced here. /// public string RelatedTarget { get; set; } = string.Empty; diff --git a/src/Butil/Bit.Butil/Publics/Fetch.cs b/src/Butil/Bit.Butil/Publics/Fetch.cs index c6050b6216..6eb13953e9 100644 --- a/src/Butil/Bit.Butil/Publics/Fetch.cs +++ b/src/Butil/Bit.Butil/Publics/Fetch.cs @@ -11,17 +11,36 @@ namespace Bit.Butil; /// HttpClient for normal API calls; reach for this when you need progress for big /// downloads or fetch-only features (CORS modes, no-cors, etc.). /// -public class Fetch(IJSRuntime js) +public class Fetch(IJSRuntime js) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokeFetchProgress); + + private readonly System.Collections.Concurrent.ConcurrentDictionary> _progressHandlers = new(); + + // Per-instance callback reference (see Keyboard): progress callbacks are isolated per circuit / + // WASM app and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + /// + /// Invoked from JS as bytes arrive. Public + so it can be + /// dispatched through the per-instance . + /// + [JSInvokable(InvokeMethodName)] + public void InvokeFetchProgress(Guid id, FetchProgress progress) + { + if (_progressHandlers.TryGetValue(id, out var handler)) handler.Invoke(progress); + } + /// /// Sends the request and returns the full response. /// /// Optional callback fired as bytes arrive. /// When triggered, aborts the request. + [DynamicDependency(nameof(InvokeFetchProgress), typeof(Fetch))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchRequest))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchResponse))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchProgress))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(FetchProgressListenersManager))] public async Task Send(FetchRequest request, Action? onProgress = null, CancellationToken cancellationToken = default) @@ -30,7 +49,7 @@ public async Task Send(FetchRequest request, var id = Guid.NewGuid(); if (onProgress is not null) - FetchProgressListenersManager.AddListener(id, onProgress); + _progressHandlers.TryAdd(id, onProgress); var registration = cancellationToken.CanBeCanceled ? cancellationToken.Register(static state => @@ -45,12 +64,12 @@ public async Task Send(FetchRequest request, { return await js.Invoke("BitButil.fetch.send", cancellationToken, - id, request, FetchProgressListenersManager.InvokeMethodName, onProgress is not null); + id, request, onProgress is not null ? DotNetRef : null, onProgress is not null); } finally { registration.Dispose(); - FetchProgressListenersManager.RemoveListener(id); + _progressHandlers.TryRemove(id, out _); } } @@ -67,4 +86,12 @@ public async Task Start(FetchRequest request) await js.InvokeVoid("BitButil.fetch.start", id, request); return new AbortableFetch(js, id); } + + public ValueTask DisposeAsync() + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } } diff --git a/src/Butil/Bit.Butil/Publics/Geolocation.cs b/src/Butil/Bit.Butil/Publics/Geolocation.cs index 5004bbb4c5..98f5a8225a 100644 --- a/src/Butil/Bit.Butil/Publics/Geolocation.cs +++ b/src/Butil/Bit.Butil/Publics/Geolocation.cs @@ -13,7 +13,15 @@ namespace Bit.Butil; /// public class Geolocation(IJSRuntime js) : IAsyncDisposable { - private readonly ConcurrentDictionary _watchIds = new(); + internal const string PositionMethodName = nameof(InvokePosition); + internal const string ErrorMethodName = nameof(InvokeError); + + private readonly ConcurrentDictionary _watches = new(); + + // Per-instance callback reference: watches live on this (scoped) instance, so they're isolated + // per circuit / WASM app and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); /// True when the runtime exposes navigator.geolocation. public async ValueTask IsSupported() @@ -35,6 +43,33 @@ public async Task GetCurrentPosition(GeolocationOptions? op throw ToException(result); } + /// + /// Invoked from JS for each watch position update. Public + + /// so it can be dispatched through the per-instance . + /// + [JSInvokable(PositionMethodName)] + public void InvokePosition(Guid id, GeolocationPosition position) + { + if (_watches.TryGetValue(id, out var listener)) listener.OnPosition?.Invoke(position); + } + + /// Invoked from JS when a watch errors. See . + [JSInvokable(ErrorMethodName)] + public void InvokeError(Guid id, int code, string message) + { + if (_watches.TryGetValue(id, out var listener)) + { + var enumCode = code switch + { + 1 => GeolocationErrorCode.PermissionDenied, + 2 => GeolocationErrorCode.PositionUnavailable, + 3 => GeolocationErrorCode.Timeout, + _ => GeolocationErrorCode.Unknown, + }; + listener.OnError?.Invoke(new GeolocationException(enumCode, message)); + } + } + /// /// Subscribes to continuous position updates. Use with the /// returned id to stop. The handler runs on the Blazor sync context. @@ -42,7 +77,6 @@ public async Task GetCurrentPosition(GeolocationOptions? op [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationPosition))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationCoordinates))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationOptions))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationListenersManager))] public async Task Watch(Action? onPosition, Action? onError = null, GeolocationOptions? options = null) @@ -50,14 +84,10 @@ public async Task Watch(Action? onPosition, if (onPosition is null && onError is null) throw new ArgumentException("At least one of onPosition or onError must be provided."); - var id = GeolocationListenersManager.AddListener(onPosition, onError); - _watchIds.TryAdd(id, 0); + var id = Guid.NewGuid(); + _watches.TryAdd(id, new Listener { OnPosition = onPosition, OnError = onError }); - await js.InvokeVoid("BitButil.geolocation.watchPosition", - GeolocationListenersManager.PositionMethodName, - GeolocationListenersManager.ErrorMethodName, - id, - options); + await js.InvokeVoid("BitButil.geolocation.watchPosition", DotNetRef, id, options); return id; } @@ -65,10 +95,8 @@ await js.InvokeVoid("BitButil.geolocation.watchPosition", /// Stops a previously registered watch. public async ValueTask ClearWatch(Guid id) { - _watchIds.TryRemove(id, out _); - GeolocationListenersManager.RemoveListeners([id]); + _watches.TryRemove(id, out _); - if (OperatingSystem.IsBrowser() is false) return; await js.InvokeVoid("BitButil.geolocation.clearWatch", id); } @@ -78,7 +106,6 @@ public async ValueTask ClearWatch(Guid id) [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationPosition))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationCoordinates))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationOptions))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GeolocationListenersManager))] public async Task SubscribeWatch(Action? onPosition, Action? onError = null, GeolocationOptions? options = null) @@ -90,11 +117,9 @@ public async Task SubscribeWatch(Action? /// Stops every watch this instance has started. public async ValueTask ClearAllWatches() { - if (_watchIds.IsEmpty) return; - var ids = _watchIds.Keys.ToArray(); - _watchIds.Clear(); - GeolocationListenersManager.RemoveListeners(ids); - if (OperatingSystem.IsBrowser() is false) return; + if (_watches.IsEmpty) return; + var ids = _watches.Keys.ToArray(); + _watches.Clear(); foreach (var id in ids) { await js.InvokeVoid("BitButil.geolocation.clearWatch", id); @@ -105,6 +130,11 @@ public async ValueTask DisposeAsync() { try { await ClearAllWatches(); } catch (JSDisconnectedException) { } + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } GC.SuppressFinalize(this); } @@ -120,6 +150,12 @@ private static GeolocationException ToException(GeolocationCallResult result) return new GeolocationException(code, result.ErrorMessage ?? "Geolocation request failed."); } + private class Listener + { + public Action? OnPosition { get; set; } + public Action? OnError { get; set; } + } + /// Internal — shape used to bridge a once-off call's success/error path. public class GeolocationCallResult { diff --git a/src/Butil/Bit.Butil/Publics/History.cs b/src/Butil/Bit.Butil/Publics/History.cs index af9714ae41..5b04893ede 100644 --- a/src/Butil/Bit.Butil/Publics/History.cs +++ b/src/Butil/Bit.Butil/Publics/History.cs @@ -14,8 +14,25 @@ namespace Bit.Butil; /// public class History(IJSRuntime js) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokeHistoryPopState); + private readonly ConcurrentDictionary> _handlers = new(); + // Per-instance callback reference (see Keyboard/Geolocation): listeners are isolated per + // circuit / WASM app and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + /// + /// Invoked from JS on popstate. Public + so it can be + /// dispatched through the per-instance . + /// + [JSInvokable(InvokeMethodName)] + public void InvokeHistoryPopState(Guid id, object state) + { + if (_handlers.TryGetValue(id, out var handler)) handler.Invoke(state); + } + /// /// Returns an Integer representing the number of elements in the session history, including the currently loaded page. /// For example, for a page loaded in a new tab this property returns 1. @@ -119,13 +136,12 @@ public async Task ReplaceState(object? state = null, string? url = null) ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event ///
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(HistoryListenersManager))] public async ValueTask AddPopState(Action handler) { - var listenerId = HistoryListenersManager.AddListener(handler); + var listenerId = Guid.NewGuid(); _handlers.TryAdd(listenerId, handler); - await js.InvokeVoid("BitButil.history.addPopState", HistoryListenersManager.InvokeMethodName, listenerId); + await js.InvokeVoid("BitButil.history.addPopState", DotNetRef, listenerId); return listenerId; } @@ -134,7 +150,6 @@ public async ValueTask AddPopState(Action handler) /// Subscribes to popstate and returns an handle that /// detaches the listener when disposed. Pair with await using. /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(HistoryListenersManager))] public async ValueTask SubscribePopState(Action handler) { var id = await AddPopState(handler); @@ -147,9 +162,16 @@ public async ValueTask SubscribePopState(Action handl ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event /// + /// + /// Listeners are matched by delegate identity, so you must pass the very same + /// instance that was registered. A newly-created lambda will not + /// match and the returned array will be empty. To avoid this, keep the + /// returned by and remove by id, or use + /// which returns a disposable . + /// public async ValueTask RemovePopState(Action handler) { - var ids = HistoryListenersManager.RemoveListener(handler); + var ids = _handlers.Where(h => h.Value == handler).Select(h => h.Key).ToArray(); await RemovePopState(ids); @@ -164,8 +186,6 @@ public async ValueTask RemovePopState(Action handler) /// public async ValueTask RemovePopState(Guid id) { - HistoryListenersManager.RemoveListeners([id]); - await RemovePopState([id]); } @@ -189,15 +209,11 @@ public async ValueTask RemoveAllPopStates() _handlers.Clear(); - HistoryListenersManager.RemoveListeners(ids); - await RemoveFromJs(ids); } private async ValueTask RemoveFromJs(Guid[] ids) { - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.history.removePopState", ids); } @@ -217,5 +233,10 @@ protected virtual async ValueTask DisposeAsync(bool disposing) await RemoveAllPopStates(); } catch (JSDisconnectedException) { } // we can ignore this exception here + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } } } diff --git a/src/Butil/Bit.Butil/Publics/IdleDetector.cs b/src/Butil/Bit.Butil/Publics/IdleDetector.cs index 8233078068..36012cd835 100644 --- a/src/Butil/Bit.Butil/Publics/IdleDetector.cs +++ b/src/Butil/Bit.Butil/Publics/IdleDetector.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -10,8 +12,17 @@ namespace Bit.Butil; /// Requires the idle-detection permission, which the browser will prompt for on first /// . /// -public class IdleDetector(IJSRuntime js) +public class IdleDetector(IJSRuntime js) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokeIdleDetector); + + private readonly ConcurrentDictionary> _handlers = new(); + + // Per-instance callback reference (see Keyboard): watches are isolated per circuit / WASM app + // and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + /// True when the runtime exposes IdleDetector. public ValueTask IsSupported() => js.Invoke("BitButil.idleDetector.isSupported"); @@ -34,27 +45,55 @@ private async ValueTask RequestPermissionInternal() }; } + /// + /// Invoked from JS on each idle state change. Public + so it + /// can be dispatched through the per-instance . + /// + [JSInvokable(InvokeMethodName)] + public void InvokeIdleDetector(Guid id, IdleState state) + { + if (_handlers.TryGetValue(id, out var handler)) handler.Invoke(state); + } + /// /// Starts watching for idle changes. The handler fires whenever user/screen state changes. /// /// Idle threshold in seconds. Spec minimum is 60. + [DynamicDependency(nameof(InvokeIdleDetector), typeof(IdleDetector))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IdleState))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IdleDetectorListenersManager))] public async Task Start(int threshold, Action handler) { if (threshold < 60) threshold = 60; - var id = IdleDetectorListenersManager.AddListener(handler); - await js.InvokeVoid("BitButil.idleDetector.start", - IdleDetectorListenersManager.InvokeMethodName, - id, - threshold); + var id = Guid.NewGuid(); + _handlers.TryAdd(id, handler); + + await js.InvokeVoid("BitButil.idleDetector.start", DotNetRef, id, threshold); return new ButilSubscription(id, async () => { - IdleDetectorListenersManager.RemoveListener(id); - if (OperatingSystem.IsBrowser() is false) return; + _handlers.TryRemove(id, out _); await js.InvokeVoid("BitButil.idleDetector.stop", id); }); } + + public async ValueTask DisposeAsync() + { + try + { + var ids = _handlers.Keys.ToArray(); + _handlers.Clear(); + foreach (var id in ids) + { + await js.InvokeVoid("BitButil.idleDetector.stop", id); + } + } + catch (JSDisconnectedException) { } + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } + GC.SuppressFinalize(this); + } } diff --git a/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverExtensions.cs b/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverExtensions.cs index cbb1cbd0f3..5a3d717bd8 100644 --- a/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverExtensions.cs +++ b/src/Butil/Bit.Butil/Publics/IntersectionObserver/IntersectionObserverExtensions.cs @@ -18,7 +18,6 @@ public static class IntersectionObserverExtensions /// [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IntersectionObserverEntry))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IntersectionObserverOptions))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IntersectionObserverListenersManager))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Rect))] public static async Task ObserveIntersection( this ElementReference element, @@ -26,19 +25,19 @@ public static async Task ObserveIntersection( Action handler, IntersectionObserverOptions? options = null) { - var listenerId = IntersectionObserverListenersManager.AddListener(handler); + var host = new IntersectionObserverInterop(handler); + var listenerId = Guid.NewGuid(); await js.InvokeVoid("BitButil.intersectionObserver.observe", - IntersectionObserverListenersManager.InvokeMethodName, + host.DotNetRef, listenerId, element, options); return new ButilSubscription(listenerId, async () => { - IntersectionObserverListenersManager.RemoveListener(listenerId); - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.intersectionObserver.unobserve", listenerId); + try { await js.InvokeVoid("BitButil.intersectionObserver.unobserve", listenerId); } + finally { host.Dispose(); } }); } } diff --git a/src/Butil/Bit.Butil/Publics/Keyboard.cs b/src/Butil/Bit.Butil/Publics/Keyboard.cs index b9e6968936..e0962e0108 100644 --- a/src/Butil/Bit.Butil/Publics/Keyboard.cs +++ b/src/Butil/Bit.Butil/Publics/Keyboard.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -9,16 +8,35 @@ namespace Bit.Butil; public class Keyboard(IJSRuntime js) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokeKeyboard); + private readonly ConcurrentDictionary _handlers = new(); - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(KeyboardListenersManager))] + // One DotNetObjectReference per service instance. Because the listeners live on this (scoped) + // instance instead of in static state, they are isolated per Blazor circuit / WASM app and are + // released when the instance is disposed — no cross-circuit bleed and no leak when a circuit + // drops without an explicit Remove. Created lazily so prerender/SSR (which never adds listeners) + // doesn't allocate one. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + /// + /// Invoked from JS when a registered shortcut fires. Public + + /// so it can be called through the per-instance . + /// + [JSInvokable(InvokeMethodName)] + public void InvokeKeyboard(Guid id) + { + if (_handlers.TryGetValue(id, out var handler)) handler.Invoke(); + } + public async Task Add(string code, Action handler, ButilModifiers modifiers = ButilModifiers.None, bool preventDefault = true, bool stopPropagation = true, bool repeat = false) { - var listenerId = KeyboardListenersManager.AddListener(handler); + var listenerId = Guid.NewGuid(); _handlers.TryAdd(listenerId, handler); await js.InvokeVoid("BitButil.keyboard.add", - KeyboardListenersManager.InvokeMethodName, + DotNetRef, listenerId, code, modifiers.HasFlag(ButilModifiers.Alt), @@ -36,7 +54,6 @@ await js.InvokeVoid("BitButil.keyboard.add", /// Same as but returns an handle that /// detaches the shortcut when disposed. /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(KeyboardListenersManager))] public async Task Subscribe(string code, Action handler, ButilModifiers modifiers = ButilModifiers.None, bool preventDefault = true, @@ -51,7 +68,6 @@ public async Task Subscribe(string code, Action handler, /// Element-scoped variant of : the shortcut only fires while the given /// element (or one of its descendants) has focus or receives the keyboard event. /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(KeyboardListenersManager))] public async Task SubscribeOn(Microsoft.AspNetCore.Components.ElementReference element, string code, Action handler, ButilModifiers modifiers = ButilModifiers.None, @@ -59,11 +75,11 @@ public async Task SubscribeOn(Microsoft.AspNetCore.Components bool stopPropagation = true, bool repeat = false) { - var listenerId = KeyboardListenersManager.AddListener(handler); + var listenerId = Guid.NewGuid(); _handlers.TryAdd(listenerId, handler); await js.InvokeVoid("BitButil.keyboard.addOn", - KeyboardListenersManager.InvokeMethodName, + DotNetRef, listenerId, element, code, @@ -78,9 +94,19 @@ await js.InvokeVoid("BitButil.keyboard.addOn", return new ButilSubscription(listenerId, () => Remove(listenerId)); } + /// + /// Removes a previously added keyboard shortcut by its handler. + /// + /// + /// Listeners are matched by delegate identity, so you must pass the very same + /// instance that was registered. A newly-created lambda will not + /// match and the returned array will be empty. To avoid this, keep the + /// returned by and call , or use + /// which returns a disposable . + /// public async ValueTask Remove(Action handler) { - var ids = KeyboardListenersManager.RemoveListener(handler); + var ids = _handlers.Where(h => h.Value == handler).Select(h => h.Key).ToArray(); await Remove(ids); @@ -89,8 +115,6 @@ public async ValueTask Remove(Action handler) public async ValueTask Remove(Guid id) { - KeyboardListenersManager.RemoveListeners([id]); - await Remove([id]); } @@ -114,15 +138,11 @@ public async ValueTask RemoveAll() _handlers.Clear(); - KeyboardListenersManager.RemoveListeners(ids); - await RemoveFromJs(ids); } private async ValueTask RemoveFromJs(Guid[] ids) { - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.keyboard.remove", ids); } @@ -142,5 +162,10 @@ protected virtual async ValueTask DisposeAsync(bool disposing) await RemoveAll(); } catch (JSDisconnectedException) { } // we can ignore this exception here + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } } } diff --git a/src/Butil/Bit.Butil/Publics/Location.cs b/src/Butil/Bit.Butil/Publics/Location.cs index c50e516b2b..fceaf31495 100644 --- a/src/Butil/Bit.Butil/Publics/Location.cs +++ b/src/Butil/Bit.Butil/Publics/Location.cs @@ -19,14 +19,14 @@ public class Location(IJSRuntime js) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/href /// public async Task GetHref() - => await js.Invoke("BitButil.location.getHref"); + => await js.InvokeFast("BitButil.location.getHref"); /// /// Sets the href of the location andn then the associated document navigates to the new page. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Location/href ///
public async Task SetHref(string value) - => await js.InvokeVoid("BitButil.location.setHref", value); + => await js.InvokeVoidFast("BitButil.location.setHref", value); /// /// A string containing the protocol scheme of the URL, including the final ':'. @@ -34,14 +34,14 @@ public async Task SetHref(string value) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/protocol /// public async Task GetProtocol() - => await js.Invoke("BitButil.location.getProtocol"); + => await js.InvokeFast("BitButil.location.getProtocol"); /// /// Sets the protocol scheme of the URL and then the associated document navigates to the new page. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Location/protocol ///
public async Task SetProtocol(string value) - => await js.InvokeVoid("BitButil.location.setProtocol", value); + => await js.InvokeVoidFast("BitButil.location.setProtocol", value); /// /// A string containing the host, that is the hostname, a ':', and the port of the URL. @@ -49,14 +49,14 @@ public async Task SetProtocol(string value) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/host /// public async Task GetHost() - => await js.Invoke("BitButil.location.getHost"); + => await js.InvokeFast("BitButil.location.getHost"); /// /// Sets the host of the location and then the associated document navigates to the new page. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Location/host ///
public async Task SetHost(string value) - => await js.InvokeVoid("BitButil.location.setHost", value); + => await js.InvokeVoidFast("BitButil.location.setHost", value); /// /// A string containing the domain of the URL. @@ -64,14 +64,14 @@ public async Task SetHost(string value) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/hostname /// public async Task GetHostname() - => await js.Invoke("BitButil.location.getHostname"); + => await js.InvokeFast("BitButil.location.getHostname"); /// /// Sets the hostname of the location and then the associated document navigates to the new page. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Location/hostname ///
public async Task SetHostname(string value) - => await js.InvokeVoid("BitButil.location.setHostname", value); + => await js.InvokeVoidFast("BitButil.location.setHostname", value); /// /// A string containing the port number of the URL. @@ -79,14 +79,14 @@ public async Task SetHostname(string value) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/port /// public async Task GetPort() - => await js.Invoke("BitButil.location.getPort"); + => await js.InvokeFast("BitButil.location.getPort"); /// /// Sets the port of the location and then the associated document navigates to the new page. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Location/port ///
public async Task SetPort(string value) - => await js.InvokeVoid("BitButil.location.setPort", value); + => await js.InvokeVoidFast("BitButil.location.setPort", value); /// /// A string containing an initial '/' followed by the path of the URL, not including the query string or fragment. @@ -94,14 +94,14 @@ public async Task SetPort(string value) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname /// public async Task GetPathname() - => await js.Invoke("BitButil.location.getPathname"); + => await js.InvokeFast("BitButil.location.getPathname"); /// /// Sets the pathname of the location and then the associated document navigates to the new page. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname ///
public async Task SetPathname(string value) - => await js.InvokeVoid("BitButil.location.setPathname", value); + => await js.InvokeVoidFast("BitButil.location.setPathname", value); /// /// A string containing a '?' followed by the parameters or "querystring" of the URL. @@ -109,14 +109,14 @@ public async Task SetPathname(string value) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/search /// public async Task GetSearch() - => await js.Invoke("BitButil.location.getSearch"); + => await js.InvokeFast("BitButil.location.getSearch"); /// /// Sets the search of the location and then the associated document navigates to the new page. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Location/search ///
public async Task SetSearch(string value) - => await js.InvokeVoid("BitButil.location.setSearch", value); + => await js.InvokeVoidFast("BitButil.location.setSearch", value); /// /// A string containing a '#' followed by the fragment identifier of the URL. @@ -124,14 +124,14 @@ public async Task SetSearch(string value) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/hash /// public async Task GetHash() - => await js.Invoke("BitButil.location.getHash"); + => await js.InvokeFast("BitButil.location.getHash"); /// /// Sets the hash of the location and then the associated document navigates to the new page. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Location/hash ///
public async Task SetHash(string value) - => await js.InvokeVoid("BitButil.location.setHash", value); + => await js.InvokeVoidFast("BitButil.location.setHash", value); /// /// Returns a string containing the canonical form of the origin of the specific location. @@ -139,7 +139,7 @@ public async Task SetHash(string value) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/origin /// public async Task GetOrigin() - => await js.Invoke("BitButil.location.origin"); + => await js.InvokeFast("BitButil.location.origin"); /// /// Loads the resource at the URL provided in parameter. @@ -147,7 +147,7 @@ public async Task GetOrigin() /// https://developer.mozilla.org/en-US/docs/Web/API/Location/assign /// public async Task Assign(string url) - => await js.InvokeVoid("BitButil.location.assign", url); + => await js.InvokeVoidFast("BitButil.location.assign", url); /// /// Reloads the current URL, like the Refresh button. @@ -155,7 +155,7 @@ public async Task Assign(string url) /// https://developer.mozilla.org/en-US/docs/Web/API/Location/reload /// public async Task Reload() - => await js.InvokeVoid("BitButil.location.reload"); + => await js.InvokeVoidFast("BitButil.location.reload"); /// /// Replaces the current resource with the one at the provided URL (redirects to the provided URL). @@ -163,5 +163,5 @@ public async Task Reload() /// https://developer.mozilla.org/en-US/docs/Web/API/Location/replace /// public async Task Replace(string url) - => await js.InvokeVoid("BitButil.location.replace", url); + => await js.InvokeVoidFast("BitButil.location.replace", url); } diff --git a/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverExtensions.cs b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverExtensions.cs index 934ad40303..dc9d992a55 100644 --- a/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverExtensions.cs +++ b/src/Butil/Bit.Butil/Publics/MutationObserver/MutationObserverExtensions.cs @@ -17,7 +17,6 @@ public static class MutationObserverExtensions /// [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MutationRecord))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MutationObserverOptions))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MutationObserverListenersManager))] public static async Task ObserveMutations( this ElementReference element, IJSRuntime js, @@ -28,19 +27,19 @@ public static async Task ObserveMutations( // (watching for nodes being added/removed inside a region). options ??= new MutationObserverOptions { ChildList = true, Subtree = true }; - var listenerId = MutationObserverListenersManager.AddListener(handler); + var host = new MutationObserverInterop(handler); + var listenerId = Guid.NewGuid(); await js.InvokeVoid("BitButil.mutationObserver.observe", - MutationObserverListenersManager.InvokeMethodName, + host.DotNetRef, listenerId, element, options); return new ButilSubscription(listenerId, async () => { - MutationObserverListenersManager.RemoveListener(listenerId); - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.mutationObserver.unobserve", listenerId); + try { await js.InvokeVoid("BitButil.mutationObserver.unobserve", listenerId); } + finally { host.Dispose(); } }); } } diff --git a/src/Butil/Bit.Butil/Publics/Nfc.cs b/src/Butil/Bit.Butil/Publics/Nfc.cs index f85d5087f1..583368c459 100644 --- a/src/Butil/Bit.Butil/Publics/Nfc.cs +++ b/src/Butil/Bit.Butil/Publics/Nfc.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -9,31 +11,56 @@ namespace Bit.Butil; /// Wraps the Web NFC API /// (NDEFReader). Available on Chromium for Android only. /// -public class Nfc(IJSRuntime js) +public class Nfc(IJSRuntime js) : IAsyncDisposable { + internal const string ReadingMethodName = nameof(InvokeNdefReading); + internal const string ErrorMethodName = nameof(InvokeNdefError); + + private readonly ConcurrentDictionary _listeners = new(); + + // Per-instance callback reference (see Keyboard): scans are isolated per circuit / WASM app + // and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + /// True when the runtime exposes NDEFReader. public ValueTask IsSupported() => js.Invoke("BitButil.nfc.isSupported"); + /// + /// Invoked from JS when a tag is read. Public + so it can be + /// dispatched through the per-instance . + /// + [JSInvokable(ReadingMethodName)] + public void InvokeNdefReading(Guid id, NdefMessage message) + { + if (_listeners.TryGetValue(id, out var l)) l.OnReading?.Invoke(message); + } + + /// Invoked from JS on a scan/read error. See . + [JSInvokable(ErrorMethodName)] + public void InvokeNdefError(Guid id, string message) + { + if (_listeners.TryGetValue(id, out var l)) l.OnError?.Invoke(message); + } + /// /// Starts scanning for NDEF tags. Use the returned to stop. /// + [DynamicDependency(nameof(InvokeNdefReading), typeof(Nfc))] + [DynamicDependency(nameof(InvokeNdefError), typeof(Nfc))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NdefMessage))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NdefRecord))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NdefListenersManager))] public async Task Scan(Action? onReading, Action? onError = null) { if (onReading is null && onError is null) throw new ArgumentException("At least one of onReading/onError must be provided."); - var listener = new NdefListenersManager.Listener { OnReading = onReading, OnError = onError }; - var id = NdefListenersManager.Add(listener); + var id = Guid.NewGuid(); + _listeners.TryAdd(id, new Listener { OnReading = onReading, OnError = onError }); - await js.InvokeVoid("BitButil.nfc.scan", - id, - NdefListenersManager.ReadingMethodName, - NdefListenersManager.ErrorMethodName); + await js.InvokeVoid("BitButil.nfc.scan", id, DotNetRef); - return new ScanHandle(js, id); + return new ScanHandle(this, js, id); } /// @@ -48,7 +75,33 @@ public ValueTask WriteText(string text, string? lang = null, string? id = public ValueTask WriteUrl(string url, string? id = null) => js.Invoke("BitButil.nfc.writeUrl", url, id); - private sealed class ScanHandle(IJSRuntime js, Guid id) : IAsyncDisposable + public async ValueTask DisposeAsync() + { + try + { + var ids = _listeners.Keys.ToArray(); + _listeners.Clear(); + foreach (var id in ids) + { + await js.InvokeVoid("BitButil.nfc.stop", id); + } + } + catch (JSDisconnectedException) { } + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } + GC.SuppressFinalize(this); + } + + private class Listener + { + public Action? OnReading { get; set; } + public Action? OnError { get; set; } + } + + private sealed class ScanHandle(Nfc owner, IJSRuntime js, Guid id) : IAsyncDisposable { private bool _disposed; @@ -56,7 +109,7 @@ public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; - NdefListenersManager.Remove(id); + owner._listeners.TryRemove(id, out _); try { await js.InvokeVoid("BitButil.nfc.stop", id); } catch (JSDisconnectedException) { } } diff --git a/src/Butil/Bit.Butil/Publics/Notification.cs b/src/Butil/Bit.Butil/Publics/Notification.cs index 48e44f41f2..cf6425859f 100644 --- a/src/Butil/Bit.Butil/Publics/Notification.cs +++ b/src/Butil/Bit.Butil/Publics/Notification.cs @@ -10,8 +10,39 @@ namespace Bit.Butil; ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Notification ///
-public class Notification(IJSRuntime js) +public class Notification(IJSRuntime js) : IAsyncDisposable { + internal const string ClickMethodName = nameof(InvokeNotificationClick); + internal const string ShowMethodName = nameof(InvokeNotificationShow); + internal const string CloseMethodName = nameof(InvokeNotificationClose); + internal const string ErrorMethodName = nameof(InvokeNotificationError); + + private readonly System.Collections.Concurrent.ConcurrentDictionary _listeners = new(); + + // Per-instance callback reference (see Keyboard): tracked notifications are isolated per circuit + // / WASM app and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + /// Removes a tracked notification's callbacks. Called by . + internal void RemoveListener(Guid id) => _listeners.TryRemove(id, out _); + + /// Invoked from JS on notification click. Dispatched via the per-instance ref. + [JSInvokable(ClickMethodName)] + public void InvokeNotificationClick(Guid id) { if (_listeners.TryGetValue(id, out var l)) l.OnClick?.Invoke(); } + + /// Invoked from JS when the notification is shown. + [JSInvokable(ShowMethodName)] + public void InvokeNotificationShow(Guid id) { if (_listeners.TryGetValue(id, out var l)) l.OnShow?.Invoke(); } + + /// Invoked from JS when the notification is closed. + [JSInvokable(CloseMethodName)] + public void InvokeNotificationClose(Guid id) { if (_listeners.TryGetValue(id, out var l)) l.OnClose?.Invoke(); } + + /// Invoked from JS on a notification error. + [JSInvokable(ErrorMethodName)] + public void InvokeNotificationError(Guid id) { if (_listeners.TryGetValue(id, out var l)) l.OnError?.Invoke(); } + /// /// Checks if the runtime (browser or web-view) is supporting the Web Notification API. /// @@ -79,9 +110,12 @@ public async ValueTask Show(string title, NotificationOptions? options = null) /// click / show / close / error callbacks and close the toast programmatically. The notification /// stays open until the user dismisses it (or you call ). /// + [DynamicDependency(nameof(InvokeNotificationClick), typeof(Notification))] + [DynamicDependency(nameof(InvokeNotificationShow), typeof(Notification))] + [DynamicDependency(nameof(InvokeNotificationClose), typeof(Notification))] + [DynamicDependency(nameof(InvokeNotificationError), typeof(Notification))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NotificationOptions))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(InternalNotificationOptions))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(NotificationListenersManager))] public async ValueTask ShowTracked(string title, NotificationOptions? options = null, Action? onClick = null, @@ -89,26 +123,36 @@ public async ValueTask ShowTracked(string title, Action? onClose = null, Action? onError = null) { - var listener = new NotificationListenersManager.Listener + var id = Guid.NewGuid(); + _listeners.TryAdd(id, new Listener { OnClick = onClick, OnShow = onShow, OnClose = onClose, OnError = onError - }; - var id = NotificationListenersManager.Add(listener); + }); InternalNotificationOptions? opts = options is null ? null : new(options); - await js.InvokeVoid("BitButil.notification.showTracked", - id, - title, - opts, - NotificationListenersManager.ClickMethodName, - NotificationListenersManager.ShowMethodName, - NotificationListenersManager.CloseMethodName, - NotificationListenersManager.ErrorMethodName); + await js.InvokeVoid("BitButil.notification.showTracked", id, title, opts, DotNetRef); + + return new NotificationHandle(this, js, id); + } - return new NotificationHandle(js, id); + public ValueTask DisposeAsync() + { + _listeners.Clear(); + _dotNetRef?.Dispose(); + _dotNetRef = null; + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } + + private class Listener + { + public Action? OnClick { get; set; } + public Action? OnShow { get; set; } + public Action? OnClose { get; set; } + public Action? OnError { get; set; } } } diff --git a/src/Butil/Bit.Butil/Publics/Notification/NotificationHandle.cs b/src/Butil/Bit.Butil/Publics/Notification/NotificationHandle.cs index c0344069ed..342c25f85e 100644 --- a/src/Butil/Bit.Butil/Publics/Notification/NotificationHandle.cs +++ b/src/Butil/Bit.Butil/Publics/Notification/NotificationHandle.cs @@ -9,11 +9,12 @@ namespace Bit.Butil; /// public sealed class NotificationHandle : IAsyncDisposable { + private readonly Notification _owner; private readonly IJSRuntime _js; private readonly Guid _id; private bool _disposed; - internal NotificationHandle(IJSRuntime js, Guid id) { _js = js; _id = id; } + internal NotificationHandle(Notification owner, IJSRuntime js, Guid id) { _owner = owner; _js = js; _id = id; } /// The internal notification id. public Guid Id => _id; @@ -25,7 +26,7 @@ public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; - NotificationListenersManager.Remove(_id); + _owner.RemoveListener(_id); try { await _js.InvokeVoid("BitButil.notification.dispose", _id); } catch (JSDisconnectedException) { } } diff --git a/src/Butil/Bit.Butil/Publics/Performance.cs b/src/Butil/Bit.Butil/Publics/Performance.cs index 9cdcf598ff..013e77b898 100644 --- a/src/Butil/Bit.Butil/Publics/Performance.cs +++ b/src/Butil/Bit.Butil/Publics/Performance.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -10,8 +11,17 @@ namespace Bit.Butil; /// Wraps the Performance /// timing and marker API. /// -public class Performance(IJSRuntime js) +public class Performance(IJSRuntime js) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokePerformanceObserver); + + private readonly System.Collections.Concurrent.ConcurrentDictionary> _handlers = new(); + + // Per-instance callback reference (see Keyboard): observers are isolated per circuit / WASM app + // and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + /// /// High-resolution timestamp (DOMHighResTimeStamp) since the time origin, in milliseconds. ///
@@ -68,6 +78,16 @@ public ValueTask GetEntries(string? name = null, string? type = n public ValueTask GetMemory() => js.Invoke("BitButil.performance.memory"); + /// + /// Invoked from JS on each observer report. Public + so it can + /// be dispatched through the per-instance . + /// + [JSInvokable(InvokeMethodName)] + public void InvokePerformanceObserver(Guid id, JsonElement[] entries) + { + if (_handlers.TryGetValue(id, out var handler)) handler.Invoke(entries); + } + /// /// Subscribes to PerformanceObserver /// for one or more entry types. Common values: "resource", "navigation", @@ -76,7 +96,7 @@ public ValueTask GetMemory() /// /// When true, the observer is also notified about entries that /// were already in the buffer when the observer registered. - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PerformanceObserverListenersManager))] + [DynamicDependency(nameof(InvokePerformanceObserver), typeof(Performance))] public async Task SubscribeObserver(string[] entryTypes, Action handler, bool buffered = true) @@ -84,18 +104,34 @@ public async Task SubscribeObserver(string[] entryTypes, if (entryTypes is null || entryTypes.Length == 0) throw new ArgumentException("At least one entry type is required.", nameof(entryTypes)); - var id = PerformanceObserverListenersManager.AddListener(handler); - await js.InvokeVoid("BitButil.performance.observe", - PerformanceObserverListenersManager.InvokeMethodName, - id, - entryTypes, - buffered); + var id = Guid.NewGuid(); + _handlers.TryAdd(id, handler); + await js.InvokeVoid("BitButil.performance.observe", DotNetRef, id, entryTypes, buffered); return new ButilSubscription(id, async () => { - PerformanceObserverListenersManager.RemoveListener(id); - if (OperatingSystem.IsBrowser() is false) return; + _handlers.TryRemove(id, out _); await js.InvokeVoid("BitButil.performance.disconnect", id); }); } + + public async ValueTask DisposeAsync() + { + try + { + var ids = _handlers.Keys.ToArray(); + _handlers.Clear(); + foreach (var id in ids) + { + await js.InvokeVoid("BitButil.performance.disconnect", id); + } + } + catch (JSDisconnectedException) { } + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } + GC.SuppressFinalize(this); + } } diff --git a/src/Butil/Bit.Butil/Publics/Reporting.cs b/src/Butil/Bit.Butil/Publics/Reporting.cs index 016d9c1745..7109f0d158 100644 --- a/src/Butil/Bit.Butil/Publics/Reporting.cs +++ b/src/Butil/Bit.Butil/Publics/Reporting.cs @@ -13,35 +13,70 @@ namespace Bit.Butil; /// Useful for surfacing browser-emitted deprecation, intervention, CSP-violation, and crash /// reports to your monitoring stack alongside ordinary errors. /// -public class Reporting(IJSRuntime js) +public class Reporting(IJSRuntime js) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokeBrowserReport); + + private readonly System.Collections.Concurrent.ConcurrentDictionary> _handlers = new(); + + // Per-instance callback reference (see Keyboard): observers are isolated per circuit / WASM app + // and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + /// True when the runtime exposes ReportingObserver. public ValueTask IsSupported() => js.Invoke("BitButil.reporting.isSupported"); + /// + /// Invoked from JS on each report batch. Public + so it can be + /// dispatched through the per-instance . + /// + [JSInvokable(InvokeMethodName)] + public void InvokeBrowserReport(Guid id, BrowserReport[] reports) + { + if (_handlers.TryGetValue(id, out var handler)) handler.Invoke(reports); + } + /// /// Subscribes to browser-generated reports. Use the returned to stop. /// /// Optional whitelist of report types (e.g. "deprecation", "intervention"). /// Pass null to receive every type. /// When true, also delivers reports queued before the observer registered. + [DynamicDependency(nameof(InvokeBrowserReport), typeof(Reporting))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BrowserReport))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ReportingListenersManager))] public async Task Subscribe(Action handler, string[]? types = null, bool buffered = true) { - var id = ReportingListenersManager.AddListener(handler); - await js.InvokeVoid("BitButil.reporting.observe", - ReportingListenersManager.InvokeMethodName, - id, - types, - buffered); + var id = Guid.NewGuid(); + _handlers.TryAdd(id, handler); + await js.InvokeVoid("BitButil.reporting.observe", DotNetRef, id, types, buffered); return new ButilSubscription(id, async () => { - ReportingListenersManager.RemoveListener(id); - if (OperatingSystem.IsBrowser() is false) return; + _handlers.TryRemove(id, out _); await js.InvokeVoid("BitButil.reporting.disconnect", id); }); } + + public async ValueTask DisposeAsync() + { + try + { + var ids = System.Linq.Enumerable.ToArray(_handlers.Keys); + _handlers.Clear(); + foreach (var id in ids) + { + await js.InvokeVoid("BitButil.reporting.disconnect", id); + } + } + catch (JSDisconnectedException) { } + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } + GC.SuppressFinalize(this); + } } diff --git a/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverExtensions.cs b/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverExtensions.cs index d5c8317f53..6f05be0883 100644 --- a/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverExtensions.cs +++ b/src/Butil/Bit.Butil/Publics/ResizeObserver/ResizeObserverExtensions.cs @@ -16,7 +16,6 @@ public static class ResizeObserverExtensions /// to stop observing. ///
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ResizeObserverEntry))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ResizeObserverListenersManager))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Rect))] public static async Task ObserveResize( this ElementReference element, @@ -24,7 +23,8 @@ public static async Task ObserveResize( Action handler, ResizeObserverBox box = ResizeObserverBox.ContentBox) { - var listenerId = ResizeObserverListenersManager.AddListener(handler); + var host = new ResizeObserverInterop(handler); + var listenerId = Guid.NewGuid(); var boxName = box switch { @@ -34,16 +34,15 @@ public static async Task ObserveResize( }; await js.InvokeVoid("BitButil.resizeObserver.observe", - ResizeObserverListenersManager.InvokeMethodName, + host.DotNetRef, listenerId, element, boxName); return new ButilSubscription(listenerId, async () => { - ResizeObserverListenersManager.RemoveListener(listenerId); - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.resizeObserver.unobserve", listenerId); + try { await js.InvokeVoid("BitButil.resizeObserver.unobserve", listenerId); } + finally { host.Dispose(); } }); } } diff --git a/src/Butil/Bit.Butil/Publics/Screen.cs b/src/Butil/Bit.Butil/Publics/Screen.cs index 6553b2ffc1..6645d3457a 100644 --- a/src/Butil/Bit.Butil/Publics/Screen.cs +++ b/src/Butil/Bit.Butil/Publics/Screen.cs @@ -15,8 +15,25 @@ namespace Bit.Butil; /// public class Screen(IJSRuntime js) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokeScreenChange); + private readonly ConcurrentDictionary _handlers = new(); + // Per-instance callback reference (see Keyboard): listeners are isolated per circuit / WASM app + // and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + /// + /// Invoked from JS on the screen change event. Public + + /// so it can be dispatched through the per-instance . + /// + [JSInvokable(InvokeMethodName)] + public void InvokeScreenChange(Guid id) + { + if (_handlers.TryGetValue(id, out var handler)) handler.Invoke(); + } + /// /// Specifies the height of the screen, in pixels, minus permanent or semipermanent user interface /// features displayed by the operating system, such as the Taskbar on Windows. @@ -80,13 +97,13 @@ public async Task GetWidth() ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event ///
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ScreenListenersManager))] + [DynamicDependency(nameof(InvokeScreenChange), typeof(Screen))] public async ValueTask AddChange(Action handler) { - var listenerId = ScreenListenersManager.AddListener(handler); + var listenerId = Guid.NewGuid(); _handlers.TryAdd(listenerId, handler); - await js.InvokeVoid("BitButil.screen.addChange", ScreenListenersManager.InvokeMethodName, listenerId); + await js.InvokeVoid("BitButil.screen.addChange", DotNetRef, listenerId); return listenerId; } @@ -94,7 +111,6 @@ public async ValueTask AddChange(Action handler) /// /// Subscribe variant returning an handle. /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ScreenListenersManager))] public async ValueTask SubscribeChange(Action handler) { var id = await AddChange(handler); @@ -107,9 +123,16 @@ public async ValueTask SubscribeChange(Action handler) ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event /// + /// + /// Listeners are matched by delegate identity, so you must pass the very same + /// instance that was registered. A newly-created lambda will not + /// match and the returned array will be empty. To avoid this, keep the + /// returned by AddChange and remove by id, or use SubscribeChange which returns a + /// disposable . + /// public async ValueTask RemoveChange(Action handler) { - var ids = ScreenListenersManager.RemoveListener(handler); + var ids = _handlers.Where(h => h.Value == handler).Select(h => h.Key).ToArray(); await RemoveChange(ids); @@ -124,8 +147,6 @@ public async ValueTask RemoveChange(Action handler) /// public async ValueTask RemoveChange(Guid id) { - ScreenListenersManager.RemoveListeners([id]); - await RemoveChange([id]); } @@ -149,15 +170,11 @@ public async ValueTask RemoveAllChanges() _handlers.Clear(); - ScreenListenersManager.RemoveListeners(ids); - await RemoveFromJs(ids); } private async ValueTask RemoveFromJs(Guid[] ids) { - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.screen.removeChange", ids); } @@ -177,5 +194,10 @@ protected virtual async ValueTask DisposeAsync(bool disposing) await RemoveAllChanges(); } catch (JSDisconnectedException) { } // we can ignore this exception here + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } } } diff --git a/src/Butil/Bit.Butil/Publics/ScreenOrientation.cs b/src/Butil/Bit.Butil/Publics/ScreenOrientation.cs index f5dc0591ac..e421730875 100644 --- a/src/Butil/Bit.Butil/Publics/ScreenOrientation.cs +++ b/src/Butil/Bit.Butil/Publics/ScreenOrientation.cs @@ -14,8 +14,25 @@ namespace Bit.Butil; /// public class ScreenOrientation(IJSRuntime js) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokeScreenOrientationChange); + private readonly ConcurrentDictionary> _handlers = new(); + // Per-instance callback reference (see Keyboard): listeners are isolated per circuit / WASM app + // and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + /// + /// Invoked from JS on the orientation change event. Public + + /// so it can be dispatched through the per-instance . + /// + [JSInvokable(InvokeMethodName)] + public void InvokeScreenOrientationChange(Guid id, OrientationState state) + { + if (_handlers.TryGetValue(id, out var handler)) handler.Invoke(state); + } + /// /// Returns the document's current orientation type. ///
@@ -81,13 +98,14 @@ public async Task Unlock() ///
/// https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/change_event ///
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ScreenOrientationListenersManager))] + [DynamicDependency(nameof(InvokeScreenOrientationChange), typeof(ScreenOrientation))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(OrientationState))] public async ValueTask AddChange(Action handler) { - var listenerId = ScreenOrientationListenersManager.AddListener(handler); + var listenerId = Guid.NewGuid(); _handlers.TryAdd(listenerId, handler); - await js.InvokeVoid("BitButil.screenOrientation.addChange", ScreenOrientationListenersManager.InvokeMethodName, listenerId); + await js.InvokeVoid("BitButil.screenOrientation.addChange", DotNetRef, listenerId); return listenerId; } @@ -95,7 +113,6 @@ public async ValueTask AddChange(Action handler) /// /// Subscribe variant returning an handle. /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ScreenOrientationListenersManager))] public async ValueTask SubscribeChange(Action handler) { var id = await AddChange(handler); @@ -108,9 +125,16 @@ public async ValueTask SubscribeChange(Action /// https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/change_event /// + /// + /// Listeners are matched by delegate identity, so you must pass the very same + /// instance that was registered. A newly-created lambda will not + /// match and the returned array will be empty. To avoid this, keep the + /// returned by AddChange and remove by id, or use SubscribeChange which returns a + /// disposable . + /// public async ValueTask RemoveChange(Action handler) { - var ids = ScreenOrientationListenersManager.RemoveListener(handler); + var ids = _handlers.Where(h => h.Value == handler).Select(h => h.Key).ToArray(); await RemoveChange(ids); @@ -125,8 +149,6 @@ public async ValueTask RemoveChange(Action handler) /// public async ValueTask RemoveChange(Guid id) { - ScreenOrientationListenersManager.RemoveListeners([id]); - await RemoveChange([id]); } @@ -150,15 +172,11 @@ public async ValueTask RemoveAllChanges() _handlers.Clear(); - ScreenOrientationListenersManager.RemoveListeners(ids); - await RemoveFromJs(ids); } private async ValueTask RemoveFromJs(Guid[] ids) { - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.screenOrientation.removeChange", ids); } @@ -178,5 +196,10 @@ protected virtual async ValueTask DisposeAsync(bool disposing) await RemoveAllChanges(); } catch (JSDisconnectedException) { } // we can ignore this exception here + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } } } diff --git a/src/Butil/Bit.Butil/Publics/ServiceWorker.cs b/src/Butil/Bit.Butil/Publics/ServiceWorker.cs index 731f1f1e95..5e7c2d5bd9 100644 --- a/src/Butil/Bit.Butil/Publics/ServiceWorker.cs +++ b/src/Butil/Bit.Butil/Publics/ServiceWorker.cs @@ -16,11 +16,39 @@ namespace Bit.Butil; /// . Subscriptions returned by / /// are detached on dispose. /// -public class ServiceWorker(IJSRuntime js) +public class ServiceWorker(IJSRuntime js) : IAsyncDisposable { + internal const string MessageMethodName = nameof(InvokeServiceWorkerMessage); + internal const string ControllerChangeMethodName = nameof(InvokeServiceWorkerControllerChange); + + private readonly System.Collections.Concurrent.ConcurrentDictionary> _messageHandlers = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _controllerChangeHandlers = new(); + + // Per-instance callback reference (see Keyboard): subscriptions are isolated per circuit / WASM + // app and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + /// True when the runtime exposes navigator.serviceWorker. public ValueTask IsSupported() => js.Invoke("BitButil.serviceWorker.isSupported"); + /// + /// Invoked from JS on a worker message. Public + so it can be + /// dispatched through the per-instance . + /// + [JSInvokable(MessageMethodName)] + public void InvokeServiceWorkerMessage(Guid id, JsonElement data) + { + if (_messageHandlers.TryGetValue(id, out var handler)) handler.Invoke(data); + } + + /// Invoked from JS when the controlling worker changes. See . + [JSInvokable(ControllerChangeMethodName)] + public void InvokeServiceWorkerControllerChange(Guid id) + { + if (_controllerChangeHandlers.TryGetValue(id, out var handler)) handler.Invoke(); + } + /// /// Registers a service worker script. The promise resolves once the registration is created. /// @@ -63,32 +91,52 @@ public ValueTask GetRegistration(string? scope = /// Subscribes to messages broadcast from the service worker. The handler receives every /// payload as a . /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ServiceWorkerListenersManager))] + [DynamicDependency(nameof(InvokeServiceWorkerMessage), typeof(ServiceWorker))] public async Task SubscribeMessage(Action handler) { - var id = ServiceWorkerListenersManager.AddMessageListener(handler); - await js.InvokeVoid("BitButil.serviceWorker.subscribeMessage", - ServiceWorkerListenersManager.MessageMethodName, id); + var id = Guid.NewGuid(); + _messageHandlers.TryAdd(id, handler); + await js.InvokeVoid("BitButil.serviceWorker.subscribeMessage", DotNetRef, id); return new ButilSubscription(id, async () => { - ServiceWorkerListenersManager.RemoveMessageListener(id); - if (OperatingSystem.IsBrowser() is false) return; + _messageHandlers.TryRemove(id, out _); await js.InvokeVoid("BitButil.serviceWorker.unsubscribeMessage", id); }); } /// Fires when navigator.serviceWorker.controller changes. - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ServiceWorkerListenersManager))] + [DynamicDependency(nameof(InvokeServiceWorkerControllerChange), typeof(ServiceWorker))] public async Task SubscribeControllerChange(Action handler) { - var id = ServiceWorkerListenersManager.AddControllerChangeListener(handler); - await js.InvokeVoid("BitButil.serviceWorker.subscribeControllerChange", - ServiceWorkerListenersManager.ControllerChangeMethodName, id); + var id = Guid.NewGuid(); + _controllerChangeHandlers.TryAdd(id, handler); + await js.InvokeVoid("BitButil.serviceWorker.subscribeControllerChange", DotNetRef, id); return new ButilSubscription(id, async () => { - ServiceWorkerListenersManager.RemoveControllerChangeListener(id); - if (OperatingSystem.IsBrowser() is false) return; + _controllerChangeHandlers.TryRemove(id, out _); await js.InvokeVoid("BitButil.serviceWorker.unsubscribeControllerChange", id); }); } + + public async ValueTask DisposeAsync() + { + try + { + var messageIds = System.Linq.Enumerable.ToArray(_messageHandlers.Keys); + var controllerIds = System.Linq.Enumerable.ToArray(_controllerChangeHandlers.Keys); + _messageHandlers.Clear(); + _controllerChangeHandlers.Clear(); + foreach (var id in messageIds) + await js.InvokeVoid("BitButil.serviceWorker.unsubscribeMessage", id); + foreach (var id in controllerIds) + await js.InvokeVoid("BitButil.serviceWorker.unsubscribeControllerChange", id); + } + catch (JSDisconnectedException) { } + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } + GC.SuppressFinalize(this); + } } diff --git a/src/Butil/Bit.Butil/Publics/SpeechRecognition.cs b/src/Butil/Bit.Butil/Publics/SpeechRecognition.cs index fece9fde72..6522efc33a 100644 --- a/src/Butil/Bit.Butil/Publics/SpeechRecognition.cs +++ b/src/Butil/Bit.Butil/Publics/SpeechRecognition.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -9,17 +11,54 @@ namespace Bit.Butil; /// Wraps the SpeechRecognition /// API (Web Speech, prefixed as webkitSpeechRecognition on Chromium). /// -public class SpeechRecognition(IJSRuntime js) +public class SpeechRecognition(IJSRuntime js) : IAsyncDisposable { + internal const string ResultMethodName = nameof(InvokeSpeechRecognitionResult); + internal const string ErrorMethodName = nameof(InvokeSpeechRecognitionError); + internal const string EndMethodName = nameof(InvokeSpeechRecognitionEnd); + + private readonly ConcurrentDictionary _listeners = new(); + + // Per-instance callback reference (see Keyboard): sessions are isolated per circuit / WASM app + // and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + /// True when the runtime exposes a SpeechRecognition implementation. public ValueTask IsSupported() => js.Invoke("BitButil.speechRecognition.isSupported"); + /// + /// Invoked from JS for each recognition result. Public + so it + /// can be dispatched through the per-instance . + /// + [JSInvokable(ResultMethodName)] + public void InvokeSpeechRecognitionResult(Guid id, SpeechRecognitionResult result) + { + if (_listeners.TryGetValue(id, out var l)) l.OnResult?.Invoke(result); + } + + /// Invoked from JS on a recognition error. See . + [JSInvokable(ErrorMethodName)] + public void InvokeSpeechRecognitionError(Guid id, string message) + { + if (_listeners.TryGetValue(id, out var l)) l.OnError?.Invoke(message); + } + + /// Invoked from JS when recognition ends. See . + [JSInvokable(EndMethodName)] + public void InvokeSpeechRecognitionEnd(Guid id) + { + if (_listeners.TryGetValue(id, out var l)) l.OnEnd?.Invoke(); + } + /// /// Starts recognition. Returns an that calls when disposed. /// + [DynamicDependency(nameof(InvokeSpeechRecognitionResult), typeof(SpeechRecognition))] + [DynamicDependency(nameof(InvokeSpeechRecognitionError), typeof(SpeechRecognition))] + [DynamicDependency(nameof(InvokeSpeechRecognitionEnd), typeof(SpeechRecognition))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SpeechRecognitionResult))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SpeechRecognitionOptions))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(SpeechRecognitionListenersManager))] public async Task Start(SpeechRecognitionOptions options, Action? onResult = null, Action? onError = null, @@ -28,28 +67,52 @@ public async Task Start(SpeechRecognitionOptions options, if (onResult is null && onError is null && onEnd is null) throw new ArgumentException("At least one of onResult/onError/onEnd must be provided."); - var listener = new SpeechRecognitionListenersManager.Listener - { - OnResult = onResult, - OnError = onError, - OnEnd = onEnd - }; - var id = SpeechRecognitionListenersManager.Add(listener); + var id = Guid.NewGuid(); + _listeners.TryAdd(id, new Listener { OnResult = onResult, OnError = onError, OnEnd = onEnd }); await js.InvokeVoid("BitButil.speechRecognition.start", id, options ?? new SpeechRecognitionOptions(), - SpeechRecognitionListenersManager.ResultMethodName, - SpeechRecognitionListenersManager.ErrorMethodName, - SpeechRecognitionListenersManager.EndMethodName); + DotNetRef); - return new RecognitionHandle(js, id); + return new RecognitionHandle(this, js, id); } /// Stops the matching recognition session early. Equivalent to disposing the handle. - public ValueTask Stop(Guid id) => js.InvokeVoid("BitButil.speechRecognition.stop", id); + public ValueTask Stop(Guid id) + { + _listeners.TryRemove(id, out _); + return js.InvokeVoid("BitButil.speechRecognition.stop", id); + } + + public async ValueTask DisposeAsync() + { + try + { + var ids = _listeners.Keys.ToArray(); + _listeners.Clear(); + foreach (var id in ids) + { + await js.InvokeVoid("BitButil.speechRecognition.stop", id); + } + } + catch (JSDisconnectedException) { } + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } + GC.SuppressFinalize(this); + } + + private class Listener + { + public Action? OnResult { get; set; } + public Action? OnError { get; set; } + public Action? OnEnd { get; set; } + } - private sealed class RecognitionHandle(IJSRuntime js, Guid id) : IAsyncDisposable + private sealed class RecognitionHandle(SpeechRecognition owner, IJSRuntime js, Guid id) : IAsyncDisposable { private bool _disposed; @@ -57,7 +120,7 @@ public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; - SpeechRecognitionListenersManager.Remove(id); + owner._listeners.TryRemove(id, out _); try { await js.InvokeVoid("BitButil.speechRecognition.stop", id); } catch (JSDisconnectedException) { } } diff --git a/src/Butil/Bit.Butil/Publics/Storage/ButilStorage.cs b/src/Butil/Bit.Butil/Publics/Storage/ButilStorage.cs index 56da9cb756..ed03ee8c6d 100644 --- a/src/Butil/Bit.Butil/Publics/Storage/ButilStorage.cs +++ b/src/Butil/Bit.Butil/Publics/Storage/ButilStorage.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.JSInterop; @@ -13,15 +15,38 @@ namespace Bit.Butil; ///
/// More info: https://developer.mozilla.org/en-US/docs/Web/API/Storage /// -public class ButilStorage(IJSRuntime js, string storageName) +public class ButilStorage(IJSRuntime js, string storageName) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokeStorageEvent); + + private readonly ConcurrentDictionary> _handlers = new(); + + // Per-instance callback reference (see Keyboard): subscriptions are isolated per circuit / WASM + // app and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + /// + /// Invoked from JS on a cross-tab storage event. Public + + /// so it can be dispatched through the per-instance . Only + /// events for this instance's storage area are forwarded to the handler. + /// + [JSInvokable(InvokeMethodName)] + public void InvokeStorageEvent(Guid id, StorageEvent evt) + { + if (_handlers.TryGetValue(id, out var handler) && + (string.IsNullOrEmpty(storageName) || string.Equals(storageName, evt.StorageArea, StringComparison.Ordinal))) + { + handler.Invoke(evt); + } + } /// /// Returns an integer representing the number of data items stored in the Storage object. ///
/// https://developer.mozilla.org/en-US/docs/Web/API/Storage/length ///
public async Task GetLength() - => await js.Invoke("BitButil.storage.length", storageName); + => await js.InvokeFast("BitButil.storage.length", storageName); /// /// When passed a number n, this method will return the name of the nth key in the storage. @@ -29,13 +54,13 @@ public async Task GetLength() /// https://developer.mozilla.org/en-US/docs/Web/API/Storage/key /// public async Task GetKey(int index) - => await js.Invoke("BitButil.storage.key", storageName, index); + => await js.InvokeFast("BitButil.storage.key", storageName, index); /// /// True when the storage contains an item with the given key. /// public async Task ContainsKey(string key) - => await js.Invoke("BitButil.storage.containsKey", storageName, key); + => await js.InvokeFast("BitButil.storage.containsKey", storageName, key); /// /// When passed a key name, will return that key's value. @@ -43,7 +68,7 @@ public async Task ContainsKey(string key) /// https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem /// public async Task GetItem(string? key) - => await js.Invoke("BitButil.storage.getItem", storageName, key); + => await js.InvokeFast("BitButil.storage.getItem", storageName, key); /// /// Returns a JSON-deserialized value, or default() when the key is missing. @@ -67,7 +92,7 @@ public async Task ContainsKey(string key) /// https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem /// public async Task SetItem(string? key, string? value) - => await js.InvokeVoid("BitButil.storage.setItem", storageName, key, value); + => await js.InvokeVoidFast("BitButil.storage.setItem", storageName, key, value); /// /// JSON-serializes and stores it under . @@ -87,7 +112,7 @@ public async Task SetItem(string? key, string? value) /// https://developer.mozilla.org/en-US/docs/Web/API/Storage/removeItem /// public async Task RemoveItem(string? key) - => await js.InvokeVoid("BitButil.storage.removeItem", storageName, key); + => await js.InvokeVoidFast("BitButil.storage.removeItem", storageName, key); /// /// When invoked, will empty all keys out of the storage. @@ -95,7 +120,7 @@ public async Task RemoveItem(string? key) /// https://developer.mozilla.org/en-US/docs/Web/API/Storage/clear /// public async Task Clear() - => await js.InvokeVoid("BitButil.storage.clear", storageName); + => await js.InvokeVoidFast("BitButil.storage.clear", storageName); /// /// Subscribes to cross-tab storage events for this storage area @@ -104,17 +129,37 @@ public async Task Clear() ///
/// window.storage ///
+ [DynamicDependency(nameof(InvokeStorageEvent), typeof(ButilStorage))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(StorageEvent))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(StorageListenersManager))] public async Task SubscribeChanges(Action handler) { - var id = StorageListenersManager.AddListener(handler, storageName); - await js.InvokeVoid("BitButil.storage.subscribe", StorageListenersManager.InvokeMethodName, id); + var id = Guid.NewGuid(); + _handlers.TryAdd(id, handler); + await js.InvokeVoid("BitButil.storage.subscribe", DotNetRef, id); return new ButilSubscription(id, async () => { - StorageListenersManager.RemoveListeners([id]); - if (OperatingSystem.IsBrowser() is false) return; + _handlers.TryRemove(id, out _); await js.InvokeVoid("BitButil.storage.unsubscribe", id); }); } + + public async ValueTask DisposeAsync() + { + try + { + var ids = _handlers.Keys.ToArray(); + _handlers.Clear(); + foreach (var id in ids) + { + await js.InvokeVoid("BitButil.storage.unsubscribe", id); + } + } + catch (JSDisconnectedException) { } + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } + GC.SuppressFinalize(this); + } } diff --git a/src/Butil/Bit.Butil/Publics/VisualViewport.cs b/src/Butil/Bit.Butil/Publics/VisualViewport.cs index 8bc3782396..d2e0c17661 100644 --- a/src/Butil/Bit.Butil/Publics/VisualViewport.cs +++ b/src/Butil/Bit.Butil/Publics/VisualViewport.cs @@ -18,8 +18,25 @@ namespace Bit.Butil; /// public class VisualViewport(IJSRuntime js) : IAsyncDisposable { + internal const string InvokeMethodName = nameof(InvokeVisualViewport); + private readonly ConcurrentDictionary _handlers = new(); + // Per-instance callback reference (see Keyboard): resize/scroll listeners are isolated per + // circuit / WASM app and released on disposal — no static state, no cross-circuit leak. + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + /// + /// Invoked from JS on a resize/scroll event. Public + so it can + /// be dispatched through the per-instance . + /// + [JSInvokable(InvokeMethodName)] + public void InvokeVisualViewport(Guid id) + { + if (_handlers.TryGetValue(id, out var handler)) handler.Invoke(); + } + /// /// Returns the offset of the left edge of the visual viewport from the left edge of /// the layout viewport in CSS pixels, or 0 if current document is not fully active. @@ -93,13 +110,13 @@ public async Task GetScale() ///
/// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport/resize_event ///
- [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(VisualViewportListenersManager))] + [DynamicDependency(nameof(InvokeVisualViewport), typeof(VisualViewport))] public async ValueTask AddResize(Action handler) { - var listenerId = VisualViewportListenersManager.AddListener(handler); + var listenerId = Guid.NewGuid(); _handlers.TryAdd(listenerId, handler); - await js.InvokeVoid("BitButil.visualViewport.addResize", VisualViewportListenersManager.InvokeMethodName, listenerId); + await js.InvokeVoid("BitButil.visualViewport.addResize", DotNetRef, listenerId); return listenerId; } @@ -107,7 +124,6 @@ public async ValueTask AddResize(Action handler) /// /// Subscribe variant of returning an handle. /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(VisualViewportListenersManager))] public async ValueTask SubscribeResize(Action handler) { var id = await AddResize(handler); @@ -119,9 +135,16 @@ public async ValueTask SubscribeResize(Action handler) ///
/// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport/resize_event /// + /// + /// Listeners are matched by delegate identity, so you must pass the very same + /// instance that was registered. A newly-created lambda will not + /// match and the returned array will be empty. To avoid this, keep the + /// returned by and remove by id, or use + /// which returns a disposable . + /// public async ValueTask RemoveResize(Action handler) { - var ids = VisualViewportListenersManager.RemoveListener(handler); + var ids = _handlers.Where(h => h.Value == handler).Select(h => h.Key).ToArray(); await RemoveResize(ids); @@ -135,8 +158,6 @@ public async ValueTask RemoveResize(Action handler) /// public async ValueTask RemoveResize(Guid id) { - VisualViewportListenersManager.RemoveListeners([id]); - await RemoveResize([id]); } @@ -154,8 +175,6 @@ private async ValueTask RemoveResize(Guid[] ids) private async ValueTask RemoveResizeFromJs(Guid[] ids) { - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.visualViewport.removeResize", ids); } @@ -164,13 +183,13 @@ private async ValueTask RemoveResizeFromJs(Guid[] ids) ///
/// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport/scroll_event /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(VisualViewportListenersManager))] + [DynamicDependency(nameof(InvokeVisualViewport), typeof(VisualViewport))] public async ValueTask AddScroll(Action handler) { - var listenerId = VisualViewportListenersManager.AddListener(handler); + var listenerId = Guid.NewGuid(); _handlers.TryAdd(listenerId, handler); - await js.InvokeVoid("BitButil.visualViewport.addScroll", VisualViewportListenersManager.InvokeMethodName, listenerId); + await js.InvokeVoid("BitButil.visualViewport.addScroll", DotNetRef, listenerId); return listenerId; } @@ -178,7 +197,6 @@ public async ValueTask AddScroll(Action handler) /// /// Subscribe variant of returning an handle. /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(VisualViewportListenersManager))] public async ValueTask SubscribeScroll(Action handler) { var id = await AddScroll(handler); @@ -190,9 +208,16 @@ public async ValueTask SubscribeScroll(Action handler) ///
/// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport/scroll_event /// + /// + /// Listeners are matched by delegate identity, so you must pass the very same + /// instance that was registered. A newly-created lambda will not + /// match and the returned array will be empty. To avoid this, keep the + /// returned by and remove by id, or use + /// which returns a disposable . + /// public async ValueTask RemoveScroll(Action handler) { - var ids = VisualViewportListenersManager.RemoveListener(handler); + var ids = _handlers.Where(h => h.Value == handler).Select(h => h.Key).ToArray(); await RemoveScroll(ids); @@ -206,8 +231,6 @@ public async ValueTask RemoveScroll(Action handler) /// public async ValueTask RemoveScroll(Guid id) { - VisualViewportListenersManager.RemoveListeners([id]); - await RemoveScroll([id]); } @@ -223,8 +246,6 @@ private async ValueTask RemoveScroll(Guid[] ids) private async ValueTask RemoveScrollFromJs(Guid[] ids) { - if (OperatingSystem.IsBrowser() is false) return; - await js.InvokeVoid("BitButil.visualViewport.removeScroll", ids); } @@ -237,8 +258,6 @@ public async ValueTask RemoveAllEventHandlers() _handlers.Clear(); - VisualViewportListenersManager.RemoveListeners(ids); - var toAwait = new List(); var resizeValueTask = RemoveResizeFromJs(ids); @@ -273,5 +292,10 @@ protected virtual async ValueTask DisposeAsync(bool disposing) await RemoveAllEventHandlers(); } catch (JSDisconnectedException) { } // we can ignore this exception here + finally + { + _dotNetRef?.Dispose(); + _dotNetRef = null; + } } } diff --git a/src/Butil/Bit.Butil/Publics/Window.cs b/src/Butil/Bit.Butil/Publics/Window.cs index 7bb32e307e..3f61f81286 100644 --- a/src/Butil/Bit.Butil/Publics/Window.cs +++ b/src/Butil/Bit.Butil/Publics/Window.cs @@ -16,19 +16,46 @@ public class Window(IJSRuntime js) : IAsyncDisposable { private const string ElementName = "window"; + internal const string MatchMediaMethodName = nameof(InvokeMediaQueryChange); + private readonly System.Collections.Concurrent.ConcurrentDictionary> _matchMediaHandlers = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary<(Guid Id, string Event, bool UseCapture), byte> _listenerIds = new(); + // DOM events go through a per-instance dispatcher; matchMedia callbacks are hosted directly on + // this instance. Both keep listeners isolated per circuit / WASM app and leak-free on disposal. + private readonly DomEventsInterop _events = new(); + private DotNetObjectReference? _dotNetRef; + private DotNetObjectReference DotNetRef => _dotNetRef ??= DotNetObjectReference.Create(this); + + /// + /// Invoked from JS when a watched media query changes. Public + + /// so it can be dispatched through the per-instance . + /// + [JSInvokable(MatchMediaMethodName)] + public void InvokeMediaQueryChange(Guid id, MediaQueryList state) + { + if (_matchMediaHandlers.TryGetValue(id, out var handler)) handler.Invoke(state); + } + public async Task AddEventListener(string domEvent, Action listener, bool useCapture = false) { - var id = await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture); + var id = await _events.AddEventListener(js, ElementName, domEvent, listener, useCapture); _listenerIds.TryAdd((id, domEvent, useCapture), 0); } + /// + /// Removes a listener previously added with . + /// + /// + /// Listeners are matched by delegate identity, so you must pass the very same + /// instance that was registered. A newly-created lambda will not + /// match and nothing will be removed. For lambdas, prefer , + /// which returns a disposable you can dispose to detach. + /// public async Task RemoveEventListener(string domEvent, Action listener, bool useCapture = false) { - var ids = await DomEventDispatcher.RemoveEventListener(js, ElementName, domEvent, listener, useCapture); + var ids = await _events.RemoveEventListener(js, ElementName, domEvent, listener, useCapture); foreach (var id in ids) _listenerIds.TryRemove((id, domEvent, useCapture), out _); } @@ -37,15 +64,14 @@ public async Task RemoveEventListener(string domEvent, Action listener, bo /// public async Task SubscribeEvent(string domEvent, Action listener, bool useCapture = false) { - var id = await DomEventDispatcher.AddEventListener(js, ElementName, domEvent, listener, useCapture); + var id = await _events.AddEventListener(js, ElementName, domEvent, listener, useCapture); var key = (id, domEvent, useCapture); _listenerIds.TryAdd(key, 0); return new ButilSubscription(id, async () => { _listenerIds.TryRemove(key, out _); - if (OperatingSystem.IsBrowser() is false) return; - await DomEventDispatcher.RemoveEventListenerById(js, ElementName, domEvent, id, useCapture); + await _events.RemoveEventListenerById(js, ElementName, domEvent, id, useCapture); }); } @@ -319,17 +345,14 @@ public async Task MatchMedia(string query) ///
/// https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList/change_event /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaQueryListenersManager))] + [DynamicDependency(nameof(InvokeMediaQueryChange), typeof(Window))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaQueryList))] public async Task SubscribeMatchMedia(string query, Action handler) { - var listenerId = MediaQueryListenersManager.AddListener(handler); + var listenerId = Guid.NewGuid(); _matchMediaHandlers.TryAdd(listenerId, handler); - await js.InvokeVoid("BitButil.window.subscribeMatchMedia", - MediaQueryListenersManager.InvokeMethodName, - listenerId, - query); + await js.InvokeVoid("BitButil.window.subscribeMatchMedia", DotNetRef, listenerId, query); return listenerId; } @@ -338,7 +361,6 @@ await js.InvokeVoid("BitButil.window.subscribeMatchMedia", /// Subscribe variant of /// returning an handle. /// - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaQueryListenersManager))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MediaQueryList))] public async Task WatchMatchMedia(string query, Action handler) { @@ -351,9 +373,7 @@ public async Task WatchMatchMedia(string query, Action public async ValueTask UnsubscribeMatchMedia(Guid id) { - MediaQueryListenersManager.RemoveListeners([id]); _matchMediaHandlers.TryRemove(id, out _); - if (OperatingSystem.IsBrowser() is false) return; await js.InvokeVoid("BitButil.window.unsubscribeMatchMedia", new[] { id }); } @@ -362,15 +382,12 @@ public async ValueTask UnsubscribeMatchMedia(Guid id) /// public async ValueTask UnsubscribeMatchMedia(Action handler) { - var ids = MediaQueryListenersManager.RemoveListener(handler); + var ids = _matchMediaHandlers.Where(h => h.Value == handler).Select(h => h.Key).ToArray(); if (ids.Length == 0) return ids; foreach (var id in ids) _matchMediaHandlers.TryRemove(id, out _); - if (OperatingSystem.IsBrowser()) - { - await js.InvokeVoid("BitButil.window.unsubscribeMatchMedia", ids); - } + await js.InvokeVoid("BitButil.window.unsubscribeMatchMedia", ids); return ids; } @@ -461,28 +478,27 @@ protected virtual async ValueTask DisposeAsync(bool disposing) { var ids = _matchMediaHandlers.Keys.ToArray(); _matchMediaHandlers.Clear(); - MediaQueryListenersManager.RemoveListeners(ids); - if (OperatingSystem.IsBrowser()) - { - await js.InvokeVoid("BitButil.window.unsubscribeMatchMedia", ids); - } + await js.InvokeVoid("BitButil.window.unsubscribeMatchMedia", ids); } if (_listenerIds.IsEmpty is false) { var snapshot = _listenerIds.Keys.ToArray(); _listenerIds.Clear(); - if (OperatingSystem.IsBrowser()) + foreach (var (id, evt, useCapture) in snapshot) { - foreach (var (id, evt, useCapture) in snapshot) - { - await DomEventDispatcher.RemoveEventListenerById(js, ElementName, evt, id, useCapture); - } + await _events.RemoveEventListenerById(js, ElementName, evt, id, useCapture); } } await js.InvokeVoid("BitButil.window.dispose"); } catch (JSDisconnectedException) { } // we can ignore this exception here + finally + { + _events.Dispose(); + _dotNetRef?.Dispose(); + _dotNetRef = null; + } } } diff --git a/src/Butil/Bit.Butil/Scripts/broadcastChannel.ts b/src/Butil/Bit.Butil/Scripts/broadcastChannel.ts index 971e8e18c0..766423b69b 100644 --- a/src/Butil/Bit.Butil/Scripts/broadcastChannel.ts +++ b/src/Butil/Bit.Butil/Scripts/broadcastChannel.ts @@ -34,14 +34,14 @@ var BitButil = BitButil || {}; try { ch.postMessage(message); } finally { ch.close(); } } - function subscribe(messageMethod: string, errorMethod: string, listenerId: string, channelName: string) { + function subscribe(dotNetRef: any, listenerId: string, channelName: string) { if (!('BroadcastChannel' in window)) return; const entry = getChannel(channelName); const onMessage = (e: MessageEvent) => { - DotNet.invokeMethodAsync('Bit.Butil', messageMethod, listenerId, e.data ?? null); + dotNetRef.invokeMethodAsync('InvokeBroadcastChannelMessage', listenerId, e.data ?? null); }; const onError = () => { - DotNet.invokeMethodAsync('Bit.Butil', errorMethod, listenerId); + dotNetRef.invokeMethodAsync('InvokeBroadcastChannelError', listenerId); }; entry.ch.addEventListener('message', onMessage); entry.ch.addEventListener('messageerror', onError); diff --git a/src/Butil/Bit.Butil/Scripts/element.ts b/src/Butil/Bit.Butil/Scripts/element.ts index c16e55c49a..3f02d1ebeb 100644 --- a/src/Butil/Bit.Butil/Scripts/element.ts +++ b/src/Butil/Bit.Butil/Scripts/element.ts @@ -89,13 +89,13 @@ var BitButil = BitButil || {}; } function subscribeEvent(element: HTMLElement, elementId: string, eventName: string, methodName: string, - listenerId: string, argsMembers: string[], useCapture: boolean, + dotNetRef: any, listenerId: string, argsMembers: string[], useCapture: boolean, preventDefault: boolean, stopPropagation: boolean) { if (!element) return; const handler = (e: any) => { preventDefault && e.preventDefault(); stopPropagation && e.stopPropagation(); - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, butil.events.mapEvent(e, argsMembers)); + dotNetRef.invokeMethodAsync(methodName, listenerId, butil.events.mapEvent(e, argsMembers)); }; _elementHandlers[listenerId] = { element, eventName, handler, options: useCapture }; element.addEventListener(eventName, handler, useCapture); diff --git a/src/Butil/Bit.Butil/Scripts/events.ts b/src/Butil/Bit.Butil/Scripts/events.ts index e45594d163..ae216b8b12 100644 --- a/src/Butil/Bit.Butil/Scripts/events.ts +++ b/src/Butil/Bit.Butil/Scripts/events.ts @@ -44,7 +44,9 @@ var BitButil = BitButil || {}; out[m] = e.clipboardData?.getData?.('text/plain') ?? null; break; case 'relatedTarget': - // RelatedTarget is a DOM node — we can only safely send a stringy id. + // A DOM node can't be marshaled to .NET, so we surface only its id. + // Empty string when there's no related target or it has no id — this matches + // the string contract of ButilMouseEventArgs.RelatedTarget. out[m] = e.relatedTarget?.id ?? ''; break; default: @@ -54,23 +56,40 @@ var BitButil = BitButil || {}; return out; } - function addEventListener(elementName, eventName, methodName, listenerId, argsMembers, options, preventDefault, stopPropagation) { + function resolveTarget(elementName: string): EventTarget | undefined { + const target = (window as any)[elementName]; + if (target && typeof target.addEventListener === 'function') return target; + // The C# side controls elementName ("window"/"document"), so reaching here means the + // target isn't available yet (or an unexpected name was passed). Warn instead of throwing + // an unhandled error from inside the interop call. + console.warn(`BitButil.events: '${elementName}' is not an available EventTarget; listener skipped.`); + return undefined; + } + + function addEventListener(elementName, eventName, methodName, dotNetRef, listenerId, argsMembers, options, preventDefault, stopPropagation) { + const target = resolveTarget(elementName); + if (!target) return; + const handler = e => { preventDefault && e.preventDefault(); stopPropagation && e.stopPropagation(); - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, mapEvent(e, argsMembers)); + dotNetRef.invokeMethodAsync(methodName, listenerId, mapEvent(e, argsMembers)); }; _handlers[listenerId] = handler; - window[elementName].addEventListener(eventName, handler, options); + target.addEventListener(eventName, handler, options); } function removeEventListener(elementName, eventName, dotnetListenerIds, options) { + const target = resolveTarget(elementName); + dotnetListenerIds.forEach(id => { const handler = _handlers[id]; delete _handlers[id]; - window[elementName].removeEventListener(eventName, handler, options); + if (target && handler) { + target.removeEventListener(eventName, handler, options); + } }); } }(BitButil)); \ No newline at end of file diff --git a/src/Butil/Bit.Butil/Scripts/fetch.ts b/src/Butil/Bit.Butil/Scripts/fetch.ts index 055ebd9b82..92b2b44112 100644 --- a/src/Butil/Bit.Butil/Scripts/fetch.ts +++ b/src/Butil/Bit.Butil/Scripts/fetch.ts @@ -35,7 +35,7 @@ var BitButil = BitButil || {}; return out; } - async function send(id: string, req: any, progressMethod: string, withProgress: boolean): Promise { + async function send(id: string, req: any, dotNetRef: any, withProgress: boolean): Promise { const controller = new AbortController(); _controllers[id] = controller; @@ -56,7 +56,7 @@ var BitButil = BitButil || {}; if (done) break; chunks.push(value); loaded += value.byteLength; - DotNet.invokeMethodAsync('Bit.Butil', progressMethod, id, { loaded, total }); + dotNetRef?.invokeMethodAsync('InvokeFetchProgress', id, { loaded, total }); } bytes = new Uint8Array(loaded); let offset = 0; @@ -65,7 +65,7 @@ var BitButil = BitButil || {}; const buf = await resp.arrayBuffer(); bytes = new Uint8Array(buf); if (withProgress) { - DotNet.invokeMethodAsync('Bit.Butil', progressMethod, id, { loaded: bytes.byteLength, total }); + dotNetRef?.invokeMethodAsync('InvokeFetchProgress', id, { loaded: bytes.byteLength, total }); } } diff --git a/src/Butil/Bit.Butil/Scripts/geolocation.ts b/src/Butil/Bit.Butil/Scripts/geolocation.ts index dba9f649cc..f9c9a8873e 100644 --- a/src/Butil/Bit.Butil/Scripts/geolocation.ts +++ b/src/Butil/Bit.Butil/Scripts/geolocation.ts @@ -50,15 +50,15 @@ var BitButil = BitButil || {}; }); } - function watchPosition(positionMethod: string, errorMethod: string, listenerId: string, options: any) { + function watchPosition(dotNetRef: any, listenerId: string, options: any) { if (!('geolocation' in window.navigator)) { - DotNet.invokeMethodAsync('Bit.Butil', errorMethod, listenerId, 0, 'Geolocation is not supported in this runtime.'); + dotNetRef.invokeMethodAsync('InvokeError', listenerId, 0, 'Geolocation is not supported in this runtime.'); return; } const watchId = window.navigator.geolocation.watchPosition( - p => DotNet.invokeMethodAsync('Bit.Butil', positionMethod, listenerId, toPosition(p)), - err => DotNet.invokeMethodAsync('Bit.Butil', errorMethod, listenerId, err.code, err.message), + p => dotNetRef.invokeMethodAsync('InvokePosition', listenerId, toPosition(p)), + err => dotNetRef.invokeMethodAsync('InvokeError', listenerId, err.code, err.message), toJsOptions(options)); _watches[listenerId] = watchId; diff --git a/src/Butil/Bit.Butil/Scripts/history.ts b/src/Butil/Bit.Butil/Scripts/history.ts index 5c0b480d76..660a51624b 100644 --- a/src/Butil/Bit.Butil/Scripts/history.ts +++ b/src/Butil/Bit.Butil/Scripts/history.ts @@ -17,9 +17,9 @@ var BitButil = BitButil || {}; removePopState }; - function addPopState(methodName, listenerId) { + function addPopState(dotNetRef, listenerId) { const handler = e => { - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, e.state); + dotNetRef.invokeMethodAsync('InvokeHistoryPopState', listenerId, e.state); }; _handlers[listenerId] = handler; diff --git a/src/Butil/Bit.Butil/Scripts/idleDetector.ts b/src/Butil/Bit.Butil/Scripts/idleDetector.ts index 1d13fcc380..3816ee775e 100644 --- a/src/Butil/Bit.Butil/Scripts/idleDetector.ts +++ b/src/Butil/Bit.Butil/Scripts/idleDetector.ts @@ -11,14 +11,14 @@ var BitButil = BitButil || {}; try { return await ID.requestPermission(); } catch { return 'denied'; } }, - async start(methodName: string, listenerId: string, threshold: number) { + async start(dotNetRef: any, listenerId: string, threshold: number) { const ID: any = (window as any).IdleDetector; if (!ID) return; const controller = new AbortController(); const detector = new ID(); const fire = () => { - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, { + dotNetRef.invokeMethodAsync('InvokeIdleDetector', listenerId, { userState: detector.userState ?? 'active', screenState: detector.screenState ?? 'unlocked' }); diff --git a/src/Butil/Bit.Butil/Scripts/intersectionObserver.ts b/src/Butil/Bit.Butil/Scripts/intersectionObserver.ts index a15be917da..009c46be96 100644 --- a/src/Butil/Bit.Butil/Scripts/intersectionObserver.ts +++ b/src/Butil/Bit.Butil/Scripts/intersectionObserver.ts @@ -13,7 +13,7 @@ var BitButil = BitButil || {}; return { x: r.x, y: r.y, width: r.width, height: r.height }; } - function observe(methodName: string, listenerId: string, element: HTMLElement, options: any) { + function observe(dotNetRef: any, listenerId: string, element: HTMLElement, options: any) { if (!element || !('IntersectionObserver' in window)) return; const init: IntersectionObserverInit = { @@ -30,7 +30,7 @@ var BitButil = BitButil || {}; intersectionRect: toRect(e.intersectionRect), rootBounds: toRect(e.rootBounds) })); - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + dotNetRef.invokeMethodAsync('InvokeIntersection', listenerId, payload); }, init); observer.observe(element); diff --git a/src/Butil/Bit.Butil/Scripts/keyboard.ts b/src/Butil/Bit.Butil/Scripts/keyboard.ts index 8ac0b586d0..0c26b34afc 100644 --- a/src/Butil/Bit.Butil/Scripts/keyboard.ts +++ b/src/Butil/Bit.Butil/Scripts/keyboard.ts @@ -9,7 +9,7 @@ var BitButil = BitButil || {}; remove }; - function makeHandler(methodName: string, listenerId: string, code: string, alt: boolean, ctrl: boolean, + function makeHandler(dotNetRef: any, listenerId: string, code: string, alt: boolean, ctrl: boolean, meta: boolean, shift: boolean, preventDefault: boolean, stopPropagation: boolean, repeat: boolean) { return (e: KeyboardEvent) => { if (e.code !== code) return; @@ -24,26 +24,26 @@ var BitButil = BitButil || {}; preventDefault && e.preventDefault(); stopPropagation && e.stopPropagation(); - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId); + dotNetRef.invokeMethodAsync('InvokeKeyboard', listenerId); }; } - function add(methodName: string, listenerId: string, code: string, alt: boolean, ctrl: boolean, + function add(dotNetRef: any, listenerId: string, code: string, alt: boolean, ctrl: boolean, meta: boolean, shift: boolean, preventDefault: boolean, stopPropagation: boolean, repeat: boolean) { - const handler = makeHandler(methodName, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat); + const handler = makeHandler(dotNetRef, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat); _handlers[listenerId] = { target: document, handler }; document.addEventListener('keydown', handler); } - function addOn(methodName: string, listenerId: string, element: HTMLElement, code: string, + function addOn(dotNetRef: any, listenerId: string, element: HTMLElement, code: string, alt: boolean, ctrl: boolean, meta: boolean, shift: boolean, preventDefault: boolean, stopPropagation: boolean, repeat: boolean) { if (!element) { // Fall back to document so callers don't lose the listener silently when the // element ref isn't ready yet. - return add(methodName, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat); + return add(dotNetRef, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat); } - const handler = makeHandler(methodName, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat); + const handler = makeHandler(dotNetRef, listenerId, code, alt, ctrl, meta, shift, preventDefault, stopPropagation, repeat); _handlers[listenerId] = { target: element, handler }; element.addEventListener('keydown', handler); } diff --git a/src/Butil/Bit.Butil/Scripts/mutationObserver.ts b/src/Butil/Bit.Butil/Scripts/mutationObserver.ts index 245d7645d5..5a42b9f95b 100644 --- a/src/Butil/Bit.Butil/Scripts/mutationObserver.ts +++ b/src/Butil/Bit.Butil/Scripts/mutationObserver.ts @@ -8,7 +8,7 @@ var BitButil = BitButil || {}; unobserve }; - function observe(methodName: string, listenerId: string, element: HTMLElement, options: any) { + function observe(dotNetRef: any, listenerId: string, element: HTMLElement, options: any) { if (!element || !('MutationObserver' in window)) return; const init: MutationObserverInit = { @@ -32,7 +32,7 @@ var BitButil = BitButil || {}; addedCount: r.addedNodes?.length ?? 0, removedCount: r.removedNodes?.length ?? 0 })); - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + dotNetRef.invokeMethodAsync('InvokeMutation', listenerId, payload); }); try { observer.observe(element, init); } diff --git a/src/Butil/Bit.Butil/Scripts/nfc.ts b/src/Butil/Bit.Butil/Scripts/nfc.ts index 91effc95b4..4592b620c4 100644 --- a/src/Butil/Bit.Butil/Scripts/nfc.ts +++ b/src/Butil/Bit.Butil/Scripts/nfc.ts @@ -32,26 +32,26 @@ var BitButil = BitButil || {}; return out; } - async function scan(id: string, readingMethod: string, errorMethod: string) { + async function scan(id: string, dotNetRef: any) { const W = window as any; if (typeof W.NDEFReader !== 'function') { - DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, 'NFC is not supported.'); + dotNetRef.invokeMethodAsync('InvokeNdefError', id, 'NFC is not supported.'); return; } const reader = new W.NDEFReader(); const controller = new AbortController(); reader.onreading = (event: any) => { - DotNet.invokeMethodAsync('Bit.Butil', readingMethod, id, { + dotNetRef.invokeMethodAsync('InvokeNdefReading', id, { serialNumber: event.serialNumber ?? '', records: (event.message?.records ?? []).map(decodeRecord) }); }; reader.onreadingerror = () => { - DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, 'reading-error'); + dotNetRef.invokeMethodAsync('InvokeNdefError', id, 'reading-error'); }; try { await reader.scan({ signal: controller.signal }); _readers[id] = { reader, controller }; } catch (e: any) { - DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, e?.message ?? String(e)); + dotNetRef.invokeMethodAsync('InvokeNdefError', id, e?.message ?? String(e)); } } diff --git a/src/Butil/Bit.Butil/Scripts/notification.ts b/src/Butil/Bit.Butil/Scripts/notification.ts index 1906b495fa..35d9e176a5 100644 --- a/src/Butil/Bit.Butil/Scripts/notification.ts +++ b/src/Butil/Bit.Butil/Scripts/notification.ts @@ -47,23 +47,22 @@ var BitButil = BitButil || {}; } } - function showTracked(id: string, title: string, options: NotificationOptions | undefined, - clickMethod: string, showMethod: string, closeMethod: string, errorMethod: string) { + function showTracked(id: string, title: string, options: NotificationOptions | undefined, dotNetRef: any) { normalize(options); try { const n = new Notification(title, options); _tracked[id] = n; - n.onclick = () => DotNet.invokeMethodAsync('Bit.Butil', clickMethod, id); - n.onshow = () => DotNet.invokeMethodAsync('Bit.Butil', showMethod, id); - n.onclose = () => DotNet.invokeMethodAsync('Bit.Butil', closeMethod, id); - n.onerror = () => DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id); + n.onclick = () => dotNetRef.invokeMethodAsync('InvokeNotificationClick', id); + n.onshow = () => dotNetRef.invokeMethodAsync('InvokeNotificationShow', id); + n.onclose = () => dotNetRef.invokeMethodAsync('InvokeNotificationClose', id); + n.onerror = () => dotNetRef.invokeMethodAsync('InvokeNotificationError', id); } catch { // Service-worker fallback can't be tracked the same way (the toast is owned by the SW) // — fire show + error so callers can detect graceful degradation. navigator.serviceWorker?.getRegistration().then(reg => { reg?.showNotification(title, options); - DotNet.invokeMethodAsync('Bit.Butil', showMethod, id); - }).catch(() => DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id)); + dotNetRef.invokeMethodAsync('InvokeNotificationShow', id); + }).catch(() => dotNetRef.invokeMethodAsync('InvokeNotificationError', id)); } } diff --git a/src/Butil/Bit.Butil/Scripts/performance.ts b/src/Butil/Bit.Butil/Scripts/performance.ts index c3ca8d829c..bba88a1577 100644 --- a/src/Butil/Bit.Butil/Scripts/performance.ts +++ b/src/Butil/Bit.Butil/Scripts/performance.ts @@ -33,11 +33,11 @@ var BitButil = BitButil || {}; usedJsHeapSize: m.usedJSHeapSize ?? null }; }, - observe(methodName: string, listenerId: string, entryTypes: string[], buffered: boolean) { + observe(dotNetRef: any, listenerId: string, entryTypes: string[], buffered: boolean) { if (!('PerformanceObserver' in window) || !entryTypes?.length) return; const observer = new PerformanceObserver(list => { const payload = list.getEntries().map(e => (e as any).toJSON ? (e as any).toJSON() : e); - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + dotNetRef.invokeMethodAsync('InvokePerformanceObserver', listenerId, payload); }); try { // observe() with a "type" + "buffered" can only handle one entry type at a time; diff --git a/src/Butil/Bit.Butil/Scripts/reporting.ts b/src/Butil/Bit.Butil/Scripts/reporting.ts index 70a80411b0..ccbc787a5b 100644 --- a/src/Butil/Bit.Butil/Scripts/reporting.ts +++ b/src/Butil/Bit.Butil/Scripts/reporting.ts @@ -5,7 +5,7 @@ var BitButil = BitButil || {}; butil.reporting = { isSupported() { return 'ReportingObserver' in window; }, - observe(methodName: string, listenerId: string, types: string[] | null, buffered: boolean) { + observe(dotNetRef: any, listenerId: string, types: string[] | null, buffered: boolean) { const W = window as any; if (typeof W.ReportingObserver !== 'function') return; const options: any = { buffered }; @@ -16,7 +16,7 @@ var BitButil = BitButil || {}; url: r.url, body: r.body ?? null })); - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + dotNetRef.invokeMethodAsync('InvokeBrowserReport', listenerId, payload); }, options); try { observer.observe(); _observers[listenerId] = observer; } catch { /* invalid options — silently ignore */ } diff --git a/src/Butil/Bit.Butil/Scripts/resizeObserver.ts b/src/Butil/Bit.Butil/Scripts/resizeObserver.ts index 2170ac0730..9905539efc 100644 --- a/src/Butil/Bit.Butil/Scripts/resizeObserver.ts +++ b/src/Butil/Bit.Butil/Scripts/resizeObserver.ts @@ -16,7 +16,7 @@ var BitButil = BitButil || {}; return { inlineSize: first?.inlineSize ?? 0, blockSize: first?.blockSize ?? 0 }; } - function observe(methodName: string, listenerId: string, element: HTMLElement, box: string) { + function observe(dotNetRef: any, listenerId: string, element: HTMLElement, box: string) { if (!element || !('ResizeObserver' in window)) return; const observer = new ResizeObserver(entries => { @@ -32,7 +32,7 @@ var BitButil = BitButil || {}; devicePixelBlockSize: device.blockSize, }; }); - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, payload); + dotNetRef.invokeMethodAsync('InvokeResize', listenerId, payload); }); try { diff --git a/src/Butil/Bit.Butil/Scripts/screen.ts b/src/Butil/Bit.Butil/Scripts/screen.ts index b937e636ca..60f86b5fac 100644 --- a/src/Butil/Bit.Butil/Scripts/screen.ts +++ b/src/Butil/Bit.Butil/Scripts/screen.ts @@ -15,9 +15,9 @@ var BitButil = BitButil || {}; removeChange }; - function addChange(methodName, listenerId) { + function addChange(dotNetRef, listenerId) { const handler = e => { - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId); + dotNetRef.invokeMethodAsync('InvokeScreenChange', listenerId); }; _handlers[listenerId] = handler; diff --git a/src/Butil/Bit.Butil/Scripts/screenOrientation.ts b/src/Butil/Bit.Butil/Scripts/screenOrientation.ts index cacb9adda7..18528af0ad 100644 --- a/src/Butil/Bit.Butil/Scripts/screenOrientation.ts +++ b/src/Butil/Bit.Butil/Scripts/screenOrientation.ts @@ -11,9 +11,9 @@ var BitButil = BitButil || {}; addChange, removeChange, }; - function addChange(methodName, listenerId) { + function addChange(dotNetRef, listenerId) { const handler = e => { - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, { angle: e.target.angle, type: e.target.type }); + dotNetRef.invokeMethodAsync('InvokeScreenOrientationChange', listenerId, { angle: e.target.angle, type: e.target.type }); }; _handlers[listenerId] = handler; diff --git a/src/Butil/Bit.Butil/Scripts/serviceWorker.ts b/src/Butil/Bit.Butil/Scripts/serviceWorker.ts index e740d38b0b..a0c33e2c46 100644 --- a/src/Butil/Bit.Butil/Scripts/serviceWorker.ts +++ b/src/Butil/Bit.Butil/Scripts/serviceWorker.ts @@ -67,11 +67,11 @@ var BitButil = BitButil || {}; try { ctrl.postMessage(message); return true; } catch { return false; } } - function subscribeMessage(methodName: string, listenerId: string) { + function subscribeMessage(dotNetRef: any, listenerId: string) { const sw = window.navigator.serviceWorker; if (!sw) return; const handler = (e: MessageEvent) => { - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, e.data ?? null); + dotNetRef.invokeMethodAsync('InvokeServiceWorkerMessage', listenerId, e.data ?? null); }; _msgListeners[listenerId] = handler; sw.addEventListener('message', handler); @@ -84,10 +84,10 @@ var BitButil = BitButil || {}; try { window.navigator.serviceWorker?.removeEventListener('message', handler); } catch { /* ignore */ } } - function subscribeControllerChange(methodName: string, listenerId: string) { + function subscribeControllerChange(dotNetRef: any, listenerId: string) { const sw = window.navigator.serviceWorker; if (!sw) return; - const handler = () => { DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId); }; + const handler = () => { dotNetRef.invokeMethodAsync('InvokeServiceWorkerControllerChange', listenerId); }; _ccListeners[listenerId] = handler; sw.addEventListener('controllerchange', handler); } diff --git a/src/Butil/Bit.Butil/Scripts/speechRecognition.ts b/src/Butil/Bit.Butil/Scripts/speechRecognition.ts index 384032b0e6..8766833335 100644 --- a/src/Butil/Bit.Butil/Scripts/speechRecognition.ts +++ b/src/Butil/Bit.Butil/Scripts/speechRecognition.ts @@ -12,11 +12,11 @@ var BitButil = BitButil || {}; stop }; - function start(id: string, options: any, resultMethod: string, errorMethod: string, endMethod: string) { + function start(id: string, options: any, dotNetRef: any) { const W = window as any; const Ctor = W.SpeechRecognition || W.webkitSpeechRecognition; if (!Ctor) { - DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, 'SpeechRecognition is not supported.'); + dotNetRef.invokeMethodAsync('InvokeSpeechRecognitionError', id, 'SpeechRecognition is not supported.'); return; } const r = new Ctor(); @@ -32,7 +32,7 @@ var BitButil = BitButil || {}; // maxAlternatives and read each result via getEntries-style observation. const top = res?.[0]; if (!top) continue; - DotNet.invokeMethodAsync('Bit.Butil', resultMethod, id, { + dotNetRef.invokeMethodAsync('InvokeSpeechRecognitionResult', id, { transcript: top.transcript ?? '', confidence: top.confidence ?? 0, isFinal: !!res.isFinal @@ -40,16 +40,16 @@ var BitButil = BitButil || {}; } }; r.onerror = (event: any) => { - DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, event?.error ?? 'unknown'); + dotNetRef.invokeMethodAsync('InvokeSpeechRecognitionError', id, event?.error ?? 'unknown'); }; r.onend = () => { - DotNet.invokeMethodAsync('Bit.Butil', endMethod, id); + dotNetRef.invokeMethodAsync('InvokeSpeechRecognitionEnd', id); delete _sessions[id]; }; try { r.start(); _sessions[id] = r; } catch (e: any) { - DotNet.invokeMethodAsync('Bit.Butil', errorMethod, id, e?.message ?? String(e)); + dotNetRef.invokeMethodAsync('InvokeSpeechRecognitionError', id, e?.message ?? String(e)); } } diff --git a/src/Butil/Bit.Butil/Scripts/storage.ts b/src/Butil/Bit.Butil/Scripts/storage.ts index a5dc3e3538..4ff6e5f95d 100644 --- a/src/Butil/Bit.Butil/Scripts/storage.ts +++ b/src/Butil/Bit.Butil/Scripts/storage.ts @@ -11,12 +11,12 @@ var BitButil = BitButil || {}; setItem(storage: string, key: string, value: string) { (window[storage] as Storage).setItem(key, value) }, removeItem(storage: string, key: string) { (window[storage] as Storage).removeItem(key) }, clear(storage: string) { (window[storage] as Storage).clear() }, - subscribe(methodName: string, listenerId: string) { + subscribe(dotNetRef: any, listenerId: string) { const handler = (e: StorageEvent) => { const area = e.storageArea === window.localStorage ? 'localStorage' : e.storageArea === window.sessionStorage ? 'sessionStorage' : ''; - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, { + dotNetRef.invokeMethodAsync('InvokeStorageEvent', listenerId, { key: e.key, oldValue: e.oldValue, newValue: e.newValue, diff --git a/src/Butil/Bit.Butil/Scripts/visualViewport.ts b/src/Butil/Bit.Butil/Scripts/visualViewport.ts index e52c0e3953..f60c4e4c25 100644 --- a/src/Butil/Bit.Butil/Scripts/visualViewport.ts +++ b/src/Butil/Bit.Butil/Scripts/visualViewport.ts @@ -15,9 +15,9 @@ var BitButil = BitButil || {}; addScroll, removeScroll }; - function addResize(methodName, listenerId) { + function addResize(dotNetRef, listenerId) { const handler = e => { - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId); + dotNetRef.invokeMethodAsync('InvokeVisualViewport', listenerId); }; _handlers[listenerId] = handler; @@ -31,9 +31,9 @@ var BitButil = BitButil || {}; }); } - function addScroll(methodName, listenerId) { + function addScroll(dotNetRef, listenerId) { const handler = e => { - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId); + dotNetRef.invokeMethodAsync('InvokeVisualViewport', listenerId); }; _handlers[listenerId] = handler; diff --git a/src/Butil/Bit.Butil/Scripts/window.ts b/src/Butil/Bit.Butil/Scripts/window.ts index 8d3f6e6aa9..0b60931d13 100644 --- a/src/Butil/Bit.Butil/Scripts/window.ts +++ b/src/Butil/Bit.Butil/Scripts/window.ts @@ -117,10 +117,10 @@ var BitButil = BitButil || {}; }; } - function subscribeMatchMedia(methodName: string, listenerId: string, query: string) { + function subscribeMatchMedia(dotNetRef: any, listenerId: string, query: string) { const mql = window.matchMedia(query); const handler = (e: MediaQueryListEvent) => { - DotNet.invokeMethodAsync('Bit.Butil', methodName, listenerId, { matches: e.matches, media: e.media }); + dotNetRef.invokeMethodAsync('InvokeMediaQueryChange', listenerId, { matches: e.matches, media: e.media }); }; // addEventListener is supported on MediaQueryList in all evergreen browsers; older From 353e8e8c63842a441da803c3b05bd82910bd9684 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Sun, 14 Jun 2026 00:19:29 +0330 Subject: [PATCH 09/10] resolve local review findings --- src/Butil/Bit.Butil/Bit.Butil.csproj | 17 +++---- src/Butil/Bit.Butil/BitButil.cs | 5 ++ .../Bit.Butil/Publics/Cookie/ButilCookie.cs | 13 +++--- .../Bit.Butil/Publics/Fetch/AbortableFetch.cs | 2 - src/Butil/Bit.Butil/Publics/Window.cs | 46 +++++++++++++------ src/Butil/Bit.Butil/Scripts/webLocks.ts | 2 +- src/Butil/Bit.Butil/Scripts/window.ts | 18 +++----- src/Butil/Bit.Butil/tsconfig.json | 10 +++- 8 files changed, 69 insertions(+), 44 deletions(-) diff --git a/src/Butil/Bit.Butil/Bit.Butil.csproj b/src/Butil/Bit.Butil/Bit.Butil.csproj index 27f6963807..1f1805edfb 100644 --- a/src/Butil/Bit.Butil/Bit.Butil.csproj +++ b/src/Butil/Bit.Butil/Bit.Butil.csproj @@ -14,6 +14,14 @@ BuildButilJavaScript; $(ResolveStaticWebAssetsInputsDependsOn) + $(NoWarn);IL2026 @@ -33,7 +41,7 @@ - + - - BuildButilJavaScript; - $(ResolveStaticWebAssetsInputsDependsOn) - + + + + + + + +