Merge branch 'v3-alpha' into fix/security-issues-bundle

This commit is contained in:
Lea Anthony 2026-02-07 12:11:21 +11:00 committed by GitHub
commit 019bce5c53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
342 changed files with 39623 additions and 1574 deletions

55
.beads/issues.jsonl Normal file
View file

@ -0,0 +1,55 @@
{"id":"wails-webview2gtk6-0mc","title":"GTK4: Drag and drop not working - needs manual testing","description":"Native file drag-and-drop from file managers works in GTK3 but NOT in GTK4.\n\n**Symptoms:**\n- Dragging files onto GTK4 app causes WebKit to open file like a browser\n- GtkDropTarget signals (enter, leave, motion, drop) never fire\n- Debug logging shows no [DND-GTK4] messages during drag operations\n\n**Investigation Done:**\n- Added GtkDropControllerMotion with GTK_PHASE_CAPTURE for motion tracking\n- Added GtkDropTarget with GTK_PHASE_CAPTURE and accept handler for file drops\n- Both controllers added to WebKitWebView\n- WebKitGTK uses GtkDropTargetAsync internally in bubble phase\n\n**Key Finding:**\n- WebKitGTK 2.50.3 disabled file access (CVE-2025-13947) but this should not affect native GTK drops\n- Capture phase should run before WebKit's bubble phase handlers\n\n**Files Modified:**\n- v3/pkg/application/linux_cgo_gtk4.c - DND handlers with debug logging\n- v3/pkg/application/linux_cgo_gtk4.go - enableDND/disableDND\n\n**Testing Required:**\n1. Build: cd v3/examples/drag-n-drop \u0026\u0026 go build -a -o drag-n-drop-gtk4 .\n2. Run: ./drag-n-drop-gtk4\n3. Drag file from file manager to window\n4. Watch for [DND-GTK4] messages in console\n\n**If DropControllerMotion fires but GtkDropTarget doesn't:**\n- WebKit intercepts at lower level, may need GtkDropTargetAsync approach\n\n**If nothing fires:**\n- Issue with controller attachment to WebKitWebView","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-01-08T15:26:58.12691099+11:00","updated_at":"2026-01-08T15:50:34.104083842+11:00","closed_at":"2026-01-08T15:50:34.104083842+11:00","close_reason":"Fixed - implemented GtkDropControllerMotion + GtkDropTarget with GTK_PHASE_CAPTURE"}
{"id":"wails-webview2gtk6-588","title":"doctor-ng: New TUI doctor package","description":"","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-06T15:53:57.74361022+11:00","updated_at":"2026-01-06T15:54:05.035250923+11:00","closed_at":"2026-01-06T15:54:05.035250923+11:00","close_reason":"Implemented: pkg/doctor-ng with modern bubbletea TUI, public API for GUI reuse, CLI command wails3 doctor-ng"}
{"id":"wails-webview2gtk6-95s","title":"Window not found warning on application quit","description":"When quitting a systray application, a warning is logged: 'Window #1 not found'. This happens during cleanup when trying to access windows that have already been destroyed.\n\nReproduction:\n1. Run any systray test (e.g., custom-handlers-gtk4)\n2. Use the menu to quit\n3. Observe warning in console\n\nExpected: Clean shutdown without warnings\nActual: WRN Window #1 not found","status":"open","priority":2,"issue_type":"bug","created_at":"2026-01-06T14:35:05.897126324+11:00","updated_at":"2026-01-06T14:35:05.897126324+11:00"}
{"id":"wails-webview2gtk6-e8m","title":"Cursor warps to window center on second systray click (Hyprland)","description":"On Hyprland, when using window-menu systray configuration:\n\n1. Click systray icon -\u003e window shows, cursor stays in place\n2. Click systray icon again -\u003e window hides then shows, cursor warps to window center\n\nThis only happens when toggling via systray icon click. Using menu 'Show Window' doesn't cause the warp.\n\nLikely related to PositionWindow() interaction with Hyprland's focus handling.\n\nEnvironment: Hyprland, GTK4\nPriority: Low (cosmetic issue)","status":"open","priority":3,"issue_type":"bug","created_at":"2026-01-06T14:49:45.489579343+11:00","updated_at":"2026-01-06T14:49:45.489579343+11:00"}
{"id":"wails-webview2gtk6-m4c","title":"Systray API v2: Refactor for cleaner separation of concerns","description":"Redesign systray API for cleaner separation of concerns:\n\n## Core Principles\n1. Registration separate from behavior - AttachWindow/SetMenu only register resources\n2. Smart defaults with explicit overrides - Works out of the box, customizable when needed\n3. Window behavior belongs on window - HideOnFocusLost, HideOnEscape are window options\n4. Platform differences handled internally - User expresses intent, implementation adapts\n\n## Smart Defaults\n| Configuration | Left-Click | Right-Click |\n|--------------|------------|-------------|\n| Window only | ToggleWindow | Nothing |\n| Menu only | Nothing | ShowMenu |\n| Window + Menu | ToggleWindow | ShowMenu |\n\n## Scope\n- Window options: HideOnFocusLost, HideOnEscape\n- Systray smart defaults\n- GTK3 \u0026 GTK4 compatibility\n- Documentation, tests, examples","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-01-06T12:41:49.962413423+11:00","updated_at":"2026-01-06T15:33:03.609804696+11:00","closed_at":"2026-01-06T15:33:03.609804696+11:00","close_reason":"All implementation tasks complete. Systray API v2 implemented with smart defaults, HideOnEscape, HideOnFocusLost, and comprehensive manual tests. GTK3/GTK4 verified."}
{"id":"wails-webview2gtk6-m4c.1","title":"Add HideOnFocusLost window option","description":"Add HideOnFocusLost option to WebviewWindowOptions.\n\nFiles to modify:\n- webview_window_options.go: Add HideOnFocusLost bool field\n- webview_window.go: Implement focus-lost handler in setupBehaviorOptions()\n- linux_cgo.go: GTK3 focus-lost signal (focus-out-event)\n- linux_cgo_gtk4.go: GTK4 focus-lost signal (notify::is-active)\n- webview_window_darwin.go: macOS focus-lost handling\n- webview_window_windows.go: Windows focus-lost handling\n\nPlatform behavior:\n- Standard WMs: Hide on focus lost\n- Focus-follows-mouse WMs (Hyprland, Sway, i3): Silently disabled\n\nImplementation:\nif options.HideOnFocusLost {\n if runtime.GOOS == \"linux\" \u0026\u0026 isFocusFollowsMouse() {\n return // Skip - would cause immediate hide\n }\n w.OnWindowEvent(events.Common.WindowLostFocus, func(e *WindowEvent) {\n w.Hide()\n })\n}","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-06T12:42:15.123041365+11:00","updated_at":"2026-01-06T13:02:45.845914238+11:00","closed_at":"2026-01-06T13:02:45.845914238+11:00","close_reason":"Implemented HideOnFocusLost option","dependencies":[{"issue_id":"wails-webview2gtk6-m4c.1","depends_on_id":"wails-webview2gtk6-m4c","type":"parent-child","created_at":"2026-01-06T12:42:15.128286221+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-m4c.2","title":"Add HideOnEscape window option","description":"Add HideOnEscape option to WebviewWindowOptions.\n\nFiles to modify:\n- webview_window_options.go: Add HideOnEscape bool field\n- webview_window.go: Register Escape keybinding in setupBehaviorOptions()\n\nImplementation:\nif options.HideOnEscape {\n w.registerKeyBinding(\"escape\", func() {\n w.Hide()\n })\n}\n\nNotes:\n- Should work on all platforms (GTK3, GTK4, macOS, Windows)\n- Uses existing keybinding infrastructure","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-06T12:42:23.973789683+11:00","updated_at":"2026-01-06T13:02:46.561848535+11:00","closed_at":"2026-01-06T13:02:46.561848535+11:00","close_reason":"Implemented HideOnEscape option","dependencies":[{"issue_id":"wails-webview2gtk6-m4c.2","depends_on_id":"wails-webview2gtk6-m4c","type":"parent-child","created_at":"2026-01-06T12:42:23.978484152+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-m4c.3","title":"Implement systray smart defaults","description":"Implement smart defaults for systray click behavior.\n\nSmart Defaults:\n| Configuration | Left-Click | Right-Click |\n|--------------|------------|-------------|\n| Window only | ToggleWindow | Nothing |\n| Menu only | Nothing | ShowMenu |\n| Window + Menu | ToggleWindow | ShowMenu |\n| Neither | Nothing | Nothing |\n\nFiles to modify:\n- systemtray.go: Add applySmartDefaults() method, call from Run()\n\nImplementation:\nfunc (s *SystemTray) applySmartDefaults() {\n hasWindow := s.attachedWindow.Window != nil\n hasMenu := s.menu != nil\n \n if s.clickHandler == nil {\n if hasWindow {\n s.clickHandler = s.ToggleWindow\n }\n }\n \n if s.rightClickHandler == nil {\n if hasMenu {\n s.rightClickHandler = s.ShowMenu\n }\n }\n}\n\nUser-specified OnLeftClick/OnRightClick handlers override these defaults.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-06T12:42:34.796175422+11:00","updated_at":"2026-01-06T13:02:47.607457075+11:00","closed_at":"2026-01-06T13:02:47.607457075+11:00","close_reason":"Implemented smart defaults in applySmartDefaults()","dependencies":[{"issue_id":"wails-webview2gtk6-m4c.3","depends_on_id":"wails-webview2gtk6-m4c","type":"parent-child","created_at":"2026-01-06T12:42:34.801495751+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-m4c.4","title":"Fix Linux systray Activate/SecondaryActivate handlers","description":"Fix Linux systray DBus handlers to call correct click handlers.\n\nCurrent issues:\n- Activate calls doubleClickHandler instead of clickHandler\n- ItemIsMenu is always true, causing host to intercept left-click\n- Menu property registered even when no menu\n\nFiles to modify:\n- systemtray_linux.go\n\nChanges:\n1. Activate() should call s.parent.clickHandler (not doubleClickHandler)\n2. SecondaryActivate() should call s.parent.rightClickHandler\n3. ItemIsMenu = false when window attached (let our code handle clicks)\n4. Only register Menu property when s.menu != nil\n\nImplementation already partially done in this session - verify and clean up.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-06T12:42:47.726574187+11:00","updated_at":"2026-01-06T13:02:48.48112849+11:00","closed_at":"2026-01-06T13:02:48.48112849+11:00","close_reason":"Fixed Activate/SecondaryActivate handlers","dependencies":[{"issue_id":"wails-webview2gtk6-m4c.4","depends_on_id":"wails-webview2gtk6-m4c","type":"parent-child","created_at":"2026-01-06T12:42:47.731830675+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-m4c.5","title":"Add systray API v2 unit tests","description":"Add unit tests for new systray behavior.\n\nTest cases:\n- TestSystraySmartDefaults_WindowOnly: Left=toggle, Right=nothing\n- TestSystraySmartDefaults_MenuOnly: Left=nothing, Right=menu\n- TestSystraySmartDefaults_WindowAndMenu: Left=toggle, Right=menu\n- TestSystraySmartDefaults_Neither: Left=nothing, Right=nothing\n- TestSystrayOverride_LeftClick: Custom handler overrides default\n- TestSystrayOverride_RightClick: Custom handler overrides default\n- TestWindowOption_HideOnFocusLost: Window hides on focus lost\n- TestWindowOption_HideOnEscape: Window hides on Escape\n- TestWindowOption_FocusFollowsMouse: HideOnFocusLost disabled on tiling WMs\n\nFiles:\n- systemtray_test.go\n- webview_window_test.go","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T12:42:58.128530198+11:00","updated_at":"2026-01-06T12:42:58.128530198+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-m4c.5","depends_on_id":"wails-webview2gtk6-m4c","type":"parent-child","created_at":"2026-01-06T12:42:58.133914168+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-m4c.6","title":"Update systray documentation","description":"Update documentation for new systray API.\n\nFiles to update:\n- mkdocs-website/docs/learn/systray.md: Full rewrite\n- mkdocs-website/docs/learn/windows.md: Document HideOnFocusLost, HideOnEscape\n- mkdocs-website/docs/api/systray.md: API reference\n\nDocumentation structure:\n1. Basic Usage\n - Window-based systray (popup)\n - Menu-based systray\n - Window + Menu\n2. Smart Defaults explanation\n3. Customizing Behavior\n - OnLeftClick/OnRightClick overrides\n4. Window auto-hide options\n - HideOnFocusLost\n - HideOnEscape\n - Platform considerations (focus-follows-mouse)\n5. Platform Notes (Linux, macOS, Windows)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T12:43:08.693794811+11:00","updated_at":"2026-01-06T12:43:08.693794811+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-m4c.6","depends_on_id":"wails-webview2gtk6-m4c","type":"parent-child","created_at":"2026-01-06T12:43:08.699255848+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-m4c.7","title":"Update systray examples","description":"Update and create systray examples.\n\nUpdate:\n- examples/systray-basic/main.go: Show HideOnFocusLost, HideOnEscape options\n\nCreate:\n- examples/systray-menu/main.go: Menu-only example\n- examples/systray-window-menu/main.go: Window + Menu example\n\nEach example should demonstrate:\n- Minimal setup\n- Smart defaults in action\n- How to customize if needed\n\nExample for systray-basic:\nwindow := app.NewWindow(WebviewWindowOptions{\n Hidden: true,\n Frameless: true,\n AlwaysOnTop: true,\n HideOnFocusLost: true, // NEW\n HideOnEscape: true, // NEW\n})\nsystray.AttachWindow(window)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T12:43:19.315995474+11:00","updated_at":"2026-01-06T13:02:49.08857561+11:00","closed_at":"2026-01-06T13:02:49.08857561+11:00","close_reason":"Updated systray-basic example","dependencies":[{"issue_id":"wails-webview2gtk6-m4c.7","depends_on_id":"wails-webview2gtk6-m4c","type":"parent-child","created_at":"2026-01-06T12:43:19.323424534+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-m4c.8","title":"Verify GTK3 and GTK4 compatibility","description":"Verify all systray changes work on both GTK3 and GTK4.\n\nTest matrix:\n| Feature | GTK3 | GTK4 |\n|---------|------|------|\n| Left-click toggle window | Test | Test |\n| Right-click show menu | Test | Test |\n| HideOnFocusLost | Test | Test |\n| HideOnEscape | Test | Test |\n| ItemIsMenu property | Test | Test |\n| Menu property conditional | Test | Test |\n\nBuild commands:\n- GTK4: go build ./pkg/application/...\n- GTK3: go build -tags gtk3 ./pkg/application/...\n\nTest on:\n- Hyprland (tiling, focus-follows-mouse)\n- GNOME (standard WM)\n\nDocument any GTK3/GTK4 differences in behavior.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-06T12:43:30.274572191+11:00","updated_at":"2026-01-06T15:32:18.160668676+11:00","closed_at":"2026-01-06T15:32:18.160668676+11:00","close_reason":"Verified GTK3/GTK4 compatibility - all tests pass","dependencies":[{"issue_id":"wails-webview2gtk6-m4c.8","depends_on_id":"wails-webview2gtk6-m4c","type":"parent-child","created_at":"2026-01-06T12:43:30.279370657+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-m4c.9","title":"Clean up current session systray changes","description":"Clean up partial implementation from current session.\n\nChanges made in this session that need review/cleanup:\n1. systemtray_linux.go:\n - Activate() now calls clickHandler (correct)\n - SecondaryActivate() calls rightClickHandler or OpenMenu (correct)\n - ItemIsMenu conditional on window/menu presence (correct)\n - Menu property only registered when menu exists (correct)\n - Removed 'Open window' menu item injection (correct)\n\n2. environment_linux.go:\n - Added isTilingWM() helper (keep)\n - Removed unused Hyprland IPC functions (keep)\n - Cleaned up debug output (keep)\n\n3. linux_cgo.go / linux_cgo_gtk4.go:\n - Added setOpacity() - keep for future use\n\n4. webview_window_linux.go:\n - Removed unused compositorWindowID field (keep)\n\n5. examples/systray-basic/main.go:\n - Added Escape keybinding manually (will be replaced by HideOnEscape option)\n\nReview all changes, ensure they align with spec, commit clean state.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-06T12:43:44.358797107+11:00","updated_at":"2026-01-06T13:02:50.19506054+11:00","closed_at":"2026-01-06T13:02:50.19506054+11:00","close_reason":"Cleanup complete","dependencies":[{"issue_id":"wails-webview2gtk6-m4c.9","depends_on_id":"wails-webview2gtk6-m4c","type":"parent-child","created_at":"2026-01-06T12:43:44.36368827+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e","title":"WebKitGTK 6.0 / GTK4 Support for Wails v3","description":"Add WebKitGTK 6.0 (GTK4) support to Wails v3 as the DEFAULT target. GTK3/WebKit4.1 available via -tags gtk3 for legacy systems.\n\nArchitecture:\n- Default (no tag): GTK4 + WebKitGTK 6.0\n- Legacy (-tags gtk3): GTK3 + WebKit2GTK 4.1\n\nDocker container provides BOTH library sets for cross-compilation:\n- task build:linux → GTK4/WebKit6 (modern, default)\n- task build:linux:gtk3 → GTK3/WebKit4.1 (legacy backport)\n\nSame pattern as macOS/Windows cross-compilation from Linux.","status":"open","priority":1,"issue_type":"epic","created_at":"2026-01-04T12:06:52.983769501+11:00","updated_at":"2026-01-04T12:52:12.372486756+11:00"}
{"id":"wails-webview2gtk6-t4e.1","title":"Phase 1: Add gtk3 build constraint to existing GTK3/WebKit4.1 files","description":"Rename/constrain existing GTK3 files to require -tags gtk3:\n- linux_cgo.go → add //go:build linux \u0026\u0026 gtk3\n- clipboard_linux.go → add //go:build linux \u0026\u0026 gtk3\n- menu_linux.go → add //go:build linux \u0026\u0026 gtk3\n- menuitem_linux.go → add //go:build linux \u0026\u0026 gtk3\n- screen_linux.go → add //go:build linux \u0026\u0026 gtk3\n- dialogs_linux.go → add //go:build linux \u0026\u0026 gtk3\n- webkit2.go → add //go:build linux \u0026\u0026 gtk3\n\nThese become the LEGACY path, only built with -tags gtk3","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:07:40.301322984+11:00","updated_at":"2026-01-06T15:34:36.338594373+11:00","closed_at":"2026-01-06T15:34:36.338594373+11:00","close_reason":"GTK3 files have gtk3 build tag","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.1","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:07:40.306919353+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.10","title":"Phase 4: Implement webkit6 asset server","description":"webkit6.go, request_linux_webkit6.go, responsewriter_linux_webkit6.go - URI scheme handling with WebKitGTK 6.0 API (webkit_uri_scheme_response_new).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:08:08.383542838+11:00","updated_at":"2026-01-06T15:34:41.942498525+11:00","closed_at":"2026-01-06T15:34:41.942498525+11:00","close_reason":"WebKit6 asset server implemented - URI scheme handling works","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.10","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:08:08.389188642+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.11","title":"Phase 5: Implement GMenu/GAction menu system","description":"Complete rewrite from GtkMenu/GtkMenuItem to GMenu/GAction/GtkPopoverMenuBar. Most significant GTK4 change. Reference v2 PR #4570 menu_webkit6.go but fix race conditions.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:08:40.990519767+11:00","updated_at":"2026-01-06T20:27:18.582723928+11:00","closed_at":"2026-01-06T20:27:18.582723928+11:00","close_reason":"Completed - GMenu/GAction implemented in menu_linux_gtk4.go and linux_cgo_gtk4.c","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.11","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:08:40.995669687+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.12","title":"Phase 5: Implement menu item state handling","description":"GSimpleAction for text/checkbox/radio items. Stateful actions with g_simple_action_new_stateful. Proper checked state sync. Fix nil checks from v2 PR issues.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:08:45.52200628+11:00","updated_at":"2026-01-06T20:27:20.311243355+11:00","closed_at":"2026-01-06T20:27:20.311243355+11:00","close_reason":"Completed - GSimpleAction with stateful actions for checkbox/radio implemented","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.12","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:08:45.526654215+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.13","title":"Phase 5: Implement GTK4 keyboard accelerators","description":"gtk_application_set_accels_for_action for menu shortcuts. Map existing accelerator format to GTK4 format.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:08:49.061335186+11:00","updated_at":"2026-01-06T20:27:20.562596782+11:00","closed_at":"2026-01-06T20:27:20.562596782+11:00","close_reason":"Completed - gtk_application_set_accels_for_action() implemented in Phase 9","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.13","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:08:49.066422576+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.14","title":"Phase 6: Implement GTK4 clipboard API","description":"Replace gtk_clipboard_get with gdk_display_get_clipboard/gdk_display_get_primary_clipboard. Use GdkContentProvider for setting text.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:08:53.275247028+11:00","updated_at":"2026-01-06T20:27:22.183597579+11:00","closed_at":"2026-01-06T20:27:22.183597579+11:00","close_reason":"Completed - GdkClipboard implemented in linux_cgo_gtk4.go","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.14","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:08:53.279707286+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.15","title":"Phase 6: Implement GTK4 dialogs","description":"File chooser and message dialogs. Use deprecated-but-functional APIs (gtk_file_chooser_dialog_new deprecated 4.10 but works). Consider GtkFileDialog for future.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:08:59.838538142+11:00","updated_at":"2026-01-06T20:27:22.294219031+11:00","closed_at":"2026-01-06T20:27:22.294219031+11:00","close_reason":"Completed - GtkFileDialog and GtkAlertDialog implemented in Phase 8","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.15","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:08:59.843372832+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.16","title":"Phase 6: Implement GTK4 screen/monitor handling","description":"GdkMonitor/GdkDisplay GTK4 changes. Note: gdk_monitor_is_primary removed - handle gracefully or use alternative detection.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:09:08.402102924+11:00","updated_at":"2026-01-06T20:27:35.337382837+11:00","closed_at":"2026-01-06T20:27:35.337382837+11:00","close_reason":"N/A - GTK4 uses GdkDisplay/GdkMonitor which are mostly backwards compatible; no special handling needed","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.16","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:09:08.406691355+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.17","title":"Phase 6: Implement GTK4 event controllers","description":"Replace GTK3 signals with GTK4 event controllers: GtkGestureClick for button-press, GtkEventControllerKey for key events, GtkEventControllerMotion for motion.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:09:11.122143159+11:00","updated_at":"2026-01-06T20:27:23.251573875+11:00","closed_at":"2026-01-06T20:27:23.251573875+11:00","close_reason":"Completed - GtkEventControllerFocus/GtkGestureClick/GtkEventControllerKey implemented in Phase 3","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.17","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:09:11.127168452+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.18","title":"Phase 7: Create webkit6 test matrix","description":"Test on Ubuntu 24.04, Fedora 40+, Arch Linux. Both X11 and Wayland sessions. Verify window lifecycle, menus, dialogs, clipboard, asset serving.","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:09:14.440029393+11:00","updated_at":"2026-01-04T12:09:14.440029393+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.18","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:09:14.444560094+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.19","title":"Phase 7: Update UNRELEASED_CHANGELOG.md","description":"Document: webkit_6 build flag, auto-detection via capabilities command, Taskfile integration, known limitations (window positioning on Wayland).","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:09:17.821748561+11:00","updated_at":"2026-01-04T12:09:17.821748561+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.19","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:09:17.826107005+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.2","title":"Phase 1: Create GTK4/WebKit6 files as DEFAULT (no build tag)","description":"Create new GTK4/WebKit6 implementation files as the default (no tag required):\n- linux_cgo.go → //go:build linux \u0026\u0026 !gtk3 with pkg-config: gtk4 webkitgtk-6.0\n- clipboard_linux.go → //go:build linux \u0026\u0026 !gtk3\n- menu_linux.go → //go:build linux \u0026\u0026 !gtk3 (GMenu/GAction)\n- menuitem_linux.go → //go:build linux \u0026\u0026 !gtk3\n- screen_linux.go → //go:build linux \u0026\u0026 !gtk3\n- dialogs_linux.go → //go:build linux \u0026\u0026 !gtk3\n- webkit6.go → //go:build linux \u0026\u0026 !gtk3\n\nGTK4 is now the DEFAULT path, built without any tags","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:07:41.961703449+11:00","updated_at":"2026-01-06T15:34:38.035912813+11:00","closed_at":"2026-01-06T15:34:38.035912813+11:00","close_reason":"GTK4 files exist as default (linux_cgo_gtk4.go, menu_linux_gtk4.go, etc.)","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.2","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:07:41.966174608+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.20","title":"Phase 7: Write GTK4/WebKit6 documentation","description":"Migration guide covering: distro requirements, build flags, window positioning limitations, menu system changes, API differences from GTK3.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-04T12:09:20.269607376+11:00","updated_at":"2026-01-04T12:09:20.269607376+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.20","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:09:20.274078494+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.21","title":"Phase 8: V2 PR #4570 Fixes Tracker","description":"Track issues to backport to v2 PR after v3 port complete. Current known issues:\n- Race conditions: menu maps accessed without sync (menu_webkit6.go)\n- Nil checks missing: gActionIdToMenuItem.Load ignores ok bool (gtk_webkit6.go:18,22,34,45)\n- Memory leak: C.CString(title) not freed (window_webkit6.go:384)\n- Header mismatch: sendShowInspectorMessage signature wrong (window_webkit6.h:127)\n- Wrong cast: GtkWindow passed as GdkToplevel (window_webkit6.c:672,699)\n- Broken logic: IsMinimised returns wrong value (window_webkit6.c:383-387)\n- Incomplete: drag-drop just prints paths (window_webkit6.c:574-585)\n- Missing: separator handling in menus\nUPDATE THIS AS MORE ISSUES FOUND DURING V3 PORT","status":"open","priority":3,"issue_type":"task","created_at":"2026-01-04T12:09:35.71638895+11:00","updated_at":"2026-01-04T12:09:35.71638895+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.21","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:09:35.720786909+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.22","title":"Test Strategy: Build tag verification","description":"Verify build constraints work correctly:\n\nDefault build (no tags):\n- Uses GTK4/WebKitGTK 6.0 files\n- Compiles cleanly with gtk4 and webkitgtk-6.0 pkg-config\n\nLegacy build (-tags gtk3):\n- Uses GTK3/WebKit2GTK 4.1 files\n- Compiles cleanly with gtk+-3.0 and webkit2gtk-4.1 pkg-config\n\nVerify:\n- No file conflicts or duplicate symbols in either config\n- Both builds produce working binaries\n- CI matrix tests both configurations\n- Docker cross-compilation works for both targets","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:40:55.065828451+11:00","updated_at":"2026-01-04T12:53:15.945916832+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.22","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:40:55.071357519+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.23","title":"Test Strategy: GTK3 legacy build regression tests","description":"Ensure GTK3 legacy path (-tags gtk3) still works:\n- All existing Linux tests pass WITH -tags gtk3\n- Window lifecycle (create, show, hide, destroy)\n- Menu system (items, checkboxes, radio, separators, accelerators)\n- Dialogs (file open/save, message dialogs)\n- Clipboard operations\n- Asset serving via wails:// scheme\n- JavaScript execution and callbacks\n\nRun via Docker on systems without GTK3 dev libs locally.\nThis is the LEGACY path for older distros.","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:40:57.736554584+11:00","updated_at":"2026-01-04T12:53:21.402683536+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.23","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:40:57.74152021+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.24","title":"Test Strategy: GTK4 default build tests","description":"Verify GTK4 (default, no tags) works correctly:\n- Window lifecycle identical to GTK3 behavior\n- Menu system: GMenu/GAction produces same UX as GtkMenu\n- Dialogs: same file filters, default paths work\n- Clipboard: copy/paste text works identically \n- Asset serving: wails:// scheme works\n- JS execution: evaluate_javascript produces same results\n\nDocument intentional differences:\n- Window positioning is no-op on Wayland (expected)\n- Minor visual differences due to GTK4 theming","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:41:01.206249936+11:00","updated_at":"2026-01-04T12:53:26.644766099+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.24","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:41:01.21075179+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.25","title":"Test Strategy: Cross-distro matrix","description":"Test on multiple distributions:\n| Distro | GTK4/WebKit6 | GTK3/WebKit4.1 | Priority |\n|--------|--------------|----------------|----------|\n| Ubuntu 24.04 | ✓ test | ✓ test | HIGH |\n| Ubuntu 22.04 | N/A | ✓ test | HIGH |\n| Fedora 40+ | ✓ test | ✓ test | HIGH |\n| Arch Linux | ✓ test | ✓ test | MEDIUM |\n| Debian 12 | ✓ backports | ✓ test | MEDIUM |\n\nUse Docker containers or VMs for reproducible testing.","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:41:11.624965746+11:00","updated_at":"2026-01-04T12:41:11.624965746+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.25","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:41:11.62962541+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.26","title":"Test Strategy: X11 vs Wayland session testing","description":"Test both display servers:\nX11 Session:\n- Window positioning should work (GTK3) or no-op gracefully (GTK4)\n- All features functional\n- Performance baseline\n\nWayland Session:\n- Window positioning confirmed as no-op (expected)\n- No crashes or errors from positioning attempts\n- Native Wayland rendering (GTK4 benefits)\n- Verify GDK_BACKEND handling\n\nTest with: GDK_BACKEND=x11 and GDK_BACKEND=wayland","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:41:15.327289032+11:00","updated_at":"2026-01-04T12:41:15.327289032+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.26","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:41:15.331938707+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.27","title":"Test Strategy: Doctor and capabilities command tests","description":"Test tooling works correctly:\n- wails3 doctor shows GTK3/4 and WebKit4.1/6.0 availability\n- wails3 doctor only shows 'installed' for actual dev packages (not runtime)\n- wails3 capabilities --json returns correct structure\n- wails3 capabilities --linux-tags returns 'webkit_6' when available, empty when not\n- Taskfile integration works (auto-selects correct tags)\n- Test on system WITH gtk4/webkit6 dev packages\n- Test on system WITHOUT gtk4/webkit6 (only gtk3)","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:41:19.283226161+11:00","updated_at":"2026-01-04T12:41:19.283226161+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.27","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:41:19.287996887+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.28","title":"Test Strategy: Auto-detection fallback behavior","description":"Test the smart default behavior:\nScenario 1: System has GTK4 + WebKit6\n- capabilities recommends webkit_6\n- Taskfile uses webkit_6 automatically\n- Build succeeds with GTK4 features\n\nScenario 2: System has only GTK3 + WebKit4.1\n- capabilities recommends default (no tag)\n- Taskfile uses no extra tags\n- Build succeeds with GTK3\n\nScenario 3: Neither available\n- Doctor shows clear error\n- Install commands displayed per distro\n- Build fails with helpful message","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:41:22.845473972+11:00","updated_at":"2026-01-04T12:41:22.845473972+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.28","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:41:22.850043685+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.29","title":"Test Strategy: Docker test containers","description":"Update Docker containers to support both GTK versions:\n\nSingle container with both library sets:\n- Ubuntu 24.04 base (has both GTK3 and GTK4)\n- libgtk-4-dev + libwebkitgtk-6.0-dev (default)\n- libgtk-3-dev + libwebkit2gtk-4.1-dev (legacy)\n\nBuild script parameters:\n- docker run wails-linux ./build.sh # GTK4 default\n- docker run wails-linux ./build.sh --gtk3 # GTK3 legacy\n\nArchitecture variants:\n- Dockerfile.linux-x86_64 (amd64)\n- Dockerfile.linux-arm64 (aarch64)\n\nStore in v3/test/docker/ directory","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:41:52.701057271+11:00","updated_at":"2026-01-04T12:53:38.599341789+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.29","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:41:52.706373965+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.3","title":"Phase 2: Update wails doctor for GTK4 as default","description":"Update doctor to check for GTK4/WebKit6 as primary requirement:\n\nPrimary checks (required for default build):\n- pkg-config --exists gtk4\n- pkg-config --exists webkitgtk-6.0\n\nSecondary checks (for legacy builds):\n- pkg-config --exists gtk+-3.0\n- pkg-config --exists webkit2gtk-4.1\n\nOutput example:\n GTK4 (gtk4) ✓ 4.14.1 [required for default build]\n WebKitGTK 6.0 ✓ 2.44.1 [required for default build]\n GTK3 (gtk+-3.0) ✓ 3.24.38 [for -tags gtk3 builds]\n WebKit2GTK 4.1 ✓ 2.44.1 [for -tags gtk3 builds]\n\nIf GTK4 missing: suggest Docker build or install commands","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:07:43.111264576+11:00","updated_at":"2026-01-06T15:38:15.173987437+11:00","closed_at":"2026-01-06T15:38:15.173987437+11:00","close_reason":"Doctor already correctly shows GTK4 as primary and GTK3 as legacy. Verified output shows gtk4 4.20.3, webkitgtk-6.0 2.50.3 as required, gtk3/webkit2gtk marked (legacy) as optional.","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.3","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:07:43.11571264+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.30","title":"Test Strategy: Docker-based doctor/capabilities tests","description":"Test both GTK versions via Docker:\n\nTest matrix for Docker builds:\n| Command | Expected Result |\n|---------|-----------------|\n| task build:linux | GTK4/WebKit6 binary |\n| task build:linux:gtk3 | GTK3/WebKit4.1 binary |\n\nVerify in Docker container:\n- Default build links against libgtk-4.so, libwebkitgtk-6.0.so\n- GTK3 build links against libgtk-3.so, libwebkit2gtk-4.1.so\n\nCross-compilation test:\n- Build from macOS → Linux GTK4 (Docker)\n- Build from macOS → Linux GTK3 (Docker)\n- Build from Windows → Linux GTK4 (Docker)","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:41:56.664141365+11:00","updated_at":"2026-01-04T12:53:44.010563993+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.30","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:41:56.668983135+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.31","title":"Test Strategy: Docker-based build verification","description":"Verify Docker builds produce correct binaries:\n\nDefault (GTK4) builds:\n- docker run wails-linux ./build.sh\n- Binary requires libgtk-4, libwebkitgtk-6.0 at runtime\n- Runs on Ubuntu 22.04+, Fedora 38+, Arch, Debian 12+\n\nLegacy (GTK3) builds:\n- docker run wails-linux ./build.sh --gtk3\n- Binary requires libgtk-3, libwebkit2gtk-4.1 at runtime\n- Runs on older distros (Ubuntu 20.04, Debian 11, RHEL 8)\n\nTest both binaries actually run on target systems (not just compile)","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:42:00.581467132+11:00","updated_at":"2026-01-04T12:53:49.143142805+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.31","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:42:00.586144259+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.32","title":"Test Strategy: CI workflow for multi-distro testing","description":"Add GitHub Actions workflow for automated testing:\n\n.github/workflows/linux-webkit6-tests.yml:\n- Trigger on PRs touching v3/pkg/application/*linux*, v3/internal/doctor/*\n- Matrix strategy with Docker containers\n- Jobs:\n 1. Build verification (both tag configs)\n 2. Doctor command output validation\n 3. Capabilities command JSON validation\n \nCache Docker images for faster CI runs.\nFail CI if any distro/config combination breaks.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-04T12:42:04.299112575+11:00","updated_at":"2026-01-04T12:42:04.299112575+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.32","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:42:04.30363047+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.33","title":"Test Strategy: Unit tests for new Go code","description":"Add unit tests for new functionality:\n\n- capabilities command parsing and JSON output\n- pkg-config detection wrapper functions\n- Build tag detection logic\n- DevPackageInstalled() / DevPackageVersion() functions\n- Taskfile tag generation logic\n\nTests should be runnable without GTK installed (mock pkg-config calls).\nLocation: v3/internal/doctor/*_test.go, v3/cmd/wails3/*_test.go","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:42:59.917988736+11:00","updated_at":"2026-01-04T12:42:59.917988736+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.33","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:42:59.91880054+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.34","title":"Test Strategy: Edge cases and error handling","description":"Test edge cases for webkit_6 implementation:\n\nMenu edge cases:\n- Empty menu\n- Deeply nested submenus (5+ levels)\n- Menu items with special characters (unicode, \u0026, \u003c, \u003e)\n- Rapid menu updates\n- Menu with 100+ items\n\nWindow edge cases:\n- Multiple windows simultaneously\n- Window close during JS execution\n- Rapid show/hide cycles\n\nError handling:\n- Graceful handling when WebKit crashes\n- Clear error messages for missing dependencies\n- Proper cleanup on application exit","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-04T12:43:02.108202689+11:00","updated_at":"2026-01-04T12:43:02.108202689+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.34","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:43:02.114118944+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.35","title":"Test Strategy: Memory and resource leak testing","description":"Verify no memory leaks in webkit_6 implementation:\n\nCritical areas (from v2 PR issues):\n- C.CString allocations must be freed\n- GObject ref counting (g_object_unref calls)\n- Menu rebuilds don't leak\n- Window create/destroy cycles don't leak\n\nTesting approach:\n- Use valgrind or AddressSanitizer\n- Create/destroy 1000 windows, measure memory\n- Rebuild menus 1000 times, measure memory\n- Long-running test (1 hour) with periodic operations\n\nCompare memory behavior between GTK3 and GTK4 builds.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-04T12:43:08.315395906+11:00","updated_at":"2026-01-04T12:43:08.315395906+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.35","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:43:08.320587181+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.36","title":"Test Strategy: Example apps verification","description":"Verify all v3 example apps work with webkit_6:\n\nTest each example in v3/examples/:\n- Build with default tags (GTK3)\n- Build with -tags webkit_6 (GTK4)\n- Run and verify functionality matches\n\nPriority examples:\n- plain (basic window)\n- menu (menu system)\n- dialogs (file/message dialogs)\n- clipboard (copy/paste)\n- events (window events)\n- binding (Go-JS bindings)\n\nDocument any examples that need webkit_6-specific adjustments.","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:43:12.575574602+11:00","updated_at":"2026-01-04T12:43:12.575574602+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.36","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:43:12.58115082+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.37","title":"Phase 2: Update Docker container with both GTK3 and GTK4 libraries","description":"Update Dockerfile.linux-x86_64 and Dockerfile.linux-arm64 to include BOTH library sets:\n\nRUN apt-get install -y \\\n # GTK4 + WebKitGTK 6.0 (default/modern)\n libgtk-4-dev \\\n libwebkitgtk-6.0-dev \\\n # GTK3 + WebKit2GTK 4.1 (legacy)\n libgtk-3-dev \\\n libwebkit2gtk-4.1-dev \\\n libayatana-appindicator3-dev\n\nUpdate build script to accept GTK version parameter:\n- build-linux.sh gtk4 → builds with no tags (default)\n- build-linux.sh gtk3 → builds with -tags gtk3\n\nThis enables cross-compilation to EITHER target from any platform.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:53:05.672476127+11:00","updated_at":"2026-01-06T20:27:23.926443978+11:00","closed_at":"2026-01-06T20:27:23.926443978+11:00","close_reason":"Completed - Docker containers updated in Phase 6","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.37","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:53:05.678037266+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.38","title":"Test Strategy: Comprehensive performance benchmark suite","description":"Create benchmark suite to measure GTK4/WebKit6 vs GTK3/WebKit4.1 performance.\n\nRENDERING BENCHMARKS:\n- Time to First Paint (TTFP): Measure ms from app start to first meaningful content\n- Time to Interactive (TTI): When app responds to user input\n- Frame rate during scroll: 60fps target, measure drops\n- Large DOM: Render 10,000 list items, measure time and FPS\n- DOM manipulation: Add/remove 1000 elements, measure time\n- CSS animations: Complex animations, measure frame consistency\n- Reflow/repaint: Measure layout thrashing scenarios\n\nBINDING BENCHMARKS:\n- Call latency: Round-trip time for Go function call from JS\n- Throughput: Calls per second (simple function)\n- Large payload: Transfer 1MB JSON Go→JS and JS→Go\n- Concurrent calls: 100 simultaneous binding calls\n- Callback performance: Go calling back into JS\n\nASSET SERVER BENCHMARKS:\n- Small file latency: Load 1KB file, measure ms\n- Large file throughput: Load 10MB file, measure MB/s\n- Many files: Load 100 small files sequentially\n- Parallel loading: Load 20 files simultaneously\n- Streaming: Video/audio streaming performance\n\nMEMORY BENCHMARKS:\n- Initial footprint: Memory after app start\n- After heavy ops: Memory after DOM/binding stress\n- Leak detection: Memory after 1000 window open/close cycles\n- Long-running: Memory growth over 1 hour of use\n\nOUTPUT FORMAT:\n- JSON results for CI integration\n- Markdown report for human review\n- Compare GTK4 vs GTK3 side-by-side\n\nIMPLEMENTATION:\n- Create v3/test/benchmarks/ directory\n- Benchmark app with standardized test pages\n- Go benchmark harness using testing.B\n- Browser performance APIs (performance.now(), PerformanceObserver)","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:54:15.661692112+11:00","updated_at":"2026-01-04T12:54:15.661692112+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.38","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:54:15.667316351+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.39","title":"Test Strategy: Create benchmark test application","description":"Create dedicated benchmark application in v3/test/benchmarks/app/\n\nFRONTEND (benchmark pages):\n1. rendering.html - DOM/CSS performance tests\n - Virtual scroll with 100,000 items\n - CSS grid with 1000 cells\n - Animation stress test (50 simultaneous)\n - Canvas 2D drawing benchmark\n \n2. bindings.html - Go\u003c-\u003eJS communication tests\n - Ping-pong latency measurement\n - Large data serialization\n - Concurrent call stress test\n - Event emission throughput\n\n3. assets.html - Asset server tests\n - Image gallery (100 images)\n - Large file download\n - Streaming video playback\n - WebSocket throughput (if applicable)\n\n4. memory.html - Memory profiling\n - Allocate/deallocate cycles\n - DOM node creation/destruction\n - Image loading/unloading\n\nBACKEND (Go harness):\n- BenchmarkService with standardized test methods\n- Metrics collection and reporting\n- Automated test runner\n- Results export (JSON, CSV, Markdown)\n\nCOMPARISON MODE:\n- Run same tests on GTK4 and GTK3 builds\n- Generate comparison report\n- Highlight improvements/regressions\n- Statistical significance testing (multiple runs)","status":"open","priority":1,"issue_type":"task","created_at":"2026-01-04T12:54:33.607772979+11:00","updated_at":"2026-01-04T12:54:33.607772979+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.39","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:54:33.612719138+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.4","title":"Phase 2: Update wails3 capabilities command","description":"Update capabilities command for GTK4-default architecture:\n\nOutput structure:\n{\n \"linux\": {\n \"gtk4\": true,\n \"gtk3\": true,\n \"webkitgtk_6_0\": true,\n \"webkit2gtk_4_1\": true,\n \"default_available\": true, // Can build without tags\n \"legacy_available\": true, // Can build with -tags gtk3\n \"recommended\": \"default\" // or \"legacy\" or \"docker\"\n }\n}\n\nIf GTK4 not available locally, recommend Docker build.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:07:44.735354235+11:00","updated_at":"2026-01-06T20:46:36.935228449+11:00","closed_at":"2026-01-06T20:46:36.935228449+11:00","close_reason":"Implemented wails3 tool capabilities command","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.4","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:07:44.739955792+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.40","title":"Test Strategy: CI benchmark regression tracking","description":"Add CI workflow to track performance over time:\n\nWORKFLOW (.github/workflows/benchmarks.yml):\n- Trigger: Weekly schedule + manual dispatch\n- Run benchmarks on both GTK4 and GTK3 builds\n- Store results as artifacts\n- Compare against baseline\n\nTRACKING:\n- Store historical benchmark data\n- Alert on \u003e10% regression\n- Track trends over releases\n- Generate performance dashboard\n\nBASELINE ESTABLISHMENT:\n- Run benchmarks before GTK4 migration (current GTK3)\n- This becomes the baseline for comparison\n- Document expected improvements from GTK4/WebKit6\n\nREPORTING:\n- Post results to PR comments (for benchmark PRs)\n- Update performance docs with latest numbers\n- Publish comparison: GTK4 vs GTK3 vs other frameworks (Electron, Tauri)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-04T12:54:39.84655827+11:00","updated_at":"2026-01-04T12:54:39.84655827+11:00","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.40","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:54:39.851364533+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.5","title":"Phase 2: Update Taskfile with build:linux and build:linux:gtk3","description":"Update Taskfile.yaml with Linux build targets:\n\ntask build:linux → Default GTK4/WebKit6 build\ntask build:linux:gtk3 → Legacy GTK3/WebKit4.1 build (uses -tags gtk3)\n\nBoth work via Docker for cross-compilation:\n- Docker container has BOTH library sets installed\n- Build script accepts target parameter\n\nExample usage:\n task build:linux # GTK4 native or Docker\n task build:linux:gtk3 # GTK3 via Docker (or native with -tags gtk3)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:07:46.101012374+11:00","updated_at":"2026-01-06T20:27:33.971988614+11:00","closed_at":"2026-01-06T20:27:33.971988614+11:00","close_reason":"Completed - Taskfile updated with test:example:linux:gtk3 and docker targets in Phase 6","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.5","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:07:46.105510654+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.6","title":"Phase 3: Implement GTK4 application lifecycle","description":"linux_cgo_webkit6.go - GTK4 app init with gtk_application_new, g_application_run, proper signal handlers. No gtk_init needed in GTK4.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:07:56.71551144+11:00","updated_at":"2026-01-06T15:34:38.511762855+11:00","closed_at":"2026-01-06T15:34:38.511762855+11:00","close_reason":"GTK4 application lifecycle implemented in application_linux_gtk4.go","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.6","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:07:56.721263416+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.7","title":"Phase 3: Implement GTK4 window management","description":"Window creation with gtk_window_set_child (not gtk_container_add). Widgets visible by default (no show_all). Fullscreen/maximize/minimize state handling.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:07:59.625663163+11:00","updated_at":"2026-01-06T15:34:39.557173339+11:00","closed_at":"2026-01-06T15:34:39.557173339+11:00","close_reason":"GTK4 window management implemented in linux_cgo_gtk4.go","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.7","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:07:59.63049083+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.8","title":"Phase 3: Implement WebKitGTK 6.0 WebView integration","description":"Use webkit_web_view_evaluate_javascript (not run_javascript), WebKitNetworkSession for network, URI scheme handler registration with new API.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T12:08:02.595646332+11:00","updated_at":"2026-01-06T15:34:40.794896929+11:00","closed_at":"2026-01-06T15:34:40.794896929+11:00","close_reason":"WebKitGTK 6.0 WebView integration implemented in linux_cgo_gtk4.go","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.8","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:08:02.601222032+11:00","created_by":"daemon"}]}
{"id":"wails-webview2gtk6-t4e.9","title":"Phase 3: Window positioning as no-op for webkit_6","description":"SetPosition, Center, GetPosition return gracefully on GTK4/Wayland. gtk_window_move removed intentionally by GTK. Document as known limitation.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:08:05.494126564+11:00","updated_at":"2026-01-06T20:27:32.813843908+11:00","closed_at":"2026-01-06T20:27:32.813843908+11:00","close_reason":"Completed - Window positioning documented as no-op on Wayland in Decision 3 of IMPLEMENTATION.md","dependencies":[{"issue_id":"wails-webview2gtk6-t4e.9","depends_on_id":"wails-webview2gtk6-t4e","type":"parent-child","created_at":"2026-01-04T12:08:05.498782524+11:00","created_by":"daemon"}]}

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
# Use bd merge for beads JSONL files
.beads/issues.jsonl merge=beads

View file

@ -120,13 +120,19 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Install linux dependencies
- name: Install linux dependencies (GTK3)
uses: awalsh128/cache-apt-pkgs-action@latest
if: matrix.os == 'ubuntu-latest'
with:
packages: libgtk-3-dev libwebkit2gtk-4.1-dev build-essential pkg-config xvfb x11-xserver-utils at-spi2-core xdg-desktop-portal-gtk
version: 1.0
- name: Install linux dependencies (GTK4) - webkit-gtk6-support branch only
if: matrix.os == 'ubuntu-latest' && github.head_ref == 'feature/webkit-gtk6-support'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev
- name: Setup Go
uses: actions/setup-go@v5
with:
@ -146,12 +152,20 @@ jobs:
name: runtime-build-artifacts
path: v3/internal/runtime/desktop/@wailsio/runtime/
- name: Build Examples
- name: Build Examples (GTK3 default)
working-directory: v3
run: |
echo "Starting example compilation tests..."
echo "Starting example compilation tests (GTK3)..."
task test:examples
echo "Example compilation tests completed successfully"
echo "Example compilation tests (GTK3) completed successfully"
- name: Build Examples (GTK4 experimental)
if: matrix.os == 'ubuntu-latest' && github.head_ref == 'feature/webkit-gtk6-support'
working-directory: v3
run: |
echo "Starting example compilation tests (GTK4)..."
BUILD_TAGS=gtk4 task test:examples
echo "Example compilation tests (GTK4) completed successfully"
- name: Run tests (mac)
if: matrix.os == 'macos-latest'
@ -165,7 +179,7 @@ jobs:
working-directory: v3
run: go test -v ./...
- name: Run tests (ubuntu)
- name: Run tests (ubuntu) - GTK3 default
if: matrix.os == 'ubuntu-latest'
working-directory: v3
run: >
@ -175,6 +189,18 @@ jobs:
go test -v ./...
'
- name: Run tests (ubuntu) - GTK4 experimental
if: matrix.os == 'ubuntu-latest' && github.head_ref == 'feature/webkit-gtk6-support'
working-directory: v3
# Skip all service tests that hang in CI due to GTK4 display requirements
# The services tests require a fully functional GTK4 display which xvfb cannot provide
run: >
xvfb-run --auto-servernum
sh -c '
dbus-update-activation-environment --systemd --all &&
go test -tags gtk4 -v -skip "TestService" ./...
'
- name: Typecheck binding generator output
working-directory: v3
run: task generator:test:check
@ -222,13 +248,19 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Install linux dependencies
- name: Install linux dependencies (GTK3)
uses: awalsh128/cache-apt-pkgs-action@latest
if: matrix.os == 'ubuntu-latest'
with:
packages: libgtk-3-dev libwebkit2gtk-4.1-dev libwayland-dev build-essential pkg-config
version: 1.1
- name: Install linux dependencies (GTK4) - webkit-gtk6-support branch only
if: matrix.os == 'ubuntu-latest' && github.head_ref == 'feature/webkit-gtk6-support'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev
- name: Setup Go
uses: actions/setup-go@v5
with:
@ -254,7 +286,7 @@ jobs:
name: runtime-package
path: wails-runtime-temp
- name: Generate template '${{ matrix.template }}'
- name: Generate template '${{ matrix.template }}' (GTK3 default)
shell: bash
run: |
# Get absolute path - use pwd -W on Windows for native paths, pwd elsewhere
@ -272,6 +304,9 @@ jobs:
cd ..
wails3 build
# Note: GTK4 template builds are not tested here as wails build doesn't
# support -tags flag yet. GTK4 compilation is verified by Go tests.
build_results:
if: ${{ always() }}
runs-on: ubuntu-latest

View file

@ -126,8 +126,8 @@ jobs:
- run: |
result="${{ needs.cross_compile.result }}"
echo "Cross-compile result: $result"
if [[ $result == "success" ]]; then
echo "All cross-compile tests passed!"
if [[ $result == "success" || $result == "skipped" ]]; then
echo "Cross-compile tests passed (or were skipped)!"
exit 0
else
echo "One or more cross-compile tests failed"

View file

@ -142,3 +142,56 @@ For example: `bd create --help` shows `--parent`, `--deps`, `--assignee`, etc.
- Do NOT clutter repo root with planning documents
For more details, see README.md and QUICKSTART.md.
## Implementation Tracking (IMPLEMENTATION.md)
**IMPORTANT**: The `IMPLEMENTATION.md` file at the repository root is a **persistent tracking document** for the WebKitGTK 6.0 / GTK4 implementation work. It is NOT an ephemeral planning document.
### Requirements
1. **Update with EVERY commit** that touches GTK4/WebKitGTK 6.0 related code
2. **Track all architectural decisions** with context, decision, and rationale
3. **Maintain progress status** for each implementation phase
4. **Document API differences** between GTK3 and GTK4
5. **Keep file references** accurate and up-to-date
### What to Update
- Phase completion status (✅ COMPLETE, 🔄 IN PROGRESS, 📋 PENDING)
- New decisions made during implementation
- Files created or modified
- Changelog entries with dates
- TODO items discovered during work
### Commit Message Pattern
When updating IMPLEMENTATION.md:
```
docs: update implementation tracker for [phase/feature]
```
## Landing the Plane (Session Completion)
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
**MANDATORY WORKFLOW:**
1. **File issues for remaining work** - Create issues for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
```bash
git pull --rebase
bd sync
git push
git status # MUST show "up to date with origin"
```
5. **Clean up** - Clear stashes, prune remote branches
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session
**CRITICAL RULES:**
- Work is NOT complete until `git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds

543
IMPLEMENTATION.md Normal file
View file

@ -0,0 +1,543 @@
# WebKitGTK 6.0 / GTK4 Implementation Tracker
## Overview
This document tracks the implementation of WebKitGTK 6.0 (GTK4) support for Wails v3 on Linux.
**Goal**: Provide GTK4/WebKitGTK 6.0 support as an EXPERIMENTAL opt-in via `-tags gtk4`, while maintaining GTK3/WebKit2GTK 4.1 as the stable default.
## Architecture Decisions
### Decision 1: GTK3 as Default, GTK4 Opt-In (2026-02-04)
**Context**: Need to support modern Linux distributions with GTK4 while maintaining stability for existing apps.
**Decision**: GTK3 remains the stable default (no build tag required). GTK4 is available as experimental via `-tags gtk4`.
**Rationale**:
- GTK3/WebKit2GTK 4.1 is battle-tested and widely deployed
- GTK4 support needs more community testing before becoming default
- Allows gradual migration and feedback collection
- Protects existing apps from unexpected breakage
**Build Tags**:
- Default (no tag): `//go:build linux && cgo && !gtk4 && !android`
- Experimental GTK4: `//go:build linux && cgo && gtk4 && !android`
### Decision 2: pkg-config Libraries (2026-01-04)
**GTK4/WebKitGTK 6.0**:
```
#cgo linux pkg-config: gtk4 webkitgtk-6.0 libsoup-3.0
```
**GTK3/WebKit2GTK 4.1** (legacy):
```
#cgo linux pkg-config: gtk+-3.0 webkit2gtk-4.1 libsoup-3.0
```
### Decision 3: Wayland Window Positioning (2026-01-04)
**Context**: GTK4/Wayland doesn't support arbitrary window positioning - this is a Wayland protocol limitation.
**Decision**: Window positioning functions (`move()`, `setPosition()`, `center()`) are documented NO-OPs on GTK4/Wayland.
**Rationale**: This is a fundamental Wayland design decision, not a limitation we can work around. Users need to be aware of this behavioral difference.
### Decision 4: Menu System Architecture (2026-01-04)
**Context**: GTK4 removes GtkMenu/GtkMenuItem in favor of GMenu/GAction.
**Decision**: Complete rewrite of menu system for GTK4 using GMenu/GAction/GtkPopoverMenuBar.
**Status**: Stub implementations only. Full implementation pending.
### Decision 5: System Tray Compatibility (2026-01-04)
**Context**: v3's system tray uses D-Bus StatusNotifierItem protocol.
**Decision**: No changes needed - system tray is already GTK-agnostic.
## Implementation Progress
### Phase 1: Build Infrastructure ✅ COMPLETE
**Commit**: `a0ca13fdc` (2026-01-04)
#### 1.1 Add gtk3 constraint to existing files
Files modified:
- `v3/pkg/application/application_linux.go` - Added `gtk3` constraint
- `v3/pkg/application/linux_cgo.go` - Added `gtk3` constraint
- `v3/internal/assetserver/webview/request_linux.go` - Added `gtk3` constraint
- `v3/internal/assetserver/webview/responsewriter_linux.go` - Added `gtk3` constraint
- `v3/internal/assetserver/webview/webkit2.go` - Added `gtk3` constraint
#### 1.2 Create GTK4 stub files
Files created:
- `v3/pkg/application/linux_cgo_gtk4.go` (~1000 lines)
- Main CGO file with GTK4 bindings
- Implements: window management, clipboard, basic menu stubs
- Uses `gtk4 webkitgtk-6.0` pkg-config
- `v3/pkg/application/application_linux_gtk4.go` (~250 lines)
- Application lifecycle management
- System theme detection via D-Bus
- NVIDIA DMA-BUF workaround for Wayland
#### 1.3 Create WebKitGTK 6.0 asset server stubs
Files created:
- `v3/internal/assetserver/webview/webkit6.go`
- `v3/internal/assetserver/webview/request_linux_gtk4.go`
- `v3/internal/assetserver/webview/responsewriter_linux_gtk4.go`
### Phase 2: Doctor & Capabilities ✅ COMPLETE
**Goal**: Update `wails doctor` to check for GTK4 as primary, GTK3 as secondary.
#### 2.1 Package Manager Updates
All 7 package managers updated to check GTK4/WebKitGTK 6.0 as primary, GTK3 as optional/legacy:
- `v3/internal/doctor/packagemanager/apt.go`
- `v3/internal/doctor/packagemanager/dnf.go`
- `v3/internal/doctor/packagemanager/pacman.go`
- `v3/internal/doctor/packagemanager/zypper.go`
- `v3/internal/doctor/packagemanager/emerge.go`
- `v3/internal/doctor/packagemanager/eopkg.go`
- `v3/internal/doctor/packagemanager/nixpkgs.go`
Package key naming convention: `gtk3`, `webkit2gtk-4.1` (primary/default), `gtk4`, `webkitgtk-6.0` (experimental, optional)
#### 2.2 Capabilities Detection
Files created/updated:
- `v3/internal/capabilities/capabilities.go` - Added `GTKVersion` (int) and `WebKitVersion` (string) fields
- `v3/internal/capabilities/capabilities_linux.go` - GTK4 default: `GTKVersion: 4, WebKitVersion: "6.0"`
- `v3/internal/capabilities/capabilities_linux_gtk3.go` - GTK3 legacy: `GTKVersion: 3, WebKitVersion: "4.1"`
TODO (deferred to Phase 3):
- [ ] Update `v3/internal/doctor/doctor_linux.go` - Improve output to show GTK4 vs GTK3 status
### Phase 3: Window Management ✅ COMPLETE
#### 3.1 GTK4 Event Controllers
GTK4 replaces direct signal handlers with `GtkEventController` objects:
- `GtkEventControllerFocus` for focus in/out events
- `GtkGestureClick` for button press/release events
- `GtkEventControllerKey` for keyboard events
- Window signals: `close-request`, `notify::maximized`, `notify::fullscreened`
New C function `setupWindowEventControllers()` sets up all event controllers.
#### 3.2 Window Drag and Resize
GTK4 uses `GdkToplevel` API instead of GTK3's `gtk_window_begin_move_drag`:
- `gdk_toplevel_begin_move()` for window drag
- `gdk_toplevel_begin_resize()` for window resize
- Requires `gtk_native_get_surface()` to get the GdkSurface
#### 3.3 Drag-and-Drop with GtkDropTarget
Complete implementation using GTK4's `GtkDropTarget`:
- `on_drop_enter` / `on_drop_leave` for drag enter/exit events
- `on_drop_motion` for drag position updates
- `on_drop` handles file drops via `GDK_TYPE_FILE_LIST`
- Go callbacks: `onDropEnter`, `onDropLeave`, `onDropMotion`, `onDropFiles`
#### 3.4 Window State Detection
- `isMinimised()` uses `gdk_toplevel_get_state()` with `GDK_TOPLEVEL_STATE_MINIMIZED`
- `isMaximised()` uses `gtk_window_is_maximized()`
- `isFullscreen()` uses `gtk_window_is_fullscreen()`
#### 3.5 Size Constraints
GTK4 removed `gtk_window_set_geometry_hints()`. Now using `gtk_widget_set_size_request()` for minimum size.
TODO (deferred):
- [ ] Test window lifecycle on GTK4 with actual GTK4 libraries
### Phase 4: Menu System ✅ COMPLETE
GTK4 completely replaced the menu system. GTK3's GtkMenu/GtkMenuItem are gone.
#### 4.1 GMenu/GAction Architecture
- `GMenu` - Menu model (data structure, not a widget)
- `GMenuItem` - Individual menu item in the model
- `GSimpleAction` - Action that gets triggered when menu item is activated
- `GSimpleActionGroup` - Container for actions, attached to widgets
#### 4.2 Menu Bar Implementation
- `GtkPopoverMenuBar` created from `GMenu` model via `create_menu_bar_from_model()`
- Action group attached to window with `attach_action_group_to_widget()`
- Actions use "app.action_name" namespace
#### 4.3 New Files Created
- `v3/pkg/application/menu_linux_gtk4.go` - GTK4 menu processing
- `v3/pkg/application/menuitem_linux_gtk4.go` - GTK4 menu item handling
#### 4.4 Build Tag Changes
- `menu_linux.go` - Added `gtk3` tag
- `menuitem_linux.go` - Added `gtk3` tag
#### 4.5 Key Functions
- `menuActionActivated()` - Callback when GAction is triggered
- `menuItemNewWithId()` - Creates GMenuItem + associated GSimpleAction
- `menuCheckItemNewWithId()` - Creates stateful toggle action
- `menuRadioItemNewWithId()` - Creates radio action
- `set_action_enabled()` / `set_action_state()` - Manage action state
TODO (deferred):
- [ ] Context menus with GtkPopoverMenu
### Phase 5: Asset Server ✅ COMPLETE
WebKitGTK 6.0 uses the same URI scheme handler API as WebKitGTK 4.1.
The asset server implementation is identical between GTK3 and GTK4.
#### 5.1 Asset Server Files (already created in Phase 1)
- `v3/internal/assetserver/webview/webkit6.go` - WebKitGTK 6.0 helpers
- `v3/internal/assetserver/webview/request_linux_gtk4.go` - Request handling
- `v3/internal/assetserver/webview/responsewriter_linux_gtk4.go` - Response writing
#### 5.2 Missing Exports Added
The GTK4 CGO file was missing two critical exports that were in the GTK3 file:
- `onProcessRequest` - Handles URI scheme requests from WebKit
- `sendMessageToBackend` - Handles JavaScript to Go communication
Both exports were added to `linux_cgo_gtk4.go`.
#### 5.3 Key Differences from GTK3
| Aspect | GTK3 | GTK4 |
|--------|------|------|
| pkg-config | `webkit2gtk-4.1` | `webkitgtk-6.0` |
| Headers | `webkit2/webkit2.h` | `webkit/webkit.h` |
| Min version | 2.40 | 6.0 |
| URI scheme API | Same | Same |
TODO (deferred to testing phase):
- [ ] Test asset loading on actual GTK4 system
- [ ] Verify JavaScript execution works correctly
### Phase 6: Docker & Build System ✅ COMPLETE
#### 6.1 Docker Container Updates
Updated both Dockerfile.linux-x86_64 and Dockerfile.linux-arm64 to install:
- GTK3 + WebKit2GTK 4.1 (default build target)
- GTK4 + WebKitGTK 6.0 (for experimental `-tags gtk4` builds)
Build scripts now support `BUILD_TAGS` environment variable:
- Default: Builds with GTK3/WebKit2GTK 4.1
- `BUILD_TAGS=gtk4`: Builds with GTK4/WebKitGTK 6.0 (experimental)
#### 6.2 Taskfile Targets
New targets added to `v3/Taskfile.yaml`:
| Target | Description |
|--------|-------------|
| `test:example:linux` | Build single example with GTK3 (native, default) |
| `test:example:linux:gtk4` | Build single example with GTK4 (native, experimental) |
| `test:examples:linux:docker:x86_64` | Build all examples with GTK3 in Docker |
| `test:examples:linux:docker:x86_64:gtk4` | Build all examples with GTK4 in Docker (experimental) |
| `test:examples:linux:docker:arm64` | Build all examples with GTK3 in Docker (ARM64) |
| `test:examples:linux:docker:arm64:gtk4` | Build all examples with GTK4 in Docker (ARM64, experimental) |
TODO (deferred):
- [ ] Update CI/CD workflows to test both GTK versions
### Phase 8: Dialog System ✅ COMPLETE
GTK4 completely replaced the dialog APIs. GTK3's `GtkFileChooserDialog` and
`gtk_message_dialog_new` are deprecated/removed.
#### 8.1 File Dialogs
GTK4 uses `GtkFileDialog` with async API:
- `gtk_file_dialog_open()` - Open single file
- `gtk_file_dialog_open_multiple()` - Open multiple files
- `gtk_file_dialog_select_folder()` - Select folder
- `gtk_file_dialog_select_multiple_folders()` - Select multiple folders
- `gtk_file_dialog_save()` - Save file
Key differences:
- No more `gtk_dialog_run()` - everything is async with callbacks
- Filters use `GListStore` of `GtkFileFilter` objects
- Results delivered via `GAsyncResult` callbacks
- Custom button text via `gtk_file_dialog_set_accept_label()`
#### 8.1.1 GTK4 File Dialog Limitations (Portal-based)
GTK4's `GtkFileDialog` uses **xdg-desktop-portal** for native file dialogs. This provides
better desktop integration but removes some application control:
| Feature | GTK3 | GTK4 | Notes |
|---------|------|------|-------|
| `ShowHiddenFiles()` | ✅ Works | ❌ No effect | User controls via portal UI toggle |
| `CanCreateDirectories()` | ✅ Works | ❌ No effect | Always enabled in portal |
| `ResolvesAliases()` | ✅ Works | ❌ No effect | Portal handles symlinks |
| `SetButtonText()` | ✅ Works | ✅ Works | `gtk_file_dialog_set_accept_label()` |
| Multiple folders | ✅ Works | ✅ Works | `gtk_file_dialog_select_multiple_folders()` |
**Why these limitations exist**: GTK4's portal-based dialogs delegate UI control to the
desktop environment (GNOME, KDE, etc.). This is intentional - the portal provides
consistent UX across applications and respects user preferences.
#### 8.2 Message Dialogs
GTK4 uses `GtkAlertDialog`:
- `gtk_alert_dialog_choose()` - Show dialog with buttons
- Buttons specified as NULL-terminated string array
- Default and cancel button indices configurable
#### 8.3 Implementation Details
- Request ID tracking for async callback matching
- `fileDialogCallback` / `alertDialogCallback` C exports for results
- `runChooserDialog()` and `runQuestionDialog()` Go wrappers
- `runOpenFileDialog()` and `runSaveFileDialog()` convenience functions
| GTK3 | GTK4 |
|------|------|
| `GtkFileChooserDialog` | `GtkFileDialog` |
| `gtk_dialog_run()` | Async callbacks |
| `gtk_message_dialog_new()` | `GtkAlertDialog` |
| `gtk_widget_destroy()` | `g_object_unref()` |
### Phase 9: Keyboard Accelerators ✅ COMPLETE
GTK4 uses `gtk_application_set_accels_for_action()` to bind keyboard shortcuts to GActions.
#### 9.1 Key Components
**C Helper Functions** (in `linux_cgo_gtk4.go`):
- `set_action_accelerator(app, action_name, accel)` - Sets accelerator for a GAction
- `build_accelerator_string(key, mods)` - Converts key+modifiers to GTK accelerator string
**Go Functions** (in `linux_cgo_gtk4.go`):
- `namedKeysToGTK` - Map of key names to GDK keysym values (e.g., "backspace" → 0xff08)
- `parseKeyGTK(key)` - Converts Wails key string to GDK keysym
- `parseModifiersGTK(modifiers)` - Converts Wails modifiers to GdkModifierType
- `acceleratorToGTK(accel)` - Converts full accelerator to GTK format
- `setMenuItemAccelerator(itemId, accel)` - Sets accelerator for a menu item
**Integration** (in `menuitem_linux_gtk4.go`):
- `setAccelerator()` method on `linuxMenuItem` calls `setMenuItemAccelerator()`
- `newMenuItemImpl()`, `newCheckMenuItemImpl()`, `newRadioMenuItemImpl()` all set accelerators during creation
#### 9.2 Accelerator String Format
GTK accelerator strings use format like:
- `<Control>q` - Ctrl+Q
- `<Control><Shift>s` - Ctrl+Shift+S
- `<Alt>F4` - Alt+F4
- `<Super>e` - Super+E (Windows/Command key)
#### 9.3 Modifier Mapping
| Wails Modifier | GDK Modifier |
|----------------|--------------|
| `CmdOrCtrlKey` | `GDK_CONTROL_MASK` |
| `ControlKey` | `GDK_CONTROL_MASK` |
| `OptionOrAltKey` | `GDK_ALT_MASK` |
| `ShiftKey` | `GDK_SHIFT_MASK` |
| `SuperKey` | `GDK_SUPER_MASK` |
### Phase 10: Testing 📋 PENDING
TODO:
- [ ] Test on Ubuntu 24.04 (native GTK4)
- [ ] Test on Ubuntu 22.04 (backported WebKitGTK 6.0)
- [ ] Test legacy build on older systems
- [ ] Performance benchmarks
- [ ] Verify file dialogs work correctly
- [ ] Verify message dialogs work correctly
## API Differences: GTK3 vs GTK4
| Feature | GTK3 | GTK4 |
|---------|------|------|
| Init | `gtk_init(&argc, &argv)` | `gtk_init_check()` |
| Container | `gtk_container_add()` | `gtk_window_set_child()` |
| Show | `gtk_widget_show_all()` | Widgets visible by default |
| Hide | `gtk_widget_hide()` | `gtk_widget_set_visible(w, FALSE)` |
| Clipboard | `GtkClipboard` | `GdkClipboard` |
| Menu | `GtkMenu/GtkMenuItem` | `GMenu/GAction` |
| Menu Bar | `GtkMenuBar` | `GtkPopoverMenuBar` |
| Window Move | `gtk_window_move()` | NO-OP on Wayland |
| Window Position | `gtk_window_get_position()` | Not available on Wayland |
| Destroy | `gtk_widget_destroy()` | `gtk_window_destroy()` |
| Drag Start | `gtk_window_begin_move_drag()` | `gtk_native_get_surface()` + surface drag |
## Files Reference
### GTK3 (Default) Files
```
v3/pkg/application/
linux_cgo.go # Main CGO (!gtk4 tag - default)
application_linux.go # App lifecycle (!gtk4 tag - default)
v3/internal/assetserver/webview/
webkit2.go # WebKit2GTK helpers (!gtk4 tag - default)
request_linux.go # Request handling (!gtk4 tag - default)
responsewriter_linux.go # Response writing (!gtk4 tag - default)
v3/internal/capabilities/
capabilities_linux_gtk3.go # GTK3 capabilities (!gtk4 tag - default)
v3/internal/operatingsystem/
webkit_linux.go # WebKit version info (!gtk4 tag - default)
```
### GTK4 (Experimental) Files
```
v3/pkg/application/
linux_cgo_gtk4.go # Main CGO (gtk4 tag - experimental)
application_linux_gtk4.go # App lifecycle (gtk4 tag - experimental)
v3/internal/assetserver/webview/
webkit6.go # WebKitGTK 6.0 helpers (gtk4 tag - experimental)
request_linux_gtk4.go # Request handling (gtk4 tag - experimental)
responsewriter_linux_gtk4.go # Response writing (gtk4 tag - experimental)
v3/internal/capabilities/
capabilities_linux.go # GTK4 capabilities (gtk4 tag - experimental)
v3/internal/operatingsystem/
webkit_linux_gtk4.go # WebKit version info (gtk4 tag - experimental)
```
### Shared Files (no GTK-specific code)
```
v3/pkg/application/
webview_window_linux.go # Window wrapper (uses methods from CGO files)
systemtray_linux.go # D-Bus based, GTK-agnostic
v3/internal/assetserver/webview/
request.go # Interface definitions
responsewriter.go # Interface definitions
```
## Changelog
### 2026-01-07 (Session 11)
- Fixed GTK4 dialog system bugs
- **File Dialog Fix**: Removed premature `g_object_unref()` that freed dialog before async callback
- GTK4 async dialogs manage their own lifecycle
- Commit: `6f9c5beb5`
- **Alert Dialog Fixes**:
- Removed premature `g_object_unref(dialog)` from `show_alert_dialog()` (same issue as file dialogs)
- Fixed deadlock in `dialogs_linux.go` - `InvokeAsync``go func()` since `runQuestionDialog` blocks internally
- Fixed `runQuestionDialog` to use `options.Title` as message (was using `options.Message`)
- Added default "OK" button when no buttons specified
- Commit: `1a77e6091`
- **Other Fixes**:
- Fixed checkptr errors with `-race` flag by changing C signal functions to accept `uintptr_t` (`3999f1f24`)
- Fixed ExecJS race condition by adding mutex for `runtimeLoaded`/`pendingJS` (`8e386034e`)
- Added DEBUG_LOG macro for compile-time debug output: `CGO_CFLAGS="-DWAILS_GTK_DEBUG" go build ...`
- Added manual dialog test suite in `v3/test/manual/dialog/`
- **Additional Dialog Fixes** (Session 11 continued):
- Added `gtk_file_dialog_set_accept_label()` for custom button text
- Added `gtk_file_dialog_select_multiple_folders()` for multiple directory selection
- Fixed data race in `application.go` cleanup - was using RLock() when writing `a.windows = nil`
- Documented GTK4 portal limitations (ShowHiddenFiles, CanCreateDirectories have no effect)
- Files modified:
- `v3/pkg/application/linux_cgo_gtk4.go` - dialog fixes, race fixes, accept label, multiple folders
- `v3/pkg/application/linux_cgo_gtk4.c` - DEBUG_LOG macro, alert dialog lifecycle fix, select_multiple_folders callback
- `v3/pkg/application/linux_cgo_gtk4.h` - uintptr_t for signal functions
- `v3/pkg/application/dialogs_linux.go` - deadlock fix
- `v3/pkg/application/webview_window.go` - pendingJS mutex
- `v3/pkg/application/application.go` - RLock → Lock for cleanup writes
- `docs/src/content/docs/reference/dialogs.mdx` - documented GTK4 limitations
### 2026-01-04 (Session 10)
- Fixed Window → Zoom menu behavior to toggle maximize/restore (was incorrectly calling webview zoomIn)
- Fixed radio button styling in GTK4 GMenu (now shows dots instead of checkmarks)
- Implemented proper GMenu radio groups with string-valued stateful actions
- All items in group share same action name with unique target values
- Added `create_radio_menu_item()` C helper and `menuRadioItemNewWithGroup()` Go wrapper
- Researched Wayland minimize behavior:
- `gtk_window_minimize()` works on GNOME/KDE (sends xdg_toplevel_set_minimized)
- May be no-op on tiling WMs (Sway, etc.) per Wayland protocol design
- Fixed app not terminating when last window closed
- Added quit logic to `unregisterWindow()` in `application_linux_gtk4.go`
- Respects `DisableQuitOnLastWindowClosed` option
- Fixed menu separators not showing
- GMenu uses sections for visual separators (not separate separator items)
- Rewrote menu processing to group items into sections, separators create new sections
- Added `menuNewSection()`, `menuAppendSection()`, `menuAppendItemToSection()` helpers
- Added CSS provider to reduce popover menu padding
- Removed all debug println statements
- Files modified:
- `v3/pkg/application/linux_cgo_gtk4.go` - added radio group support, section helpers
- `v3/pkg/application/linux_cgo_gtk4.c` - added create_radio_menu_item(), init_menu_css()
- `v3/pkg/application/linux_cgo_gtk4.h` - added function declaration
- `v3/pkg/application/application_linux_gtk4.go` - added quit-on-last-window logic
- `v3/pkg/application/menu_linux_gtk4.go` - section-based menu processing, radio groups
- `v3/pkg/application/menuitem_linux_gtk4.go` - updated radio item creation
- `v3/pkg/application/webview_window_linux.go` - fixed zoom() to toggle maximize
- `v3/pkg/application/window_manager.go` - removed debug output
### 2026-01-04 (Session 9)
- Fixed GTK4 window creation crash (SIGSEGV in gtk_application_window_new)
- **Root Cause**: GTK4 requires app to be "activated" before creating windows
- **Solution**: Added activation synchronization mechanism:
- Added `activated` channel and `sync.Once` to `linuxApp` struct
- Added `markActivated()` method called from `activateLinux()` callback
- Added `waitForActivation()` method for callers to block until ready
- Modified `WebviewWindow.Run()` to wait for activation before `InvokeSync`
- Files modified:
- `v3/pkg/application/application_linux_gtk4.go` - activation gate
- `v3/pkg/application/linux_cgo_gtk4.go` - call markActivated() in activateLinux
- `v3/pkg/application/webview_window.go` - wait for activation on GTK4
- GTK4 apps now create windows successfully without crashes
### 2026-01-04 (Session 8)
- Fixed GTK3/GTK4 symbol conflict in operatingsystem package
- Added `gtk3` build tag to `v3/internal/operatingsystem/webkit_linux.go`
- Created `v3/internal/operatingsystem/webkit_linux_gtk4.go` with GTK4/WebKitGTK 6.0
- Moved app initialization from `init()` to `newPlatformApp()` for cleaner setup
- Resolved runtime crash: "GTK 2/3 symbols detected in GTK 4 process"
- Verified menu example runs successfully with GTK 4.20.3 and WebKitGTK 2.50.3
### 2026-01-04 (Session 7)
- Completed Phase 9: Keyboard Accelerators
- Added namedKeysToGTK map with GDK keysym values for all special keys
- Added parseKeyGTK() and parseModifiersGTK() conversion functions
- Added acceleratorToGTK() to convert Wails accelerator format to GTK
- Added setMenuItemAccelerator() Go wrapper that calls C helpers
- Integrated accelerator setting in all menu item creation functions
- Uses gtk_application_set_accels_for_action() for GTK4 shortcut binding
### 2026-01-04 (Session 6)
- Completed Phase 8: Dialog System
- Implemented GtkFileDialog for file open/save/folder dialogs
- Implemented GtkAlertDialog for message dialogs
- Added async callback system for GTK4 dialogs (no more gtk_dialog_run)
- Added C helper functions and Go wrapper functions
### 2026-01-04 (Session 5 continued)
- Completed Phase 6: Docker & Build System
- Updated Dockerfile.linux-x86_64 and Dockerfile.linux-arm64 for GTK4 + GTK3
- Added BUILD_TAGS environment variable support in build scripts
- Added Taskfile targets for GTK4 (default) and GTK3 (legacy) builds
### 2026-01-04 (Session 5)
- Completed Phase 5: Asset Server
- Verified WebKitGTK 6.0 uses same URI scheme handler API as WebKitGTK 4.1
- Added missing `onProcessRequest` export to linux_cgo_gtk4.go
- Added missing `sendMessageToBackend` export to linux_cgo_gtk4.go
- Confirmed asset server files (webkit6.go, request/responsewriter) are complete
### 2026-01-04 (Session 4)
- Completed Phase 4: Menu System
- Implemented GMenu/GAction architecture for GTK4 menus
- Created GtkPopoverMenuBar integration
- Added menu_linux_gtk4.go and menuitem_linux_gtk4.go
- Added gtk3 build tags to original menu files
- Implemented stateful actions for checkboxes and radio items
### 2026-01-04 (Session 3)
- Completed Phase 3: Window Management
- Implemented GTK4 event controllers (GtkEventControllerFocus, GtkGestureClick, GtkEventControllerKey)
- Implemented window drag using GdkToplevel API (gdk_toplevel_begin_move/resize)
- Implemented complete drag-and-drop with GtkDropTarget
- Fixed window state detection (isMinimised, isMaximised, isFullscreen)
- Fixed size() function to properly return window dimensions
- Updated windowSetGeometryHints for GTK4 (uses gtk_widget_set_size_request)
### 2026-01-04 (Session 2)
- Completed Phase 2: Doctor & Capabilities
- Updated all 7 package managers for GTK4/WebKitGTK 6.0 as primary
- Added GTKVersion and WebKitVersion fields to Capabilities struct
- Created capabilities_linux_gtk3.go for legacy build path
### 2026-01-04 (Session 1)
- Initial implementation of GTK4 build infrastructure
- Added `gtk3` constraint to 5 existing files
- Created 5 new GTK4 stub files
- Updated UNRELEASED_CHANGELOG.md

View file

@ -30,6 +30,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
*/
## [Unreleased]
## v3.0.0-alpha.67 - 2026-02-04
## Added
- Added how to do `One Time Handlers` in the docs for `Listening to Events in JavaScript` by @AbdelhadiSeddar
## Changed
- Changed the use of `Event` into `Events` according to changes in `@wailsio/runtime` and appropriate function calls in the docs in `Features/Events/Event System` by @AbdelhadiSeddar
## Fixed
- Fix "ghost windows" issue on macOS caused by not accessing AppKit APIs from the Main Thread in App.Window.Current() (#4947) by @wimaha
- Fix HTML `<input type="file">` not working on macOS by implementing WKUIDelegate runOpenPanelWithParameters (#4862)
- Fix native file drag-and-drop not working when using `@wailsio/runtime` npm module on macOS/Linux (#4953) by @leaanthony
- Fix binding generation for cross-package type aliases (#4578) by @fbbdev
## v3.0.0-alpha.66 - 2026-02-03
## Added

View file

@ -439,7 +439,7 @@ childWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
<TabItem label="Linux" icon="linux">
**Linux-specific features:**
```go
// Set window icon
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
@ -448,15 +448,17 @@ childWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
},
})
```
**Desktop environment notes:**
- GNOME: Full support
- KDE Plasma: Full support
- XFCE: Partial support
- Others: Varies
**Window managers:**
- Tiling WMs may ignore size/position
**Tiling window managers (Hyprland, Sway, i3, etc.):**
- `Minimise()` and `Maximise()` may not work as expected - the WM controls window geometry
- `SetSize()` and `SetPosition()` requests are advisory and may be ignored
- `Fullscreen()` typically works as expected
- Some WMs don't support always-on-top
</TabItem>
</Tabs>

View file

@ -773,6 +773,27 @@ func (s *Service) OpenWithDefaults(app *application.App) (string, error) {
- Qt dialogs on Qt-based systems
- Matches desktop environment
#### Linux GTK4 Limitations
On Linux with GTK4 (the default for modern distributions), file dialogs use the **xdg-desktop-portal**
for native integration. This provides better desktop integration but means some options have no effect:
| Option | GTK3 | GTK4 | Notes |
|--------|------|------|-------|
| `ShowHiddenFiles()` | ✅ Works | ❌ No effect | User controls via dialog's UI toggle (Ctrl+H or menu) |
| `CanCreateDirectories()` | ✅ Works | ❌ No effect | Always enabled in the portal |
| `ResolvesAliases()` | ✅ Works | ❌ No effect | Portal handles symlink resolution |
| `SetButtonText()` | ✅ Works | ✅ Works | Custom accept button text works |
**Why these limitations exist:** GTK4's portal-based dialogs delegate UI control to the desktop
environment (GNOME, KDE, etc.). This is by design - the portal provides consistent UX across
applications and respects user preferences.
:::note
The table above shows GTK4 behavior when building with `-tags gtk4`. By default, Wails uses GTK3
which provides full control over these dialog options.
:::
## Common Patterns
### "Save As" Pattern

View file

@ -604,6 +604,34 @@ if err != nil {
**Note:** This triggers the native OS print dialog, allowing the user to select printer settings and print the current window content.
## Platform-Specific Options
### Linux
Linux windows support the following platform-specific options via `LinuxWindow`:
#### MenuStyle
Controls how the application menu is displayed. This option is only available on GTK4 builds and is ignored on GTK3.
| Value | Description |
|-------|-------------|
| `LinuxMenuStyleMenuBar` | Traditional menu bar below the title bar (default) |
| `LinuxMenuStylePrimaryMenu` | Primary menu button in the header bar (GNOME style) |
**Example:**
```go
window := app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
Title: "My Application",
Linux: application.LinuxWindow{
MenuStyle: application.LinuxMenuStylePrimaryMenu,
},
})
window.SetMenu(menu)
```
**Note:** The primary menu style displays a hamburger button (☰) in the header bar, following GNOME Human Interface Guidelines. This is the recommended style for modern GNOME applications.
## Complete Example
```go

3455
pr_4570.diff Normal file

File diff suppressed because it is too large Load diff

3
v3/.gitignore vendored
View file

@ -9,4 +9,5 @@ cmd/wails3/wails
/cmd/wails3/ui/.task/
!internal/commands/webview2/MicrosoftEdgeWebview2Setup.exe
internal/commands/appimage_testfiles/appimage_testfiles
testiosapp/
testiosapp/
test/manual/systray/bin/

View file

@ -92,14 +92,25 @@ tasks:
GOARCH: amd64
test:example:linux:
summary: Build example for Linux with GTK3 (default)
dir: 'examples/{{.DIR}}'
platforms:
- linux
cmds:
- echo "Building example {{.DIR}} for Linux"
- echo "Building example {{.DIR}} for Linux (GTK3)"
- go mod tidy
- go build -o "testbuild-{{.DIR}}-linux"
test:example:linux:gtk4:
summary: Build example for Linux with GTK4 (experimental, opt-in via -tags gtk4)
dir: 'examples/{{.DIR}}'
platforms:
- linux
cmds:
- echo "Building example {{.DIR}} for Linux (GTK4 experimental)"
- go mod tidy
- go build -tags gtk4 -o "testbuild-{{.DIR}}-linux-gtk4"
test:example:linux:docker:arm64:
summary: Build a single example for Linux ARM64 using Docker (Ubuntu 24.04)
cmds:
@ -115,21 +126,37 @@ tasks:
- docker run --rm wails-v3-linux-x86_64 /build/build-linux-x86_64.sh {{.DIR}}
test:examples:linux:docker:arm64:
summary: Build all examples for Linux ARM64 using Docker (Ubuntu 24.04)
summary: Build all examples for Linux ARM64 using Docker (Ubuntu 24.04, GTK4)
cmds:
- echo "Building Docker image for Linux ARM64 compilation..."
- docker build --pull -f test/docker/Dockerfile.linux-arm64 -t wails-v3-linux-arm64 .
- echo "Running Linux ARM64 compilation in Docker container..."
- echo "Running Linux ARM64 compilation in Docker container (GTK4)..."
- docker run --rm wails-v3-linux-arm64
test:examples:linux:docker:x86_64:
summary: Build all examples for Linux x86_64 using Docker (Ubuntu 24.04)
summary: Build all examples for Linux x86_64 using Docker (Ubuntu 24.04, GTK4)
cmds:
- echo "Building Docker image for Linux x86_64 compilation..."
- docker build --pull -f test/docker/Dockerfile.linux-x86_64 -t wails-v3-linux-x86_64 .
- echo "Running Linux x86_64 compilation in Docker container..."
- echo "Running Linux x86_64 compilation in Docker container (GTK4)..."
- docker run --rm wails-v3-linux-x86_64
test:examples:linux:docker:x86_64:gtk3:
summary: Build all examples for Linux x86_64 using Docker with GTK3 (legacy)
cmds:
- echo "Building Docker image for Linux x86_64 compilation..."
- docker build --pull -f test/docker/Dockerfile.linux-x86_64 -t wails-v3-linux-x86_64 .
- echo "Running Linux x86_64 compilation in Docker container (GTK3 legacy)..."
- docker run --rm -e BUILD_TAGS=gtk3 wails-v3-linux-x86_64
test:examples:linux:docker:arm64:gtk3:
summary: Build all examples for Linux ARM64 using Docker with GTK3 (legacy)
cmds:
- echo "Building Docker image for Linux ARM64 compilation..."
- docker build --pull -f test/docker/Dockerfile.linux-arm64 -t wails-v3-linux-arm64 .
- echo "Running Linux ARM64 compilation in Docker container (GTK3 legacy)..."
- docker run --rm -e BUILD_TAGS=gtk3 wails-v3-linux-arm64
test:example:linux:docker:
summary: Build a single example for Linux using Docker (auto-detect architecture)
cmds:
@ -338,6 +365,12 @@ tasks:
vars:
DIR: "{{.ITEM}}"
platforms: [linux]
# GTK4 tests are run separately via Docker tasks since CI doesn't have GTK4 deps
# - for: { var: EXAMPLEDIRS }
# task: test:example:linux:gtk4
# vars:
# DIR: "{{.ITEM}}"
# platforms: [linux]
- for: { var: EXAMPLEDIRS }
task: test:example:windows
vars:
@ -368,6 +401,32 @@ tasks:
- task: clean:test:binaries
- echo "✅ All tests completed successfully"
sanity:
summary: Quick compilation sanity check for key examples (GTK3 default + GTK4 opt-in)
platforms:
- linux
cmds:
- echo "🔍 Running quick sanity check (GTK3 - default)..."
- cd examples/menu && go build -o /dev/null .
- cd examples/window && go build -o /dev/null .
- cd examples/events && go build -o /dev/null .
- cd examples/plain && go build -o /dev/null .
- cd examples/dialogs-basic && go build -o /dev/null .
- echo "✅ GTK3 sanity check passed"
- task: sanity:gtk4
sanity:gtk4:
summary: Quick compilation sanity check for GTK4 (experimental, opt-in via -tags gtk4)
platforms:
- linux
cmds:
- echo "🔍 Running GTK4 sanity check..."
- cd examples/menu && go build -tags gtk4 -o /dev/null .
- cd examples/window && go build -tags gtk4 -o /dev/null .
- cd examples/events && go build -tags gtk4 -o /dev/null .
- cd examples/plain && go build -tags gtk4 -o /dev/null .
- echo "✅ GTK4 sanity check passed"
build:server:
summary: Build an application in server mode (no GUI, HTTP server only)
desc: |

View file

@ -17,21 +17,26 @@ After processing, the content will be moved to the main changelog and this file
## Added
<!-- New features, capabilities, or enhancements -->
- Added how to do `One Time Handlers` in the docs for `Listening to Events in JavaScript` by @AbdelhadiSeddar
## Changed
<!-- Changes in existing functionality -->
- Changed the use of `Event` into `Events` according to changes in `@wailsio/runtime` and appropriate function calls in the docs in `Features/Events/Event System` by @AbdelhadiSeddar
- Add Web API examples in `v3/examples/web-apis/` demonstrating 41 browser APIs including Storage (localStorage, sessionStorage, IndexedDB, Cache API), Network (Fetch, WebSocket, XMLHttpRequest, EventSource, Beacon), Media (Canvas, WebGL, Web Audio, MediaDevices, MediaRecorder, Speech Synthesis), Device (Geolocation, Clipboard, Fullscreen, Device Orientation, Vibration, Gamepad), Performance (Performance API, Mutation Observer, Intersection/Resize Observer), UI (Web Components, Pointer Events, Selection, Dialog, Drag and Drop), and more
- Add WebView API compatibility checker example (`v3/examples/webview-api-check/`) that tests 200+ browser APIs across platforms
- Add `internal/libpath` package for finding native library paths on Linux with parallel search, caching, and support for Flatpak/Snap/Nix
- **WIP:** Add experimental WebKitGTK 6.0 / GTK4 support for Linux, available via `-tags gtk4` (GTK3/WebKit2GTK 4.1 remains the default)
- Note: On tiling window managers (e.g., Hyprland, Sway), Minimize/Maximize operations may not work as expected since the WM controls window geometry
## Changed
<!-- Changes in existing functionality -->
- **BREAKING**: Map keys in generated JS/TS bindings are now marked optional to accurately reflect Go map semantics. Map value access in Typescript now returns `T | undefined` instead of `T`, requiring null checks or assertions (#4943) by `@fbbdev`
## Fixed
<!-- Bug fixes -->
- Fix "ghost windows" issue on macOS caused by not accessing AppKit APIs from the Main Thread in App.Window.Current() (#4947) by @wimaha
- Fix HTML `<input type="file">` not working on macOS by implementing WKUIDelegate runOpenPanelWithParameters (#4862)
- Fix native file drag-and-drop not working when using `@wailsio/runtime` npm module on macOS/Linux (#4953) by @leaanthony
- Fix binding generation for cross-package type aliases (#4578) by @fbbdev
- Fix file drag-and-drop on Windows not working at non-100% display scaling
- Fix HTML5 internal drag-and-drop being broken when file drop was enabled on Windows
- Fix file drop coordinates being in wrong pixel space on Windows (physical vs CSS pixels)
- Fix file drag-and-drop on Linux not working reliably with hover effects
- Fix HTML5 internal drag-and-drop being broken when file drop was enabled on Linux
- Fix DPI scaling on Linux/GTK4 by implementing proper PhysicalBounds calculation and fractional scaling support via `gdk_monitor_get_scale` (GTK 4.14+)
- Fix menu items duplicating when creating new windows on Linux/GTK4
- Fix generation of mapped types with enum keys in JS/TS bindings (#4437) by @fbbdev
## Deprecated
<!-- Soon-to-be removed features -->

View file

@ -50,6 +50,7 @@ func main() {
return commands.Package(&pkgFlags, pkg.OtherArgs())
})
app.NewSubCommandFunction("doctor", "System status report", commands.Doctor)
app.NewSubCommandFunction("doctor-ng", "System status report (new TUI)", commands.DoctorNg)
app.NewSubCommandFunction("releasenotes", "Show release notes", commands.ReleaseNotes)
task := app.NewSubCommand("task", "Run and list tasks")
@ -94,6 +95,7 @@ func main() {
tool.NewSubCommandFunction("package", "Generate Linux packages (deb, rpm, archlinux)", commands.ToolPackage)
tool.NewSubCommandFunction("version", "Bump semantic version", commands.ToolVersion)
tool.NewSubCommandFunction("lipo", "Create macOS universal binary from multiple architectures", commands.ToolLipo)
tool.NewSubCommandFunction("capabilities", "Check system build capabilities (GTK4/GTK3 availability)", commands.ToolCapabilities)
// Low-level sign tool (used by Taskfiles)
toolSign := tool.NewSubCommand("sign", "Sign a binary or package directly")
@ -127,10 +129,10 @@ func main() {
return commands.SignWrapper(&signWrapperFlags, sign.OtherArgs())
})
// iOS tools
ios := app.NewSubCommand("ios", "iOS tooling")
ios.NewSubCommandFunction("overlay:gen", "Generate Go overlay for iOS bridge shim", commands.IOSOverlayGen)
ios.NewSubCommandFunction("xcode:gen", "Generate Xcode project in output directory", commands.IOSXcodeGen)
// iOS tools
ios := app.NewSubCommand("ios", "iOS tooling")
ios.NewSubCommandFunction("overlay:gen", "Generate Go overlay for iOS bridge shim", commands.IOSOverlayGen)
ios.NewSubCommandFunction("xcode:gen", "Generate Xcode project in output directory", commands.IOSXcodeGen)
app.NewSubCommandFunction("version", "Print the version", commands.Version)
app.NewSubCommand("sponsor", "Sponsor the project").Action(openSponsor)

View file

@ -368,6 +368,7 @@ func main() {
// Create window with UseApplicationMenu to inherit the app menu on Windows/Linux
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Dialogs Demo",
UseApplicationMenu: true,
})

View file

@ -13,6 +13,8 @@
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
background-color: white;
user-select: none;
-webkit-user-select: none;
}
#container {
display: flex;

View file

@ -1,8 +1,6 @@
package main
import (
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
@ -11,6 +9,22 @@ type ScreenService struct {
isExampleLayout bool
}
// Helper to safely get float64 from interface{}
func getFloat64(v interface{}) float64 {
if v == nil {
return 0
}
if f, ok := v.(float64); ok {
return f
}
return 0
}
// Helper to safely get int from interface{} (expecting float64 from JSON)
func getInt(v interface{}) int {
return int(getFloat64(v))
}
func (s *ScreenService) GetSystemScreens() []*application.Screen {
s.isExampleLayout = false
screens := application.Get().Screen.GetAll()
@ -21,11 +35,14 @@ func (s *ScreenService) ProcessExampleScreens(rawScreens []interface{}) []*appli
s.isExampleLayout = true
parseRect := func(m map[string]interface{}) application.Rect {
if m == nil {
return application.Rect{}
}
return application.Rect{
X: int(m["X"].(float64)),
Y: int(m["Y"].(float64)),
Width: int(m["Width"].(float64)),
Height: int(m["Height"].(float64)),
X: getInt(m["X"]),
Y: getInt(m["Y"]),
Width: getInt(m["Width"]),
Height: getInt(m["Height"]),
}
}
@ -37,22 +54,56 @@ func (s *ScreenService) ProcessExampleScreens(rawScreens []interface{}) []*appli
screens := make([]*application.Screen, 0, len(rawScreens))
for _, s := range rawScreens {
s := s.(map[string]interface{})
sm, ok := s.(map[string]interface{})
if !ok {
continue
}
bounds := parseRect(s["Bounds"].(map[string]interface{}))
boundsVal, ok := sm["Bounds"]
if !ok {
continue
}
boundsMap, ok := boundsVal.(map[string]interface{})
if !ok {
continue
}
bounds := parseRect(boundsMap)
var id, name string
var isPrimary bool
if idVal, ok := sm["ID"].(string); ok {
id = idVal
}
if nameVal, ok := sm["Name"].(string); ok {
name = nameVal
}
if primaryVal, ok := sm["IsPrimary"].(bool); ok {
isPrimary = primaryVal
}
var physicalBounds, workArea, physicalWorkArea application.Rect
if pb, ok := sm["PhysicalBounds"].(map[string]interface{}); ok {
physicalBounds = parseRect(pb)
}
if wa, ok := sm["WorkArea"].(map[string]interface{}); ok {
workArea = parseRect(wa)
}
if pwa, ok := sm["PhysicalWorkArea"].(map[string]interface{}); ok {
physicalWorkArea = parseRect(pwa)
}
screens = append(screens, &application.Screen{
ID: s["ID"].(string),
Name: s["Name"].(string),
ID: id,
Name: name,
X: bounds.X,
Y: bounds.Y,
Size: application.Size{Width: bounds.Width, Height: bounds.Height},
Bounds: bounds,
PhysicalBounds: parseRect(s["PhysicalBounds"].(map[string]interface{})),
WorkArea: parseRect(s["WorkArea"].(map[string]interface{})),
PhysicalWorkArea: parseRect(s["PhysicalWorkArea"].(map[string]interface{})),
IsPrimary: s["IsPrimary"].(bool),
ScaleFactor: float32(s["ScaleFactor"].(float64)),
PhysicalBounds: physicalBounds,
WorkArea: workArea,
PhysicalWorkArea: physicalWorkArea,
IsPrimary: isPrimary,
ScaleFactor: float32(getFloat64(sm["ScaleFactor"])),
Rotation: 0,
})
}
@ -69,13 +120,6 @@ func (s *ScreenService) transformPoint(point application.Point, toDIP bool) appl
return s.screenManager.DipToPhysicalPoint(point)
}
} else {
// =======================
// TODO: remove this block when DPI is implemented in Linux & Mac
if runtime.GOOS != "windows" {
println("DPI not implemented yet!")
return point
}
// =======================
if toDIP {
return application.PhysicalToDipPoint(point)
} else {
@ -85,9 +129,13 @@ func (s *ScreenService) transformPoint(point application.Point, toDIP bool) appl
}
func (s *ScreenService) TransformPoint(point map[string]interface{}, toDIP bool) (points [2]application.Point) {
if point == nil {
return points
}
pt := application.Point{
X: int(point["X"].(float64)),
Y: int(point["Y"].(float64)),
X: getInt(point["X"]),
Y: getInt(point["Y"]),
}
ptTransformed := s.transformPoint(pt, toDIP)
@ -107,11 +155,15 @@ func (s *ScreenService) TransformPoint(point map[string]interface{}, toDIP bool)
}
func (s *ScreenService) TransformRect(rect map[string]interface{}, toDIP bool) application.Rect {
if rect == nil {
return application.Rect{}
}
r := application.Rect{
X: int(rect["X"].(float64)),
Y: int(rect["Y"].(float64)),
Width: int(rect["Width"].(float64)),
Height: int(rect["Height"].(float64)),
X: getInt(rect["X"]),
Y: getInt(rect["Y"]),
Width: getInt(rect["Width"]),
Height: getInt(rect["Height"]),
}
if s.isExampleLayout {
@ -121,13 +173,6 @@ func (s *ScreenService) TransformRect(rect map[string]interface{}, toDIP bool) a
return s.screenManager.DipToPhysicalRect(r)
}
} else {
// =======================
// TODO: remove this block when DPI is implemented in Linux & Mac
if runtime.GOOS != "windows" {
println("DPI not implemented yet!")
return r
}
// =======================
if toDIP {
return application.PhysicalToDipRect(r)
} else {

View file

@ -2,11 +2,11 @@ package main
import (
_ "embed"
"github.com/wailsapp/wails/v3/pkg/events"
"log"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/icons"
)
@ -23,13 +23,15 @@ func main() {
systemTray := app.SystemTray.New()
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Width: 500,
Height: 500,
Name: "Systray Demo Window",
Frameless: true,
AlwaysOnTop: true,
Hidden: true,
DisableResize: true,
Width: 500,
Height: 500,
Name: "Systray Demo Window",
Frameless: true,
AlwaysOnTop: true,
Hidden: true,
DisableResize: true,
HideOnEscape: true,
HideOnFocusLost: true,
Windows: application.WindowsWindow{
HiddenOnTaskbar: true,
},
@ -40,11 +42,8 @@ func main() {
},
})
// Register a hook to hide the window when the window is closing
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
// Hide the window
window.Hide()
// Cancel the event so it doesn't get destroyed
e.Cancel()
})

View file

@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beacon API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
height: 100vh;
padding: 15px;
overflow: hidden;
}
.container {
max-width: 900px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
h1 { color: #4ade80; font-size: 1.4rem; }
h1 a { color: #4ade80; text-decoration: none; }
h1 a:hover { text-decoration: underline; }
.status { font-size: 0.85rem; color: #4ade80; }
.status.error { color: #f87171; }
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
flex: 1;
min-height: 0;
}
.card {
background: rgba(255,255,255,0.05);
border-radius: 10px;
padding: 15px;
display: flex;
flex-direction: column;
}
h2 { font-size: 1rem; margin-bottom: 10px; color: #e94560; }
label { display: block; margin-bottom: 4px; color: #aaa; font-size: 0.8rem; }
input, textarea, select {
width: 100%;
padding: 8px;
border: 1px solid #333;
border-radius: 5px;
background: #1a1a2e;
color: #eee;
font-size: 0.9rem;
margin-bottom: 10px;
}
textarea { resize: none; font-family: monospace; font-size: 0.8rem; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 8px 14px;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.small { padding: 5px 10px; font-size: 0.75rem; }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.data-types { display: flex; gap: 5px; margin-bottom: 10px; }
.data-type {
padding: 4px 10px;
background: transparent;
border: 1px solid #333;
color: #888;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.data-type.active { background: #4ade80; color: #1a1a2e; border-color: #4ade80; }
.stats {
display: flex;
gap: 15px;
margin-bottom: 10px;
}
.stat { text-align: center; }
.stat-value { font-size: 1.3rem; font-weight: bold; color: #4ade80; }
.stat-value.error { color: #f87171; }
.stat-label { font-size: 0.7rem; color: #666; }
.event-log {
flex: 1;
background: #0a0a1a;
border-radius: 5px;
padding: 10px;
font-family: monospace;
font-size: 0.75rem;
overflow-y: auto;
min-height: 0;
}
.log-entry {
padding: 4px 8px;
margin: 2px 0;
border-radius: 3px;
display: flex;
gap: 8px;
}
.log-entry.success { background: rgba(74, 222, 128, 0.1); border-left: 2px solid #4ade80; }
.log-entry.error { background: rgba(248, 113, 113, 0.1); border-left: 2px solid #f87171; }
.log-entry.info { background: rgba(96, 165, 250, 0.1); border-left: 2px solid #60a5fa; }
.log-time { color: #555; }
.log-type { width: 55px; font-weight: 600; }
.log-type.success { color: #4ade80; }
.log-type.error { color: #f87171; }
.log-type.info { color: #60a5fa; }
.log-msg { color: #aaa; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.note { background: rgba(251,191,36,0.1); border-radius: 5px; padding: 8px; color: #fbbf24; font-size: 0.75rem; margin-bottom: 10px; }
</style>
</head>
<body>
<div class="container">
<header>
<h1><a href="https://developer.mozilla.org/en-US/docs/Web/API/Beacon_API" target="_blank">Beacon API</a> Demo</h1>
<span id="status" class="status">Checking...</span>
</header>
<div class="main-grid">
<div class="card">
<h2>Send Beacon</h2>
<div class="note">Beacons are fire-and-forget. Returns true/false for queue status only.</div>
<label>Endpoint</label>
<input type="text" id="url" value="https://httpbin.org/post">
<label>Data Type</label>
<div class="data-types">
<button class="data-type active" data-type="string">String</button>
<button class="data-type" data-type="json">JSON</button>
<button class="data-type" data-type="formdata">FormData</button>
<button class="data-type" data-type="blob">Blob</button>
</div>
<label>Data</label>
<textarea id="data" rows="3">event=page_view&amp;time=1234567890</textarea>
<div class="btn-row">
<button onclick="sendBeacon()">Send Beacon</button>
<button class="secondary small" onclick="loadExample('analytics')">Analytics</button>
<button class="secondary small" onclick="loadExample('error')">Error</button>
<button class="secondary small" onclick="loadExample('timing')">Timing</button>
</div>
</div>
<div class="card">
<h2>Statistics & Log</h2>
<div class="stats">
<div class="stat">
<div class="stat-value" id="total">0</div>
<div class="stat-label">Sent</div>
</div>
<div class="stat">
<div class="stat-value" id="queued">0</div>
<div class="stat-label">Queued</div>
</div>
<div class="stat">
<div class="stat-value error" id="failed">0</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat">
<div class="stat-value" id="bytes">0</div>
<div class="stat-label">Bytes</div>
</div>
</div>
<div class="btn-row">
<button class="small" onclick="clearLog()">Clear</button>
<label style="display:flex;align-items:center;gap:5px;font-size:0.75rem;color:#888;">
<input type="checkbox" id="autoBeacon"> Auto-send on unload
</label>
</div>
<div class="event-log" id="log">
<div class="log-entry info">
<span class="log-time">--:--</span>
<span class="log-type info">READY</span>
<span class="log-msg">Configure endpoint and send beacons</span>
</div>
</div>
</div>
</div>
</div>
<script>
let dataType = 'string';
let stats = { total: 0, queued: 0, failed: 0, bytes: 0 };
// Check support
const supported = 'sendBeacon' in navigator;
document.getElementById('status').textContent = supported ? 'Beacon API: Supported' : 'Beacon API: Not Supported';
document.getElementById('status').className = supported ? 'status' : 'status error';
// Data type selection
document.querySelectorAll('.data-type').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.data-type').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
dataType = btn.dataset.type;
updatePlaceholder();
};
});
function updatePlaceholder() {
const d = document.getElementById('data');
switch (dataType) {
case 'string': d.value = 'event=page_view&time=' + Date.now(); break;
case 'json': d.value = JSON.stringify({ event: 'custom', ts: Date.now() }, null, 2); break;
case 'formdata': d.value = 'key1=value1\nkey2=value2'; break;
case 'blob': d.value = 'Binary content here'; break;
}
}
function prepareData(raw) {
let data, size;
switch (dataType) {
case 'string':
data = raw; size = new Blob([raw]).size; break;
case 'json':
JSON.parse(raw); // validate
data = new Blob([raw], { type: 'application/json' }); size = data.size; break;
case 'formdata':
data = new FormData();
raw.split('\n').forEach(line => {
const [k, ...v] = line.split('=');
if (k && v.length) data.append(k.trim(), v.join('=').trim());
});
size = raw.length; break;
case 'blob':
data = new Blob([raw], { type: 'text/plain' }); size = data.size; break;
}
return { data, size };
}
function sendBeacon() {
const url = document.getElementById('url').value;
const raw = document.getElementById('data').value;
if (!url) { log('ERROR', 'Enter a URL', 'error'); return; }
try {
const { data, size } = prepareData(raw);
const result = navigator.sendBeacon(url, data);
stats.total++; stats.bytes += size;
result ? stats.queued++ : stats.failed++;
updateStats();
log(result ? 'QUEUED' : 'FAILED', `${dataType} ~${size}B → ${result}`, result ? 'success' : 'error');
} catch (e) {
stats.total++; stats.failed++; updateStats();
log('ERROR', e.message, 'error');
}
}
function loadExample(type) {
document.querySelectorAll('.data-type').forEach(b => b.classList.remove('active'));
document.querySelector('[data-type="json"]').classList.add('active');
dataType = 'json';
const d = document.getElementById('data');
switch (type) {
case 'analytics':
d.value = JSON.stringify({ event: 'page_view', page: location.pathname, ts: Date.now() }, null, 2); break;
case 'error':
d.value = JSON.stringify({ type: 'error', msg: 'Test error', line: 42, ts: Date.now() }, null, 2); break;
case 'timing':
d.value = JSON.stringify({ type: 'perf', load: performance.now().toFixed(0), ts: Date.now() }, null, 2); break;
}
}
function updateStats() {
document.getElementById('total').textContent = stats.total;
document.getElementById('queued').textContent = stats.queued;
document.getElementById('failed').textContent = stats.failed;
document.getElementById('bytes').textContent = stats.bytes < 1024 ? stats.bytes : (stats.bytes/1024).toFixed(1)+'K';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function log(type, msg, cls) {
const el = document.getElementById('log');
const t = new Date().toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' });
el.innerHTML += `<div class="log-entry ${escapeHtml(cls)}"><span class="log-time">${escapeHtml(t)}</span><span class="log-type ${escapeHtml(cls)}">${escapeHtml(type)}</span><span class="log-msg">${escapeHtml(msg)}</span></div>`;
el.scrollTop = el.scrollHeight;
}
function clearLog() {
document.getElementById('log').innerHTML = '';
stats = { total: 0, queued: 0, failed: 0, bytes: 0 };
updateStats();
}
// Auto-beacon on unload
window.addEventListener('beforeunload', () => {
if (document.getElementById('autoBeacon').checked) {
const url = document.getElementById('url').value;
if (url) navigator.sendBeacon(url, JSON.stringify({ event: 'unload', ts: Date.now() }));
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,27 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Beacon API Demo",
Description: "Beacon API for analytics",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Beacon API Demo",
Width: 900,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blob API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
height: 100vh;
padding: 12px;
overflow: hidden;
}
.container { max-width: 950px; margin: 0 auto; height: 100%; display: flex; flex-direction: column; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
h1 { color: #4ade80; font-size: 1.3rem; }
h1 a { color: #4ade80; text-decoration: none; }
h1 a:hover { text-decoration: underline; }
.status { font-size: 0.8rem; color: #4ade80; }
.main { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; flex: 1; min-height: 0; }
.card { background: rgba(255,255,255,0.05); border-radius: 8px; padding: 12px; display: flex; flex-direction: column; min-height: 0; }
.card.span2 { grid-column: span 2; }
h2 { font-size: 0.9rem; margin-bottom: 8px; color: #e94560; }
label { display: block; margin-bottom: 3px; color: #888; font-size: 0.75rem; }
input, textarea, select {
width: 100%;
padding: 6px 8px;
border: 1px solid #333;
border-radius: 4px;
background: #1a1a2e;
color: #eee;
font-size: 0.85rem;
margin-bottom: 8px;
}
textarea { resize: none; font-family: monospace; font-size: 0.75rem; }
select { padding: 5px; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888'%3E%3Cpath d='M6 8L2 4h8z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; }
select option { background: #1a1a2e; color: #eee; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 0.8rem;
}
button:hover { opacity: 0.9; }
button.sm { padding: 4px 8px; font-size: 0.7rem; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
.btn-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.form-row { display: flex; gap: 8px; }
.form-row > * { flex: 1; }
.output {
flex: 1;
background: #0a0a1a;
border-radius: 4px;
padding: 8px;
font-family: monospace;
font-size: 0.7rem;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
min-height: 0;
}
.blob-list { flex: 1; overflow-y: auto; min-height: 0; }
.blob-item {
background: rgba(0,0,0,0.2);
padding: 8px;
border-radius: 5px;
margin-bottom: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.blob-name { font-size: 0.8rem; color: #4ade80; font-weight: 600; }
.blob-meta { font-size: 0.7rem; color: #666; }
.blob-actions { display: flex; gap: 4px; }
.features { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.feature { font-size: 0.7rem; padding: 3px 8px; border-radius: 3px; background: rgba(74,222,128,0.2); color: #4ade80; }
.feature.no { background: rgba(248,113,113,0.2); color: #f87171; }
.tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.tab { padding: 4px 10px; background: transparent; border: 1px solid #333; color: #888; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
.tab.active { background: #4ade80; color: #1a1a2e; border-color: #4ade80; }
.tab-content { display: none; flex-direction: column; flex: 1; min-height: 0; }
.tab-content.active { display: flex; }
.preview { text-align: center; margin: 8px 0; }
.preview img { max-height: 60px; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<header>
<h1><a href="https://developer.mozilla.org/en-US/docs/Web/API/Blob" target="_blank">Blob API</a> Demo</h1>
<span class="status" id="status">Checking...</span>
</header>
<div class="main">
<!-- Create Blobs Column -->
<div class="card">
<h2>Create Blob</h2>
<div class="tabs">
<button class="tab active" onclick="showTab('text')">Text</button>
<button class="tab" onclick="showTab('json')">JSON</button>
<button class="tab" onclick="showTab('binary')">Binary</button>
<button class="tab" onclick="showTab('svg')">SVG</button>
</div>
<div id="tab-text" class="tab-content active">
<textarea id="textContent" rows="3">Hello from Blob API!</textarea>
<div class="form-row">
<div><label>Type</label><select id="textType">
<option value="text/plain">text/plain</option>
<option value="text/html">text/html</option>
<option value="application/json">application/json</option>
</select></div>
<div><label>Name</label><input id="textName" value="text-blob"></div>
</div>
<button onclick="createTextBlob()">Create Text Blob</button>
</div>
<div id="tab-json" class="tab-content">
<textarea id="jsonContent" rows="4">{"name": "demo", "value": 42}</textarea>
<button onclick="createJsonBlob()">Create JSON Blob</button>
</div>
<div id="tab-binary" class="tab-content">
<div class="form-row">
<div><label>Type</label><select id="binaryType">
<option value="uint8">Uint8Array</option>
<option value="uint16">Uint16Array</option>
<option value="float32">Float32Array</option>
</select></div>
</div>
<label>Values (comma-separated)</label>
<input id="binaryValues" value="72, 101, 108, 108, 111">
<button onclick="createBinaryBlob()">Create Binary Blob</button>
</div>
<div id="tab-svg" class="tab-content">
<div class="form-row">
<div><label>BG</label><input type="color" id="svgBg" value="#4ade80"></div>
<div><label>FG</label><input type="color" id="svgFg" value="#1a1a2e"></div>
<div><label>Text</label><input id="svgText" value="BLOB"></div>
</div>
<button onclick="createSvgBlob()">Create SVG Blob</button>
<div class="preview" id="svgPreview"></div>
</div>
<div class="output" id="createOutput">Select a tab and create a blob...</div>
</div>
<!-- Stored Blobs Column -->
<div class="card">
<h2>Stored Blobs</h2>
<div class="blob-list" id="blobList">
<p style="color:#555;font-size:0.8rem;">No blobs yet</p>
</div>
<div style="border-top:1px solid #333;padding-top:8px;margin-top:auto;">
<h2 style="margin-top:0;">Operations</h2>
<div class="form-row">
<div><label>Source</label><select id="opSource"></select></div>
</div>
<div class="btn-row">
<button class="sm secondary" onclick="convertTo('text')">→Text</button>
<button class="sm secondary" onclick="convertTo('buffer')">→Buffer</button>
<button class="sm secondary" onclick="convertTo('base64')">→Base64</button>
</div>
<div class="form-row">
<div><label>Start</label><input type="number" id="sliceStart" value="0" min="0"></div>
<div><label>End</label><input type="number" id="sliceEnd" value="10"></div>
<div style="display:flex;align-items:end;"><button class="sm" onclick="sliceBlob()">Slice</button></div>
</div>
</div>
</div>
<!-- Output & Features Column -->
<div class="card">
<h2>API Features</h2>
<div class="features" id="features"></div>
<h2>Output</h2>
<div class="output" id="opOutput">Conversion/slice results appear here...</div>
</div>
</div>
</div>
<script>
const blobs = new Map();
// Check support
const hasBlob = typeof Blob !== 'undefined';
document.getElementById('status').textContent = hasBlob ? 'Blob API: Supported' : 'Not Supported';
document.getElementById('status').style.color = hasBlob ? '#4ade80' : '#f87171';
const checks = [
['Blob', typeof Blob !== 'undefined'],
['slice()', hasBlob && 'slice' in Blob.prototype],
['text()', hasBlob && 'text' in Blob.prototype],
['arrayBuffer()', hasBlob && 'arrayBuffer' in Blob.prototype],
['stream()', hasBlob && 'stream' in Blob.prototype],
['createObjectURL', typeof URL !== 'undefined' && 'createObjectURL' in URL],
];
document.getElementById('features').innerHTML = checks.map(([n, s]) =>
`<span class="feature${s ? '' : ' no'}">${n}</span>`
).join('');
// Tabs
function showTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelector(`[onclick="showTab('${name}')"]`).classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
}
// Blob storage
function store(name, blob) {
const url = URL.createObjectURL(blob);
blobs.set(name, { blob, url });
updateList();
}
function updateList() {
const list = document.getElementById('blobList');
const select = document.getElementById('opSource');
if (blobs.size === 0) {
list.innerHTML = '<p style="color:#555;font-size:0.8rem;">No blobs yet</p>';
select.innerHTML = '';
return;
}
list.innerHTML = '';
select.innerHTML = '';
blobs.forEach((data, name) => {
list.innerHTML += `<div class="blob-item">
<div><div class="blob-name">${name}</div><div class="blob-meta">${fmtSize(data.blob.size)} - ${data.blob.type || 'no type'}</div></div>
<div class="blob-actions">
<button class="sm secondary" onclick="download('${name}')">DL</button>
<button class="sm danger" onclick="del('${name}')">X</button>
</div>
</div>`;
select.innerHTML += `<option value="${name}">${name}</option>`;
});
}
function fmtSize(b) { return b < 1024 ? b + 'B' : (b/1024).toFixed(1) + 'KB'; }
function download(name) {
const d = blobs.get(name); if (!d) return;
const a = document.createElement('a');
a.href = d.url; a.download = name; a.click();
}
function del(name) {
const d = blobs.get(name); if (!d) return;
URL.revokeObjectURL(d.url);
blobs.delete(name);
updateList();
}
// Create blobs
function createTextBlob() {
const content = document.getElementById('textContent').value;
const type = document.getElementById('textType').value;
const name = document.getElementById('textName').value || 'text-' + Date.now();
const blob = new Blob([content], { type });
store(name, blob);
document.getElementById('createOutput').textContent = `Created: ${name}\nSize: ${fmtSize(blob.size)}\nType: ${blob.type}\n\nContent:\n${content.substring(0, 200)}`;
}
function createJsonBlob() {
const content = document.getElementById('jsonContent').value;
try {
JSON.parse(content);
const blob = new Blob([content], { type: 'application/json' });
const name = 'json-' + Date.now();
store(name, blob);
document.getElementById('createOutput').textContent = `Created: ${name}\nSize: ${fmtSize(blob.size)}\nType: ${blob.type}\n\nJSON:\n${content}`;
} catch (e) {
document.getElementById('createOutput').textContent = 'Invalid JSON: ' + e.message;
}
}
function createBinaryBlob() {
const type = document.getElementById('binaryType').value;
const vals = document.getElementById('binaryValues').value.split(',').map(v => parseFloat(v.trim()));
const arr = type === 'uint8' ? new Uint8Array(vals) : type === 'uint16' ? new Uint16Array(vals) : new Float32Array(vals);
const blob = new Blob([arr], { type: 'application/octet-stream' });
const name = 'binary-' + Date.now();
store(name, blob);
document.getElementById('createOutput').textContent = `Created: ${name}\nSize: ${fmtSize(blob.size)}\nArray: ${type}\nValues: ${vals.join(', ')}\nAs text: ${new TextDecoder().decode(arr)}`;
}
function createSvgBlob() {
const bg = document.getElementById('svgBg').value;
const fg = document.getElementById('svgFg').value;
const text = document.getElementById('svgText').value;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="60"><rect width="120" height="60" fill="${bg}" rx="8"/><text x="60" y="38" font-family="system-ui" font-size="20" font-weight="bold" fill="${fg}" text-anchor="middle">${text}</text></svg>`;
const blob = new Blob([svg], { type: 'image/svg+xml' });
const name = 'svg-' + Date.now();
const url = URL.createObjectURL(blob);
document.getElementById('svgPreview').innerHTML = `<img src="${url}">`;
store(name, blob);
document.getElementById('createOutput').textContent = `Created: ${name}\nSize: ${fmtSize(blob.size)}\nType: image/svg+xml`;
}
// Operations
async function convertTo(format) {
const name = document.getElementById('opSource').value;
const d = blobs.get(name);
if (!d) { document.getElementById('opOutput').textContent = 'Select a blob first'; return; }
const out = document.getElementById('opOutput');
try {
if (format === 'text') {
const text = await d.blob.text();
out.textContent = `${name} as Text:\n\n${text}`;
} else if (format === 'buffer') {
const buf = await d.blob.arrayBuffer();
const arr = new Uint8Array(buf);
const hex = Array.from(arr.slice(0, 64)).map(b => b.toString(16).padStart(2, '0')).join(' ');
out.textContent = `${name} as ArrayBuffer:\n\nSize: ${buf.byteLength} bytes\n\nHex (first 64):\n${hex}`;
} else if (format === 'base64') {
const reader = new FileReader();
reader.onload = () => {
const b64 = reader.result.split(',')[1];
out.textContent = `${name} as Base64:\n\n${b64.substring(0, 200)}${b64.length > 200 ? '...' : ''}`;
};
reader.readAsDataURL(d.blob);
}
} catch (e) { out.textContent = 'Error: ' + e.message; }
}
async function sliceBlob() {
const name = document.getElementById('opSource').value;
const d = blobs.get(name);
if (!d) { document.getElementById('opOutput').textContent = 'Select a blob first'; return; }
const start = parseInt(document.getElementById('sliceStart').value);
const end = parseInt(document.getElementById('sliceEnd').value);
const slice = d.blob.slice(start, end);
const text = await slice.text();
const sliceName = name + `-slice-${start}-${end}`;
store(sliceName, slice);
document.getElementById('opOutput').textContent = `Sliced ${name} [${start}:${end}]\n\nNew blob: ${sliceName}\nSize: ${fmtSize(slice.size)}\n\nContent:\n${text}`;
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Blob API Demo",
Description: "Binary large objects",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Blob API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Broadcast Channel API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
height: 100vh;
padding: 12px;
overflow: hidden;
}
.container { max-width: 800px; margin: 0 auto; height: 100%; display: flex; flex-direction: column; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
h1 { color: #4ade80; font-size: 1.3rem; }
h1 a { color: #4ade80; text-decoration: none; }
h1 a:hover { text-decoration: underline; }
.status { font-size: 0.8rem; padding: 4px 10px; border-radius: 4px; }
.status.on { background: rgba(74,222,128,0.2); color: #4ade80; }
.status.off { background: rgba(248,113,113,0.2); color: #f87171; }
.main { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; flex: 1; min-height: 0; }
.card { background: rgba(255,255,255,0.05); border-radius: 8px; padding: 12px; display: flex; flex-direction: column; min-height: 0; }
h2 { font-size: 0.9rem; margin-bottom: 8px; color: #e94560; }
label { display: block; margin-bottom: 3px; color: #888; font-size: 0.75rem; }
input {
width: 100%; padding: 8px; border: 1px solid #333; border-radius: 4px;
background: #1a1a2e; color: #eee; font-size: 0.85rem; margin-bottom: 8px;
}
button {
background: #4ade80; color: #1a1a2e; border: none; padding: 6px 12px;
border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.8rem;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
button.sm { padding: 4px 8px; font-size: 0.75rem; }
.btn-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.info-row { display: flex; gap: 12px; margin-bottom: 10px; }
.info-box { flex: 1; background: rgba(0,0,0,0.2); padding: 8px 12px; border-radius: 6px; }
.info-label { font-size: 0.7rem; color: #666; }
.info-value { font-size: 1rem; font-weight: 600; color: #4ade80; }
.quick-btns { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 10px; }
.quick-btn {
background: rgba(96,165,250,0.2); color: #60a5fa; border: 1px solid rgba(96,165,250,0.3);
padding: 4px 10px; border-radius: 12px; font-size: 0.75rem; cursor: pointer;
}
.quick-btn:hover { background: rgba(96,165,250,0.3); }
.messages { flex: 1; background: #0a0a1a; border-radius: 6px; padding: 8px; overflow-y: auto; min-height: 0; }
.msg {
padding: 8px 10px; margin-bottom: 6px; border-radius: 6px;
border-left: 3px solid #4ade80; background: rgba(74,222,128,0.1);
}
.msg.sent { border-left-color: #60a5fa; background: rgba(96,165,250,0.1); }
.msg.system { border-left-color: #fbbf24; background: rgba(251,191,36,0.1); }
.msg-header { display: flex; justify-content: space-between; font-size: 0.7rem; margin-bottom: 4px; }
.msg-type { font-weight: 600; }
.msg-type.sent { color: #60a5fa; }
.msg-type.received { color: #4ade80; }
.msg-type.system { color: #fbbf24; }
.msg-time { color: #555; }
.msg-content { color: #ccc; font-size: 0.85rem; word-break: break-word; }
.msg-sender { color: #666; font-size: 0.7rem; margin-top: 3px; }
.stats { display: flex; gap: 15px; margin-bottom: 10px; }
.stat { text-align: center; }
.stat-val { font-size: 1.2rem; font-weight: bold; color: #4ade80; }
.stat-lbl { font-size: 0.65rem; color: #555; }
.empty { color: #444; font-size: 0.8rem; text-align: center; padding: 20px; }
.features { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.feature { font-size: 0.7rem; padding: 3px 8px; border-radius: 3px; background: rgba(74,222,128,0.2); color: #4ade80; }
.feature.no { background: rgba(248,113,113,0.2); color: #f87171; }
</style>
</head>
<body>
<div class="container">
<header>
<h1><a href="https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API" target="_blank">Broadcast Channel API</a> Demo</h1>
<span id="status" class="status off">Checking...</span>
</header>
<div class="main">
<div class="card">
<h2>API Features</h2>
<div class="features" id="features"></div>
<h2>Channel</h2>
<div class="info-row">
<div class="info-box">
<div class="info-label">Channel</div>
<div class="info-value" id="channelName">demo-channel</div>
</div>
<div class="info-box">
<div class="info-label">Window ID</div>
<div class="info-value" id="windowId">--</div>
</div>
</div>
<div class="btn-row">
<button onclick="openNewWindow()">Open New Window</button>
<button class="secondary" onclick="sendPing()">Ping All Windows</button>
</div>
<h2>Send Message</h2>
<input type="text" id="msgInput" placeholder="Type a message..." onkeypress="if(event.key==='Enter')sendMsg()">
<div class="btn-row">
<button onclick="sendMsg()">Send</button>
<button class="sm danger" onclick="closeChannel()">Close Channel</button>
</div>
<div class="quick-btns">
<span class="quick-btn" onclick="sendQuick('Hello!')">Hello!</span>
<span class="quick-btn" onclick="sendQuick('Sync')">Sync</span>
<span class="quick-btn" onclick="sendQuick('Logout')">Logout</span>
<span class="quick-btn" onclick="sendQuick('Theme changed')">Theme</span>
</div>
<h2>Stats</h2>
<div class="stats">
<div class="stat"><div class="stat-val" id="sent">0</div><div class="stat-lbl">Sent</div></div>
<div class="stat"><div class="stat-val" id="received">0</div><div class="stat-lbl">Received</div></div>
</div>
</div>
<div class="card">
<h2>Messages <button class="sm secondary" onclick="clearMsgs()" style="float:right;">Clear</button></h2>
<div class="messages" id="messages">
<div class="empty">Open another window to test cross-window messaging</div>
</div>
</div>
</div>
</div>
<script src="/wails/runtime.js" type="module"></script>
<script>
let channel = null;
const windowId = Math.random().toString(36).substring(2, 6).toUpperCase();
let stats = { sent: 0, received: 0 };
let msgs = [];
document.getElementById('windowId').textContent = windowId;
function openNewWindow() {
wails.Call.ByName('main.WindowService.OpenNewWindow');
}
// Check support & show features
const supported = typeof BroadcastChannel !== 'undefined';
document.getElementById('status').textContent = supported ? 'Connected' : 'Not Supported';
document.getElementById('status').className = supported ? 'status on' : 'status off';
const checks = [
['BroadcastChannel', typeof BroadcastChannel !== 'undefined'],
['postMessage', supported && 'postMessage' in BroadcastChannel.prototype],
['close', supported && 'close' in BroadcastChannel.prototype],
['onmessage', supported],
['onmessageerror', supported],
['MessageChannel', typeof MessageChannel !== 'undefined'],
];
document.getElementById('features').innerHTML = checks.map(([n, s]) =>
`<span class="feature${s ? '' : ' no'}">${n}</span>`
).join('');
if (supported) {
channel = new BroadcastChannel('demo-channel');
channel.onmessage = (e) => {
stats.received++;
updateStats();
addMsg('received', e.data);
};
// Announce join
channel.postMessage({ type: 'join', windowId, ts: Date.now() });
}
function sendMsg() {
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text || !channel) return;
const msg = { type: 'text', content: text, windowId, ts: Date.now() };
channel.postMessage(msg);
stats.sent++;
updateStats();
addMsg('sent', msg);
input.value = '';
}
function sendQuick(text) {
if (!channel) return;
const msg = { type: 'text', content: text, windowId, ts: Date.now() };
channel.postMessage(msg);
stats.sent++;
updateStats();
addMsg('sent', msg);
}
function sendPing() {
if (!channel) return;
const msg = { type: 'ping', windowId, ts: Date.now() };
channel.postMessage(msg);
stats.sent++;
updateStats();
addMsg('sent', { ...msg, content: 'PING' });
}
function closeChannel() {
if (channel) {
channel.postMessage({ type: 'leave', windowId, ts: Date.now() });
channel.close();
channel = null;
document.getElementById('status').textContent = 'Disconnected';
document.getElementById('status').className = 'status off';
}
}
function addMsg(dir, data) {
msgs.unshift({ dir, data, time: new Date() });
if (msgs.length > 30) msgs.pop();
renderMsgs();
}
function renderMsgs() {
const el = document.getElementById('messages');
if (!msgs.length) {
el.innerHTML = '<div class="empty">No messages yet</div>';
return;
}
el.innerHTML = msgs.map(m => {
const isSystem = m.data.type === 'join' || m.data.type === 'leave';
const cls = isSystem ? 'system' : m.dir;
let content = m.data.content || '';
if (m.data.type === 'join') content = `Window ${m.data.windowId} joined`;
if (m.data.type === 'leave') content = `Window ${m.data.windowId} left`;
if (m.data.type === 'ping') content = `PING from ${m.data.windowId}`;
return `<div class="msg ${cls}">
<div class="msg-header">
<span class="msg-type ${cls}">${isSystem ? 'System' : (m.dir === 'sent' ? 'Sent' : 'Received')}</span>
<span class="msg-time">${m.time.toLocaleTimeString()}</span>
</div>
<div class="msg-content">${escapeHtml(content)}</div>
${m.data.windowId && !isSystem ? `<div class="msg-sender">From: ${m.data.windowId}</div>` : ''}
</div>`;
}).join('');
}
function clearMsgs() { msgs = []; renderMsgs(); }
function updateStats() {
document.getElementById('sent').textContent = stats.sent;
document.getElementById('received').textContent = stats.received;
}
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
window.addEventListener('beforeunload', () => {
if (channel) channel.postMessage({ type: 'leave', windowId, ts: Date.now() });
});
</script>
</body>
</html>

View file

@ -0,0 +1,47 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
var app *application.App
// WindowService allows the frontend to open new windows
type WindowService struct{}
// OpenNewWindow creates a new application window
func (s *WindowService) OpenNewWindow() {
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Broadcast Channel Demo",
Width: 800,
Height: 550,
URL: "/",
})
}
func main() {
app = application.New(application.Options{
Name: "Broadcast Channel Demo",
Description: "Cross-window communication via BroadcastChannel API",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
Services: []application.Service{
application.NewService(&WindowService{}),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Broadcast Channel Demo",
Width: 800,
Height: 550,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,600 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cache API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input, textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.cache-list { list-style: none; }
.cache-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: rgba(0,0,0,0.2);
margin-bottom: 5px;
border-radius: 4px;
flex-wrap: wrap;
gap: 10px;
}
.cache-list .url {
color: #4ade80;
font-family: monospace;
word-break: break-all;
flex: 1;
min-width: 200px;
}
.cache-list .actions {
display: flex;
gap: 5px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.tabs {
display: flex;
gap: 5px;
margin-bottom: 15px;
}
.tab {
padding: 8px 16px;
background: rgba(0,0,0,0.3);
border: none;
color: #aaa;
cursor: pointer;
border-radius: 6px 6px 0 0;
}
.tab.active {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
}
.response-preview {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
margin-top: 15px;
}
.response-preview h4 {
color: #4ade80;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>Cache API Demo</h1>
<p class="description">
The Cache API provides a mechanism for storing and retrieving network requests and responses.
It's commonly used with Service Workers for offline support and performance optimization.
</p>
<div id="status" class="status success">
Cache API: <span id="available">checking...</span>
</div>
<div class="card">
<h2>Cache Management</h2>
<div class="form-group">
<label for="cacheName">Cache Name</label>
<input type="text" id="cacheName" value="demo-cache-v1" placeholder="Enter cache name">
</div>
<button onclick="createCache()">Create/Open Cache</button>
<button onclick="listCaches()">List All Caches</button>
<button class="danger" onclick="deleteCache()">Delete Cache</button>
<div class="output" id="cacheList" style="margin-top: 15px;">Cache list will appear here...</div>
</div>
<div class="card">
<h2>Add to Cache</h2>
<div class="form-group">
<label for="urlToCache">URL to Cache</label>
<input type="text" id="urlToCache" value="https://jsonplaceholder.typicode.com/posts/1" placeholder="Enter URL to cache">
</div>
<button onclick="addUrlToCache()">Fetch & Cache URL</button>
<button class="secondary" onclick="addCustomResponse()">Add Custom Response</button>
<button class="secondary" onclick="addMultiple()">Cache Multiple URLs</button>
</div>
<div class="card">
<h2>Retrieve from Cache</h2>
<div class="form-group">
<label for="urlToMatch">URL to Match</label>
<input type="text" id="urlToMatch" value="https://jsonplaceholder.typicode.com/posts/1" placeholder="Enter URL to match">
</div>
<button onclick="matchUrl()">Match URL</button>
<button class="secondary" onclick="matchAll()">Match All</button>
<div class="response-preview" id="responsePreview" style="display: none;">
<h4>Cached Response</h4>
<div class="output" id="responseContent"></div>
</div>
</div>
<div class="card">
<h2>Cached Entries (<span id="entryCount">0</span>)</h2>
<button onclick="refreshEntries()">Refresh</button>
<button class="danger" onclick="clearCache()">Clear All Entries</button>
<ul class="cache-list" id="entriesList"></ul>
</div>
<div class="card">
<h2>Cache-First Strategy Demo</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Demonstrates a common caching pattern: try cache first, fall back to network.
</p>
<div class="form-group">
<label for="strategyUrl">Test URL</label>
<input type="text" id="strategyUrl" value="https://jsonplaceholder.typicode.com/users/1" placeholder="URL for strategy test">
</div>
<button onclick="cacheFirstFetch()">Cache-First Fetch</button>
<button class="secondary" onclick="networkFirstFetch()">Network-First Fetch</button>
<div class="output" id="strategyOutput" style="margin-top: 15px;">Strategy results will appear here...</div>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Cache operations will be logged here...</div>
</div>
</div>
<script>
let currentCache = null;
let logEntries = [];
function checkSupport() {
const hasCache = 'caches' in window;
document.getElementById('available').textContent = hasCache ? 'Available' : 'Not available';
document.getElementById('status').className = hasCache ? 'status success' : 'status error';
return hasCache;
}
function checkFeatures() {
const features = document.getElementById('features');
const checks = [
{ name: 'caches', supported: 'caches' in window },
{ name: 'caches.open()', supported: 'caches' in window && typeof caches.open === 'function' },
{ name: 'caches.match()', supported: 'caches' in window && typeof caches.match === 'function' },
{ name: 'caches.has()', supported: 'caches' in window && typeof caches.has === 'function' },
{ name: 'caches.delete()', supported: 'caches' in window && typeof caches.delete === 'function' },
{ name: 'caches.keys()', supported: 'caches' in window && typeof caches.keys === 'function' },
{ name: 'Cache.add()', supported: typeof Cache !== 'undefined' },
{ name: 'Cache.addAll()', supported: typeof Cache !== 'undefined' },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
}
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 30) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Log cleared.';
}
async function createCache() {
const name = document.getElementById('cacheName').value;
if (!name) {
alert('Please enter a cache name');
return;
}
try {
currentCache = await caches.open(name);
log(`Cache "${name}" opened/created`);
listCaches();
refreshEntries();
} catch (e) {
log(`Error opening cache: ${e.message}`);
}
}
async function listCaches() {
try {
const names = await caches.keys();
const cacheList = document.getElementById('cacheList');
if (names.length === 0) {
cacheList.textContent = 'No caches found.';
} else {
cacheList.textContent = `Found ${names.length} cache(s):\n\n${names.map((n, i) => `${i + 1}. ${n}`).join('\n')}`;
}
log(`Listed ${names.length} cache(s)`);
} catch (e) {
log(`Error listing caches: ${e.message}`);
}
}
async function deleteCache() {
const name = document.getElementById('cacheName').value;
if (!name) {
alert('Please enter a cache name');
return;
}
if (!confirm(`Delete cache "${name}"?`)) return;
try {
const deleted = await caches.delete(name);
if (deleted) {
log(`Cache "${name}" deleted`);
currentCache = null;
} else {
log(`Cache "${name}" not found`);
}
listCaches();
refreshEntries();
} catch (e) {
log(`Error deleting cache: ${e.message}`);
}
}
async function ensureCache() {
if (!currentCache) {
const name = document.getElementById('cacheName').value || 'demo-cache-v1';
currentCache = await caches.open(name);
}
return currentCache;
}
async function addUrlToCache() {
const url = document.getElementById('urlToCache').value;
if (!url) {
alert('Please enter a URL');
return;
}
try {
const cache = await ensureCache();
log(`Fetching: ${url}`);
await cache.add(url);
log(`Cached: ${url}`);
refreshEntries();
} catch (e) {
log(`Error caching URL: ${e.message}`);
}
}
async function addCustomResponse() {
const url = document.getElementById('urlToCache').value || 'custom://my-data';
try {
const cache = await ensureCache();
const customData = {
message: 'This is custom cached data',
timestamp: new Date().toISOString(),
data: { foo: 'bar', count: 42 }
};
const response = new Response(JSON.stringify(customData, null, 2), {
headers: { 'Content-Type': 'application/json' }
});
await cache.put(url, response);
log(`Added custom response to cache: ${url}`);
refreshEntries();
} catch (e) {
log(`Error adding custom response: ${e.message}`);
}
}
async function addMultiple() {
const urls = [
'https://jsonplaceholder.typicode.com/posts/1',
'https://jsonplaceholder.typicode.com/posts/2',
'https://jsonplaceholder.typicode.com/posts/3',
];
try {
const cache = await ensureCache();
log(`Caching ${urls.length} URLs...`);
await cache.addAll(urls);
log(`Successfully cached ${urls.length} URLs`);
refreshEntries();
} catch (e) {
log(`Error caching multiple URLs: ${e.message}`);
}
}
async function matchUrl() {
const url = document.getElementById('urlToMatch').value;
if (!url) {
alert('Please enter a URL');
return;
}
try {
const cache = await ensureCache();
const response = await cache.match(url);
const preview = document.getElementById('responsePreview');
const content = document.getElementById('responseContent');
if (response) {
const clone = response.clone();
const text = await clone.text();
const headers = [...response.headers.entries()]
.map(([k, v]) => `${k}: ${v}`).join('\n');
content.textContent = `Status: ${response.status} ${response.statusText}
URL: ${response.url}
Type: ${response.type}
Headers:
${headers}
Body:
${text.substring(0, 1000)}${text.length > 1000 ? '\n...(truncated)' : ''}`;
preview.style.display = 'block';
log(`Found cached response for: ${url}`);
} else {
content.textContent = 'No cached response found for this URL.';
preview.style.display = 'block';
log(`No cache match for: ${url}`);
}
} catch (e) {
log(`Error matching URL: ${e.message}`);
}
}
async function matchAll() {
try {
const cache = await ensureCache();
const responses = await cache.matchAll();
const content = document.getElementById('responseContent');
const preview = document.getElementById('responsePreview');
if (responses.length > 0) {
content.textContent = `Found ${responses.length} cached responses:\n\n${responses.map((r, i) =>
`${i + 1}. ${r.url || '(no URL)'} - ${r.status}`
).join('\n')}`;
preview.style.display = 'block';
log(`Found ${responses.length} cached responses`);
} else {
content.textContent = 'No cached responses found.';
preview.style.display = 'block';
log('No cached responses found');
}
} catch (e) {
log(`Error matching all: ${e.message}`);
}
}
async function refreshEntries() {
const list = document.getElementById('entriesList');
list.innerHTML = '';
try {
const cache = await ensureCache();
const requests = await cache.keys();
document.getElementById('entryCount').textContent = requests.length;
if (requests.length === 0) {
list.innerHTML = '<li style="color: #aaa;">No entries in cache</li>';
return;
}
for (const request of requests) {
const li = document.createElement('li');
li.innerHTML = `
<span class="url">${escapeHtml(request.url)}</span>
<div class="actions">
<button onclick="viewEntry('${escapeHtml(request.url)}')" class="secondary" style="padding: 5px 10px; font-size: 0.8rem;">View</button>
<button onclick="deleteEntry('${escapeHtml(request.url)}')" class="danger" style="padding: 5px 10px; font-size: 0.8rem;">Delete</button>
</div>
`;
list.appendChild(li);
}
} catch (e) {
log(`Error refreshing entries: ${e.message}`);
}
}
async function viewEntry(url) {
document.getElementById('urlToMatch').value = url;
await matchUrl();
}
async function deleteEntry(url) {
try {
const cache = await ensureCache();
const deleted = await cache.delete(url);
if (deleted) {
log(`Deleted entry: ${url}`);
} else {
log(`Entry not found: ${url}`);
}
refreshEntries();
} catch (e) {
log(`Error deleting entry: ${e.message}`);
}
}
async function clearCache() {
if (!confirm('Clear all entries from the current cache?')) return;
try {
const cache = await ensureCache();
const requests = await cache.keys();
for (const request of requests) {
await cache.delete(request);
}
log(`Cleared ${requests.length} entries from cache`);
refreshEntries();
} catch (e) {
log(`Error clearing cache: ${e.message}`);
}
}
async function cacheFirstFetch() {
const url = document.getElementById('strategyUrl').value;
const output = document.getElementById('strategyOutput');
try {
const startTime = performance.now();
// Try cache first
const cachedResponse = await caches.match(url);
if (cachedResponse) {
const data = await cachedResponse.text();
const elapsed = (performance.now() - startTime).toFixed(2);
output.textContent = `Source: CACHE (${elapsed}ms)\n\n${data.substring(0, 500)}`;
log(`Cache-first: Served from cache (${elapsed}ms)`);
return;
}
// Fall back to network
log('Cache-first: Cache miss, fetching from network...');
const response = await fetch(url);
const data = await response.text();
const elapsed = (performance.now() - startTime).toFixed(2);
// Cache for next time
const cache = await ensureCache();
await cache.put(url, new Response(data, {
headers: response.headers
}));
output.textContent = `Source: NETWORK (${elapsed}ms) - Now cached\n\n${data.substring(0, 500)}`;
log(`Cache-first: Fetched from network (${elapsed}ms), now cached`);
refreshEntries();
} catch (e) {
output.textContent = `Error: ${e.message}`;
log(`Cache-first error: ${e.message}`);
}
}
async function networkFirstFetch() {
const url = document.getElementById('strategyUrl').value;
const output = document.getElementById('strategyOutput');
try {
const startTime = performance.now();
// Try network first
try {
const response = await fetch(url);
const data = await response.text();
const elapsed = (performance.now() - startTime).toFixed(2);
// Update cache
const cache = await ensureCache();
await cache.put(url, new Response(data, {
headers: response.headers
}));
output.textContent = `Source: NETWORK (${elapsed}ms) - Cache updated\n\n${data.substring(0, 500)}`;
log(`Network-first: Fetched from network (${elapsed}ms), cache updated`);
refreshEntries();
return;
} catch (networkError) {
// Fall back to cache
log('Network-first: Network failed, trying cache...');
const cachedResponse = await caches.match(url);
if (cachedResponse) {
const data = await cachedResponse.text();
const elapsed = (performance.now() - startTime).toFixed(2);
output.textContent = `Source: CACHE (fallback, ${elapsed}ms)\nNetwork error: ${networkError.message}\n\n${data.substring(0, 500)}`;
log(`Network-first: Served from cache fallback (${elapsed}ms)`);
return;
}
throw networkError;
}
} catch (e) {
output.textContent = `Error: ${e.message}\nNo cached fallback available.`;
log(`Network-first error: ${e.message}`);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize
if (checkSupport()) {
checkFeatures();
listCaches();
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Cache API Demo",
Description: "Cache API for offline resources",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Cache API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas 2D API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 1000px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
.canvas-container {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
canvas {
background: #0a0a1a;
border-radius: 8px;
cursor: crosshair;
}
.controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 15px;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.control-group label {
color: #aaa;
font-size: 0.9rem;
}
input[type="color"] {
width: 40px;
height: 30px;
border: none;
border-radius: 4px;
cursor: pointer;
}
input[type="range"] {
width: 100px;
}
.demos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.demo-card {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 15px;
text-align: center;
}
.demo-card canvas {
margin-bottom: 10px;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
</style>
</head>
<body>
<div class="container">
<h1>Canvas 2D API Demo</h1>
<p class="description">
The Canvas API provides a means for drawing graphics via JavaScript and HTML.
It can be used for animation, game graphics, data visualization, and image manipulation.
</p>
<div class="status success">
Canvas 2D supported: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Drawing Canvas</h2>
<div class="controls">
<div class="control-group">
<label>Color:</label>
<input type="color" id="color" value="#4ade80">
</div>
<div class="control-group">
<label>Size:</label>
<input type="range" id="size" min="1" max="50" value="5">
<span id="sizeValue">5</span>
</div>
<div class="control-group">
<label>Tool:</label>
<button onclick="setTool('brush')" id="brushBtn" class="secondary">Brush</button>
<button onclick="setTool('line')">Line</button>
<button onclick="setTool('rect')">Rectangle</button>
<button onclick="setTool('circle')">Circle</button>
</div>
</div>
<div class="controls">
<button onclick="clearCanvas()">Clear</button>
<button onclick="saveCanvas()">Save as PNG</button>
<button onclick="fillBackground()">Fill Background</button>
</div>
<canvas id="drawCanvas" width="600" height="400"></canvas>
</div>
<div class="card">
<h2>Canvas Demos</h2>
<div class="demos">
<div class="demo-card">
<canvas id="shapesDemo" width="180" height="150"></canvas>
<div>Shapes & Paths</div>
</div>
<div class="demo-card">
<canvas id="gradientDemo" width="180" height="150"></canvas>
<div>Gradients</div>
</div>
<div class="demo-card">
<canvas id="textDemo" width="180" height="150"></canvas>
<div>Text Rendering</div>
</div>
<div class="demo-card">
<canvas id="animationDemo" width="180" height="150"></canvas>
<div>Animation</div>
</div>
</div>
</div>
</div>
<script>
// Check Canvas support
const canvas = document.getElementById('drawCanvas');
const ctx = canvas.getContext('2d');
document.getElementById('supported').textContent = ctx ? 'Yes' : 'No';
let isDrawing = false;
let lastX = 0, lastY = 0;
let currentTool = 'brush';
let startX = 0, startY = 0;
// Drawing canvas setup
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
document.getElementById('size').addEventListener('input', (e) => {
document.getElementById('sizeValue').textContent = e.target.value;
});
function setTool(tool) {
currentTool = tool;
document.querySelectorAll('.controls button').forEach(b => b.classList.remove('secondary'));
document.getElementById(tool + 'Btn')?.classList.add('secondary');
}
canvas.addEventListener('mousedown', (e) => {
isDrawing = true;
[lastX, lastY] = [e.offsetX, e.offsetY];
[startX, startY] = [e.offsetX, e.offsetY];
});
canvas.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
const color = document.getElementById('color').value;
const size = document.getElementById('size').value;
ctx.strokeStyle = color;
ctx.lineWidth = size;
if (currentTool === 'brush') {
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
[lastX, lastY] = [e.offsetX, e.offsetY];
}
});
canvas.addEventListener('mouseup', (e) => {
if (!isDrawing) return;
isDrawing = false;
const color = document.getElementById('color').value;
const size = document.getElementById('size').value;
ctx.strokeStyle = color;
ctx.lineWidth = size;
if (currentTool === 'line') {
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
} else if (currentTool === 'rect') {
ctx.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
} else if (currentTool === 'circle') {
const radius = Math.sqrt(Math.pow(e.offsetX - startX, 2) + Math.pow(e.offsetY - startY, 2));
ctx.beginPath();
ctx.arc(startX, startY, radius, 0, Math.PI * 2);
ctx.stroke();
}
});
canvas.addEventListener('mouseleave', () => isDrawing = false);
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
function fillBackground() {
ctx.fillStyle = document.getElementById('color').value;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
function saveCanvas() {
const link = document.createElement('a');
link.download = 'canvas-drawing.png';
link.href = canvas.toDataURL();
link.click();
}
// Demo: Shapes
const shapesCtx = document.getElementById('shapesDemo').getContext('2d');
shapesCtx.fillStyle = '#4ade80';
shapesCtx.fillRect(10, 10, 50, 50);
shapesCtx.fillStyle = '#60a5fa';
shapesCtx.beginPath();
shapesCtx.arc(120, 35, 25, 0, Math.PI * 2);
shapesCtx.fill();
shapesCtx.fillStyle = '#e94560';
shapesCtx.beginPath();
shapesCtx.moveTo(90, 130);
shapesCtx.lineTo(130, 130);
shapesCtx.lineTo(110, 90);
shapesCtx.closePath();
shapesCtx.fill();
shapesCtx.strokeStyle = '#fbbf24';
shapesCtx.lineWidth = 3;
shapesCtx.strokeRect(10, 80, 60, 60);
// Demo: Gradients
const gradCtx = document.getElementById('gradientDemo').getContext('2d');
const linearGrad = gradCtx.createLinearGradient(0, 0, 180, 0);
linearGrad.addColorStop(0, '#4ade80');
linearGrad.addColorStop(0.5, '#60a5fa');
linearGrad.addColorStop(1, '#e94560');
gradCtx.fillStyle = linearGrad;
gradCtx.fillRect(10, 10, 160, 50);
const radialGrad = gradCtx.createRadialGradient(90, 110, 5, 90, 110, 40);
radialGrad.addColorStop(0, '#fbbf24');
radialGrad.addColorStop(1, '#1a1a2e');
gradCtx.fillStyle = radialGrad;
gradCtx.beginPath();
gradCtx.arc(90, 110, 40, 0, Math.PI * 2);
gradCtx.fill();
// Demo: Text
const textCtx = document.getElementById('textDemo').getContext('2d');
textCtx.fillStyle = '#4ade80';
textCtx.font = 'bold 24px sans-serif';
textCtx.fillText('Canvas', 20, 40);
textCtx.font = '16px sans-serif';
textCtx.fillStyle = '#60a5fa';
textCtx.fillText('2D Context', 20, 70);
textCtx.strokeStyle = '#e94560';
textCtx.lineWidth = 2;
textCtx.font = 'bold 28px sans-serif';
textCtx.strokeText('API', 50, 120);
// Demo: Animation
const animCtx = document.getElementById('animationDemo').getContext('2d');
let angle = 0;
function animate() {
animCtx.clearRect(0, 0, 180, 150);
// Rotating square
animCtx.save();
animCtx.translate(90, 75);
animCtx.rotate(angle);
animCtx.fillStyle = '#4ade80';
animCtx.fillRect(-25, -25, 50, 50);
animCtx.restore();
// Orbiting circle
const x = 90 + Math.cos(angle * 2) * 50;
const y = 75 + Math.sin(angle * 2) * 30;
animCtx.fillStyle = '#e94560';
animCtx.beginPath();
animCtx.arc(x, y, 10, 0, Math.PI * 2);
animCtx.fill();
angle += 0.02;
requestAnimationFrame(animate);
}
animate();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Canvas 2D Demo",
Description: "Demonstrates the Canvas 2D API for graphics rendering",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Canvas 2D Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clipboard API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
textarea {
width: 100%;
padding: 15px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
min-height: 120px;
resize: vertical;
margin-bottom: 15px;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
min-height: 100px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
background: #4ade80;
color: #1a1a2e;
font-weight: 600;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
}
.notification.show {
transform: translateY(0);
opacity: 1;
}
</style>
</head>
<body>
<div class="container">
<h1>Clipboard API Demo</h1>
<p class="description">
The Clipboard API provides asynchronous read and write access to the system clipboard,
enabling secure copy/paste operations for text and other data types.
</p>
<div class="status success">
Clipboard API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Write to Clipboard</h2>
<textarea id="copyText" placeholder="Enter text to copy to clipboard...">Hello from Wails WebView!</textarea>
<button onclick="copyText()">Copy Text</button>
<button class="secondary" onclick="copyRichText()">Copy Rich Text (HTML)</button>
</div>
<div class="card">
<h2>Read from Clipboard</h2>
<button onclick="pasteText()">Paste Text</button>
<button class="secondary" onclick="readAll()">Read All Formats</button>
<div class="output" id="pasteOutput">Click "Paste Text" to read clipboard contents...</div>
</div>
<div class="card">
<h2>Clipboard Events</h2>
<p style="color: #aaa; margin-bottom: 15px;">Try using Ctrl+C/Ctrl+V in the text area below:</p>
<textarea id="eventText" placeholder="Type here and try copy/paste..."></textarea>
<div class="output" id="eventLog">Clipboard events will be logged here...</div>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
</div>
<div class="notification" id="notification"></div>
<script>
function checkSupport() {
const hasClipboard = 'clipboard' in navigator;
document.getElementById('supported').textContent = hasClipboard ? 'Available' : 'Not available';
document.getElementById('supported').parentElement.className = hasClipboard ? 'status success' : 'status error';
const features = document.getElementById('features');
const checks = [
{ name: 'navigator.clipboard', supported: 'clipboard' in navigator },
{ name: 'clipboard.writeText()', supported: navigator.clipboard && 'writeText' in navigator.clipboard },
{ name: 'clipboard.readText()', supported: navigator.clipboard && 'readText' in navigator.clipboard },
{ name: 'clipboard.write()', supported: navigator.clipboard && 'write' in navigator.clipboard },
{ name: 'clipboard.read()', supported: navigator.clipboard && 'read' in navigator.clipboard },
{ name: 'ClipboardItem', supported: typeof ClipboardItem !== 'undefined' },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
}
function showNotification(message, isError = false) {
const notif = document.getElementById('notification');
notif.textContent = message;
notif.style.background = isError ? '#f87171' : '#4ade80';
notif.classList.add('show');
setTimeout(() => notif.classList.remove('show'), 2000);
}
async function copyText() {
const text = document.getElementById('copyText').value;
try {
await navigator.clipboard.writeText(text);
showNotification('Text copied to clipboard!');
} catch (e) {
showNotification('Failed to copy: ' + e.message, true);
}
}
async function copyRichText() {
const text = document.getElementById('copyText').value;
try {
const html = `<div style="color: #4ade80; font-weight: bold;">${text}</div>`;
const blob = new Blob([html], { type: 'text/html' });
const textBlob = new Blob([text], { type: 'text/plain' });
await navigator.clipboard.write([
new ClipboardItem({
'text/html': blob,
'text/plain': textBlob
})
]);
showNotification('Rich text copied!');
} catch (e) {
showNotification('Failed: ' + e.message, true);
}
}
async function pasteText() {
try {
const text = await navigator.clipboard.readText();
document.getElementById('pasteOutput').textContent = text || '(empty clipboard)';
showNotification('Text pasted from clipboard!');
} catch (e) {
document.getElementById('pasteOutput').textContent = 'Error: ' + e.message + '\n\nNote: Reading clipboard requires user permission.';
}
}
async function readAll() {
try {
const items = await navigator.clipboard.read();
let output = `Clipboard contains ${items.length} item(s):\n\n`;
for (const item of items) {
output += `Types: ${item.types.join(', ')}\n`;
for (const type of item.types) {
const blob = await item.getType(type);
if (type.startsWith('text/')) {
const text = await blob.text();
output += `\n[${type}]:\n${text.substring(0, 500)}${text.length > 500 ? '...' : ''}\n`;
} else {
output += `\n[${type}]: ${blob.size} bytes\n`;
}
}
}
document.getElementById('pasteOutput').textContent = output;
} catch (e) {
document.getElementById('pasteOutput').textContent = 'Error: ' + e.message;
}
}
// Clipboard events
const eventText = document.getElementById('eventText');
const eventLog = document.getElementById('eventLog');
let logEntries = [];
function logEvent(type, data) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${type}: ${data}`);
if (logEntries.length > 10) logEntries.pop();
eventLog.textContent = logEntries.join('\n');
}
eventText.addEventListener('copy', (e) => {
logEvent('copy', `"${window.getSelection().toString().substring(0, 50)}..."`);
});
eventText.addEventListener('cut', (e) => {
logEvent('cut', `"${window.getSelection().toString().substring(0, 50)}..."`);
});
eventText.addEventListener('paste', (e) => {
const text = e.clipboardData.getData('text/plain');
logEvent('paste', `"${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
});
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Clipboard API Demo",
Description: "Demonstrates the Clipboard API for copy/paste",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Clipboard API Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,475 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Orientation API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.status.warning { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.orientation-display {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.orientation-item {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
text-align: center;
}
.orientation-item .label { color: #aaa; font-size: 0.85rem; margin-bottom: 5px; }
.orientation-item .value { font-size: 2rem; color: #4ade80; font-family: monospace; }
.orientation-item .unit { font-size: 0.85rem; color: #666; }
.orientation-item .bar {
height: 8px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
margin-top: 10px;
overflow: hidden;
}
.orientation-item .bar-fill {
height: 100%;
background: #4ade80;
border-radius: 4px;
transition: width 0.1s ease;
}
.visualizer-container {
display: flex;
gap: 20px;
flex-wrap: wrap;
justify-content: center;
}
.visualizer {
width: 200px;
height: 200px;
position: relative;
background: rgba(0,0,0,0.3);
border-radius: 50%;
border: 3px solid #333;
}
.visualizer-label {
text-align: center;
color: #aaa;
margin-top: 10px;
font-size: 0.9rem;
}
.compass {
position: relative;
width: 100%;
height: 100%;
}
.compass-needle {
position: absolute;
top: 50%;
left: 50%;
width: 4px;
height: 80px;
background: linear-gradient(to bottom, #f87171 50%, #4ade80 50%);
transform-origin: center top;
transform: translateX(-50%);
border-radius: 2px;
}
.compass-center {
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.compass-marks {
position: absolute;
width: 100%;
height: 100%;
}
.compass-mark {
position: absolute;
color: #666;
font-size: 0.8rem;
font-weight: bold;
}
.device-3d {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
perspective: 300px;
}
.device-cube {
width: 80px;
height: 120px;
position: relative;
transform-style: preserve-3d;
transition: transform 0.1s ease;
}
.cube-face {
position: absolute;
width: 80px;
height: 120px;
border: 2px solid #4ade80;
background: rgba(74, 222, 128, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
color: #4ade80;
}
.cube-front { transform: translateZ(40px); }
.cube-back { transform: translateZ(-40px) rotateY(180deg); }
.cube-left { width: 80px; transform: translateX(-40px) rotateY(-90deg); }
.cube-right { width: 80px; transform: translateX(40px) rotateY(90deg); }
.cube-top { height: 80px; transform: translateY(-60px) rotateX(90deg); }
.cube-bottom { height: 80px; transform: translateY(60px) rotateX(-90deg); }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
max-height: 200px;
overflow-y: auto;
}
.log-entry {
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.log-entry .time { color: #666; margin-right: 10px; }
.log-entry.event { color: #60a5fa; }
.log-entry.data { color: #4ade80; }
.log-entry.error { color: #f87171; }
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 10px;
}
.info-item {
background: rgba(0,0,0,0.2);
padding: 10px 15px;
border-radius: 6px;
display: flex;
justify-content: space-between;
}
.info-item .label { color: #aaa; }
.info-item .value { color: #4ade80; font-family: monospace; }
</style>
</head>
<body>
<div class="container">
<h1>Device Orientation API Demo</h1>
<p class="description">
The Device Orientation API provides access to the physical orientation of the device.
It reports alpha (compass direction), beta (front-to-back tilt), and gamma (left-to-right tilt).
</p>
<div id="status" class="status">
Device Orientation: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Orientation Controls</h2>
<button id="startBtn" onclick="startListening()">Start Listening</button>
<button id="stopBtn" onclick="stopListening()" class="danger" disabled>Stop Listening</button>
<button onclick="requestPermission()">Request Permission (iOS)</button>
<button onclick="calibrate()">Calibrate</button>
<button onclick="clearLog()">Clear Log</button>
</div>
<div class="card">
<h2>Orientation Values</h2>
<div class="orientation-display">
<div class="orientation-item">
<div class="label">Alpha (Z-axis rotation)</div>
<div class="value" id="alpha">-</div>
<div class="unit">degrees (0-360)</div>
<div class="bar"><div class="bar-fill" id="alphaBar" style="width: 0%"></div></div>
</div>
<div class="orientation-item">
<div class="label">Beta (X-axis tilt)</div>
<div class="value" id="beta">-</div>
<div class="unit">degrees (-180 to 180)</div>
<div class="bar"><div class="bar-fill" id="betaBar" style="width: 50%"></div></div>
</div>
<div class="orientation-item">
<div class="label">Gamma (Y-axis tilt)</div>
<div class="value" id="gamma">-</div>
<div class="unit">degrees (-90 to 90)</div>
<div class="bar"><div class="bar-fill" id="gammaBar" style="width: 50%"></div></div>
</div>
</div>
</div>
<div class="card">
<h2>Visual Representation</h2>
<div class="visualizer-container">
<div>
<div class="visualizer">
<div class="compass">
<div class="compass-marks">
<span class="compass-mark" style="top: 5px; left: 50%; transform: translateX(-50%);">N</span>
<span class="compass-mark" style="bottom: 5px; left: 50%; transform: translateX(-50%);">S</span>
<span class="compass-mark" style="left: 5px; top: 50%; transform: translateY(-50%);">W</span>
<span class="compass-mark" style="right: 5px; top: 50%; transform: translateY(-50%);">E</span>
</div>
<div class="compass-needle" id="compassNeedle"></div>
<div class="compass-center"></div>
</div>
</div>
<div class="visualizer-label">Compass (Alpha)</div>
</div>
<div>
<div class="visualizer">
<div class="device-3d">
<div class="device-cube" id="deviceCube">
<div class="cube-face cube-front">FRONT</div>
<div class="cube-face cube-back">BACK</div>
<div class="cube-face cube-left">L</div>
<div class="cube-face cube-right">R</div>
<div class="cube-face cube-top">TOP</div>
<div class="cube-face cube-bottom">BOT</div>
</div>
</div>
</div>
<div class="visualizer-label">Device Tilt (Beta/Gamma)</div>
</div>
</div>
</div>
<div class="card">
<h2>Additional Information</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Absolute Orientation</span>
<span class="value" id="absolute">-</span>
</div>
<div class="info-item">
<span class="label">WebKit Compass Heading</span>
<span class="value" id="compassHeading">-</span>
</div>
<div class="info-item">
<span class="label">WebKit Compass Accuracy</span>
<span class="value" id="compassAccuracy">-</span>
</div>
<div class="info-item">
<span class="label">Update Count</span>
<span class="value" id="updateCount">0</span>
</div>
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<div class="output" id="eventLog">
<div class="log-entry">Waiting for orientation events...</div>
</div>
</div>
</div>
<script>
let listening = false;
let updateCount = 0;
let calibrationOffset = { alpha: 0, beta: 0, gamma: 0 };
function checkSupport() {
const hasOrientation = 'DeviceOrientationEvent' in window;
const hasAbsolute = 'DeviceOrientationAbsoluteEvent' in window;
let supportText = '';
if (hasOrientation && hasAbsolute) {
supportText = 'Supported (with absolute)';
} else if (hasOrientation) {
supportText = 'Supported (relative only)';
} else {
supportText = 'Not supported';
}
document.getElementById('supported').textContent = supportText;
document.getElementById('status').className = hasOrientation ? 'status success' : 'status error';
return hasOrientation;
}
function log(message, type = 'event') {
const logEl = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.innerHTML = `<span class="time">${time}</span>${message}`;
logEl.insertBefore(entry, logEl.firstChild);
// Keep only last 50 entries
while (logEl.children.length > 50) {
logEl.removeChild(logEl.lastChild);
}
}
function clearLog() {
document.getElementById('eventLog').innerHTML = '<div class="log-entry">Log cleared.</div>';
}
async function requestPermission() {
if (typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const permission = await DeviceOrientationEvent.requestPermission();
if (permission === 'granted') {
log('Permission granted', 'data');
startListening();
} else {
log('Permission denied', 'error');
}
} catch (error) {
log(`Permission error: ${error.message}`, 'error');
}
} else {
log('Permission API not required on this device', 'event');
}
}
function handleOrientation(event) {
updateCount++;
document.getElementById('updateCount').textContent = updateCount;
const alpha = event.alpha !== null ? (event.alpha - calibrationOffset.alpha + 360) % 360 : null;
const beta = event.beta !== null ? event.beta - calibrationOffset.beta : null;
const gamma = event.gamma !== null ? event.gamma - calibrationOffset.gamma : null;
// Update value displays
document.getElementById('alpha').textContent = alpha !== null ? alpha.toFixed(1) : '-';
document.getElementById('beta').textContent = beta !== null ? beta.toFixed(1) : '-';
document.getElementById('gamma').textContent = gamma !== null ? gamma.toFixed(1) : '-';
// Update bars
if (alpha !== null) {
document.getElementById('alphaBar').style.width = (alpha / 360 * 100) + '%';
}
if (beta !== null) {
document.getElementById('betaBar').style.width = ((beta + 180) / 360 * 100) + '%';
}
if (gamma !== null) {
document.getElementById('gammaBar').style.width = ((gamma + 90) / 180 * 100) + '%';
}
// Update compass needle
if (alpha !== null) {
document.getElementById('compassNeedle').style.transform =
`translateX(-50%) rotate(${alpha}deg)`;
}
// Update 3D device visualization
if (beta !== null && gamma !== null) {
document.getElementById('deviceCube').style.transform =
`rotateX(${-beta}deg) rotateY(${gamma}deg)`;
}
// Update additional info
document.getElementById('absolute').textContent = event.absolute ? 'Yes' : 'No';
// WebKit compass (Safari)
if (event.webkitCompassHeading !== undefined) {
document.getElementById('compassHeading').textContent =
event.webkitCompassHeading.toFixed(1) + ' deg';
}
if (event.webkitCompassAccuracy !== undefined) {
document.getElementById('compassAccuracy').textContent =
event.webkitCompassAccuracy.toFixed(1) + ' deg';
}
// Log every 10th update to avoid spam
if (updateCount % 10 === 0) {
log(`Alpha: ${alpha?.toFixed(1) || '-'}, Beta: ${beta?.toFixed(1) || '-'}, Gamma: ${gamma?.toFixed(1) || '-'}`, 'data');
}
}
function startListening() {
if (listening) return;
window.addEventListener('deviceorientation', handleOrientation);
listening = true;
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
log('Started listening for orientation events', 'event');
}
function stopListening() {
if (!listening) return;
window.removeEventListener('deviceorientation', handleOrientation);
listening = false;
document.getElementById('startBtn').disabled = false;
document.getElementById('stopBtn').disabled = true;
log('Stopped listening for orientation events', 'event');
}
function calibrate() {
// Store current values as offset for next reading
const alpha = document.getElementById('alpha').textContent;
const beta = document.getElementById('beta').textContent;
const gamma = document.getElementById('gamma').textContent;
if (alpha !== '-' && beta !== '-' && gamma !== '-') {
calibrationOffset = {
alpha: parseFloat(alpha),
beta: parseFloat(beta),
gamma: parseFloat(gamma)
};
log('Calibrated - current position set as reference', 'data');
} else {
log('Cannot calibrate - no orientation data available', 'error');
}
}
// Initialize
checkSupport();
// Auto-start on devices that don't require permission
if (!window.DeviceOrientationEvent || typeof DeviceOrientationEvent.requestPermission !== 'function') {
log('Auto-starting orientation listener...', 'event');
startListening();
} else {
log('iOS detected - tap "Request Permission" to enable orientation', 'event');
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Device Orientation Demo",
Description: "Accelerometer and gyroscope",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Device Orientation Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,587 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dialog Element API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
min-height: 100px;
max-height: 200px;
overflow-y: auto;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
/* Dialog styling */
dialog {
background: #1a1a2e;
color: #eee;
border: 1px solid #333;
border-radius: 12px;
padding: 0;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
.dialog-header {
background: rgba(74, 222, 128, 0.1);
padding: 15px 20px;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header h3 {
color: #4ade80;
font-size: 1.1rem;
}
.dialog-close {
background: transparent;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
margin: 0;
line-height: 1;
}
.dialog-close:hover { color: #fff; }
.dialog-body {
padding: 20px;
}
.dialog-footer {
padding: 15px 20px;
border-top: 1px solid #333;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.dialog-footer button { margin: 0; }
/* Form inside dialog */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
color: #aaa;
margin-bottom: 5px;
font-size: 0.9rem;
}
.form-group input, .form-group textarea, .form-group select {
width: 100%;
padding: 10px;
background: #0a0a1a;
border: 1px solid #333;
border-radius: 6px;
color: #eee;
font-size: 1rem;
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
outline: none;
border-color: #4ade80;
}
/* Dialog variants */
dialog.warning .dialog-header {
background: rgba(251, 191, 36, 0.1);
}
dialog.warning .dialog-header h3 {
color: #fbbf24;
}
dialog.danger .dialog-header {
background: rgba(248, 113, 113, 0.1);
}
dialog.danger .dialog-header h3 {
color: #f87171;
}
dialog.info .dialog-header {
background: rgba(96, 165, 250, 0.1);
}
dialog.info .dialog-header h3 {
color: #60a5fa;
}
/* Non-modal dialog positioning */
dialog.non-modal {
position: fixed;
top: 20px;
right: 20px;
margin: 0;
max-width: 300px;
}
.result-display {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
margin-top: 15px;
}
.result-label {
color: #888;
font-size: 0.85rem;
margin-bottom: 5px;
}
.result-value {
color: #4ade80;
font-family: monospace;
}
</style>
</head>
<body>
<div class="container">
<h1>Dialog Element API Demo</h1>
<p class="description">
The HTML &lt;dialog&gt; element provides a native way to create modal and non-modal dialog boxes,
with built-in accessibility features and backdrop handling.
</p>
<div class="status success">
Dialog Element API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Basic Modal Dialog</h2>
<p style="color: #aaa; margin-bottom: 15px;">Modal dialogs block interaction with the rest of the page:</p>
<button onclick="showBasicModal()">Open Modal Dialog</button>
<div class="result-display">
<div class="result-label">Last action:</div>
<div class="result-value" id="basicResult">-</div>
</div>
</div>
<div class="card">
<h2>Non-Modal Dialog</h2>
<p style="color: #aaa; margin-bottom: 15px;">Non-modal dialogs allow interaction with the page:</p>
<button onclick="showNonModal()">Open Non-Modal Dialog</button>
<button class="secondary" onclick="closeNonModal()">Close Non-Modal</button>
</div>
<div class="card">
<h2>Confirm Dialog</h2>
<p style="color: #aaa; margin-bottom: 15px;">A custom confirm dialog that returns user choice:</p>
<button onclick="showConfirmDialog()">Show Confirm Dialog</button>
<div class="result-display">
<div class="result-label">User choice:</div>
<div class="result-value" id="confirmResult">-</div>
</div>
</div>
<div class="card">
<h2>Form Dialog</h2>
<p style="color: #aaa; margin-bottom: 15px;">Dialog with form that uses method="dialog":</p>
<button onclick="showFormDialog()">Open Form Dialog</button>
<div class="result-display">
<div class="result-label">Form data:</div>
<div class="result-value" id="formResult">-</div>
</div>
</div>
<div class="card">
<h2>Alert Variants</h2>
<p style="color: #aaa; margin-bottom: 15px;">Different styled dialogs for various purposes:</p>
<button onclick="showAlert('success')">Success Alert</button>
<button onclick="showAlert('warning')" class="secondary">Warning Alert</button>
<button onclick="showAlert('danger')" class="danger">Error Alert</button>
<button onclick="showAlert('info')">Info Alert</button>
</div>
<div class="card">
<h2>Stacked Dialogs</h2>
<p style="color: #aaa; margin-bottom: 15px;">Multiple dialogs can be stacked:</p>
<button onclick="showStackedDialog(1)">Open First Dialog</button>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Dialog events will appear here...</div>
</div>
</div>
<!-- Basic Modal Dialog -->
<dialog id="basicModal">
<div class="dialog-header">
<h3>Basic Modal Dialog</h3>
<button class="dialog-close" onclick="closeBasicModal()">&times;</button>
</div>
<div class="dialog-body">
<p>This is a modal dialog. The backdrop prevents interaction with the page behind it.</p>
<p style="margin-top: 10px; color: #aaa;">Press Escape or click outside to close (if enabled).</p>
</div>
<div class="dialog-footer">
<button class="secondary" onclick="closeBasicModal()">Cancel</button>
<button onclick="confirmBasicModal()">OK</button>
</div>
</dialog>
<!-- Non-Modal Dialog -->
<dialog id="nonModal" class="non-modal">
<div class="dialog-header">
<h3>Non-Modal Dialog</h3>
<button class="dialog-close" onclick="closeNonModal()">&times;</button>
</div>
<div class="dialog-body">
<p>This dialog allows interaction with the page behind it.</p>
<p style="margin-top: 10px; color: #aaa;">You can still click buttons and scroll the page.</p>
</div>
</dialog>
<!-- Confirm Dialog -->
<dialog id="confirmModal">
<div class="dialog-header">
<h3>Confirm Action</h3>
<button class="dialog-close" onclick="document.getElementById('confirmModal').close('cancelled')">&times;</button>
</div>
<div class="dialog-body">
<p>Are you sure you want to proceed with this action?</p>
<p style="margin-top: 10px; color: #aaa;">This action may have consequences.</p>
</div>
<div class="dialog-footer">
<button class="danger" onclick="document.getElementById('confirmModal').close('declined')">No, Cancel</button>
<button onclick="document.getElementById('confirmModal').close('confirmed')">Yes, Proceed</button>
</div>
</dialog>
<!-- Form Dialog -->
<dialog id="formModal">
<form method="dialog" id="dialogForm">
<div class="dialog-header">
<h3>User Information</h3>
<button type="button" class="dialog-close" onclick="document.getElementById('formModal').close()">&times;</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label for="userName">Name</label>
<input type="text" id="userName" name="name" placeholder="Enter your name" required>
</div>
<div class="form-group">
<label for="userEmail">Email</label>
<input type="email" id="userEmail" name="email" placeholder="Enter your email" required>
</div>
<div class="form-group">
<label for="userRole">Role</label>
<select id="userRole" name="role">
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label for="userMessage">Message</label>
<textarea id="userMessage" name="message" rows="3" placeholder="Optional message"></textarea>
</div>
</div>
<div class="dialog-footer">
<button type="button" class="secondary" onclick="document.getElementById('formModal').close()">Cancel</button>
<button type="submit" value="submit">Submit</button>
</div>
</form>
</dialog>
<!-- Alert Dialog Template -->
<dialog id="alertModal">
<div class="dialog-header">
<h3 id="alertTitle">Alert</h3>
<button class="dialog-close" onclick="document.getElementById('alertModal').close()">&times;</button>
</div>
<div class="dialog-body">
<p id="alertMessage">Alert message</p>
</div>
<div class="dialog-footer">
<button onclick="document.getElementById('alertModal').close()">OK</button>
</div>
</dialog>
<!-- Stacked Dialog 1 -->
<dialog id="stackedDialog1">
<div class="dialog-header">
<h3>First Dialog</h3>
<button class="dialog-close" onclick="document.getElementById('stackedDialog1').close()">&times;</button>
</div>
<div class="dialog-body">
<p>This is the first dialog in the stack.</p>
<p style="margin-top: 10px; color: #aaa;">Click the button below to open another dialog on top.</p>
</div>
<div class="dialog-footer">
<button class="secondary" onclick="document.getElementById('stackedDialog1').close()">Close</button>
<button onclick="showStackedDialog(2)">Open Second Dialog</button>
</div>
</dialog>
<!-- Stacked Dialog 2 -->
<dialog id="stackedDialog2">
<div class="dialog-header">
<h3>Second Dialog</h3>
<button class="dialog-close" onclick="document.getElementById('stackedDialog2').close()">&times;</button>
</div>
<div class="dialog-body">
<p>This is the second dialog, stacked on top of the first.</p>
<p style="margin-top: 10px; color: #aaa;">You can stack as many dialogs as needed.</p>
</div>
<div class="dialog-footer">
<button class="secondary" onclick="document.getElementById('stackedDialog2').close()">Close</button>
<button onclick="showStackedDialog(3)">Open Third Dialog</button>
</div>
</dialog>
<!-- Stacked Dialog 3 -->
<dialog id="stackedDialog3" class="info">
<div class="dialog-header">
<h3>Third Dialog</h3>
<button class="dialog-close" onclick="document.getElementById('stackedDialog3').close()">&times;</button>
</div>
<div class="dialog-body">
<p>This is the third and final dialog in the stack.</p>
<p style="margin-top: 10px; color: #aaa;">Close dialogs in reverse order by pressing Escape or clicking Close.</p>
</div>
<div class="dialog-footer">
<button onclick="document.getElementById('stackedDialog3').close()">Close</button>
</div>
</dialog>
<script>
// Event logging
let logEntries = [];
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 50) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Log cleared.';
}
// Check API support
function checkSupport() {
const dialog = document.createElement('dialog');
const hasDialog = typeof dialog.showModal === 'function';
document.getElementById('supported').textContent = hasDialog ? 'Available' : 'Not available';
document.getElementById('supported').parentElement.className = hasDialog ? 'status success' : 'status error';
const features = document.getElementById('features');
const checks = [
{ name: '<dialog> element', supported: 'HTMLDialogElement' in window },
{ name: 'showModal()', supported: typeof dialog.showModal === 'function' },
{ name: 'show()', supported: typeof dialog.show === 'function' },
{ name: 'close()', supported: typeof dialog.close === 'function' },
{ name: 'open attribute', supported: 'open' in dialog },
{ name: 'returnValue', supported: 'returnValue' in dialog },
{ name: '::backdrop pseudo', supported: CSS.supports('selector(::backdrop)') },
{ name: 'method="dialog"', supported: true },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
log('API support check completed');
}
// Setup dialog event listeners
function setupDialogEvents(dialogId) {
const dialog = document.getElementById(dialogId);
dialog.addEventListener('close', () => {
log(`Dialog "${dialogId}" closed with returnValue: "${dialog.returnValue || '(empty)'}"`);
});
dialog.addEventListener('cancel', (e) => {
log(`Dialog "${dialogId}" cancel event (Escape pressed)`);
});
// Close on backdrop click for modal dialogs
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
dialog.close('backdrop-click');
log(`Dialog "${dialogId}" closed via backdrop click`);
}
});
}
// Basic Modal
const basicModal = document.getElementById('basicModal');
setupDialogEvents('basicModal');
function showBasicModal() {
basicModal.showModal();
log('Basic modal dialog opened');
}
function closeBasicModal() {
basicModal.close('cancelled');
document.getElementById('basicResult').textContent = 'Cancelled';
}
function confirmBasicModal() {
basicModal.close('confirmed');
document.getElementById('basicResult').textContent = 'Confirmed';
}
// Non-Modal
const nonModal = document.getElementById('nonModal');
setupDialogEvents('nonModal');
function showNonModal() {
nonModal.show();
log('Non-modal dialog opened');
}
function closeNonModal() {
nonModal.close();
}
// Confirm Dialog
const confirmModal = document.getElementById('confirmModal');
setupDialogEvents('confirmModal');
function showConfirmDialog() {
confirmModal.showModal();
log('Confirm dialog opened');
}
confirmModal.addEventListener('close', () => {
document.getElementById('confirmResult').textContent = confirmModal.returnValue || 'No value';
});
// Form Dialog
const formModal = document.getElementById('formModal');
const dialogForm = document.getElementById('dialogForm');
setupDialogEvents('formModal');
function showFormDialog() {
dialogForm.reset();
formModal.showModal();
log('Form dialog opened');
}
dialogForm.addEventListener('submit', (e) => {
const formData = new FormData(dialogForm);
const data = Object.fromEntries(formData.entries());
document.getElementById('formResult').textContent = JSON.stringify(data, null, 2);
log(`Form submitted: ${JSON.stringify(data)}`);
});
// Alert Dialog
const alertModal = document.getElementById('alertModal');
setupDialogEvents('alertModal');
const alertConfigs = {
success: {
title: 'Success!',
message: 'Your operation completed successfully.',
class: ''
},
warning: {
title: 'Warning',
message: 'Please review before proceeding.',
class: 'warning'
},
danger: {
title: 'Error',
message: 'Something went wrong. Please try again.',
class: 'danger'
},
info: {
title: 'Information',
message: 'Here is some helpful information for you.',
class: 'info'
}
};
function showAlert(type) {
const config = alertConfigs[type];
alertModal.className = config.class;
document.getElementById('alertTitle').textContent = config.title;
document.getElementById('alertMessage').textContent = config.message;
alertModal.showModal();
log(`Alert dialog opened: ${type}`);
}
// Stacked Dialogs
['stackedDialog1', 'stackedDialog2', 'stackedDialog3'].forEach(id => {
setupDialogEvents(id);
});
function showStackedDialog(num) {
const dialog = document.getElementById(`stackedDialog${num}`);
dialog.showModal();
log(`Stacked dialog ${num} opened`);
}
// Initialize
checkSupport();
log('Dialog Element demo initialized');
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Dialog Element Demo",
Description: "Native modal dialogs",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Dialog Element Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,740 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 Drag and Drop API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 250px;
overflow-y: auto;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
/* Drag and Drop specific styles */
.drag-container {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.draggable-items {
flex: 1;
min-width: 200px;
}
.drop-zones {
flex: 2;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.draggable {
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
color: #1a1a2e;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 10px;
cursor: grab;
font-weight: 600;
transition: transform 0.2s, opacity 0.2s, box-shadow 0.2s;
user-select: none;
}
.draggable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.3);
}
.draggable.dragging {
opacity: 0.5;
cursor: grabbing;
}
.draggable.fruit { background: linear-gradient(135deg, #fb923c 0%, #f97316 100%); }
.draggable.vegetable { background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%); }
.draggable.dairy { background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); }
.draggable.meat { background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); }
.drop-zone {
flex: 1;
min-width: 150px;
min-height: 200px;
background: rgba(0,0,0,0.3);
border: 3px dashed #444;
border-radius: 12px;
padding: 15px;
transition: all 0.3s;
display: flex;
flex-direction: column;
}
.drop-zone.drag-over {
border-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
box-shadow: 0 0 20px rgba(74, 222, 128, 0.2);
}
.drop-zone-title {
text-align: center;
color: #aaa;
font-weight: 600;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #333;
}
.drop-zone-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.drop-zone .draggable {
margin-bottom: 0;
}
.drop-zone-empty {
color: #555;
text-align: center;
padding: 20px;
font-style: italic;
}
/* File drop zone */
.file-drop-zone {
border: 3px dashed #444;
border-radius: 12px;
padding: 40px;
text-align: center;
transition: all 0.3s;
background: rgba(0,0,0,0.2);
}
.file-drop-zone.drag-over {
border-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
}
.file-drop-zone-icon {
font-size: 48px;
margin-bottom: 15px;
}
.file-drop-zone-text {
color: #aaa;
margin-bottom: 10px;
}
.file-list {
margin-top: 15px;
text-align: left;
}
.file-item {
background: rgba(0,0,0,0.3);
padding: 10px 15px;
border-radius: 6px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.file-item-name { color: #4ade80; font-family: monospace; }
.file-item-size { color: #aaa; font-size: 0.85rem; }
.file-item-type { color: #60a5fa; font-size: 0.85rem; }
/* Sortable list */
.sortable-list {
list-style: none;
max-width: 400px;
}
.sortable-item {
background: rgba(0,0,0,0.3);
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 8px;
cursor: grab;
display: flex;
align-items: center;
gap: 15px;
transition: all 0.2s;
border: 2px solid transparent;
}
.sortable-item:hover {
background: rgba(0,0,0,0.4);
}
.sortable-item.dragging {
opacity: 0.5;
border-color: #4ade80;
}
.sortable-item.drag-over {
border-color: #60a5fa;
transform: translateY(5px);
}
.sortable-handle {
color: #555;
font-size: 1.2rem;
}
.sortable-rank {
background: #4ade80;
color: #1a1a2e;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem;
}
.sortable-text {
flex: 1;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: rgba(0,0,0,0.3);
padding: 15px;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 1.8rem;
font-weight: bold;
color: #4ade80;
}
.stat-label {
color: #aaa;
font-size: 0.8rem;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="container">
<h1>HTML5 Drag and Drop API Demo</h1>
<p class="description">
The HTML5 Drag and Drop API enables drag-and-drop functionality for
web applications, supporting both element dragging and file drops.
</p>
<div id="status" class="status success">
Drag and Drop API: <span id="available">checking...</span>
</div>
<div class="card">
<h2>Statistics</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="dragCount">0</div>
<div class="stat-label">Drag Operations</div>
</div>
<div class="stat-card">
<div class="stat-value" id="dropCount">0</div>
<div class="stat-label">Successful Drops</div>
</div>
<div class="stat-card">
<div class="stat-value" id="fileCount">0</div>
<div class="stat-label">Files Dropped</div>
</div>
<div class="stat-card">
<div class="stat-value" id="sortCount">0</div>
<div class="stat-label">Sort Operations</div>
</div>
</div>
</div>
<div class="card">
<h2>Drag Items to Categories</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Drag the food items on the left and drop them into the correct category zones.
</p>
<button onclick="resetCategories()">Reset Items</button>
<div class="drag-container" style="margin-top: 15px;">
<div class="draggable-items" id="sourceItems">
<div class="draggable fruit" draggable="true" data-type="fruit" data-name="Apple">Apple</div>
<div class="draggable vegetable" draggable="true" data-type="vegetable" data-name="Carrot">Carrot</div>
<div class="draggable dairy" draggable="true" data-type="dairy" data-name="Cheese">Cheese</div>
<div class="draggable meat" draggable="true" data-type="meat" data-name="Steak">Steak</div>
<div class="draggable fruit" draggable="true" data-type="fruit" data-name="Banana">Banana</div>
<div class="draggable vegetable" draggable="true" data-type="vegetable" data-name="Broccoli">Broccoli</div>
<div class="draggable dairy" draggable="true" data-type="dairy" data-name="Milk">Milk</div>
<div class="draggable meat" draggable="true" data-type="meat" data-name="Chicken">Chicken</div>
</div>
<div class="drop-zones">
<div class="drop-zone" data-category="fruit">
<div class="drop-zone-title">Fruits</div>
<div class="drop-zone-content" id="fruitZone">
<div class="drop-zone-empty">Drop fruits here</div>
</div>
</div>
<div class="drop-zone" data-category="vegetable">
<div class="drop-zone-title">Vegetables</div>
<div class="drop-zone-content" id="vegetableZone">
<div class="drop-zone-empty">Drop vegetables here</div>
</div>
</div>
<div class="drop-zone" data-category="dairy">
<div class="drop-zone-title">Dairy</div>
<div class="drop-zone-content" id="dairyZone">
<div class="drop-zone-empty">Drop dairy here</div>
</div>
</div>
<div class="drop-zone" data-category="meat">
<div class="drop-zone-title">Meat</div>
<div class="drop-zone-content" id="meatZone">
<div class="drop-zone-empty">Drop meat here</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<h2>File Drop Zone</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Drag and drop files from your computer into the zone below.
</p>
<div class="file-drop-zone" id="fileDropZone">
<div class="file-drop-zone-icon">&#128193;</div>
<div class="file-drop-zone-text">Drag files here or click to select</div>
<input type="file" id="fileInput" multiple style="display: none;">
</div>
<div class="file-list" id="fileList"></div>
<button onclick="clearFiles()" style="margin-top: 10px;">Clear Files</button>
</div>
<div class="card">
<h2>Sortable List</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Drag items to reorder the list. The rank numbers will update automatically.
</p>
<button onclick="resetSortableList()">Reset Order</button>
<ul class="sortable-list" id="sortableList" style="margin-top: 15px;">
<li class="sortable-item" draggable="true" data-id="1">
<span class="sortable-handle">&#9776;</span>
<span class="sortable-rank">1</span>
<span class="sortable-text">Learn HTML5 Drag and Drop</span>
</li>
<li class="sortable-item" draggable="true" data-id="2">
<span class="sortable-handle">&#9776;</span>
<span class="sortable-rank">2</span>
<span class="sortable-text">Build interactive interfaces</span>
</li>
<li class="sortable-item" draggable="true" data-id="3">
<span class="sortable-handle">&#9776;</span>
<span class="sortable-rank">3</span>
<span class="sortable-text">Implement file uploads</span>
</li>
<li class="sortable-item" draggable="true" data-id="4">
<span class="sortable-handle">&#9776;</span>
<span class="sortable-rank">4</span>
<span class="sortable-text">Create sortable lists</span>
</li>
<li class="sortable-item" draggable="true" data-id="5">
<span class="sortable-handle">&#9776;</span>
<span class="sortable-rank">5</span>
<span class="sortable-text">Master drag events</span>
</li>
</ul>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog" style="margin-top: 10px;">Drag and drop events will be logged here...
Try dragging items between zones or dropping files.</div>
</div>
</div>
<script>
let logEntries = [];
let stats = {
dragCount: 0,
dropCount: 0,
fileCount: 0,
sortCount: 0
};
let droppedFiles = [];
let draggedElement = null;
let draggedSortableItem = null;
// Original items for reset
const originalItems = `
<div class="draggable fruit" draggable="true" data-type="fruit" data-name="Apple">Apple</div>
<div class="draggable vegetable" draggable="true" data-type="vegetable" data-name="Carrot">Carrot</div>
<div class="draggable dairy" draggable="true" data-type="dairy" data-name="Cheese">Cheese</div>
<div class="draggable meat" draggable="true" data-type="meat" data-name="Steak">Steak</div>
<div class="draggable fruit" draggable="true" data-type="fruit" data-name="Banana">Banana</div>
<div class="draggable vegetable" draggable="true" data-type="vegetable" data-name="Broccoli">Broccoli</div>
<div class="draggable dairy" draggable="true" data-type="dairy" data-name="Milk">Milk</div>
<div class="draggable meat" draggable="true" data-type="meat" data-name="Chicken">Chicken</div>
`;
const originalSortableItems = [
'Learn HTML5 Drag and Drop',
'Build interactive interfaces',
'Implement file uploads',
'Create sortable lists',
'Master drag events'
];
function checkSupport() {
const hasDragDrop = 'draggable' in document.createElement('div');
const hasDataTransfer = typeof DataTransfer !== 'undefined';
const supported = hasDragDrop && hasDataTransfer;
document.getElementById('available').textContent = supported ? 'Available' : 'Not available';
document.getElementById('status').className = supported ? 'status success' : 'status error';
return supported;
}
function checkFeatures() {
const features = document.getElementById('features');
const testEl = document.createElement('div');
const checks = [
{ name: 'draggable attribute', supported: 'draggable' in testEl },
{ name: 'DataTransfer API', supported: typeof DataTransfer !== 'undefined' },
{ name: 'ondragstart event', supported: 'ondragstart' in testEl },
{ name: 'ondragover event', supported: 'ondragover' in testEl },
{ name: 'ondrop event', supported: 'ondrop' in testEl },
{ name: 'DataTransfer.files', supported: typeof DataTransfer !== 'undefined' },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
}
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 50) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Log cleared.';
}
function updateStats() {
document.getElementById('dragCount').textContent = stats.dragCount;
document.getElementById('dropCount').textContent = stats.dropCount;
document.getElementById('fileCount').textContent = stats.fileCount;
document.getElementById('sortCount').textContent = stats.sortCount;
}
// Initialize draggable items
function initDraggableItems() {
const items = document.querySelectorAll('.draggable-items .draggable, .drop-zone-content .draggable');
items.forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedElement = item;
item.classList.add('dragging');
e.dataTransfer.setData('text/plain', item.dataset.name);
e.dataTransfer.setData('application/x-item-type', item.dataset.type);
e.dataTransfer.effectAllowed = 'move';
stats.dragCount++;
updateStats();
log(`Drag started: ${item.dataset.name} (${item.dataset.type})`);
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
draggedElement = null;
log(`Drag ended: ${item.dataset.name}`);
});
});
}
// Initialize drop zones
function initDropZones() {
const zones = document.querySelectorAll('.drop-zone');
zones.forEach(zone => {
zone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
zone.classList.add('drag-over');
});
zone.addEventListener('dragleave', (e) => {
if (!zone.contains(e.relatedTarget)) {
zone.classList.remove('drag-over');
}
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('drag-over');
if (!draggedElement) return;
const itemType = e.dataTransfer.getData('application/x-item-type');
const itemName = e.dataTransfer.getData('text/plain');
const zoneCategory = zone.dataset.category;
const content = zone.querySelector('.drop-zone-content');
const emptyMsg = content.querySelector('.drop-zone-empty');
if (emptyMsg) emptyMsg.remove();
// Move the item to the new zone
content.appendChild(draggedElement);
stats.dropCount++;
updateStats();
if (itemType === zoneCategory) {
log(`Correct! ${itemName} dropped in ${zoneCategory} zone`);
} else {
log(`${itemName} dropped in ${zoneCategory} zone (expected: ${itemType})`);
}
// Re-initialize draggable items
initDraggableItems();
});
});
// Also allow dropping back to source
const sourceItems = document.getElementById('sourceItems');
sourceItems.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
sourceItems.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedElement) {
sourceItems.appendChild(draggedElement);
log(`${draggedElement.dataset.name} returned to source`);
initDraggableItems();
}
});
}
function resetCategories() {
document.getElementById('sourceItems').innerHTML = originalItems;
['fruitZone', 'vegetableZone', 'dairyZone', 'meatZone'].forEach(id => {
const zone = document.getElementById(id);
zone.innerHTML = '<div class="drop-zone-empty">Drop items here</div>';
});
initDraggableItems();
log('Categories reset');
}
// File drop zone
function initFileDropZone() {
const fileDropZone = document.getElementById('fileDropZone');
const fileInput = document.getElementById('fileInput');
fileDropZone.addEventListener('click', () => fileInput.click());
fileDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
fileDropZone.classList.add('drag-over');
});
fileDropZone.addEventListener('dragleave', () => {
fileDropZone.classList.remove('drag-over');
});
fileDropZone.addEventListener('drop', (e) => {
e.preventDefault();
fileDropZone.classList.remove('drag-over');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
}
function handleFiles(files) {
Array.from(files).forEach(file => {
droppedFiles.push(file);
stats.fileCount++;
log(`File dropped: ${file.name} (${formatFileSize(file.size)})`);
});
updateStats();
renderFileList();
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function renderFileList() {
const fileList = document.getElementById('fileList');
if (droppedFiles.length === 0) {
fileList.innerHTML = '';
return;
}
fileList.innerHTML = droppedFiles.map((file, index) => `
<div class="file-item">
<span class="file-item-name">${escapeHtml(file.name)}</span>
<span class="file-item-type">${file.type || 'unknown'}</span>
<span class="file-item-size">${formatFileSize(file.size)}</span>
</div>
`).join('');
}
function clearFiles() {
droppedFiles = [];
renderFileList();
log('File list cleared');
}
// Sortable list
function initSortableList() {
const list = document.getElementById('sortableList');
const items = list.querySelectorAll('.sortable-item');
items.forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedSortableItem = item;
item.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
log(`Started sorting: "${item.querySelector('.sortable-text').textContent}"`);
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
draggedSortableItem = null;
updateSortableRanks();
});
item.addEventListener('dragover', (e) => {
e.preventDefault();
if (draggedSortableItem && draggedSortableItem !== item) {
item.classList.add('drag-over');
}
});
item.addEventListener('dragleave', () => {
item.classList.remove('drag-over');
});
item.addEventListener('drop', (e) => {
e.preventDefault();
item.classList.remove('drag-over');
if (draggedSortableItem && draggedSortableItem !== item) {
const allItems = [...list.querySelectorAll('.sortable-item')];
const draggedIndex = allItems.indexOf(draggedSortableItem);
const dropIndex = allItems.indexOf(item);
if (draggedIndex < dropIndex) {
item.after(draggedSortableItem);
} else {
item.before(draggedSortableItem);
}
stats.sortCount++;
updateStats();
log(`Reordered list item`);
}
});
});
}
function updateSortableRanks() {
const items = document.querySelectorAll('.sortable-item');
items.forEach((item, index) => {
item.querySelector('.sortable-rank').textContent = index + 1;
});
}
function resetSortableList() {
const list = document.getElementById('sortableList');
list.innerHTML = originalSortableItems.map((text, index) => `
<li class="sortable-item" draggable="true" data-id="${index + 1}">
<span class="sortable-handle">&#9776;</span>
<span class="sortable-rank">${index + 1}</span>
<span class="sortable-text">${text}</span>
</li>
`).join('');
initSortableList();
log('Sortable list reset');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize everything
if (checkSupport()) {
checkFeatures();
initDraggableItems();
initDropZones();
initFileDropZone();
initSortableList();
log('Drag and Drop API initialized');
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Drag and Drop Demo",
Description: "Native drag and drop",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Drag and Drop Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server-Sent Events (EventSource) Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
.form-row { display: flex; gap: 10px; align-items: end; }
.form-row .form-group { flex: 1; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input, textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
word-break: break-all;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.status.warning { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: #666;
}
.indicator.connected { background: #4ade80; }
.indicator.connecting { background: #fbbf24; animation: pulse 1s infinite; }
.indicator.disconnected { background: #f87171; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.event-log {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.8rem;
max-height: 350px;
overflow-y: auto;
}
.log-entry {
padding: 6px 10px;
margin: 3px 0;
border-radius: 4px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.log-entry.message { background: rgba(96, 165, 250, 0.1); border-left: 3px solid #60a5fa; }
.log-entry.open { background: rgba(74, 222, 128, 0.1); border-left: 3px solid #4ade80; }
.log-entry.error { background: rgba(248, 113, 113, 0.1); border-left: 3px solid #f87171; }
.log-entry.custom { background: rgba(167, 139, 250, 0.1); border-left: 3px solid #a78bfa; }
.log-entry.mock { background: rgba(251, 191, 36, 0.1); border-left: 3px solid #fbbf24; }
.log-time { color: #666; flex-shrink: 0; }
.log-type { color: #60a5fa; flex-shrink: 0; width: 100px; font-weight: 600; }
.log-message { color: #ccc; flex: 1; }
.mock-controls {
background: rgba(251, 191, 36, 0.1);
border: 1px dashed #fbbf24;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.mock-controls h3 { color: #fbbf24; font-size: 1rem; margin-bottom: 10px; }
.stats { display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 15px; }
.stat {
background: rgba(0,0,0,0.3);
padding: 10px 15px;
border-radius: 6px;
text-align: center;
}
.stat-value { font-size: 1.5rem; font-weight: bold; color: #4ade80; }
.stat-label { font-size: 0.8rem; color: #888; }
</style>
</head>
<body>
<div class="container">
<h1>Server-Sent Events (EventSource) Demo</h1>
<p class="description">
Server-Sent Events (SSE) enable servers to push data to web clients over HTTP.
Unlike WebSockets, SSE is unidirectional (server to client) and works over standard HTTP.
</p>
<div id="status" class="status">
<div class="indicator" id="indicator"></div>
<span>EventSource: <span id="connectionStatus">Checking...</span></span>
</div>
<div class="card">
<h2>Connection</h2>
<div class="form-row">
<div class="form-group">
<label for="sseUrl">SSE Endpoint URL</label>
<input type="text" id="sseUrl" value="https://sse.dev/test" placeholder="Enter SSE endpoint URL">
</div>
</div>
<button id="connectBtn" onclick="connect()">Connect</button>
<button id="disconnectBtn" onclick="disconnect()" disabled class="danger">Disconnect</button>
<p style="color: #666; font-size: 0.85rem; margin-top: 10px;">
Public SSE test servers: sse.dev/test, or use the mock stream below for testing.
</p>
</div>
<div class="card">
<h2>Mock Event Stream</h2>
<div class="mock-controls">
<h3>Local Mock (No Server Required)</h3>
<p style="color: #aaa; font-size: 0.85rem; margin-bottom: 10px;">
Since SSE requires a server, this mock simulates event streams locally for testing.
</p>
<div class="form-group">
<label for="mockInterval">Event Interval (ms)</label>
<input type="number" id="mockInterval" value="1000" min="100" max="10000">
</div>
<div class="form-group">
<label for="mockEventType">Event Type</label>
<select id="mockEventType">
<option value="message">message (default)</option>
<option value="update">update (custom)</option>
<option value="notification">notification (custom)</option>
<option value="random">random types</option>
</select>
</div>
<button id="mockStartBtn" onclick="startMock()">Start Mock Stream</button>
<button id="mockStopBtn" onclick="stopMock()" disabled class="danger">Stop Mock Stream</button>
</div>
</div>
<div class="card">
<h2>Statistics</h2>
<div class="stats">
<div class="stat">
<div class="stat-value" id="totalEvents">0</div>
<div class="stat-label">Total Events</div>
</div>
<div class="stat">
<div class="stat-value" id="messageEvents">0</div>
<div class="stat-label">Messages</div>
</div>
<div class="stat">
<div class="stat-value" id="customEvents">0</div>
<div class="stat-label">Custom Events</div>
</div>
<div class="stat">
<div class="stat-value" id="errorCount">0</div>
<div class="stat-label">Errors</div>
</div>
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="event-log" id="eventLog">
<div class="log-entry open">
<span class="log-time">--:--:--</span>
<span class="log-type">INFO</span>
<span class="log-message">Ready to connect to an SSE endpoint or start mock stream...</span>
</div>
</div>
</div>
<div class="card">
<h2>EventSource API Reference</h2>
<div class="output">
// Create EventSource connection
const source = new EventSource('https://api.example.com/stream');
// Connection opened
source.onopen = (event) => {
console.log('Connection opened');
};
// Default message event
source.onmessage = (event) => {
console.log('Message:', event.data);
console.log('Last Event ID:', event.lastEventId);
console.log('Origin:', event.origin);
};
// Custom named events
source.addEventListener('update', (event) => {
console.log('Update event:', event.data);
});
source.addEventListener('notification', (event) => {
console.log('Notification:', event.data);
});
// Error handling
source.onerror = (event) => {
if (source.readyState === EventSource.CONNECTING) {
console.log('Reconnecting...');
} else {
console.log('Error occurred');
}
};
// Close connection
source.close();
// Ready states
EventSource.CONNECTING // 0 - Connecting
EventSource.OPEN // 1 - Open
EventSource.CLOSED // 2 - Closed
// Server response format (text/event-stream):
// data: Simple message\n\n
// data: {"json": "data"}\n\n
// event: custom\ndata: Custom event\n\n
// id: 123\ndata: Message with ID\n\n
// retry: 5000\n\n</div>
</div>
</div>
<script>
let eventSource = null;
let mockInterval = null;
let mockEventId = 0;
let stats = { total: 0, messages: 0, custom: 0, errors: 0 };
const readyStates = {
0: 'CONNECTING',
1: 'OPEN',
2: 'CLOSED'
};
function checkEventSource() {
const available = typeof EventSource !== 'undefined';
const indicator = document.getElementById('indicator');
const statusText = document.getElementById('connectionStatus');
const status = document.getElementById('status');
if (available) {
indicator.className = 'indicator disconnected';
statusText.textContent = 'Available (Disconnected)';
status.className = 'status success';
} else {
indicator.className = 'indicator';
statusText.textContent = 'Not Supported';
status.className = 'status error';
document.getElementById('connectBtn').disabled = true;
}
return available;
}
function updateStatus(state) {
const indicator = document.getElementById('indicator');
const statusText = document.getElementById('connectionStatus');
const status = document.getElementById('status');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
switch (state) {
case 'connecting':
indicator.className = 'indicator connecting';
statusText.textContent = 'Connecting...';
status.className = 'status warning';
connectBtn.disabled = true;
disconnectBtn.disabled = true;
break;
case 'connected':
indicator.className = 'indicator connected';
statusText.textContent = 'Connected';
status.className = 'status success';
connectBtn.disabled = true;
disconnectBtn.disabled = false;
break;
case 'disconnected':
indicator.className = 'indicator disconnected';
statusText.textContent = 'Disconnected';
status.className = 'status error';
connectBtn.disabled = false;
disconnectBtn.disabled = true;
break;
}
}
function updateStats() {
document.getElementById('totalEvents').textContent = stats.total;
document.getElementById('messageEvents').textContent = stats.messages;
document.getElementById('customEvents').textContent = stats.custom;
document.getElementById('errorCount').textContent = stats.errors;
}
function logEvent(type, message, className = 'message') {
const log = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = `log-entry ${className}`;
entry.innerHTML = `
<span class="log-time">${escapeHtml(time)}</span>
<span class="log-type">${escapeHtml(type)}</span>
<span class="log-message">${escapeHtml(message)}</span>
`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function clearLog() {
document.getElementById('eventLog').innerHTML = '';
stats = { total: 0, messages: 0, custom: 0, errors: 0 };
updateStats();
logEvent('INFO', 'Log cleared', 'open');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function connect() {
const url = document.getElementById('sseUrl').value;
if (!url) {
alert('Please enter an SSE endpoint URL');
return;
}
updateStatus('connecting');
logEvent('CONNECTING', `Connecting to ${url}...`, 'open');
try {
eventSource = new EventSource(url);
eventSource.onopen = function(event) {
updateStatus('connected');
logEvent('OPEN', 'Connection established', 'open');
};
eventSource.onmessage = function(event) {
stats.total++;
stats.messages++;
updateStats();
logEvent('MESSAGE', `Data: ${event.data}${event.lastEventId ? ` | ID: ${event.lastEventId}` : ''}`, 'message');
};
eventSource.onerror = function(event) {
stats.errors++;
updateStats();
if (eventSource.readyState === EventSource.CONNECTING) {
logEvent('RECONNECT', 'Connection lost, attempting to reconnect...', 'error');
updateStatus('connecting');
} else if (eventSource.readyState === EventSource.CLOSED) {
logEvent('ERROR', 'Connection failed or closed', 'error');
updateStatus('disconnected');
eventSource = null;
}
};
// Listen for common custom event types
['update', 'notification', 'ping', 'status'].forEach(eventType => {
eventSource.addEventListener(eventType, function(event) {
stats.total++;
stats.custom++;
updateStats();
logEvent(eventType.toUpperCase(), `Data: ${event.data}`, 'custom');
});
});
} catch (e) {
logEvent('ERROR', `Failed to create EventSource: ${e.message}`, 'error');
updateStatus('disconnected');
}
}
function disconnect() {
if (eventSource) {
eventSource.close();
eventSource = null;
logEvent('CLOSE', 'Connection closed by user', 'error');
updateStatus('disconnected');
}
}
// Mock stream functionality
function startMock() {
const interval = parseInt(document.getElementById('mockInterval').value) || 1000;
const eventType = document.getElementById('mockEventType').value;
document.getElementById('mockStartBtn').disabled = true;
document.getElementById('mockStopBtn').disabled = false;
logEvent('MOCK START', `Starting mock stream (interval: ${interval}ms, type: ${eventType})`, 'mock');
const eventTypes = ['message', 'update', 'notification', 'ping'];
const sampleData = [
{ type: 'message', data: 'Hello from mock stream!' },
{ type: 'update', data: JSON.stringify({ status: 'active', time: Date.now() }) },
{ type: 'notification', data: 'New notification received' },
{ type: 'ping', data: 'pong' },
{ type: 'message', data: JSON.stringify({ id: Math.random(), value: Math.floor(Math.random() * 100) }) },
{ type: 'update', data: 'System update in progress...' },
{ type: 'notification', data: JSON.stringify({ alert: 'Warning', level: 'medium' }) }
];
mockInterval = setInterval(() => {
mockEventId++;
let selectedType, data;
if (eventType === 'random') {
const sample = sampleData[Math.floor(Math.random() * sampleData.length)];
selectedType = sample.type;
data = sample.data;
} else {
selectedType = eventType;
if (eventType === 'message') {
data = `Mock message #${mockEventId} - ${new Date().toISOString()}`;
} else {
data = JSON.stringify({
eventId: mockEventId,
type: selectedType,
timestamp: Date.now(),
random: Math.random().toFixed(4)
});
}
}
stats.total++;
if (selectedType === 'message') {
stats.messages++;
} else {
stats.custom++;
}
updateStats();
const className = selectedType === 'message' ? 'message' : 'custom';
logEvent(selectedType.toUpperCase(), `ID: ${mockEventId} | Data: ${data}`, className);
}, interval);
}
function stopMock() {
if (mockInterval) {
clearInterval(mockInterval);
mockInterval = null;
document.getElementById('mockStartBtn').disabled = false;
document.getElementById('mockStopBtn').disabled = true;
logEvent('MOCK STOP', 'Mock stream stopped', 'mock');
}
}
// Initialize
checkEventSource();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Server-Sent Events Demo",
Description: "EventSource/SSE API demonstration",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Server-Sent Events Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,286 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fetch API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
.form-row { display: flex; gap: 10px; align-items: end; }
.form-row .form-group { flex: 1; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input, textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
word-break: break-all;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.status.loading { background: rgba(96, 165, 250, 0.2); color: #60a5fa; }
.tabs { display: flex; gap: 5px; margin-bottom: 15px; }
.tab {
padding: 8px 16px;
background: transparent;
border: 1px solid #333;
color: #aaa;
cursor: pointer;
}
.tab.active { background: #4ade80; color: #1a1a2e; border-color: #4ade80; }
.response-info { display: flex; gap: 20px; margin-bottom: 15px; flex-wrap: wrap; }
.response-info span { padding: 5px 10px; background: rgba(0,0,0,0.3); border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<h1>Fetch API Demo</h1>
<p class="description">
The Fetch API provides a modern interface for making HTTP requests.
It returns Promises and supports various request/response types.
</p>
<div id="status" class="status success">
Fetch API available: <span id="available">checking...</span>
</div>
<div class="card">
<h2>Make Request</h2>
<div class="form-row">
<div class="form-group" style="flex: 0 0 120px;">
<label for="method">Method</label>
<select id="method">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="form-group">
<label for="url">URL</label>
<input type="text" id="url" value="https://jsonplaceholder.typicode.com/posts/1">
</div>
</div>
<div class="form-group">
<label for="headers">Headers (JSON)</label>
<textarea id="headers" rows="2" placeholder='{"Content-Type": "application/json"}'></textarea>
</div>
<div class="form-group">
<label for="body">Body (for POST/PUT)</label>
<textarea id="body" rows="3" placeholder='{"title": "foo", "body": "bar", "userId": 1}'></textarea>
</div>
<button onclick="makeRequest()">Send Request</button>
<button class="secondary" onclick="loadExample('posts')">Example: Get Posts</button>
<button class="secondary" onclick="loadExample('users')">Example: Get Users</button>
<button class="secondary" onclick="loadExample('create')">Example: Create Post</button>
</div>
<div class="card">
<h2>Response</h2>
<div id="responseStatus" class="status" style="display: none;"></div>
<div class="response-info" id="responseInfo" style="display: none;">
<span>Status: <strong id="resStatus"></strong></span>
<span>Type: <strong id="resType"></strong></span>
<span>Time: <strong id="resTime"></strong></span>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('body')">Body</button>
<button class="tab" onclick="showTab('headers')">Headers</button>
</div>
<div id="responseBody" class="output">Make a request to see the response...</div>
<div id="responseHeaders" class="output" style="display: none;"></div>
</div>
<div class="card">
<h2>Fetch API Features</h2>
<div class="output">
fetch(url, options) returns a Promise&lt;Response&gt;
Options:
- method: 'GET', 'POST', 'PUT', 'DELETE', etc.
- headers: Headers object or plain object
- body: String, FormData, Blob, ArrayBuffer, URLSearchParams
- mode: 'cors', 'no-cors', 'same-origin'
- credentials: 'omit', 'same-origin', 'include'
- cache: 'default', 'no-store', 'reload', 'no-cache', 'force-cache'
- redirect: 'follow', 'error', 'manual'
- signal: AbortController.signal for cancellation
Response methods:
- response.json() - Parse as JSON
- response.text() - Get as text
- response.blob() - Get as Blob
- response.arrayBuffer() - Get as ArrayBuffer
- response.formData() - Get as FormData
Response properties:
- response.ok (status 200-299)
- response.status
- response.statusText
- response.headers
- response.url
- response.type</div>
</div>
</div>
<script>
let currentTab = 'body';
function checkFetch() {
const available = typeof fetch !== 'undefined';
document.getElementById('available').textContent = available ? 'Yes' : 'No';
document.getElementById('status').className = available ? 'status success' : 'status error';
return available;
}
function loadExample(type) {
const method = document.getElementById('method');
const url = document.getElementById('url');
const headers = document.getElementById('headers');
const body = document.getElementById('body');
switch (type) {
case 'posts':
method.value = 'GET';
url.value = 'https://jsonplaceholder.typicode.com/posts?_limit=5';
headers.value = '';
body.value = '';
break;
case 'users':
method.value = 'GET';
url.value = 'https://jsonplaceholder.typicode.com/users';
headers.value = '';
body.value = '';
break;
case 'create':
method.value = 'POST';
url.value = 'https://jsonplaceholder.typicode.com/posts';
headers.value = '{"Content-Type": "application/json"}';
body.value = JSON.stringify({ title: 'foo', body: 'bar', userId: 1 }, null, 2);
break;
}
}
async function makeRequest() {
const method = document.getElementById('method').value;
const url = document.getElementById('url').value;
const headersStr = document.getElementById('headers').value;
const bodyStr = document.getElementById('body').value;
const statusEl = document.getElementById('responseStatus');
const infoEl = document.getElementById('responseInfo');
const bodyEl = document.getElementById('responseBody');
const headersEl = document.getElementById('responseHeaders');
statusEl.style.display = 'block';
statusEl.className = 'status loading';
statusEl.textContent = 'Loading...';
infoEl.style.display = 'none';
const startTime = performance.now();
try {
const options = { method };
if (headersStr) {
options.headers = JSON.parse(headersStr);
}
if (bodyStr && (method === 'POST' || method === 'PUT')) {
options.body = bodyStr;
}
const response = await fetch(url, options);
const endTime = performance.now();
// Display response info
infoEl.style.display = 'flex';
document.getElementById('resStatus').textContent = `${response.status} ${response.statusText}`;
document.getElementById('resType').textContent = response.headers.get('content-type') || 'unknown';
document.getElementById('resTime').textContent = `${(endTime - startTime).toFixed(0)}ms`;
// Display headers
let headersText = '';
response.headers.forEach((value, key) => {
headersText += `${key}: ${value}\n`;
});
headersEl.textContent = headersText || 'No headers';
// Display body
const contentType = response.headers.get('content-type') || '';
let bodyText;
if (contentType.includes('application/json')) {
const json = await response.json();
bodyText = JSON.stringify(json, null, 2);
} else {
bodyText = await response.text();
}
bodyEl.textContent = bodyText;
statusEl.className = response.ok ? 'status success' : 'status error';
statusEl.textContent = response.ok ? 'Request successful' : `Request failed: ${response.status}`;
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Error: ' + e.message;
bodyEl.textContent = e.stack || e.message;
}
}
function showTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.tab[onclick="showTab('${tab}')"]`).classList.add('active');
document.getElementById('responseBody').style.display = tab === 'body' ? 'block' : 'none';
document.getElementById('responseHeaders').style.display = tab === 'headers' ? 'block' : 'none';
}
checkFetch();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Fetch API Demo",
Description: "Demonstrates the Fetch API for network requests",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Fetch API Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,623 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input[type="text"], input[type="number"], select {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
word-break: break-all;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.event-log {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.8rem;
max-height: 200px;
overflow-y: auto;
}
.event-log .event { margin-bottom: 5px; padding: 5px; border-radius: 4px; }
.event-log .event.info { background: rgba(96, 165, 250, 0.2); color: #60a5fa; }
.event-log .event.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.event-log .event.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.event-log .event.progress { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.drop-zone {
border: 2px dashed #333;
border-radius: 12px;
padding: 40px;
text-align: center;
color: #aaa;
transition: all 0.3s;
margin-bottom: 15px;
}
.drop-zone.dragover {
border-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
color: #4ade80;
}
.drop-zone input[type="file"] {
display: none;
}
.file-list {
margin-top: 15px;
}
.file-item {
background: rgba(0,0,0,0.2);
padding: 12px;
border-radius: 8px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.file-info {
flex: 1;
}
.file-name { font-weight: 600; margin-bottom: 3px; }
.file-meta { color: #aaa; font-size: 0.85rem; }
.file-actions { display: flex; gap: 5px; }
.file-actions button { margin: 0; padding: 6px 12px; font-size: 0.85rem; }
.progress-bar {
width: 100%;
height: 10px;
background: #0a0a1a;
border-radius: 5px;
overflow: hidden;
margin-top: 10px;
}
.progress-bar .fill {
height: 100%;
background: linear-gradient(90deg, #4ade80, #60a5fa);
transition: width 0.1s;
}
.preview-container {
margin-top: 15px;
text-align: center;
}
.preview-container img {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
}
.tabs { display: flex; gap: 5px; margin-bottom: 15px; }
.tab {
padding: 8px 16px;
background: transparent;
border: 1px solid #333;
color: #aaa;
cursor: pointer;
border-radius: 6px;
}
.tab.active { background: #4ade80; color: #1a1a2e; border-color: #4ade80; }
</style>
</head>
<body>
<div class="container">
<h1>File API Demo</h1>
<p class="description">
The File API provides access to file information and content, enabling web applications
to read files selected by users through file inputs or drag-and-drop operations.
</p>
<div id="status" class="status success">
File API available: <span id="available">checking...</span>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Select Files</h2>
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
<p style="font-size: 1.2rem; margin-bottom: 10px;">Drop files here or click to select</p>
<p style="font-size: 0.9rem;">Supports multiple files of any type</p>
<input type="file" id="fileInput" multiple onchange="handleFiles(this.files)">
</div>
<div class="file-list" id="fileList"></div>
</div>
<div class="card">
<h2>File Information</h2>
<p style="color: #aaa; margin-bottom: 15px;">Select a file above to see its details</p>
<div class="output" id="fileInfo">No file selected yet...</div>
</div>
<div class="card">
<h2>Read File Content</h2>
<p style="color: #aaa; margin-bottom: 15px;">Choose how to read the selected file</p>
<div class="tabs">
<button class="tab active" onclick="setReadMode('text')">As Text</button>
<button class="tab" onclick="setReadMode('dataURL')">As Data URL</button>
<button class="tab" onclick="setReadMode('arrayBuffer')">As ArrayBuffer</button>
<button class="tab" onclick="setReadMode('binaryString')">As Binary String</button>
</div>
<button onclick="readSelectedFile()" id="readBtn" disabled>Read File</button>
<button class="secondary" onclick="abortRead()" id="abortBtn" disabled>Abort</button>
<div class="progress-bar">
<div class="fill" id="readProgress" style="width: 0%"></div>
</div>
<div class="output" id="fileContent">Select a file and click "Read File" to see content...</div>
<div class="preview-container" id="previewContainer"></div>
</div>
<div class="card">
<h2>File Slice Demo</h2>
<p style="color: #aaa; margin-bottom: 15px;">Read a portion of the selected file using slice()</p>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<div class="form-group" style="flex: 1;">
<label for="sliceStart">Start byte:</label>
<input type="number" id="sliceStart" value="0" min="0">
</div>
<div class="form-group" style="flex: 1;">
<label for="sliceEnd">End byte:</label>
<input type="number" id="sliceEnd" value="100">
</div>
</div>
<button onclick="readSlice()" id="sliceBtn" disabled>Read Slice</button>
<div class="output" id="sliceContent">Select a file and define a range to read a slice...</div>
</div>
<div class="card">
<h2>Create File from Text</h2>
<p style="color: #aaa; margin-bottom: 15px;">Create a new File object from text content</p>
<div class="form-group">
<label for="createContent">File content:</label>
<textarea id="createContent" style="width: 100%; min-height: 80px; padding: 10px; border: 1px solid #333; border-radius: 6px; background: #1a1a2e; color: #eee; resize: vertical;">Hello, this is a test file created using the File API!</textarea>
</div>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<div class="form-group" style="flex: 1;">
<label for="createName">File name:</label>
<input type="text" id="createName" value="test-file.txt">
</div>
<div class="form-group" style="flex: 1;">
<label for="createType">MIME type:</label>
<input type="text" id="createType" value="text/plain">
</div>
</div>
<button onclick="createFile()">Create File</button>
<button class="secondary" onclick="downloadCreatedFile()" id="downloadBtn" disabled>Download File</button>
<div class="output" id="createOutput">Click "Create File" to create a new File object...</div>
</div>
<div class="card">
<h2>Event Log</h2>
<button class="secondary" onclick="clearLog()">Clear Log</button>
<div class="event-log" id="eventLog">
<div class="event info">Ready to log file events...</div>
</div>
</div>
</div>
<script>
let selectedFile = null;
let currentReader = null;
let readMode = 'text';
let createdFile = null;
function checkSupport() {
const hasFile = typeof File !== 'undefined';
const hasFileReader = typeof FileReader !== 'undefined';
const hasFileList = typeof FileList !== 'undefined';
const available = hasFile && hasFileReader;
document.getElementById('available').textContent = available ? 'Yes' : 'No';
document.getElementById('status').className = available ? 'status success' : 'status error';
const features = document.getElementById('features');
const checks = [
{ name: 'File', supported: hasFile },
{ name: 'FileReader', supported: hasFileReader },
{ name: 'FileList', supported: hasFileList },
{ name: 'Blob', supported: typeof Blob !== 'undefined' },
{ name: 'FileReader.readAsText()', supported: hasFileReader && 'readAsText' in FileReader.prototype },
{ name: 'FileReader.readAsDataURL()', supported: hasFileReader && 'readAsDataURL' in FileReader.prototype },
{ name: 'FileReader.readAsArrayBuffer()', supported: hasFileReader && 'readAsArrayBuffer' in FileReader.prototype },
{ name: 'FileReader.readAsBinaryString()', supported: hasFileReader && 'readAsBinaryString' in FileReader.prototype },
{ name: 'Blob.slice()', supported: typeof Blob !== 'undefined' && 'slice' in Blob.prototype },
{ name: 'File.lastModified', supported: hasFile && 'lastModified' in File.prototype },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
}
function log(message, type = 'info') {
const logEl = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = `event ${type}`;
div.textContent = `[${time}] ${message}`;
logEl.insertBefore(div, logEl.firstChild);
}
function clearLog() {
document.getElementById('eventLog').innerHTML = '<div class="event info">Log cleared</div>';
}
function formatSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Drag and drop
const dropZone = document.getElementById('dropZone');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
function handleFiles(files) {
const fileList = document.getElementById('fileList');
fileList.innerHTML = '';
log(`${files.length} file(s) selected`, 'info');
for (let i = 0; i < files.length; i++) {
const file = files[i];
const div = document.createElement('div');
div.className = 'file-item';
div.innerHTML = `
<div class="file-info">
<div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-meta">${escapeHtml(formatSize(file.size))} - ${escapeHtml(file.type || 'unknown type')}</div>
</div>
<div class="file-actions">
<button onclick="selectFile(${i})" class="secondary">Select</button>
</div>
`;
fileList.appendChild(div);
log(`File: ${file.name} (${formatSize(file.size)})`, 'info');
}
// Auto-select first file
if (files.length > 0) {
window.loadedFiles = files;
selectFile(0);
}
}
function selectFile(index) {
selectedFile = window.loadedFiles[index];
updateFileInfo();
document.getElementById('readBtn').disabled = false;
document.getElementById('sliceBtn').disabled = false;
document.getElementById('sliceEnd').value = Math.min(100, selectedFile.size);
log(`Selected: ${selectedFile.name}`, 'success');
}
function updateFileInfo() {
if (!selectedFile) return;
const info = document.getElementById('fileInfo');
const lastModified = new Date(selectedFile.lastModified);
info.textContent = `File Properties:
--------------------
Name: ${selectedFile.name}
Size: ${formatSize(selectedFile.size)} (${selectedFile.size} bytes)
Type: ${selectedFile.type || 'Not specified'}
Last Modified: ${lastModified.toLocaleString()}
Additional Info:
--------------------
Is a Blob: ${selectedFile instanceof Blob}
Is a File: ${selectedFile instanceof File}
Constructor: ${selectedFile.constructor.name}`;
}
function setReadMode(mode) {
readMode = mode;
document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.tabs .tab[onclick="setReadMode('${mode}')"]`).classList.add('active');
document.getElementById('previewContainer').innerHTML = '';
}
function readSelectedFile() {
if (!selectedFile) return;
const content = document.getElementById('fileContent');
const progress = document.getElementById('readProgress');
const preview = document.getElementById('previewContainer');
const abortBtn = document.getElementById('abortBtn');
content.textContent = 'Reading file...';
progress.style.width = '0%';
preview.innerHTML = '';
abortBtn.disabled = false;
currentReader = new FileReader();
currentReader.onloadstart = () => {
log(`Started reading: ${selectedFile.name}`, 'info');
};
currentReader.onprogress = (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
progress.style.width = `${percent}%`;
log(`Progress: ${percent.toFixed(1)}% (${formatSize(e.loaded)} / ${formatSize(e.total)})`, 'progress');
}
};
currentReader.onload = (e) => {
progress.style.width = '100%';
abortBtn.disabled = true;
const result = e.target.result;
switch (readMode) {
case 'text':
content.textContent = result.length > 10000
? result.substring(0, 10000) + '\n\n... (truncated)'
: result;
break;
case 'dataURL':
content.textContent = `Data URL (${result.length} chars):\n\n${result.substring(0, 500)}${result.length > 500 ? '...' : ''}`;
if (selectedFile.type.startsWith('image/')) {
preview.innerHTML = `<img src="${result}" alt="Preview">`;
}
break;
case 'arrayBuffer':
const arr = new Uint8Array(result);
const hexView = Array.from(arr.slice(0, 256))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
content.textContent = `ArrayBuffer (${result.byteLength} bytes)
First 256 bytes (hex):
${hexView}
First 256 bytes as numbers:
${Array.from(arr.slice(0, 256)).join(', ')}`;
break;
case 'binaryString':
content.textContent = `Binary String (${result.length} chars)
First 500 chars (showing char codes):
${result.substring(0, 500).split('').map(c => c.charCodeAt(0)).join(', ')}`;
break;
}
log(`Read complete: ${selectedFile.name}`, 'success');
};
currentReader.onerror = (e) => {
content.textContent = `Error reading file: ${e.target.error.message}`;
log(`Read error: ${e.target.error.message}`, 'error');
abortBtn.disabled = true;
};
currentReader.onabort = () => {
content.textContent = 'File read was aborted.';
log('Read aborted', 'error');
abortBtn.disabled = true;
};
switch (readMode) {
case 'text':
currentReader.readAsText(selectedFile);
break;
case 'dataURL':
currentReader.readAsDataURL(selectedFile);
break;
case 'arrayBuffer':
currentReader.readAsArrayBuffer(selectedFile);
break;
case 'binaryString':
currentReader.readAsBinaryString(selectedFile);
break;
}
}
function abortRead() {
if (currentReader) {
currentReader.abort();
}
}
function readSlice() {
if (!selectedFile) return;
const start = parseInt(document.getElementById('sliceStart').value);
const end = parseInt(document.getElementById('sliceEnd').value);
const content = document.getElementById('sliceContent');
if (start >= end) {
content.textContent = 'Error: Start must be less than end';
return;
}
if (end > selectedFile.size) {
content.textContent = `Error: End (${end}) exceeds file size (${selectedFile.size})`;
return;
}
const slice = selectedFile.slice(start, end);
log(`Created slice: bytes ${start}-${end} (${slice.size} bytes)`, 'info');
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target.result;
const hexView = Array.from(new Uint8Array(result))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
content.textContent = `Slice Info:
--------------------
Original file: ${selectedFile.name}
Slice range: bytes ${start} to ${end}
Slice size: ${slice.size} bytes
Slice type: ${slice.type || 'inherited from original'}
Content (hex):
${hexView}
Content (text attempt):
${new TextDecoder().decode(result)}`;
log(`Read slice: ${slice.size} bytes`, 'success');
};
reader.onerror = () => {
content.textContent = `Error reading slice: ${reader.error.message}`;
log(`Slice read error: ${reader.error.message}`, 'error');
};
reader.readAsArrayBuffer(slice);
}
function createFile() {
const content = document.getElementById('createContent').value;
const name = document.getElementById('createName').value;
const type = document.getElementById('createType').value;
const output = document.getElementById('createOutput');
try {
createdFile = new File([content], name, {
type: type,
lastModified: Date.now()
});
output.textContent = `File created successfully!
Properties:
--------------------
Name: ${createdFile.name}
Size: ${formatSize(createdFile.size)} (${createdFile.size} bytes)
Type: ${createdFile.type}
Last Modified: ${new Date(createdFile.lastModified).toLocaleString()}
Content preview:
--------------------
${content.substring(0, 500)}${content.length > 500 ? '...' : ''}`;
document.getElementById('downloadBtn').disabled = false;
log(`Created file: ${name} (${formatSize(createdFile.size)})`, 'success');
} catch (e) {
output.textContent = `Error creating file: ${e.message}`;
log(`Create error: ${e.message}`, 'error');
}
}
function downloadCreatedFile() {
if (!createdFile) return;
const url = URL.createObjectURL(createdFile);
const a = document.createElement('a');
a.href = url;
a.download = createdFile.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
log(`Downloaded: ${createdFile.name}`, 'success');
}
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "File API Demo",
Description: "File reading and handling",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "File API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,286 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fullscreen API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.fullscreen-target {
background: linear-gradient(135deg, #0f3460 0%, #1a1a2e 100%);
border-radius: 12px;
padding: 40px;
text-align: center;
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
.fullscreen-target h3 {
font-size: 2rem;
color: #4ade80;
}
.fullscreen-target p {
color: #aaa;
}
.fullscreen-target:fullscreen {
background: linear-gradient(135deg, #1a1a2e 0%, #0a0a1a 100%);
}
.fullscreen-target:fullscreen h3 {
font-size: 4rem;
}
.video-container {
width: 100%;
max-width: 640px;
background: #000;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.video-container video {
width: 100%;
display: block;
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
display: flex;
gap: 10px;
}
.video-controls button {
padding: 5px 10px;
font-size: 0.85rem;
}
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
/* Fullscreen styles */
:fullscreen {
background: #0a0a1a;
}
::backdrop {
background: rgba(0, 0, 0, 0.9);
}
</style>
</head>
<body>
<div class="container">
<h1>Fullscreen API Demo</h1>
<p class="description">
The Fullscreen API allows elements to be displayed in full-screen mode,
hiding all browser UI for an immersive experience.
</p>
<div class="status success">
Fullscreen API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Element Fullscreen</h2>
<div class="fullscreen-target" id="fullscreenTarget">
<h3>Fullscreen Target</h3>
<p>Click the button below to make this element fullscreen</p>
<p id="fullscreenStatus">Press ESC or click Exit to leave fullscreen</p>
<button onclick="enterFullscreen('fullscreenTarget')">Enter Fullscreen</button>
<button class="danger" onclick="exitFullscreen()">Exit Fullscreen</button>
</div>
</div>
<div class="card">
<h2>Document Fullscreen</h2>
<button onclick="enterFullscreen('document')">Fullscreen Entire Page</button>
<button onclick="toggleFullscreen()">Toggle Fullscreen</button>
<button class="danger" onclick="exitFullscreen()">Exit Fullscreen</button>
</div>
<div class="card">
<h2>Video Fullscreen</h2>
<div class="video-container" id="videoContainer">
<video id="demoVideo" loop muted>
<source src="data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAA" type="video/mp4">
Your browser does not support the video tag.
</video>
<div class="video-controls">
<button onclick="document.getElementById('demoVideo').play()">Play</button>
<button onclick="document.getElementById('demoVideo').pause()">Pause</button>
<button onclick="enterFullscreen('videoContainer')">Fullscreen</button>
</div>
</div>
<p style="color: #666; margin-top: 10px; font-size: 0.85rem;">
Note: A sample video would play here. The fullscreen button demonstrates video fullscreen.
</p>
</div>
<div class="card">
<h2>Fullscreen State</h2>
<div class="output" id="stateOutput">Current state will be displayed here...</div>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
</div>
<script>
function checkSupport() {
const enabled = document.fullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.mozFullScreenEnabled ||
document.msFullscreenEnabled;
document.getElementById('supported').textContent = enabled ? 'Available' : 'Not available';
const features = document.getElementById('features');
const checks = [
{ name: 'document.fullscreenEnabled', supported: !!document.fullscreenEnabled },
{ name: 'element.requestFullscreen()', supported: !!document.documentElement.requestFullscreen },
{ name: 'document.exitFullscreen()', supported: !!document.exitFullscreen },
{ name: 'document.fullscreenElement', supported: 'fullscreenElement' in document },
{ name: ':fullscreen pseudo-class', supported: CSS.supports('selector(:fullscreen)') },
{ name: '::backdrop pseudo-element', supported: true }, // Can't easily test
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
updateState();
}
function enterFullscreen(target) {
let element;
if (target === 'document') {
element = document.documentElement;
} else {
element = document.getElementById(target);
}
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
}
function exitFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
function toggleFullscreen() {
if (document.fullscreenElement) {
exitFullscreen();
} else {
enterFullscreen('document');
}
}
function updateState() {
const fullscreenElement = document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement;
const output = document.getElementById('stateOutput');
output.textContent = `Fullscreen active: ${!!fullscreenElement}
Fullscreen element: ${fullscreenElement ? fullscreenElement.id || fullscreenElement.tagName : 'none'}
Document fullscreen enabled: ${document.fullscreenEnabled}
Keyboard shortcuts:
- F11: Browser fullscreen (not API)
- ESC: Exit fullscreen
- Click outside: May exit fullscreen`;
}
// Event listeners
document.addEventListener('fullscreenchange', () => {
updateState();
const status = document.getElementById('fullscreenStatus');
if (document.fullscreenElement) {
status.textContent = 'Now in fullscreen mode! Press ESC to exit.';
} else {
status.textContent = 'Press ESC or click Exit to leave fullscreen';
}
});
document.addEventListener('fullscreenerror', (e) => {
console.error('Fullscreen error:', e);
document.getElementById('stateOutput').textContent = 'Fullscreen request failed. This may happen if:\n- User gesture required\n- Element not in DOM\n- Permission denied';
});
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Fullscreen Demo",
Description: "Demonstrates the Fullscreen API",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Fullscreen Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,754 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gamepad API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 1000px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
h3 { font-size: 1rem; margin-bottom: 10px; color: #60a5fa; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.status.warning { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.controller-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.controller-slot {
background: rgba(0,0,0,0.2);
border: 2px dashed #333;
border-radius: 8px;
padding: 20px;
text-align: center;
min-height: 100px;
display: flex;
flex-direction: column;
justify-content: center;
}
.controller-slot.connected {
border-style: solid;
border-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
}
.controller-slot .index { font-size: 2rem; color: #333; }
.controller-slot.connected .index { color: #4ade80; }
.controller-slot .name { font-size: 0.85rem; color: #aaa; margin-top: 5px; }
.gamepad-visual {
position: relative;
width: 100%;
max-width: 500px;
margin: 0 auto;
aspect-ratio: 16/9;
background: #0a0a1a;
border-radius: 12px;
padding: 20px;
}
.stick-container {
display: flex;
justify-content: space-around;
margin-bottom: 30px;
}
.stick {
width: 100px;
height: 100px;
background: rgba(255,255,255,0.05);
border: 2px solid #333;
border-radius: 50%;
position: relative;
}
.stick-label {
text-align: center;
color: #666;
font-size: 0.8rem;
margin-top: 5px;
}
.stick-dot {
width: 30px;
height: 30px;
background: #4ade80;
border-radius: 50%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: all 0.05s ease;
}
.stick-values {
font-family: monospace;
font-size: 0.75rem;
color: #666;
text-align: center;
margin-top: 5px;
}
.buttons-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
margin-top: 20px;
}
.gamepad-btn {
aspect-ratio: 1;
background: #222;
border: 2px solid #333;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #666;
transition: all 0.1s;
}
.gamepad-btn.pressed {
background: #4ade80;
border-color: #4ade80;
color: #1a1a2e;
transform: scale(0.95);
}
.gamepad-btn.analog {
background: linear-gradient(to top, #4ade80 var(--pressure, 0%), #222 var(--pressure, 0%));
}
.triggers-container {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.trigger {
width: 80px;
height: 30px;
background: #222;
border: 2px solid #333;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.trigger-fill {
height: 100%;
background: #4ade80;
width: 0%;
transition: width 0.05s ease;
}
.trigger-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.7rem;
color: #666;
z-index: 1;
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 30px);
grid-template-rows: repeat(3, 30px);
gap: 2px;
justify-content: center;
}
.dpad-btn {
background: #222;
border: 1px solid #333;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
color: #666;
}
.dpad-btn.pressed { background: #4ade80; color: #1a1a2e; }
.dpad-center { background: transparent; border: none; }
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin-top: 15px;
}
.info-item {
background: rgba(0,0,0,0.2);
padding: 10px 15px;
border-radius: 6px;
display: flex;
justify-content: space-between;
}
.info-item .label { color: #aaa; }
.info-item .value { color: #4ade80; font-family: monospace; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
max-height: 200px;
overflow-y: auto;
}
.log-entry {
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.log-entry .time { color: #666; margin-right: 10px; }
.log-entry.event { color: #60a5fa; }
.log-entry.data { color: #4ade80; }
.log-entry.error { color: #f87171; }
.log-entry.button { color: #fbbf24; }
.axes-visual {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
}
.axis-bar {
flex: 1;
min-width: 100px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
overflow: hidden;
position: relative;
height: 24px;
}
.axis-bar .fill {
position: absolute;
top: 0;
height: 100%;
background: #4ade80;
transition: all 0.05s ease;
}
.axis-bar .center-line {
position: absolute;
top: 0;
left: 50%;
width: 2px;
height: 100%;
background: #666;
}
.axis-bar .label {
position: absolute;
top: 50%;
left: 5px;
transform: translateY(-50%);
font-size: 0.7rem;
color: #fff;
z-index: 1;
}
.no-gamepad {
text-align: center;
padding: 40px;
color: #666;
}
.no-gamepad .icon {
font-size: 3rem;
margin-bottom: 15px;
}
.rumble-controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
}
.rumble-slider {
display: flex;
align-items: center;
gap: 8px;
}
.rumble-slider label { color: #aaa; font-size: 0.9rem; }
input[type="range"] {
width: 100px;
}
</style>
</head>
<body>
<div class="container">
<h1>Gamepad API Demo</h1>
<p class="description">
The Gamepad API provides access to game controllers. Connect a gamepad and press any button to activate it.
Supports standard controllers, analog sticks, triggers, and rumble feedback.
</p>
<div id="status" class="status">
Gamepad API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Connected Controllers</h2>
<div class="controller-list" id="controllerList">
<div class="controller-slot" id="slot0">
<div class="index">1</div>
<div class="name">No controller</div>
</div>
<div class="controller-slot" id="slot1">
<div class="index">2</div>
<div class="name">No controller</div>
</div>
<div class="controller-slot" id="slot2">
<div class="index">3</div>
<div class="name">No controller</div>
</div>
<div class="controller-slot" id="slot3">
<div class="index">4</div>
<div class="name">No controller</div>
</div>
</div>
</div>
<div class="card" id="gamepadDisplay" style="display: none;">
<h2>Active Gamepad: <span id="activeGamepadName">-</span></h2>
<div class="gamepad-visual">
<div class="triggers-container">
<div>
<div class="trigger" id="triggerL2">
<div class="trigger-fill" id="triggerL2Fill"></div>
<span class="trigger-label">L2</span>
</div>
</div>
<div>
<div class="trigger" id="triggerR2">
<div class="trigger-fill" id="triggerR2Fill"></div>
<span class="trigger-label">R2</span>
</div>
</div>
</div>
<div class="stick-container">
<div>
<div class="stick" id="leftStick">
<div class="stick-dot" id="leftStickDot"></div>
</div>
<div class="stick-label">Left Stick</div>
<div class="stick-values" id="leftStickValues">X: 0.00, Y: 0.00</div>
</div>
<div class="dpad" id="dpad">
<div class="dpad-btn"></div>
<div class="dpad-btn" id="dpadUp">^</div>
<div class="dpad-btn"></div>
<div class="dpad-btn" id="dpadLeft">&lt;</div>
<div class="dpad-btn dpad-center"></div>
<div class="dpad-btn" id="dpadRight">&gt;</div>
<div class="dpad-btn"></div>
<div class="dpad-btn" id="dpadDown">v</div>
<div class="dpad-btn"></div>
</div>
<div>
<div class="stick" id="rightStick">
<div class="stick-dot" id="rightStickDot"></div>
</div>
<div class="stick-label">Right Stick</div>
<div class="stick-values" id="rightStickValues">X: 0.00, Y: 0.00</div>
</div>
</div>
<div class="buttons-grid" id="buttonsGrid">
<div class="gamepad-btn" id="btn0">A</div>
<div class="gamepad-btn" id="btn1">B</div>
<div class="gamepad-btn" id="btn2">X</div>
<div class="gamepad-btn" id="btn3">Y</div>
<div class="gamepad-btn" id="btn4">L1</div>
<div class="gamepad-btn" id="btn5">R1</div>
<div class="gamepad-btn" id="btn6">L2</div>
<div class="gamepad-btn" id="btn7">R2</div>
<div class="gamepad-btn" id="btn8">Sel</div>
<div class="gamepad-btn" id="btn9">Sta</div>
<div class="gamepad-btn" id="btn10">L3</div>
<div class="gamepad-btn" id="btn11">R3</div>
<div class="gamepad-btn" id="btn12">Up</div>
<div class="gamepad-btn" id="btn13">Dn</div>
<div class="gamepad-btn" id="btn14">Lt</div>
<div class="gamepad-btn" id="btn15">Rt</div>
</div>
</div>
</div>
<div class="card" id="rumbleCard" style="display: none;">
<h2>Rumble / Haptic Feedback</h2>
<div class="rumble-controls">
<div class="rumble-slider">
<label>Weak Motor:</label>
<input type="range" id="weakMotor" min="0" max="1" step="0.1" value="0.5">
<span id="weakValue">0.5</span>
</div>
<div class="rumble-slider">
<label>Strong Motor:</label>
<input type="range" id="strongMotor" min="0" max="1" step="0.1" value="0.5">
<span id="strongValue">0.5</span>
</div>
<div class="rumble-slider">
<label>Duration:</label>
<input type="range" id="rumbleDuration" min="100" max="2000" step="100" value="500">
<span id="durationValue">500ms</span>
</div>
<button onclick="testRumble()">Test Rumble</button>
<button onclick="pulseRumble()">Pulse</button>
</div>
</div>
<div class="card">
<h2>Axes Values</h2>
<div class="axes-visual" id="axesVisual">
<div class="no-gamepad">Connect a gamepad to see axis values</div>
</div>
</div>
<div class="card">
<h2>Gamepad Info</h2>
<div class="info-grid" id="infoGrid">
<div class="info-item">
<span class="label">ID</span>
<span class="value" id="infoId">-</span>
</div>
<div class="info-item">
<span class="label">Mapping</span>
<span class="value" id="infoMapping">-</span>
</div>
<div class="info-item">
<span class="label">Buttons</span>
<span class="value" id="infoButtons">-</span>
</div>
<div class="info-item">
<span class="label">Axes</span>
<span class="value" id="infoAxes">-</span>
</div>
<div class="info-item">
<span class="label">Timestamp</span>
<span class="value" id="infoTimestamp">-</span>
</div>
<div class="info-item">
<span class="label">Vibration</span>
<span class="value" id="infoVibration">-</span>
</div>
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">
<div class="log-entry">Waiting for gamepad events... Press a button on your controller.</div>
</div>
</div>
</div>
<script>
let activeGamepadIndex = null;
let animationFrame = null;
let previousButtonStates = {};
function checkSupport() {
const supported = 'getGamepads' in navigator;
document.getElementById('supported').textContent = supported ? 'Supported' : 'Not supported';
document.getElementById('status').className = supported ? 'status success' : 'status error';
return supported;
}
function log(message, type = 'event') {
const logEl = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.innerHTML = `<span class="time">${time}</span>${message}`;
logEl.insertBefore(entry, logEl.firstChild);
while (logEl.children.length > 100) {
logEl.removeChild(logEl.lastChild);
}
}
function clearLog() {
document.getElementById('eventLog').innerHTML = '<div class="log-entry">Log cleared.</div>';
}
function updateControllerSlots() {
const gamepads = navigator.getGamepads();
for (let i = 0; i < 4; i++) {
const slot = document.getElementById(`slot${i}`);
const gamepad = gamepads[i];
if (gamepad) {
slot.classList.add('connected');
slot.querySelector('.name').textContent = gamepad.id.substring(0, 30) + (gamepad.id.length > 30 ? '...' : '');
} else {
slot.classList.remove('connected');
slot.querySelector('.name').textContent = 'No controller';
}
}
}
function handleGamepadConnected(event) {
log(`Gamepad connected: ${event.gamepad.id} (index: ${event.gamepad.index})`, 'data');
updateControllerSlots();
if (activeGamepadIndex === null) {
setActiveGamepad(event.gamepad.index);
}
}
function handleGamepadDisconnected(event) {
log(`Gamepad disconnected: ${event.gamepad.id} (index: ${event.gamepad.index})`, 'error');
updateControllerSlots();
if (activeGamepadIndex === event.gamepad.index) {
activeGamepadIndex = null;
document.getElementById('gamepadDisplay').style.display = 'none';
document.getElementById('rumbleCard').style.display = 'none';
// Find another connected gamepad
const gamepads = navigator.getGamepads();
for (let i = 0; i < gamepads.length; i++) {
if (gamepads[i]) {
setActiveGamepad(i);
break;
}
}
}
}
function setActiveGamepad(index) {
activeGamepadIndex = index;
const gamepad = navigator.getGamepads()[index];
if (!gamepad) return;
document.getElementById('gamepadDisplay').style.display = 'block';
document.getElementById('activeGamepadName').textContent = gamepad.id;
// Update info
document.getElementById('infoId').textContent = gamepad.id.substring(0, 40);
document.getElementById('infoMapping').textContent = gamepad.mapping || 'non-standard';
document.getElementById('infoButtons').textContent = gamepad.buttons.length;
document.getElementById('infoAxes').textContent = gamepad.axes.length;
// Check vibration support
const hasVibration = gamepad.vibrationActuator !== undefined;
document.getElementById('infoVibration').textContent = hasVibration ? 'Supported' : 'Not supported';
document.getElementById('rumbleCard').style.display = hasVibration ? 'block' : 'none';
// Create axes visual
const axesVisual = document.getElementById('axesVisual');
axesVisual.innerHTML = '';
for (let i = 0; i < gamepad.axes.length; i++) {
const bar = document.createElement('div');
bar.className = 'axis-bar';
bar.id = `axis${i}`;
bar.innerHTML = `
<div class="center-line"></div>
<div class="fill" id="axisFill${i}"></div>
<span class="label">Axis ${i}</span>
`;
axesVisual.appendChild(bar);
}
log(`Active gamepad set to: ${gamepad.id}`, 'event');
}
function updateGamepadState() {
if (activeGamepadIndex === null) {
animationFrame = requestAnimationFrame(updateGamepadState);
return;
}
const gamepad = navigator.getGamepads()[activeGamepadIndex];
if (!gamepad) {
animationFrame = requestAnimationFrame(updateGamepadState);
return;
}
// Update timestamp
document.getElementById('infoTimestamp').textContent = gamepad.timestamp.toFixed(0);
// Update buttons
gamepad.buttons.forEach((button, i) => {
const btnEl = document.getElementById(`btn${i}`);
if (btnEl) {
if (button.pressed) {
btnEl.classList.add('pressed');
} else {
btnEl.classList.remove('pressed');
}
// For analog buttons
if (button.value > 0 && button.value < 1) {
btnEl.classList.add('analog');
btnEl.style.setProperty('--pressure', (button.value * 100) + '%');
} else {
btnEl.classList.remove('analog');
}
// Log button press/release
const prevState = previousButtonStates[i];
if (button.pressed && !prevState) {
log(`Button ${i} pressed (value: ${button.value.toFixed(2)})`, 'button');
} else if (!button.pressed && prevState) {
log(`Button ${i} released`, 'button');
}
previousButtonStates[i] = button.pressed;
}
});
// Update D-pad (buttons 12-15 on standard mapping)
const dpadMap = { 12: 'dpadUp', 13: 'dpadDown', 14: 'dpadLeft', 15: 'dpadRight' };
Object.entries(dpadMap).forEach(([btnIndex, dpadId]) => {
const btn = gamepad.buttons[btnIndex];
const dpadEl = document.getElementById(dpadId);
if (btn && dpadEl) {
if (btn.pressed) {
dpadEl.classList.add('pressed');
} else {
dpadEl.classList.remove('pressed');
}
}
});
// Update triggers (L2: button 6, R2: button 7)
if (gamepad.buttons[6]) {
document.getElementById('triggerL2Fill').style.width = (gamepad.buttons[6].value * 100) + '%';
}
if (gamepad.buttons[7]) {
document.getElementById('triggerR2Fill').style.width = (gamepad.buttons[7].value * 100) + '%';
}
// Update sticks
if (gamepad.axes.length >= 2) {
const leftX = gamepad.axes[0];
const leftY = gamepad.axes[1];
const leftDot = document.getElementById('leftStickDot');
leftDot.style.left = (50 + leftX * 35) + '%';
leftDot.style.top = (50 + leftY * 35) + '%';
document.getElementById('leftStickValues').textContent =
`X: ${leftX.toFixed(2)}, Y: ${leftY.toFixed(2)}`;
}
if (gamepad.axes.length >= 4) {
const rightX = gamepad.axes[2];
const rightY = gamepad.axes[3];
const rightDot = document.getElementById('rightStickDot');
rightDot.style.left = (50 + rightX * 35) + '%';
rightDot.style.top = (50 + rightY * 35) + '%';
document.getElementById('rightStickValues').textContent =
`X: ${rightX.toFixed(2)}, Y: ${rightY.toFixed(2)}`;
}
// Update axes visual
gamepad.axes.forEach((value, i) => {
const fill = document.getElementById(`axisFill${i}`);
if (fill) {
if (value >= 0) {
fill.style.left = '50%';
fill.style.width = (value * 50) + '%';
} else {
fill.style.left = (50 + value * 50) + '%';
fill.style.width = (-value * 50) + '%';
}
}
});
animationFrame = requestAnimationFrame(updateGamepadState);
}
async function testRumble() {
if (activeGamepadIndex === null) {
log('No active gamepad', 'error');
return;
}
const gamepad = navigator.getGamepads()[activeGamepadIndex];
if (!gamepad || !gamepad.vibrationActuator) {
log('Gamepad does not support vibration', 'error');
return;
}
const weak = parseFloat(document.getElementById('weakMotor').value);
const strong = parseFloat(document.getElementById('strongMotor').value);
const duration = parseInt(document.getElementById('rumbleDuration').value);
try {
await gamepad.vibrationActuator.playEffect('dual-rumble', {
startDelay: 0,
duration: duration,
weakMagnitude: weak,
strongMagnitude: strong
});
log(`Rumble: weak=${weak}, strong=${strong}, duration=${duration}ms`, 'data');
} catch (err) {
log(`Rumble error: ${err.message}`, 'error');
}
}
async function pulseRumble() {
if (activeGamepadIndex === null) return;
const gamepad = navigator.getGamepads()[activeGamepadIndex];
if (!gamepad || !gamepad.vibrationActuator) return;
for (let i = 0; i < 3; i++) {
await gamepad.vibrationActuator.playEffect('dual-rumble', {
startDelay: 0,
duration: 100,
weakMagnitude: 0.5,
strongMagnitude: 0.5
});
await new Promise(r => setTimeout(r, 150));
}
log('Pulse rumble played', 'data');
}
// Setup slider value displays
document.getElementById('weakMotor').addEventListener('input', (e) => {
document.getElementById('weakValue').textContent = e.target.value;
});
document.getElementById('strongMotor').addEventListener('input', (e) => {
document.getElementById('strongValue').textContent = e.target.value;
});
document.getElementById('rumbleDuration').addEventListener('input', (e) => {
document.getElementById('durationValue').textContent = e.target.value + 'ms';
});
// Initialize
if (checkSupport()) {
window.addEventListener('gamepadconnected', handleGamepadConnected);
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected);
// Check for already connected gamepads
const gamepads = navigator.getGamepads();
for (let i = 0; i < gamepads.length; i++) {
if (gamepads[i]) {
updateControllerSlots();
setActiveGamepad(i);
break;
}
}
// Start polling loop
updateGamepadState();
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Gamepad API Demo",
Description: "Game controller input",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Gamepad API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geolocation API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.status.warning { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.location-display {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.location-item {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.location-item .label { color: #aaa; font-size: 0.85rem; margin-bottom: 5px; }
.location-item .value { font-size: 1.5rem; color: #4ade80; font-family: monospace; }
.location-item .unit { font-size: 0.85rem; color: #666; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.map-placeholder {
width: 100%;
height: 200px;
background: #0a0a1a;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
margin-top: 15px;
}
.history { list-style: none; }
.history li {
padding: 10px;
background: rgba(0,0,0,0.2);
margin-bottom: 5px;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
}
.history .time { color: #666; }
</style>
</head>
<body>
<div class="container">
<h1>Geolocation API Demo</h1>
<p class="description">
The Geolocation API allows web applications to access the user's geographical location.
It requires user permission and works best with GPS-enabled devices.
</p>
<div id="status" class="status">
Geolocation: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Current Position</h2>
<button onclick="getPosition()">Get Current Position</button>
<button id="watchBtn" onclick="toggleWatch()">Start Watching</button>
<button class="danger" onclick="clearHistory()">Clear History</button>
<div class="location-display" id="locationDisplay" style="margin-top: 20px; display: none;">
<div class="location-item">
<div class="label">Latitude</div>
<div class="value" id="latitude">-</div>
</div>
<div class="location-item">
<div class="label">Longitude</div>
<div class="value" id="longitude">-</div>
</div>
<div class="location-item">
<div class="label">Accuracy</div>
<div class="value" id="accuracy">-</div>
<div class="unit">meters</div>
</div>
<div class="location-item">
<div class="label">Altitude</div>
<div class="value" id="altitude">-</div>
<div class="unit">meters</div>
</div>
<div class="location-item">
<div class="label">Speed</div>
<div class="value" id="speed">-</div>
<div class="unit">m/s</div>
</div>
<div class="location-item">
<div class="label">Heading</div>
<div class="value" id="heading">-</div>
<div class="unit">degrees</div>
</div>
</div>
<div class="map-placeholder" id="mapPlaceholder">
Get location to see coordinates
</div>
</div>
<div class="card">
<h2>Position Options</h2>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="highAccuracy"> High Accuracy (GPS)
</label>
<label style="display: flex; align-items: center; gap: 8px;">
Timeout: <input type="number" id="timeout" value="10000" style="width: 80px; padding: 5px; background: #1a1a2e; border: 1px solid #333; color: #eee; border-radius: 4px;"> ms
</label>
<label style="display: flex; align-items: center; gap: 8px;">
Max Age: <input type="number" id="maxAge" value="0" style="width: 80px; padding: 5px; background: #1a1a2e; border: 1px solid #333; color: #eee; border-radius: 4px;"> ms
</label>
</div>
</div>
<div class="card">
<h2>Position History</h2>
<ul class="history" id="history">
<li style="color: #666;">No positions recorded yet...</li>
</ul>
</div>
<div class="card">
<h2>Raw Position Data</h2>
<div class="output" id="rawData">Position data will appear here...</div>
</div>
</div>
<script>
let watchId = null;
let history = [];
function checkSupport() {
const supported = 'geolocation' in navigator;
document.getElementById('supported').textContent = supported ? 'Available' : 'Not available';
document.getElementById('status').className = supported ? 'status success' : 'status error';
return supported;
}
function getOptions() {
return {
enableHighAccuracy: document.getElementById('highAccuracy').checked,
timeout: parseInt(document.getElementById('timeout').value) || 10000,
maximumAge: parseInt(document.getElementById('maxAge').value) || 0
};
}
function getPosition() {
updateStatus('Requesting position...', 'warning');
navigator.geolocation.getCurrentPosition(
(position) => {
updateStatus('Position acquired', 'success');
displayPosition(position);
addToHistory(position);
},
(error) => {
handleError(error);
},
getOptions()
);
}
function toggleWatch() {
const btn = document.getElementById('watchBtn');
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
watchId = null;
btn.textContent = 'Start Watching';
btn.classList.remove('danger');
updateStatus('Watch stopped', 'success');
} else {
updateStatus('Watching position...', 'warning');
btn.textContent = 'Stop Watching';
btn.classList.add('danger');
watchId = navigator.geolocation.watchPosition(
(position) => {
updateStatus('Position updated', 'success');
displayPosition(position);
addToHistory(position);
},
(error) => {
handleError(error);
},
getOptions()
);
}
}
function displayPosition(position) {
document.getElementById('locationDisplay').style.display = 'grid';
const coords = position.coords;
document.getElementById('latitude').textContent = coords.latitude.toFixed(6);
document.getElementById('longitude').textContent = coords.longitude.toFixed(6);
document.getElementById('accuracy').textContent = coords.accuracy ? coords.accuracy.toFixed(0) : '-';
document.getElementById('altitude').textContent = coords.altitude ? coords.altitude.toFixed(1) : '-';
document.getElementById('speed').textContent = coords.speed ? coords.speed.toFixed(2) : '-';
document.getElementById('heading').textContent = coords.heading ? coords.heading.toFixed(0) : '-';
// Update map placeholder
const mapUrl = `https://www.openstreetmap.org/?mlat=${coords.latitude}&mlon=${coords.longitude}#map=15/${coords.latitude}/${coords.longitude}`;
document.getElementById('mapPlaceholder').innerHTML = `
<a href="${mapUrl}" target="_blank" style="color: #4ade80;">
View on OpenStreetMap: ${coords.latitude.toFixed(4)}, ${coords.longitude.toFixed(4)}
</a>
`;
// Raw data
document.getElementById('rawData').textContent = JSON.stringify({
coords: {
latitude: coords.latitude,
longitude: coords.longitude,
accuracy: coords.accuracy,
altitude: coords.altitude,
altitudeAccuracy: coords.altitudeAccuracy,
heading: coords.heading,
speed: coords.speed
},
timestamp: position.timestamp,
timestampISO: new Date(position.timestamp).toISOString()
}, null, 2);
}
function addToHistory(position) {
const time = new Date(position.timestamp).toLocaleTimeString();
const coords = position.coords;
history.unshift({
time,
lat: coords.latitude.toFixed(6),
lng: coords.longitude.toFixed(6),
accuracy: coords.accuracy ? coords.accuracy.toFixed(0) : '-'
});
if (history.length > 10) history.pop();
const historyEl = document.getElementById('history');
historyEl.innerHTML = history.map(h => `
<li>
<span class="time">${h.time}</span> -
Lat: ${h.lat}, Lng: ${h.lng} (±${h.accuracy}m)
</li>
`).join('');
}
function clearHistory() {
history = [];
document.getElementById('history').innerHTML = '<li style="color: #666;">No positions recorded yet...</li>';
}
function handleError(error) {
const messages = {
1: 'Permission denied - User declined location access',
2: 'Position unavailable - Location information unavailable',
3: 'Timeout - Request took too long'
};
updateStatus(messages[error.code] || error.message, 'error');
document.getElementById('rawData').textContent = `Error ${error.code}: ${messages[error.code] || error.message}`;
}
function updateStatus(message, type) {
const status = document.getElementById('status');
status.innerHTML = `Geolocation: ${message}`;
status.className = `status ${type}`;
}
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Geolocation Demo",
Description: "Demonstrates the Geolocation API",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Geolocation Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,736 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>History API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input, select {
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
margin-bottom: 10px;
}
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
/* History API specific styles */
.url-display {
background: rgba(0,0,0,0.4);
border-radius: 8px;
padding: 15px 20px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
overflow-x: auto;
}
.url-label {
color: #aaa;
font-size: 0.9rem;
white-space: nowrap;
}
.url-value {
font-family: monospace;
color: #4ade80;
word-break: break-all;
}
.nav-buttons {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
align-items: center;
}
.nav-buttons button {
display: flex;
align-items: center;
gap: 8px;
}
.nav-arrow {
font-size: 1.2rem;
}
.state-display {
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.state-label {
color: #aaa;
font-size: 0.85rem;
margin-bottom: 8px;
}
.state-content {
font-family: monospace;
color: #60a5fa;
white-space: pre-wrap;
word-break: break-all;
}
.form-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 15px;
}
.form-row input {
flex: 1;
min-width: 150px;
margin-bottom: 0;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
color: #aaa;
font-size: 0.9rem;
margin-bottom: 5px;
}
.form-group input {
width: 100%;
margin-bottom: 0;
}
.history-stack {
max-height: 250px;
overflow-y: auto;
}
.history-item {
background: rgba(0,0,0,0.2);
border-radius: 6px;
padding: 12px 15px;
margin-bottom: 8px;
border-left: 3px solid #333;
transition: all 0.2s;
}
.history-item.current {
border-left-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
}
.history-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.history-item-index {
background: #333;
color: #aaa;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
.history-item-index.current {
background: #4ade80;
color: #1a1a2e;
}
.history-item-time {
color: #666;
font-size: 0.8rem;
}
.history-item-url {
font-family: monospace;
color: #eee;
font-size: 0.9rem;
margin-bottom: 5px;
word-break: break-all;
}
.history-item-state {
font-family: monospace;
color: #60a5fa;
font-size: 0.8rem;
}
.quick-pages {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 15px;
}
.quick-page {
background: rgba(96, 165, 250, 0.2);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.3);
padding: 8px 15px;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.quick-page:hover {
background: rgba(96, 165, 250, 0.3);
}
.quick-page.active {
background: #4ade80;
color: #1a1a2e;
border-color: #4ade80;
}
.page-content {
min-height: 150px;
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
transition: all 0.3s;
}
.page-icon {
font-size: 48px;
margin-bottom: 15px;
}
.page-title {
font-size: 1.5rem;
color: #4ade80;
margin-bottom: 10px;
}
.page-desc {
color: #aaa;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: rgba(0,0,0,0.3);
padding: 15px;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 1.8rem;
font-weight: bold;
color: #4ade80;
}
.stat-label {
color: #aaa;
font-size: 0.8rem;
margin-top: 5px;
}
.info-box {
background: rgba(96, 165, 250, 0.1);
border: 1px solid rgba(96, 165, 250, 0.3);
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.info-box-title {
color: #60a5fa;
font-weight: 600;
margin-bottom: 8px;
}
.info-box-content {
color: #aaa;
font-size: 0.9rem;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<h1>History API Demo</h1>
<p class="description">
The History API allows manipulation of the browser session history, enabling
single-page applications (SPAs) to update the URL without full page reloads.
</p>
<div id="status" class="status success">
History API: <span id="available">checking...</span>
</div>
<div class="card">
<h2>Current State</h2>
<div class="url-display">
<span class="url-label">URL:</span>
<span class="url-value" id="currentUrl">--</span>
</div>
<div class="state-display">
<div class="state-label">history.state:</div>
<div class="state-content" id="currentState">null</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="historyLength">0</div>
<div class="stat-label">History Length</div>
</div>
<div class="stat-card">
<div class="stat-value" id="pushCount">0</div>
<div class="stat-label">pushState Calls</div>
</div>
<div class="stat-card">
<div class="stat-value" id="replaceCount">0</div>
<div class="stat-label">replaceState Calls</div>
</div>
<div class="stat-card">
<div class="stat-value" id="popstateCount">0</div>
<div class="stat-label">popstate Events</div>
</div>
</div>
</div>
<div class="card">
<h2>Navigation Controls</h2>
<div class="nav-buttons">
<button onclick="goBack()">
<span class="nav-arrow">&larr;</span> Back
</button>
<button onclick="goForward()">
Forward <span class="nav-arrow">&rarr;</span>
</button>
<button class="secondary" onclick="go(-2)">Go -2</button>
<button class="secondary" onclick="go(2)">Go +2</button>
</div>
<div class="info-box">
<div class="info-box-title">Note on Navigation</div>
<div class="info-box-content">
The back/forward buttons use <code>history.back()</code> and <code>history.forward()</code>.
You can also use <code>history.go(n)</code> to jump multiple entries.
Navigation will trigger the <code>popstate</code> event.
</div>
</div>
</div>
<div class="card">
<h2>Simulated SPA Navigation</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Click pages below to navigate. The URL updates without a page reload.
</p>
<div class="quick-pages" id="quickPages">
<span class="quick-page active" data-page="home" onclick="navigateToPage('home')">Home</span>
<span class="quick-page" data-page="products" onclick="navigateToPage('products')">Products</span>
<span class="quick-page" data-page="about" onclick="navigateToPage('about')">About</span>
<span class="quick-page" data-page="contact" onclick="navigateToPage('contact')">Contact</span>
<span class="quick-page" data-page="settings" onclick="navigateToPage('settings')">Settings</span>
</div>
<div class="page-content" id="pageContent">
<div class="page-icon" id="pageIcon">&#127968;</div>
<div class="page-title" id="pageTitle">Home</div>
<div class="page-desc" id="pageDesc">Welcome to the home page</div>
</div>
</div>
<div class="card">
<h2>pushState</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Add a new entry to the browser history. This does not reload the page.
</p>
<div class="form-group">
<label>URL Path</label>
<input type="text" id="pushUrl" placeholder="/page/new-page">
</div>
<div class="form-group">
<label>State Data (JSON)</label>
<input type="text" id="pushState" placeholder='{"id": 1, "name": "example"}'>
</div>
<div class="form-group">
<label>Title (mostly ignored by browsers)</label>
<input type="text" id="pushTitle" placeholder="Page Title">
</div>
<button onclick="doPushState()">pushState</button>
</div>
<div class="card">
<h2>replaceState</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Replace the current history entry. Does not create a new entry.
</p>
<div class="form-group">
<label>URL Path</label>
<input type="text" id="replaceUrl" placeholder="/page/replaced">
</div>
<div class="form-group">
<label>State Data (JSON)</label>
<input type="text" id="replaceState" placeholder='{"replaced": true}'>
</div>
<button onclick="doReplaceState()">replaceState</button>
</div>
<div class="card">
<h2>History Stack (Local Tracking)</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Note: The History API does not provide access to the full history stack for privacy reasons.
This list tracks only entries created during this session.
</p>
<button onclick="clearLocalHistory()">Clear Local Tracking</button>
<div class="history-stack" id="historyStack" style="margin-top: 15px;">
<div class="history-item current">
<div class="history-item-header">
<span class="history-item-index current">0</span>
<span class="history-item-time">--</span>
</div>
<div class="history-item-url">Loading...</div>
<div class="history-item-state">state: null</div>
</div>
</div>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog" style="margin-top: 10px;">History events will be logged here...
Use the controls above to manipulate history and see events logged.</div>
</div>
</div>
<script>
let logEntries = [];
let localHistory = [];
let currentLocalIndex = 0;
let stats = {
pushCount: 0,
replaceCount: 0,
popstateCount: 0
};
// Page definitions for SPA simulation
const pages = {
home: { icon: '&#127968;', title: 'Home', desc: 'Welcome to the home page' },
products: { icon: '&#128722;', title: 'Products', desc: 'Browse our product catalog' },
about: { icon: '&#128100;', title: 'About', desc: 'Learn more about us' },
contact: { icon: '&#128231;', title: 'Contact', desc: 'Get in touch with our team' },
settings: { icon: '&#9881;', title: 'Settings', desc: 'Configure your preferences' }
};
function checkSupport() {
const hasHistory = !!(window.history && window.history.pushState);
document.getElementById('available').textContent = hasHistory ? 'Available' : 'Not available';
document.getElementById('status').className = hasHistory ? 'status success' : 'status error';
return hasHistory;
}
function checkFeatures() {
const features = document.getElementById('features');
const checks = [
{ name: 'history.pushState', supported: typeof history.pushState === 'function' },
{ name: 'history.replaceState', supported: typeof history.replaceState === 'function' },
{ name: 'history.state', supported: 'state' in history },
{ name: 'history.length', supported: 'length' in history },
{ name: 'history.go', supported: typeof history.go === 'function' },
{ name: 'popstate event', supported: 'onpopstate' in window },
{ name: 'history.scrollRestoration', supported: 'scrollRestoration' in history },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
}
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 50) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Log cleared.';
}
function updateDisplay() {
document.getElementById('currentUrl').textContent = window.location.href;
document.getElementById('currentState').textContent =
history.state ? JSON.stringify(history.state, null, 2) : 'null';
document.getElementById('historyLength').textContent = history.length;
document.getElementById('pushCount').textContent = stats.pushCount;
document.getElementById('replaceCount').textContent = stats.replaceCount;
document.getElementById('popstateCount').textContent = stats.popstateCount;
// Update page content based on state
if (history.state && history.state.page) {
updatePageContent(history.state.page);
}
}
function updatePageContent(pageName) {
const page = pages[pageName] || pages.home;
document.getElementById('pageIcon').innerHTML = page.icon;
document.getElementById('pageTitle').textContent = page.title;
document.getElementById('pageDesc').textContent = page.desc;
// Update active state of quick pages
document.querySelectorAll('.quick-page').forEach(el => {
el.classList.toggle('active', el.dataset.page === pageName);
});
}
function addToLocalHistory(url, state) {
// Remove any forward history if we're not at the end
if (currentLocalIndex < localHistory.length - 1) {
localHistory = localHistory.slice(0, currentLocalIndex + 1);
}
localHistory.push({
url: url,
state: state,
time: new Date()
});
currentLocalIndex = localHistory.length - 1;
renderLocalHistory();
}
function renderLocalHistory() {
const stack = document.getElementById('historyStack');
if (localHistory.length === 0) {
stack.innerHTML = '<div style="color: #666; text-align: center; padding: 20px;">No history entries tracked</div>';
return;
}
stack.innerHTML = localHistory.map((entry, index) => {
const isCurrent = index === currentLocalIndex;
return `
<div class="history-item ${isCurrent ? 'current' : ''}">
<div class="history-item-header">
<span class="history-item-index ${isCurrent ? 'current' : ''}">${index}</span>
<span class="history-item-time">${entry.time.toLocaleTimeString()}</span>
</div>
<div class="history-item-url">${escapeHtml(entry.url)}</div>
<div class="history-item-state">state: ${entry.state ? JSON.stringify(entry.state) : 'null'}</div>
</div>
`;
}).join('');
}
function clearLocalHistory() {
localHistory = [{
url: window.location.href,
state: history.state,
time: new Date()
}];
currentLocalIndex = 0;
renderLocalHistory();
log('Local history tracking cleared');
}
// Navigation functions
function goBack() {
history.back();
log('Called history.back()');
}
function goForward() {
history.forward();
log('Called history.forward()');
}
function go(n) {
history.go(n);
log(`Called history.go(${n})`);
}
// SPA navigation
function navigateToPage(pageName) {
const page = pages[pageName];
if (!page) return;
const state = { page: pageName, timestamp: Date.now() };
const url = `?page=${pageName}`;
history.pushState(state, page.title, url);
stats.pushCount++;
addToLocalHistory(window.location.href, state);
updateDisplay();
log(`Navigated to: ${pageName} (pushState)`);
}
// Manual pushState
function doPushState() {
const url = document.getElementById('pushUrl').value || null;
const stateStr = document.getElementById('pushState').value;
const title = document.getElementById('pushTitle').value || '';
let state = null;
if (stateStr) {
try {
state = JSON.parse(stateStr);
} catch (e) {
state = { raw: stateStr };
}
}
try {
history.pushState(state, title, url);
stats.pushCount++;
addToLocalHistory(window.location.href, state);
updateDisplay();
log(`pushState: url=${url || '(none)'}, state=${JSON.stringify(state)}`);
} catch (e) {
log(`pushState error: ${e.message}`);
}
}
// Manual replaceState
function doReplaceState() {
const url = document.getElementById('replaceUrl').value || null;
const stateStr = document.getElementById('replaceState').value;
let state = null;
if (stateStr) {
try {
state = JSON.parse(stateStr);
} catch (e) {
state = { raw: stateStr };
}
}
try {
history.replaceState(state, '', url);
stats.replaceCount++;
// Update current entry in local history
if (localHistory.length > 0) {
localHistory[currentLocalIndex] = {
url: window.location.href,
state: state,
time: new Date()
};
renderLocalHistory();
}
updateDisplay();
log(`replaceState: url=${url || '(current)'}, state=${JSON.stringify(state)}`);
} catch (e) {
log(`replaceState error: ${e.message}`);
}
}
// Handle popstate events
window.addEventListener('popstate', (event) => {
stats.popstateCount++;
// Try to find matching entry in local history
const currentUrl = window.location.href;
const matchIndex = localHistory.findIndex(entry => entry.url === currentUrl);
if (matchIndex !== -1) {
currentLocalIndex = matchIndex;
}
renderLocalHistory();
updateDisplay();
log(`popstate event: state=${JSON.stringify(event.state)}`);
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize
if (checkSupport()) {
checkFeatures();
// Initialize local history with current state
localHistory.push({
url: window.location.href,
state: history.state,
time: new Date()
});
// Check URL for page parameter
const urlParams = new URLSearchParams(window.location.search);
const pageName = urlParams.get('page');
if (pageName && pages[pageName]) {
// Restore page state without creating new history entry
history.replaceState({ page: pageName, timestamp: Date.now() }, pages[pageName].title);
updatePageContent(pageName);
}
renderLocalHistory();
updateDisplay();
log('History API initialized');
log(`Initial history.length: ${history.length}`);
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "History API Demo",
Description: "Browser history manipulation",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "History API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IndexedDB API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
.form-row { display: flex; gap: 10px; }
.form-row .form-group { flex: 1; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input, textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
table { width: 100%; border-collapse: collapse; margin-top: 15px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #333; }
th { color: #4ade80; }
td button { margin: 0; padding: 5px 10px; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="container">
<h1>IndexedDB API Demo</h1>
<p class="description">
IndexedDB is a low-level API for storing significant amounts of structured data,
including files and blobs. It uses indexes for high-performance searches.
</p>
<div id="status" class="status success">
IndexedDB available: <span id="available">checking...</span>
</div>
<div class="card">
<h2>Add Person Record</h2>
<div class="form-row">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" placeholder="John Doe">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" placeholder="john@example.com">
</div>
<div class="form-group">
<label for="age">Age</label>
<input type="number" id="age" placeholder="30">
</div>
</div>
<button onclick="addRecord()">Add Record</button>
<button class="secondary" onclick="addSampleData()">Add Sample Data</button>
</div>
<div class="card">
<h2>Search Records</h2>
<div class="form-row">
<div class="form-group">
<label for="searchField">Search By</label>
<select id="searchField">
<option value="name">Name</option>
<option value="email">Email</option>
</select>
</div>
<div class="form-group">
<label for="searchValue">Value</label>
<input type="text" id="searchValue" placeholder="Search...">
</div>
</div>
<button onclick="searchRecords()">Search</button>
<button onclick="getAllRecords()">Show All</button>
<button class="danger" onclick="clearDatabase()">Clear Database</button>
</div>
<div class="card">
<h2>Records</h2>
<table id="recordsTable">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Age</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="recordsBody"></tbody>
</table>
</div>
<div class="card">
<h2>Database Info</h2>
<div class="output" id="info"></div>
</div>
</div>
<script>
const DB_NAME = 'WebAPIDemo';
const DB_VERSION = 1;
const STORE_NAME = 'people';
let db = null;
// Check if IndexedDB is available
function checkIndexedDB() {
if (!window.indexedDB) {
document.getElementById('available').textContent = 'No';
document.getElementById('status').className = 'status error';
return false;
}
document.getElementById('available').textContent = 'Yes';
return true;
}
// Initialize database
async function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
const database = event.target.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
const store = database.createObjectStore(STORE_NAME, {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('name', 'name', { unique: false });
store.createIndex('email', 'email', { unique: true });
}
};
});
}
async function addRecord() {
const name = document.getElementById('name').value;
const email = document.getElementById('email').value;
const age = parseInt(document.getElementById('age').value) || 0;
if (!name || !email) {
alert('Please fill in name and email');
return;
}
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
try {
await new Promise((resolve, reject) => {
const request = store.add({ name, email, age, created: new Date().toISOString() });
request.onsuccess = resolve;
request.onerror = () => reject(request.error);
});
document.getElementById('name').value = '';
document.getElementById('email').value = '';
document.getElementById('age').value = '';
getAllRecords();
updateInfo();
} catch (e) {
alert('Error adding record: ' + e.message);
}
}
async function addSampleData() {
const samples = [
{ name: 'Alice Johnson', email: 'alice@example.com', age: 28 },
{ name: 'Bob Smith', email: 'bob@example.com', age: 35 },
{ name: 'Carol White', email: 'carol@example.com', age: 42 },
{ name: 'David Brown', email: 'david@example.com', age: 31 },
];
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
for (const person of samples) {
try {
await new Promise((resolve, reject) => {
const request = store.add({ ...person, created: new Date().toISOString() });
request.onsuccess = resolve;
request.onerror = resolve; // Ignore duplicates
});
} catch (e) {}
}
getAllRecords();
updateInfo();
}
async function getAllRecords() {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const records = await new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
displayRecords(records);
}
async function searchRecords() {
const field = document.getElementById('searchField').value;
const value = document.getElementById('searchValue').value;
if (!value) {
getAllRecords();
return;
}
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const index = store.index(field);
const records = await new Promise((resolve, reject) => {
const request = index.getAll(value);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
displayRecords(records);
}
async function deleteRecord(id) {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
await new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = resolve;
request.onerror = () => reject(request.error);
});
getAllRecords();
updateInfo();
}
async function clearDatabase() {
if (!confirm('Clear all records?')) return;
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
await new Promise((resolve, reject) => {
const request = store.clear();
request.onsuccess = resolve;
request.onerror = () => reject(request.error);
});
getAllRecords();
updateInfo();
}
function displayRecords(records) {
const tbody = document.getElementById('recordsBody');
tbody.innerHTML = records.map(r => `
<tr>
<td>${r.id}</td>
<td>${escapeHtml(r.name)}</td>
<td>${escapeHtml(r.email)}</td>
<td>${r.age}</td>
<td><button class="danger" onclick="deleteRecord(${r.id})">Delete</button></td>
</tr>
`).join('');
}
async function updateInfo() {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const count = await new Promise((resolve) => {
const req = store.count();
req.onsuccess = () => resolve(req.result);
});
document.getElementById('info').textContent = `Database: ${DB_NAME}
Version: ${DB_VERSION}
Object Store: ${STORE_NAME}
Records: ${count}
Key Features:
- Asynchronous API (Promise-based)
- Supports indexes for fast queries
- Can store large amounts of data
- Supports transactions (ACID)
- Can store complex objects, files, blobs
API Pattern:
1. Open database: indexedDB.open(name, version)
2. Create object stores in onupgradeneeded
3. Use transactions for all operations
4. Use indexes for efficient queries`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize
if (checkIndexedDB()) {
initDB().then(() => {
getAllRecords();
updateInfo();
}).catch(e => {
document.getElementById('status').className = 'status error';
document.getElementById('available').textContent = 'Error: ' + e.message;
});
}
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "IndexedDB Demo",
Description: "Demonstrates the IndexedDB API for client-side database storage",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "IndexedDB Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intersection Observer API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; position: sticky; top: 10px; z-index: 100; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.scroll-container {
height: 400px;
overflow-y: auto;
background: #0a0a1a;
border-radius: 8px;
padding: 20px;
}
.spacer { height: 200px; }
.observe-target {
padding: 40px;
margin: 20px 0;
border-radius: 8px;
text-align: center;
transition: all 0.3s;
opacity: 0.3;
transform: translateX(-20px);
}
.observe-target.visible {
opacity: 1;
transform: translateX(0);
}
.observe-target.box1 { background: linear-gradient(135deg, #4ade80, #22c55e); color: #1a1a2e; }
.observe-target.box2 { background: linear-gradient(135deg, #60a5fa, #3b82f6); }
.observe-target.box3 { background: linear-gradient(135deg, #e94560, #dc2626); }
.observe-target.box4 { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #1a1a2e; }
.observe-target.box5 { background: linear-gradient(135deg, #a855f7, #7c3aed); }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 15px;
align-items: center;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label { color: #aaa; font-size: 0.85rem; }
input[type="range"] { width: 120px; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
}
button:hover { opacity: 0.9; }
.lazy-image {
width: 100%;
height: 200px;
background: #333;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
transition: background 0.3s;
}
.lazy-image.loaded {
background: linear-gradient(45deg, #4ade80, #60a5fa, #e94560, #fbbf24);
background-size: 400% 400%;
animation: gradient 3s ease infinite;
}
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.visibility-indicator {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.8);
padding: 15px;
border-radius: 8px;
font-family: monospace;
font-size: 0.8rem;
}
.visibility-indicator .item {
display: flex;
align-items: center;
gap: 8px;
margin: 5px 0;
}
.visibility-indicator .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #666;
}
.visibility-indicator .dot.active { background: #4ade80; }
</style>
</head>
<body>
<div class="container">
<h1>Intersection Observer API Demo</h1>
<p class="description">
The Intersection Observer API provides a way to asynchronously observe changes
in the intersection of a target element with an ancestor element or viewport.
</p>
<div class="status success">
Intersection Observer: <span id="supported">checking...</span>
</div>
<div class="visibility-indicator">
<div><strong>Visibility</strong></div>
<div class="item"><span class="dot" id="dot1"></span> Box 1</div>
<div class="item"><span class="dot" id="dot2"></span> Box 2</div>
<div class="item"><span class="dot" id="dot3"></span> Box 3</div>
<div class="item"><span class="dot" id="dot4"></span> Box 4</div>
<div class="item"><span class="dot" id="dot5"></span> Box 5</div>
</div>
<div class="card">
<h2>Observer Options</h2>
<div class="controls">
<div class="control-group">
<label>Threshold: <span id="thresholdVal">0.5</span></label>
<input type="range" id="threshold" min="0" max="1" step="0.1" value="0.5" onchange="updateObserver()">
</div>
<div class="control-group">
<label>Root Margin: <span id="marginVal">0</span>px</label>
<input type="range" id="rootMargin" min="-100" max="100" step="10" value="0" onchange="updateObserver()">
</div>
<button onclick="updateObserver()">Apply Settings</button>
</div>
</div>
<div class="card">
<h2>Scroll Demo</h2>
<p style="color: #aaa; margin-bottom: 15px;">Scroll down to see elements animate when they enter the viewport.</p>
<div class="scroll-container" id="scrollContainer">
<div class="spacer"></div>
<div class="observe-target box1" data-box="1">Box 1 - I animate when visible!</div>
<div class="spacer"></div>
<div class="observe-target box2" data-box="2">Box 2 - Intersection detected!</div>
<div class="spacer"></div>
<div class="observe-target box3" data-box="3">Box 3 - Lazy loading example</div>
<div class="spacer"></div>
<div class="observe-target box4" data-box="4">Box 4 - Analytics trigger</div>
<div class="spacer"></div>
<div class="observe-target box5" data-box="5">Box 5 - Infinite scroll trigger</div>
<div class="spacer"></div>
</div>
</div>
<div class="card">
<h2>Lazy Loading Images</h2>
<p style="color: #aaa; margin-bottom: 15px;">Images load when they enter the viewport.</p>
<div id="lazyContainer">
<div class="lazy-image" data-src="image1">Scroll to load...</div>
<div class="lazy-image" data-src="image2">Scroll to load...</div>
<div class="lazy-image" data-src="image3">Scroll to load...</div>
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<div class="output" id="eventLog">Intersection events will appear here...</div>
</div>
</div>
<script>
let observer = null;
let lazyObserver = null;
let logEntries = [];
function checkSupport() {
const supported = 'IntersectionObserver' in window;
document.getElementById('supported').textContent = supported ? 'Available' : 'Not available';
return supported;
}
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 20) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function updateObserver() {
const threshold = parseFloat(document.getElementById('threshold').value);
const rootMargin = parseInt(document.getElementById('rootMargin').value);
document.getElementById('thresholdVal').textContent = threshold;
document.getElementById('marginVal').textContent = rootMargin;
// Disconnect old observer
if (observer) observer.disconnect();
// Create new observer
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const box = entry.target;
const boxNum = box.dataset.box;
if (entry.isIntersecting) {
box.classList.add('visible');
document.getElementById('dot' + boxNum).classList.add('active');
log(`Box ${boxNum}: VISIBLE (${(entry.intersectionRatio * 100).toFixed(0)}% visible)`);
} else {
box.classList.remove('visible');
document.getElementById('dot' + boxNum).classList.remove('active');
log(`Box ${boxNum}: HIDDEN`);
}
});
}, {
root: document.getElementById('scrollContainer'),
threshold: threshold,
rootMargin: `${rootMargin}px`
});
// Observe all boxes
document.querySelectorAll('.observe-target').forEach(el => observer.observe(el));
log(`Observer updated: threshold=${threshold}, rootMargin=${rootMargin}px`);
}
// Lazy loading observer
function setupLazyLoading() {
lazyObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// Simulate image loading
setTimeout(() => {
img.classList.add('loaded');
img.textContent = 'Image loaded!';
log(`Lazy image "${img.dataset.src}" loaded`);
}, 500);
lazyObserver.unobserve(img);
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.lazy-image').forEach(el => lazyObserver.observe(el));
}
if (checkSupport()) {
updateObserver();
setupLazyLoading();
}
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Intersection Observer Demo",
Description: "Demonstrates the Intersection Observer API",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Intersection Observer Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,226 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalStorage API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input, textarea {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.items-list { list-style: none; }
.items-list li {
display: flex;
justify-content: space-between;
padding: 10px;
background: rgba(0,0,0,0.2);
margin-bottom: 5px;
border-radius: 4px;
}
.items-list .key { color: #4ade80; font-family: monospace; }
.items-list .value { color: #aaa; font-family: monospace; max-width: 300px; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body>
<div class="container">
<h1>LocalStorage API Demo</h1>
<p class="description">
The Web Storage API provides localStorage for persistent key-value storage
that survives browser restarts.
</p>
<div id="status" class="status success">
localStorage is available: <span id="available">checking...</span>
</div>
<div class="card">
<h2>Store Data</h2>
<div class="form-group">
<label for="key">Key</label>
<input type="text" id="key" placeholder="Enter key name">
</div>
<div class="form-group">
<label for="value">Value</label>
<textarea id="value" rows="3" placeholder="Enter value (can be JSON)"></textarea>
</div>
<button onclick="saveItem()">Save to localStorage</button>
<button onclick="loadItem()">Load from localStorage</button>
</div>
<div class="card">
<h2>Stored Items (<span id="count">0</span>)</h2>
<button onclick="refreshList()">Refresh List</button>
<button class="danger" onclick="clearAll()">Clear All</button>
<ul class="items-list" id="itemsList"></ul>
</div>
<div class="card">
<h2>Storage Info</h2>
<div class="output" id="info"></div>
</div>
</div>
<script>
// Check if localStorage is available
function checkStorage() {
try {
localStorage.setItem('__test__', 'test');
localStorage.removeItem('__test__');
document.getElementById('available').textContent = 'Yes';
document.getElementById('status').className = 'status success';
return true;
} catch (e) {
document.getElementById('available').textContent = 'No - ' + e.message;
document.getElementById('status').className = 'status error';
return false;
}
}
function saveItem() {
const key = document.getElementById('key').value;
const value = document.getElementById('value').value;
if (!key) {
alert('Please enter a key');
return;
}
try {
localStorage.setItem(key, value);
refreshList();
updateInfo();
} catch (e) {
alert('Error saving: ' + e.message);
}
}
function loadItem() {
const key = document.getElementById('key').value;
if (!key) {
alert('Please enter a key');
return;
}
const value = localStorage.getItem(key);
if (value !== null) {
document.getElementById('value').value = value;
} else {
alert('Key not found');
}
}
function deleteItem(key) {
localStorage.removeItem(key);
refreshList();
updateInfo();
}
function clearAll() {
if (confirm('Clear all localStorage data?')) {
localStorage.clear();
refreshList();
updateInfo();
}
}
function refreshList() {
const list = document.getElementById('itemsList');
list.innerHTML = '';
const count = localStorage.length;
document.getElementById('count').textContent = count;
for (let i = 0; i < count; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
const li = document.createElement('li');
li.innerHTML = `
<span class="key">${escapeHtml(key)}</span>
<span class="value">${escapeHtml(value.substring(0, 50))}${value.length > 50 ? '...' : ''}</span>
<button onclick='deleteItem(${JSON.stringify(key)})' class="danger" style="padding: 5px 10px; font-size: 0.8rem;">Delete</button>
`;
list.appendChild(li);
}
}
function updateInfo() {
const info = document.getElementById('info');
let totalSize = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
totalSize += key.length + localStorage.getItem(key).length;
}
info.textContent = `Items: ${localStorage.length}
Total size: ~${(totalSize * 2 / 1024).toFixed(2)} KB (UTF-16)
Typical quota: 5-10 MB per origin
API Methods:
- localStorage.setItem(key, value)
- localStorage.getItem(key)
- localStorage.removeItem(key)
- localStorage.clear()
- localStorage.key(index)
- localStorage.length`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize
if (checkStorage()) {
refreshList();
updateInfo();
}
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "LocalStorage Demo",
Description: "Demonstrates the Web Storage API (localStorage)",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "LocalStorage API Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,560 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MediaDevices API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.device-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 15px;
}
.device-item {
background: rgba(0,0,0,0.3);
padding: 15px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 15px;
}
.device-icon {
font-size: 1.5rem;
width: 40px;
text-align: center;
}
.device-info { flex: 1; }
.device-info .label { font-weight: 600; }
.device-info .id { font-size: 0.75rem; color: #666; font-family: monospace; word-break: break-all; }
.device-type {
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.device-type.audioinput { background: #4ade80; color: #1a1a2e; }
.device-type.videoinput { background: #60a5fa; color: #1a1a2e; }
.device-type.audiooutput { background: #f97316; color: #1a1a2e; }
.preview-container {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-top: 15px;
}
.preview-box {
flex: 1;
min-width: 280px;
background: #0a0a1a;
border-radius: 8px;
padding: 15px;
}
.preview-box h3 {
color: #aaa;
font-size: 0.9rem;
margin-bottom: 10px;
}
video {
width: 100%;
border-radius: 6px;
background: #000;
}
.audio-meter {
width: 100%;
height: 30px;
background: #1a1a2e;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.audio-bar {
height: 100%;
background: linear-gradient(90deg, #4ade80, #22c55e);
width: 0%;
transition: width 0.05s;
}
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
max-height: 200px;
overflow-y: auto;
}
.log-entry { margin-bottom: 5px; }
.log-entry .time { color: #666; }
.log-entry .info { color: #60a5fa; }
.log-entry .success { color: #4ade80; }
.log-entry .error { color: #f87171; }
.log-entry .warn { color: #fbbf24; }
select {
padding: 8px 12px;
background: #1a1a2e;
color: #eee;
border: 1px solid #333;
border-radius: 4px;
margin-right: 10px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>MediaDevices API Demo</h1>
<p class="description">
The MediaDevices API provides access to connected media input devices like cameras and microphones.
It enables applications to enumerate devices and request audio/video streams.
</p>
<div class="status success" id="apiStatus">
MediaDevices API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Available Devices</h2>
<button onclick="enumerateDevices()">Enumerate Devices</button>
<button class="secondary" onclick="requestPermissions()">Request Permissions First</button>
<div class="device-list" id="deviceList">
<p style="color: #aaa;">Click "Enumerate Devices" to list available media devices...</p>
</div>
</div>
<div class="card">
<h2>Camera & Microphone Preview</h2>
<div style="margin-bottom: 15px;">
<select id="videoSelect">
<option value="">Select Camera</option>
</select>
<select id="audioSelect">
<option value="">Select Microphone</option>
</select>
</div>
<button onclick="startPreview()">Start Preview</button>
<button class="danger" onclick="stopPreview()">Stop Preview</button>
<button class="secondary" onclick="toggleVideo()">Toggle Video</button>
<button class="secondary" onclick="toggleAudio()">Toggle Audio</button>
<div class="preview-container">
<div class="preview-box">
<h3>Video Preview</h3>
<video id="videoPreview" autoplay muted playsinline></video>
</div>
<div class="preview-box">
<h3>Audio Level</h3>
<div class="audio-meter">
<div class="audio-bar" id="audioBar"></div>
</div>
<p id="audioStatus" style="color: #aaa; font-size: 0.85rem; margin-top: 10px;">
Start preview to see audio levels...
</p>
</div>
</div>
</div>
<div class="card">
<h2>Constraints Builder</h2>
<p style="color: #aaa; margin-bottom: 15px;">Configure media constraints for getUserMedia:</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 15px;">
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="constraintVideo" checked> Video
</label>
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="constraintAudio" checked> Audio
</label>
<label style="display: flex; align-items: center; gap: 10px;">
Video Width: <input type="number" id="constraintWidth" value="1280" style="width: 80px; padding: 5px; background: #1a1a2e; color: #eee; border: 1px solid #333; border-radius: 4px;">
</label>
<label style="display: flex; align-items: center; gap: 10px;">
Video Height: <input type="number" id="constraintHeight" value="720" style="width: 80px; padding: 5px; background: #1a1a2e; color: #eee; border: 1px solid #333; border-radius: 4px;">
</label>
<label style="display: flex; align-items: center; gap: 10px;">
Frame Rate: <input type="number" id="constraintFps" value="30" style="width: 60px; padding: 5px; background: #1a1a2e; color: #eee; border: 1px solid #333; border-radius: 4px;">
</label>
<label style="display: flex; align-items: center; gap: 10px;">
Facing Mode:
<select id="constraintFacing" style="flex: 1;">
<option value="">Any</option>
<option value="user">User (Front)</option>
<option value="environment">Environment (Back)</option>
</select>
</label>
</div>
<button onclick="startWithConstraints()">Start with Constraints</button>
<button class="secondary" onclick="showConstraints()">Show JSON</button>
</div>
<div class="card">
<h2>Event Log</h2>
<button class="danger" onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Events will be logged here...</div>
</div>
</div>
<script>
let currentStream = null;
let audioContext = null;
let analyser = null;
let animationId = null;
// Check API Support
function checkSupport() {
const hasMediaDevices = 'mediaDevices' in navigator;
const hasGetUserMedia = hasMediaDevices && 'getUserMedia' in navigator.mediaDevices;
const supported = document.getElementById('supported');
const apiStatus = document.getElementById('apiStatus');
if (hasGetUserMedia) {
supported.textContent = 'Available';
apiStatus.className = 'status success';
} else {
supported.textContent = 'Not available';
apiStatus.className = 'status error';
}
const features = document.getElementById('features');
const checks = [
{ name: 'navigator.mediaDevices', supported: hasMediaDevices },
{ name: 'getUserMedia()', supported: hasGetUserMedia },
{ name: 'enumerateDevices()', supported: hasMediaDevices && 'enumerateDevices' in navigator.mediaDevices },
{ name: 'getSupportedConstraints()', supported: hasMediaDevices && 'getSupportedConstraints' in navigator.mediaDevices },
{ name: 'getDisplayMedia()', supported: hasMediaDevices && 'getDisplayMedia' in navigator.mediaDevices },
{ name: 'devicechange event', supported: hasMediaDevices && 'ondevicechange' in navigator.mediaDevices },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
return hasGetUserMedia;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Logging
function log(message, type = 'info') {
const logDiv = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = `<span class="time">[${escapeHtml(time)}]</span> <span class="${escapeHtml(type)}">${escapeHtml(message)}</span>`;
logDiv.insertBefore(entry, logDiv.firstChild);
}
function clearLog() {
document.getElementById('eventLog').innerHTML = '<div class="log-entry"><span class="info">Log cleared</span></div>';
}
// Enumerate Devices
async function enumerateDevices() {
try {
log('Enumerating devices...', 'info');
const devices = await navigator.mediaDevices.enumerateDevices();
const deviceList = document.getElementById('deviceList');
const videoSelect = document.getElementById('videoSelect');
const audioSelect = document.getElementById('audioSelect');
videoSelect.innerHTML = '<option value="">Select Camera</option>';
audioSelect.innerHTML = '<option value="">Select Microphone</option>';
if (devices.length === 0) {
deviceList.innerHTML = '<p style="color: #aaa;">No devices found</p>';
log('No devices found', 'warn');
return;
}
const icons = {
audioinput: '🎤',
videoinput: '📷',
audiooutput: '🔊'
};
deviceList.innerHTML = devices.map(device => `
<div class="device-item">
<div class="device-icon">${icons[device.kind] || '📱'}</div>
<div class="device-info">
<div class="label">${device.label || 'Unnamed device (permission needed)'}</div>
<div class="id">${device.deviceId || 'No ID'}</div>
</div>
<span class="device-type ${device.kind}">${device.kind}</span>
</div>
`).join('');
devices.forEach(device => {
const option = document.createElement('option');
option.value = device.deviceId;
option.textContent = device.label || `${device.kind} (${device.deviceId.substring(0, 8)}...)`;
if (device.kind === 'videoinput') {
videoSelect.appendChild(option);
} else if (device.kind === 'audioinput') {
audioSelect.appendChild(option);
}
});
log(`Found ${devices.length} device(s)`, 'success');
} catch (e) {
log(`Error enumerating devices: ${e.message}`, 'error');
}
}
// Request Permissions
async function requestPermissions() {
try {
log('Requesting camera and microphone permissions...', 'info');
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => track.stop());
log('Permissions granted!', 'success');
await enumerateDevices();
} catch (e) {
log(`Permission denied: ${e.message}`, 'error');
}
}
// Start Preview
async function startPreview() {
await stopPreview();
try {
const videoId = document.getElementById('videoSelect').value;
const audioId = document.getElementById('audioSelect').value;
const constraints = {
video: videoId ? { deviceId: { exact: videoId } } : true,
audio: audioId ? { deviceId: { exact: audioId } } : true
};
log(`Starting preview with constraints: ${JSON.stringify(constraints)}`, 'info');
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.getElementById('videoPreview');
video.srcObject = currentStream;
// Log track info
currentStream.getTracks().forEach(track => {
log(`Track started: ${track.kind} - ${track.label}`, 'success');
track.onended = () => log(`Track ended: ${track.kind}`, 'warn');
});
// Start audio meter
startAudioMeter();
} catch (e) {
log(`Error starting preview: ${e.message}`, 'error');
}
}
// Stop Preview
async function stopPreview() {
if (currentStream) {
currentStream.getTracks().forEach(track => {
track.stop();
log(`Stopped track: ${track.kind}`, 'info');
});
currentStream = null;
}
document.getElementById('videoPreview').srcObject = null;
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
document.getElementById('audioBar').style.width = '0%';
document.getElementById('audioStatus').textContent = 'Start preview to see audio levels...';
}
// Toggle Video
function toggleVideo() {
if (!currentStream) {
log('No active stream', 'warn');
return;
}
currentStream.getVideoTracks().forEach(track => {
track.enabled = !track.enabled;
log(`Video ${track.enabled ? 'enabled' : 'disabled'}`, 'info');
});
}
// Toggle Audio
function toggleAudio() {
if (!currentStream) {
log('No active stream', 'warn');
return;
}
currentStream.getAudioTracks().forEach(track => {
track.enabled = !track.enabled;
log(`Audio ${track.enabled ? 'enabled' : 'disabled'}`, 'info');
});
}
// Audio Meter
function startAudioMeter() {
if (!currentStream) return;
const audioTrack = currentStream.getAudioTracks()[0];
if (!audioTrack) {
document.getElementById('audioStatus').textContent = 'No audio track available';
return;
}
audioContext = new (window.AudioContext || window.webkitAudioContext)();
const source = audioContext.createMediaStreamSource(currentStream);
analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const audioBar = document.getElementById('audioBar');
const audioStatus = document.getElementById('audioStatus');
function updateMeter() {
animationId = requestAnimationFrame(updateMeter);
analyser.getByteFrequencyData(dataArray);
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
const percent = Math.min(100, (average / 128) * 100);
audioBar.style.width = percent + '%';
audioStatus.textContent = `Level: ${Math.round(percent)}%`;
}
updateMeter();
}
// Constraints Builder
function buildConstraints() {
const video = document.getElementById('constraintVideo').checked;
const audio = document.getElementById('constraintAudio').checked;
const width = parseInt(document.getElementById('constraintWidth').value) || 1280;
const height = parseInt(document.getElementById('constraintHeight').value) || 720;
const fps = parseInt(document.getElementById('constraintFps').value) || 30;
const facing = document.getElementById('constraintFacing').value;
const constraints = {
audio: audio,
video: video ? {
width: { ideal: width },
height: { ideal: height },
frameRate: { ideal: fps }
} : false
};
if (video && facing) {
constraints.video.facingMode = facing;
}
return constraints;
}
function showConstraints() {
const constraints = buildConstraints();
log(`Constraints: ${JSON.stringify(constraints, null, 2)}`, 'info');
}
async function startWithConstraints() {
await stopPreview();
try {
const constraints = buildConstraints();
log(`Starting with constraints: ${JSON.stringify(constraints)}`, 'info');
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.getElementById('videoPreview');
video.srcObject = currentStream;
currentStream.getTracks().forEach(track => {
const settings = track.getSettings();
log(`Track: ${track.kind} - Settings: ${JSON.stringify(settings)}`, 'success');
});
if (constraints.audio) {
startAudioMeter();
}
} catch (e) {
log(`Error: ${e.message}`, 'error');
}
}
// Device change listener
if (navigator.mediaDevices && 'ondevicechange' in navigator.mediaDevices) {
navigator.mediaDevices.addEventListener('devicechange', () => {
log('Device change detected!', 'warn');
enumerateDevices();
});
}
// Initialize
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Media Devices Demo",
Description: "Camera and microphone access",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Media Devices Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,803 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MediaRecorder API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
button.warning { background: #fbbf24; color: #1a1a2e; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.preview-container {
display: flex;
gap: 20px;
flex-wrap: wrap;
margin-top: 15px;
}
.preview-box {
flex: 1;
min-width: 300px;
background: #0a0a1a;
border-radius: 8px;
padding: 15px;
}
.preview-box h3 {
color: #aaa;
font-size: 0.9rem;
margin-bottom: 10px;
}
video, audio {
width: 100%;
border-radius: 6px;
background: #000;
}
.recording-indicator {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 15px;
border-radius: 20px;
background: rgba(248, 113, 113, 0.2);
color: #f87171;
margin-bottom: 15px;
}
.recording-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #f87171;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.recording-indicator.paused {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.recording-indicator.paused .recording-dot {
background: #fbbf24;
animation: none;
}
.recording-indicator.stopped {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
}
.recording-indicator.stopped .recording-dot {
background: #4ade80;
animation: none;
}
.hidden { display: none !important; }
select {
padding: 8px 12px;
background: #1a1a2e;
color: #eee;
border: 1px solid #333;
border-radius: 4px;
margin-right: 10px;
margin-bottom: 10px;
}
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
max-height: 200px;
overflow-y: auto;
}
.log-entry { margin-bottom: 5px; }
.log-entry .time { color: #666; }
.log-entry .info { color: #60a5fa; }
.log-entry .success { color: #4ade80; }
.log-entry .error { color: #f87171; }
.log-entry .warn { color: #fbbf24; }
.recordings-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 15px;
}
.recording-item {
background: rgba(0,0,0,0.3);
padding: 15px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.recording-info { flex: 1; min-width: 150px; }
.recording-info .name { font-weight: 600; color: #4ade80; }
.recording-info .meta { font-size: 0.85rem; color: #aaa; }
.recording-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.recording-actions button { margin: 0; padding: 6px 12px; font-size: 0.85rem; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin: 15px 0;
}
.stat-item {
background: rgba(0,0,0,0.2);
padding: 12px;
border-radius: 6px;
text-align: center;
}
.stat-value { font-size: 1.5rem; font-weight: 600; color: #4ade80; }
.stat-label { font-size: 0.8rem; color: #aaa; margin-top: 4px; }
.timeslice-control {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.timeslice-control input {
width: 100px;
padding: 8px;
background: #1a1a2e;
color: #eee;
border: 1px solid #333;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>MediaRecorder API Demo</h1>
<p class="description">
The MediaRecorder API enables recording audio and video streams from MediaDevices.
Create recordings from camera, microphone, or screen capture sources.
</p>
<div class="status success" id="apiStatus">
MediaRecorder API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Supported MIME Types</h2>
<div class="feature-grid" id="mimeTypes"></div>
</div>
<div class="card">
<h2>Audio Recording</h2>
<div id="audioRecordingStatus" class="recording-indicator hidden">
<div class="recording-dot"></div>
<span id="audioStatusText">Recording...</span>
<span id="audioTimer">00:00</span>
</div>
<button id="startAudioBtn" onclick="startAudioRecording()">Start Audio Recording</button>
<button id="pauseAudioBtn" class="warning hidden" onclick="pauseAudioRecording()">Pause</button>
<button id="resumeAudioBtn" class="secondary hidden" onclick="resumeAudioRecording()">Resume</button>
<button id="stopAudioBtn" class="danger hidden" onclick="stopAudioRecording()">Stop</button>
<div class="preview-box" style="margin-top: 15px;">
<h3>Audio Playback</h3>
<audio id="audioPlayback" controls></audio>
</div>
</div>
<div class="card">
<h2>Video Recording</h2>
<div id="videoRecordingStatus" class="recording-indicator hidden">
<div class="recording-dot"></div>
<span id="videoStatusText">Recording...</span>
<span id="videoTimer">00:00</span>
</div>
<div style="margin-bottom: 15px;">
<select id="videoMimeType">
<option value="">Auto-select MIME type</option>
</select>
</div>
<div class="timeslice-control">
<label>Timeslice (ms):</label>
<input type="number" id="timeslice" value="1000" min="100" step="100">
<span style="color: #aaa; font-size: 0.85rem;">Data chunks interval</span>
</div>
<button id="startVideoBtn" onclick="startVideoRecording()">Start Camera Recording</button>
<button id="startScreenBtn" class="secondary" onclick="startScreenRecording()">Start Screen Recording</button>
<button id="pauseVideoBtn" class="warning hidden" onclick="pauseVideoRecording()">Pause</button>
<button id="resumeVideoBtn" class="secondary hidden" onclick="resumeVideoRecording()">Resume</button>
<button id="stopVideoBtn" class="danger hidden" onclick="stopVideoRecording()">Stop</button>
<div class="preview-container">
<div class="preview-box">
<h3>Live Preview</h3>
<video id="livePreview" autoplay muted playsinline></video>
</div>
<div class="preview-box">
<h3>Recording Playback</h3>
<video id="videoPlayback" controls playsinline></video>
</div>
</div>
<div class="stats-grid" id="recordingStats">
<div class="stat-item">
<div class="stat-value" id="statChunks">0</div>
<div class="stat-label">Data Chunks</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statSize">0 KB</div>
<div class="stat-label">Total Size</div>
</div>
<div class="stat-item">
<div class="stat-value" id="statState">inactive</div>
<div class="stat-label">State</div>
</div>
</div>
</div>
<div class="card">
<h2>Recordings</h2>
<button class="danger" onclick="clearRecordings()">Clear All</button>
<div class="recordings-list" id="recordingsList">
<p style="color: #aaa;">No recordings yet. Start recording to see them here.</p>
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<button class="danger" onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Events will be logged here...</div>
</div>
</div>
<script>
let audioRecorder = null;
let videoRecorder = null;
let audioStream = null;
let videoStream = null;
let audioChunks = [];
let videoChunks = [];
let recordings = [];
let audioStartTime = null;
let videoStartTime = null;
let audioTimerInterval = null;
let videoTimerInterval = null;
// Check API Support
function checkSupport() {
const hasMediaRecorder = typeof MediaRecorder !== 'undefined';
const supported = document.getElementById('supported');
const apiStatus = document.getElementById('apiStatus');
if (hasMediaRecorder) {
supported.textContent = 'Available';
apiStatus.className = 'status success';
} else {
supported.textContent = 'Not available';
apiStatus.className = 'status error';
}
const features = document.getElementById('features');
const checks = [
{ name: 'MediaRecorder', supported: hasMediaRecorder },
{ name: 'isTypeSupported()', supported: hasMediaRecorder && 'isTypeSupported' in MediaRecorder },
{ name: 'pause()', supported: hasMediaRecorder && 'pause' in MediaRecorder.prototype },
{ name: 'resume()', supported: hasMediaRecorder && 'resume' in MediaRecorder.prototype },
{ name: 'requestData()', supported: hasMediaRecorder && 'requestData' in MediaRecorder.prototype },
{ name: 'BlobEvent', supported: typeof BlobEvent !== 'undefined' },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
// Check MIME types
checkMimeTypes();
return hasMediaRecorder;
}
function checkMimeTypes() {
const mimeTypesDiv = document.getElementById('mimeTypes');
const videoSelect = document.getElementById('videoMimeType');
const mimeTypes = [
'video/webm',
'video/webm;codecs=vp8',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9,opus',
'video/mp4',
'audio/webm',
'audio/webm;codecs=opus',
'audio/ogg',
'audio/ogg;codecs=opus',
];
const results = mimeTypes.map(type => ({
name: type,
supported: MediaRecorder.isTypeSupported(type)
}));
mimeTypesDiv.innerHTML = results.map(r => `
<div class="feature">
<div class="name">${r.name}</div>
<div class="${r.supported ? 'supported' : 'unsupported'}">${r.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
// Populate video MIME select
results.filter(r => r.supported && r.name.startsWith('video')).forEach(r => {
const option = document.createElement('option');
option.value = r.name;
option.textContent = r.name;
videoSelect.appendChild(option);
});
}
// Logging
function log(message, type = 'info') {
const logDiv = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = `<span class="time">[${time}]</span> <span class="${type}">${message}</span>`;
logDiv.insertBefore(entry, logDiv.firstChild);
}
function clearLog() {
document.getElementById('eventLog').innerHTML = '<div class="log-entry"><span class="info">Log cleared</span></div>';
}
// Format time
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Format size
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
// Audio Recording
async function startAudioRecording() {
try {
log('Requesting microphone access...', 'info');
audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioRecorder = new MediaRecorder(audioStream);
audioChunks = [];
audioRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunks.push(e.data);
log(`Audio chunk received: ${formatSize(e.data.size)}`, 'info');
}
};
audioRecorder.onstart = () => {
log('Audio recording started', 'success');
audioStartTime = Date.now();
updateAudioUI('recording');
audioTimerInterval = setInterval(updateAudioTimer, 1000);
};
audioRecorder.onpause = () => {
log('Audio recording paused', 'warn');
updateAudioUI('paused');
};
audioRecorder.onresume = () => {
log('Audio recording resumed', 'success');
updateAudioUI('recording');
};
audioRecorder.onstop = () => {
log('Audio recording stopped', 'success');
clearInterval(audioTimerInterval);
updateAudioUI('stopped');
const blob = new Blob(audioChunks, { type: 'audio/webm' });
const url = URL.createObjectURL(blob);
document.getElementById('audioPlayback').src = url;
addRecording('audio', blob, url);
audioStream.getTracks().forEach(track => track.stop());
};
audioRecorder.onerror = (e) => {
log(`Audio recording error: ${e.error}`, 'error');
};
audioRecorder.start();
} catch (e) {
log(`Error: ${e.message}`, 'error');
}
}
function pauseAudioRecording() {
if (audioRecorder && audioRecorder.state === 'recording') {
audioRecorder.pause();
}
}
function resumeAudioRecording() {
if (audioRecorder && audioRecorder.state === 'paused') {
audioRecorder.resume();
}
}
function stopAudioRecording() {
if (audioRecorder && audioRecorder.state !== 'inactive') {
audioRecorder.stop();
}
}
function updateAudioTimer() {
const elapsed = (Date.now() - audioStartTime) / 1000;
document.getElementById('audioTimer').textContent = formatTime(elapsed);
}
function updateAudioUI(state) {
const status = document.getElementById('audioRecordingStatus');
const statusText = document.getElementById('audioStatusText');
const startBtn = document.getElementById('startAudioBtn');
const pauseBtn = document.getElementById('pauseAudioBtn');
const resumeBtn = document.getElementById('resumeAudioBtn');
const stopBtn = document.getElementById('stopAudioBtn');
status.classList.remove('hidden', 'paused', 'stopped');
switch (state) {
case 'recording':
statusText.textContent = 'Recording...';
startBtn.classList.add('hidden');
pauseBtn.classList.remove('hidden');
resumeBtn.classList.add('hidden');
stopBtn.classList.remove('hidden');
break;
case 'paused':
status.classList.add('paused');
statusText.textContent = 'Paused';
pauseBtn.classList.add('hidden');
resumeBtn.classList.remove('hidden');
break;
case 'stopped':
status.classList.add('stopped');
statusText.textContent = 'Stopped';
setTimeout(() => {
status.classList.add('hidden');
startBtn.classList.remove('hidden');
pauseBtn.classList.add('hidden');
resumeBtn.classList.add('hidden');
stopBtn.classList.add('hidden');
}, 2000);
break;
}
}
// Video Recording
async function startVideoRecording() {
try {
log('Requesting camera and microphone access...', 'info');
videoStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
document.getElementById('livePreview').srcObject = videoStream;
startRecording(videoStream);
} catch (e) {
log(`Error: ${e.message}`, 'error');
}
}
async function startScreenRecording() {
try {
log('Requesting screen capture...', 'info');
videoStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true
});
document.getElementById('livePreview').srcObject = videoStream;
// Handle stream ending (user stops sharing)
videoStream.getVideoTracks()[0].onended = () => {
log('Screen sharing ended', 'warn');
stopVideoRecording();
};
startRecording(videoStream);
} catch (e) {
log(`Error: ${e.message}`, 'error');
}
}
function startRecording(stream) {
const mimeType = document.getElementById('videoMimeType').value;
const timeslice = parseInt(document.getElementById('timeslice').value) || 1000;
const options = mimeType ? { mimeType } : {};
try {
videoRecorder = new MediaRecorder(stream, options);
} catch (e) {
log(`MIME type not supported, using default: ${e.message}`, 'warn');
videoRecorder = new MediaRecorder(stream);
}
videoChunks = [];
let totalSize = 0;
videoRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
videoChunks.push(e.data);
totalSize += e.data.size;
document.getElementById('statChunks').textContent = videoChunks.length;
document.getElementById('statSize').textContent = formatSize(totalSize);
log(`Video chunk #${videoChunks.length}: ${formatSize(e.data.size)}`, 'info');
}
};
videoRecorder.onstart = () => {
log(`Video recording started (mimeType: ${videoRecorder.mimeType})`, 'success');
videoStartTime = Date.now();
updateVideoUI('recording');
videoTimerInterval = setInterval(updateVideoTimer, 1000);
};
videoRecorder.onpause = () => {
log('Video recording paused', 'warn');
updateVideoUI('paused');
};
videoRecorder.onresume = () => {
log('Video recording resumed', 'success');
updateVideoUI('recording');
};
videoRecorder.onstop = () => {
log('Video recording stopped', 'success');
clearInterval(videoTimerInterval);
updateVideoUI('stopped');
document.getElementById('statState').textContent = 'inactive';
const blob = new Blob(videoChunks, { type: videoRecorder.mimeType });
const url = URL.createObjectURL(blob);
document.getElementById('videoPlayback').src = url;
addRecording('video', blob, url);
document.getElementById('livePreview').srcObject = null;
videoStream.getTracks().forEach(track => track.stop());
};
videoRecorder.onerror = (e) => {
log(`Video recording error: ${e.error}`, 'error');
};
videoRecorder.start(timeslice);
document.getElementById('statState').textContent = 'recording';
}
function pauseVideoRecording() {
if (videoRecorder && videoRecorder.state === 'recording') {
videoRecorder.pause();
document.getElementById('statState').textContent = 'paused';
}
}
function resumeVideoRecording() {
if (videoRecorder && videoRecorder.state === 'paused') {
videoRecorder.resume();
document.getElementById('statState').textContent = 'recording';
}
}
function stopVideoRecording() {
if (videoRecorder && videoRecorder.state !== 'inactive') {
videoRecorder.stop();
}
}
function updateVideoTimer() {
const elapsed = (Date.now() - videoStartTime) / 1000;
document.getElementById('videoTimer').textContent = formatTime(elapsed);
}
function updateVideoUI(state) {
const status = document.getElementById('videoRecordingStatus');
const statusText = document.getElementById('videoStatusText');
const startBtn = document.getElementById('startVideoBtn');
const screenBtn = document.getElementById('startScreenBtn');
const pauseBtn = document.getElementById('pauseVideoBtn');
const resumeBtn = document.getElementById('resumeVideoBtn');
const stopBtn = document.getElementById('stopVideoBtn');
status.classList.remove('hidden', 'paused', 'stopped');
switch (state) {
case 'recording':
statusText.textContent = 'Recording...';
startBtn.classList.add('hidden');
screenBtn.classList.add('hidden');
pauseBtn.classList.remove('hidden');
resumeBtn.classList.add('hidden');
stopBtn.classList.remove('hidden');
break;
case 'paused':
status.classList.add('paused');
statusText.textContent = 'Paused';
pauseBtn.classList.add('hidden');
resumeBtn.classList.remove('hidden');
break;
case 'stopped':
status.classList.add('stopped');
statusText.textContent = 'Stopped';
setTimeout(() => {
status.classList.add('hidden');
startBtn.classList.remove('hidden');
screenBtn.classList.remove('hidden');
pauseBtn.classList.add('hidden');
resumeBtn.classList.add('hidden');
stopBtn.classList.add('hidden');
}, 2000);
break;
}
}
// Recordings Management
function addRecording(type, blob, url) {
const recording = {
id: Date.now(),
type,
blob,
url,
size: blob.size,
mimeType: blob.type,
timestamp: new Date().toLocaleString()
};
recordings.push(recording);
updateRecordingsList();
log(`Recording saved: ${type} - ${formatSize(blob.size)}`, 'success');
}
function updateRecordingsList() {
const list = document.getElementById('recordingsList');
if (recordings.length === 0) {
list.innerHTML = '<p style="color: #aaa;">No recordings yet. Start recording to see them here.</p>';
return;
}
list.innerHTML = recordings.map(r => `
<div class="recording-item">
<div class="recording-info">
<div class="name">${r.type === 'audio' ? '🎤' : '🎬'} ${r.type.charAt(0).toUpperCase() + r.type.slice(1)} Recording</div>
<div class="meta">${r.timestamp} - ${formatSize(r.size)} - ${r.mimeType}</div>
</div>
<div class="recording-actions">
<button onclick="playRecording(${r.id})">Play</button>
<button class="secondary" onclick="downloadRecording(${r.id})">Download</button>
<button class="danger" onclick="deleteRecording(${r.id})">Delete</button>
</div>
</div>
`).join('');
}
function playRecording(id) {
const recording = recordings.find(r => r.id === id);
if (!recording) return;
if (recording.type === 'audio') {
document.getElementById('audioPlayback').src = recording.url;
document.getElementById('audioPlayback').play();
} else {
document.getElementById('videoPlayback').src = recording.url;
document.getElementById('videoPlayback').play();
}
log(`Playing recording: ${recording.type}`, 'info');
}
function downloadRecording(id) {
const recording = recordings.find(r => r.id === id);
if (!recording) return;
const ext = recording.type === 'audio' ? 'webm' : 'webm';
const filename = `${recording.type}-recording-${recording.id}.${ext}`;
const a = document.createElement('a');
a.href = recording.url;
a.download = filename;
a.click();
log(`Downloading: ${filename}`, 'success');
}
function deleteRecording(id) {
const index = recordings.findIndex(r => r.id === id);
if (index === -1) return;
URL.revokeObjectURL(recordings[index].url);
recordings.splice(index, 1);
updateRecordingsList();
log('Recording deleted', 'warn');
}
function clearRecordings() {
recordings.forEach(r => URL.revokeObjectURL(r.url));
recordings = [];
updateRecordingsList();
log('All recordings cleared', 'warn');
}
// Initialize
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "MediaRecorder Demo",
Description: "Record audio/video streams",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "MediaRecorder Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,506 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MutationObserver API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(233, 69, 96, 0.2); color: #e94560; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 250px;
overflow-y: auto;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #e94560; }
button.warning { background: #fbbf24; color: #1a1a2e; }
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.observed-container {
background: #0a0a1a;
border: 2px dashed #4ade80;
border-radius: 8px;
padding: 20px;
min-height: 150px;
margin-bottom: 15px;
transition: border-color 0.3s;
}
.observed-container.active {
border-color: #e94560;
box-shadow: 0 0 20px rgba(233, 69, 96, 0.3);
}
.child-element {
background: linear-gradient(135deg, #4ade80, #22c55e);
color: #1a1a2e;
padding: 15px;
border-radius: 6px;
margin: 10px 0;
font-weight: 600;
transition: all 0.3s;
}
.child-element.modified {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
}
.child-element.highlighted {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
}
.observer-config {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.config-option {
background: #0a0a1a;
padding: 15px;
border-radius: 8px;
}
.config-option label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.config-option input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: #4ade80;
}
.config-option .option-desc {
color: #888;
font-size: 0.8rem;
margin-top: 5px;
}
.mutation-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-right: 5px;
}
.mutation-badge.childList { background: #4ade80; color: #1a1a2e; }
.mutation-badge.attributes { background: #60a5fa; color: #1a1a2e; }
.mutation-badge.characterData { background: #fbbf24; color: #1a1a2e; }
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-bottom: 15px;
}
.stat-box {
background: #0a0a1a;
border-radius: 8px;
padding: 15px;
text-align: center;
}
.stat-box .value {
font-size: 2rem;
font-weight: 600;
color: #4ade80;
font-family: monospace;
}
.stat-box .label {
color: #aaa;
font-size: 0.85rem;
}
.text-content {
background: #0a0a1a;
padding: 15px;
border-radius: 8px;
min-height: 60px;
}
.text-content[contenteditable="true"] {
border: 2px solid #333;
cursor: text;
}
.text-content[contenteditable="true"]:focus {
border-color: #4ade80;
outline: none;
}
.observer-status {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.observer-status.active {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
}
.observer-status.inactive {
background: rgba(233, 69, 96, 0.2);
color: #e94560;
}
</style>
</head>
<body>
<div class="container">
<h1>MutationObserver API Demo</h1>
<p class="description">
The MutationObserver API provides the ability to watch for changes being made to the DOM tree,
including child elements, attributes, and text content.
</p>
<div class="status success" id="status">
MutationObserver: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Observer Configuration</h2>
<p style="color: #aaa; margin-bottom: 15px;">Configure what types of mutations to observe:</p>
<div class="observer-config">
<div class="config-option">
<label>
<input type="checkbox" id="configChildList" checked>
<span>Child List</span>
</label>
<div class="option-desc">Watch for added/removed child nodes</div>
</div>
<div class="config-option">
<label>
<input type="checkbox" id="configAttributes" checked>
<span>Attributes</span>
</label>
<div class="option-desc">Watch for attribute changes</div>
</div>
<div class="config-option">
<label>
<input type="checkbox" id="configCharacterData" checked>
<span>Character Data</span>
</label>
<div class="option-desc">Watch for text content changes</div>
</div>
<div class="config-option">
<label>
<input type="checkbox" id="configSubtree" checked>
<span>Subtree</span>
</label>
<div class="option-desc">Include descendant nodes</div>
</div>
</div>
<div class="controls">
<button onclick="startObserver()">Start Observer</button>
<button class="danger" onclick="stopObserver()">Stop Observer</button>
<span class="observer-status inactive" id="observerStatus">Inactive</span>
</div>
</div>
<div class="card">
<h2>Mutation Statistics</h2>
<div class="stats-grid">
<div class="stat-box">
<div class="value" id="statChildList">0</div>
<div class="label">Child List Mutations</div>
</div>
<div class="stat-box">
<div class="value" id="statAttributes">0</div>
<div class="label">Attribute Mutations</div>
</div>
<div class="stat-box">
<div class="value" id="statCharacterData">0</div>
<div class="label">Character Data Mutations</div>
</div>
</div>
<button class="secondary" onclick="resetStats()">Reset Statistics</button>
</div>
<div class="card">
<h2>Child List Mutations</h2>
<p style="color: #aaa; margin-bottom: 15px;">Add, remove, or modify child elements:</p>
<div class="controls">
<button onclick="addChild()">Add Child</button>
<button class="secondary" onclick="removeLastChild()">Remove Last Child</button>
<button class="warning" onclick="clearChildren()">Clear All</button>
</div>
<div class="observed-container" id="childContainer">
<div class="child-element">Initial Element 1</div>
<div class="child-element">Initial Element 2</div>
</div>
</div>
<div class="card">
<h2>Attribute Mutations</h2>
<p style="color: #aaa; margin-bottom: 15px;">Modify element attributes:</p>
<div class="controls">
<button onclick="toggleClass()">Toggle Class</button>
<button class="secondary" onclick="changeStyle()">Change Style</button>
<button class="warning" onclick="setDataAttribute()">Set Data Attribute</button>
</div>
<div class="observed-container" id="attributeTarget">
<div class="child-element" id="attrElement">Watch my attributes change!</div>
</div>
</div>
<div class="card">
<h2>Character Data Mutations</h2>
<p style="color: #aaa; margin-bottom: 15px;">Edit the text content below (click to edit):</p>
<div class="text-content" id="textContent" contenteditable="true">
Edit this text to trigger characterData mutations. Try typing, deleting, or pasting text!
</div>
</div>
<div class="card">
<h2>Batch Mutations</h2>
<p style="color: #aaa; margin-bottom: 15px;">Perform multiple mutations at once:</p>
<div class="controls">
<button onclick="batchMutations(5)">5 Mutations</button>
<button class="secondary" onclick="batchMutations(10)">10 Mutations</button>
<button class="warning" onclick="batchMutations(25)">25 Mutations</button>
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<div class="output" id="eventLog">Mutation events will appear here...</div>
<button onclick="clearLog()" style="margin-top: 15px;">Clear Log</button>
</div>
</div>
<script>
let observer = null;
let logEntries = [];
let childCounter = 2;
let stats = { childList: 0, attributes: 0, characterData: 0 };
function checkSupport() {
const supported = 'MutationObserver' in window;
const statusEl = document.getElementById('status');
const supportedEl = document.getElementById('supported');
if (supported) {
supportedEl.textContent = 'Available';
statusEl.classList.remove('error');
statusEl.classList.add('success');
} else {
supportedEl.textContent = 'Not available';
statusEl.classList.remove('success');
statusEl.classList.add('error');
}
return supported;
}
function log(type, message) {
const time = new Date().toLocaleTimeString();
const badge = `<span class="mutation-badge ${type}">${type}</span>`;
logEntries.unshift(`[${time}] ${type.toUpperCase()}: ${message}`);
if (logEntries.length > 30) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Mutation events will appear here...';
}
function updateStats() {
document.getElementById('statChildList').textContent = stats.childList;
document.getElementById('statAttributes').textContent = stats.attributes;
document.getElementById('statCharacterData').textContent = stats.characterData;
}
function resetStats() {
stats = { childList: 0, attributes: 0, characterData: 0 };
updateStats();
log('info', 'Statistics reset');
}
function getConfig() {
return {
childList: document.getElementById('configChildList').checked,
attributes: document.getElementById('configAttributes').checked,
characterData: document.getElementById('configCharacterData').checked,
subtree: document.getElementById('configSubtree').checked,
attributeOldValue: true,
characterDataOldValue: true
};
}
function startObserver() {
if (observer) {
observer.disconnect();
}
const config = getConfig();
observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
switch (mutation.type) {
case 'childList':
stats.childList++;
if (mutation.addedNodes.length > 0) {
log('childList', `${mutation.addedNodes.length} node(s) added`);
}
if (mutation.removedNodes.length > 0) {
log('childList', `${mutation.removedNodes.length} node(s) removed`);
}
break;
case 'attributes':
stats.attributes++;
log('attributes', `"${mutation.attributeName}" changed on <${mutation.target.tagName.toLowerCase()}>`);
break;
case 'characterData':
stats.characterData++;
const preview = mutation.target.textContent.substring(0, 30);
log('characterData', `Text changed: "${preview}..."`);
break;
}
updateStats();
});
// Flash the container to show mutation detected
document.querySelectorAll('.observed-container').forEach(el => {
el.classList.add('active');
setTimeout(() => el.classList.remove('active'), 300);
});
});
// Observe all containers
const targets = [
document.getElementById('childContainer'),
document.getElementById('attributeTarget'),
document.getElementById('textContent')
];
targets.forEach(target => {
if (target) observer.observe(target, config);
});
document.getElementById('observerStatus').textContent = 'Active';
document.getElementById('observerStatus').className = 'observer-status active';
log('info', 'Observer started with config: ' + JSON.stringify(config));
}
function stopObserver() {
if (observer) {
observer.disconnect();
observer = null;
}
document.getElementById('observerStatus').textContent = 'Inactive';
document.getElementById('observerStatus').className = 'observer-status inactive';
log('info', 'Observer stopped');
}
// Child List mutations
function addChild() {
const container = document.getElementById('childContainer');
childCounter++;
const child = document.createElement('div');
child.className = 'child-element';
child.textContent = `Dynamic Element ${childCounter}`;
container.appendChild(child);
}
function removeLastChild() {
const container = document.getElementById('childContainer');
const children = container.querySelectorAll('.child-element');
if (children.length > 0) {
children[children.length - 1].remove();
}
}
function clearChildren() {
const container = document.getElementById('childContainer');
container.innerHTML = '';
childCounter = 0;
}
// Attribute mutations
let toggleState = false;
function toggleClass() {
const el = document.getElementById('attrElement');
toggleState = !toggleState;
el.classList.toggle('modified', toggleState);
el.classList.toggle('highlighted', !toggleState);
}
let styleState = 0;
const styles = [
{ transform: 'scale(1)', borderRadius: '6px' },
{ transform: 'scale(1.02)', borderRadius: '12px' },
{ transform: 'scale(0.98)', borderRadius: '20px' }
];
function changeStyle() {
const el = document.getElementById('attrElement');
styleState = (styleState + 1) % styles.length;
Object.assign(el.style, styles[styleState]);
}
let dataCounter = 0;
function setDataAttribute() {
const el = document.getElementById('attrElement');
dataCounter++;
el.dataset.counter = dataCounter;
el.dataset.timestamp = Date.now();
}
// Batch mutations
function batchMutations(count) {
const container = document.getElementById('childContainer');
for (let i = 0; i < count; i++) {
setTimeout(() => {
// Add a child
childCounter++;
const child = document.createElement('div');
child.className = 'child-element';
child.textContent = `Batch Element ${childCounter}`;
container.appendChild(child);
// Also modify an attribute
child.dataset.batchIndex = i;
}, i * 50);
}
log('info', `Batch of ${count} mutations started`);
}
// Initialize
if (checkSupport()) {
startObserver();
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Mutation Observer Demo",
Description: "DOM change observation",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Mutation Observer Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,299 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notifications API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
input, textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
margin-bottom: 15px;
}
textarea { min-height: 80px; resize: vertical; }
label { display: block; color: #aaa; font-size: 0.9rem; margin-bottom: 5px; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.warning { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.row { display: flex; gap: 15px; flex-wrap: wrap; }
.row > div { flex: 1; min-width: 200px; }
.preview {
background: #2d2d3a;
border-radius: 8px;
padding: 15px;
display: flex;
gap: 15px;
align-items: flex-start;
}
.preview-icon {
width: 48px;
height: 48px;
background: #4ade80;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.preview-content { flex: 1; }
.preview-title { font-weight: 600; margin-bottom: 5px; }
.preview-body { color: #aaa; font-size: 0.9rem; }
</style>
</head>
<body>
<div class="container">
<h1>Notifications API Demo</h1>
<p class="description">
The Notifications API allows web applications to display system notifications
to the user, even when the app is in the background.
</p>
<div id="status" class="status">
Notifications: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Permission</h2>
<p style="margin-bottom: 15px;">Current permission: <strong id="permissionStatus">checking...</strong></p>
<button onclick="requestPermission()">Request Permission</button>
<p style="color: #666; font-size: 0.85rem; margin-top: 10px;">
Note: Permission must be granted before notifications can be shown.
Some browsers block permission requests in webviews.
</p>
</div>
<div class="card">
<h2>Create Notification</h2>
<div class="row">
<div>
<label for="notifTitle">Title</label>
<input type="text" id="notifTitle" value="Hello from Wails!">
</div>
</div>
<label for="notifBody">Body</label>
<textarea id="notifBody">This is a notification from the WebView API demo.</textarea>
<div class="row">
<div>
<label for="notifIcon">Icon (emoji)</label>
<select id="notifIcon">
<option value="">None</option>
<option value="bell">Bell</option>
<option value="check">Check</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
<div>
<label for="notifTag">Tag (for grouping)</label>
<input type="text" id="notifTag" placeholder="optional-tag">
</div>
</div>
<label style="display: flex; align-items: center; gap: 8px; margin-bottom: 15px;">
<input type="checkbox" id="notifRequireInteraction"> Require interaction (stays visible)
</label>
<button onclick="showNotification()">Show Notification</button>
<button class="secondary" onclick="showMultiple()">Show Multiple</button>
</div>
<div class="card">
<h2>Preview</h2>
<div class="preview">
<div class="preview-icon" id="previewIcon">-</div>
<div class="preview-content">
<div class="preview-title" id="previewTitle">Notification Title</div>
<div class="preview-body" id="previewBody">Notification body text...</div>
</div>
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<div class="output" id="eventLog">Notification events will appear here...</div>
</div>
</div>
<script>
const icons = {
bell: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%234ade80"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/></svg>',
check: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%234ade80"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>',
warning: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23fbbf24"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>',
error: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23f87171"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>'
};
let logEntries = [];
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 20) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function checkSupport() {
const supported = 'Notification' in window;
document.getElementById('supported').textContent = supported ? 'Available' : 'Not available';
document.getElementById('status').className = supported ? 'status success' : 'status error';
if (supported) {
updatePermissionStatus();
}
}
function updatePermissionStatus() {
const permission = Notification.permission;
document.getElementById('permissionStatus').textContent = permission;
const status = document.getElementById('status');
if (permission === 'granted') {
status.className = 'status success';
status.innerHTML = 'Notifications: <span>Permission granted</span>';
} else if (permission === 'denied') {
status.className = 'status error';
status.innerHTML = 'Notifications: <span>Permission denied</span>';
} else {
status.className = 'status warning';
status.innerHTML = 'Notifications: <span>Permission not requested</span>';
}
}
async function requestPermission() {
try {
const permission = await Notification.requestPermission();
updatePermissionStatus();
log(`Permission request result: ${permission}`);
} catch (e) {
log(`Permission request failed: ${e.message}`);
}
}
function showNotification() {
if (Notification.permission !== 'granted') {
log('Permission not granted. Request permission first.');
alert('Please grant notification permission first.');
return;
}
const title = document.getElementById('notifTitle').value;
const body = document.getElementById('notifBody').value;
const iconType = document.getElementById('notifIcon').value;
const tag = document.getElementById('notifTag').value;
const requireInteraction = document.getElementById('notifRequireInteraction').checked;
const options = {
body,
requireInteraction,
};
if (iconType && icons[iconType]) {
options.icon = icons[iconType];
}
if (tag) {
options.tag = tag;
}
try {
const notification = new Notification(title, options);
notification.onclick = () => {
log('Notification clicked');
window.focus();
notification.close();
};
notification.onshow = () => log('Notification shown');
notification.onclose = () => log('Notification closed');
notification.onerror = (e) => log(`Notification error: ${e.message}`);
log(`Notification created: "${title}"`);
} catch (e) {
log(`Failed to create notification: ${e.message}`);
}
}
function showMultiple() {
const notifications = [
{ title: 'Message 1', body: 'First notification' },
{ title: 'Message 2', body: 'Second notification' },
{ title: 'Message 3', body: 'Third notification' },
];
notifications.forEach((n, i) => {
setTimeout(() => {
new Notification(n.title, { body: n.body, tag: 'multi-' + i });
log(`Sent notification ${i + 1}`);
}, i * 500);
});
}
// Update preview
function updatePreview() {
document.getElementById('previewTitle').textContent = document.getElementById('notifTitle').value || 'Notification Title';
document.getElementById('previewBody').textContent = document.getElementById('notifBody').value || 'Notification body text...';
const iconType = document.getElementById('notifIcon').value;
const previewIcon = document.getElementById('previewIcon');
if (iconType === 'bell') previewIcon.textContent = '';
else if (iconType === 'check') previewIcon.textContent = '';
else if (iconType === 'warning') previewIcon.textContent = '';
else if (iconType === 'error') previewIcon.textContent = '';
else previewIcon.textContent = '';
}
document.getElementById('notifTitle').addEventListener('input', updatePreview);
document.getElementById('notifBody').addEventListener('input', updatePreview);
document.getElementById('notifIcon').addEventListener('change', updatePreview);
checkSupport();
updatePreview();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Notifications Demo",
Description: "Demonstrates the Notifications API",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Notifications Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,543 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Visibility API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
.visibility-display {
text-align: center;
padding: 40px;
margin-bottom: 20px;
}
.visibility-icon {
font-size: 80px;
margin-bottom: 20px;
transition: all 0.3s;
}
.visibility-state {
font-size: 2rem;
font-weight: bold;
margin-bottom: 10px;
transition: color 0.3s;
}
.visibility-state.visible { color: #4ade80; }
.visibility-state.hidden { color: #f87171; }
.visibility-detail { color: #aaa; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: rgba(0,0,0,0.3);
padding: 20px;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #4ade80;
margin-bottom: 5px;
}
.stat-label {
color: #aaa;
font-size: 0.85rem;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.demo-section {
margin-top: 20px;
padding: 15px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
}
.demo-section h3 {
color: #60a5fa;
margin-bottom: 10px;
font-size: 1rem;
}
.timer-display {
font-family: monospace;
font-size: 3rem;
color: #4ade80;
text-align: center;
padding: 20px;
background: rgba(0,0,0,0.3);
border-radius: 8px;
margin-bottom: 15px;
}
.timer-display.paused { color: #fbbf24; }
.video-placeholder {
width: 100%;
height: 200px;
background: linear-gradient(45deg, #333, #444);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 15px;
position: relative;
overflow: hidden;
}
.video-placeholder::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
background: linear-gradient(45deg, transparent 40%, rgba(255,255,255,0.1) 50%, transparent 60%);
animation: shimmer 2s infinite;
}
.video-placeholder.paused::before {
animation: none;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.play-indicator {
width: 60px;
height: 60px;
background: rgba(74, 222, 128, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.play-indicator::after {
content: '';
border-left: 20px solid #1a1a2e;
border-top: 12px solid transparent;
border-bottom: 12px solid transparent;
margin-left: 5px;
}
.paused .play-indicator {
background: rgba(251, 191, 36, 0.9);
}
.paused .play-indicator::after {
border-left: 8px solid #1a1a2e;
border-right: 8px solid #1a1a2e;
border-top: none;
border-bottom: none;
width: 8px;
height: 20px;
margin-left: 0;
}
.notification-badge {
display: inline-block;
background: #f87171;
color: white;
padding: 3px 8px;
border-radius: 12px;
font-size: 0.8rem;
margin-left: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>Page Visibility API Demo</h1>
<p class="description">
The Page Visibility API provides events to know when a document becomes visible or hidden,
allowing you to pause/resume activities, save resources, and improve user experience.
</p>
<div id="status" class="status success">
Page Visibility API: <span id="available">checking...</span>
</div>
<div class="card">
<div class="visibility-display">
<div class="visibility-icon" id="visibilityIcon">&#128065;</div>
<div class="visibility-state visible" id="visibilityState">VISIBLE</div>
<div class="visibility-detail" id="visibilityDetail">document.visibilityState: visible</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="visibleTime">0s</div>
<div class="stat-label">Time Visible</div>
</div>
<div class="stat-card">
<div class="stat-value" id="hiddenTime">0s</div>
<div class="stat-label">Time Hidden</div>
</div>
<div class="stat-card">
<div class="stat-value" id="changeCount">0</div>
<div class="stat-label">State Changes</div>
</div>
<div class="stat-card">
<div class="stat-value" id="hiddenCount">0</div>
<div class="stat-label">Times Hidden</div>
</div>
</div>
</div>
<div class="card">
<h2>Timer Demo (Pauses When Hidden)</h2>
<p style="color: #aaa; margin-bottom: 15px;">
This timer pauses when the page is hidden and resumes when visible.
Try switching to another tab or minimizing the window.
</p>
<div class="timer-display" id="timerDisplay">00:00:00</div>
<button onclick="startTimer()">Start Timer</button>
<button onclick="pauseTimer()">Pause</button>
<button class="danger" onclick="resetTimer()">Reset</button>
<span id="timerStatus" style="color: #aaa; margin-left: 10px;">Timer stopped</span>
</div>
<div class="card">
<h2>Video Simulation (Pauses When Hidden)</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Simulated video player that automatically pauses when you switch tabs.
</p>
<div class="video-placeholder" id="videoPlaceholder">
<div class="play-indicator"></div>
</div>
<button onclick="playVideo()">Play</button>
<button onclick="pauseVideo()">Pause</button>
<span id="videoStatus" style="color: #aaa; margin-left: 10px;">Video paused</span>
</div>
<div class="card">
<h2>Use Cases</h2>
<div class="demo-section">
<h3>Common Applications</h3>
<ul style="color: #aaa; padding-left: 20px; line-height: 1.8;">
<li><strong style="color: #4ade80;">Pause media playback</strong> - Stop videos/audio when tab is hidden</li>
<li><strong style="color: #4ade80;">Stop animations</strong> - Pause CSS/JS animations to save CPU</li>
<li><strong style="color: #4ade80;">Defer updates</strong> - Skip DOM updates while hidden</li>
<li><strong style="color: #4ade80;">Analytics</strong> - Track actual time spent viewing content</li>
<li><strong style="color: #4ade80;">Save resources</strong> - Stop polling/WebSocket activity</li>
<li><strong style="color: #4ade80;">Show notifications</strong> - Alert users to updates when they return</li>
</ul>
</div>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Visibility events will be logged here...
Try switching tabs, minimizing the window, or switching to another application.</div>
</div>
</div>
<script>
let logEntries = [];
let stateChangeCount = 0;
let hiddenCount = 0;
let visibleStartTime = Date.now();
let hiddenStartTime = null;
let totalVisibleTime = 0;
let totalHiddenTime = 0;
// Timer state
let timerInterval = null;
let timerSeconds = 0;
let timerRunning = false;
let timerWasRunning = false;
// Video state
let videoPlaying = false;
let videoWasPlaying = false;
function checkSupport() {
const hasVisibility = 'visibilityState' in document;
document.getElementById('available').textContent = hasVisibility ? 'Available' : 'Not available';
document.getElementById('status').className = hasVisibility ? 'status success' : 'status error';
return hasVisibility;
}
function checkFeatures() {
const features = document.getElementById('features');
const checks = [
{ name: 'document.visibilityState', supported: 'visibilityState' in document },
{ name: 'document.hidden', supported: 'hidden' in document },
{ name: 'visibilitychange event', supported: 'onvisibilitychange' in document },
{ name: 'Page Lifecycle API', supported: 'onfreeze' in document },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
}
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 30) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Log cleared.';
}
function updateVisibilityDisplay() {
const state = document.visibilityState;
const isHidden = document.hidden;
const stateEl = document.getElementById('visibilityState');
const iconEl = document.getElementById('visibilityIcon');
const detailEl = document.getElementById('visibilityDetail');
if (isHidden) {
stateEl.textContent = 'HIDDEN';
stateEl.className = 'visibility-state hidden';
iconEl.textContent = '\u{1F648}'; // See-no-evil monkey
iconEl.style.opacity = '0.5';
} else {
stateEl.textContent = 'VISIBLE';
stateEl.className = 'visibility-state visible';
iconEl.textContent = '\u{1F441}'; // Eye
iconEl.style.opacity = '1';
}
detailEl.textContent = `document.visibilityState: ${state}\ndocument.hidden: ${isHidden}`;
}
function updateStats() {
const now = Date.now();
if (!document.hidden && visibleStartTime) {
totalVisibleTime = (now - visibleStartTime) / 1000;
}
document.getElementById('visibleTime').textContent = formatTime(totalVisibleTime);
document.getElementById('hiddenTime').textContent = formatTime(totalHiddenTime);
document.getElementById('changeCount').textContent = stateChangeCount;
document.getElementById('hiddenCount').textContent = hiddenCount;
}
function formatTime(seconds) {
if (seconds < 60) return Math.floor(seconds) + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + Math.floor(seconds % 60) + 's';
return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
}
// Timer functions
function startTimer() {
if (timerInterval) return;
timerRunning = true;
timerInterval = setInterval(() => {
timerSeconds++;
updateTimerDisplay();
}, 1000);
document.getElementById('timerStatus').textContent = 'Timer running';
document.getElementById('timerDisplay').classList.remove('paused');
log('Timer started');
}
function pauseTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
timerRunning = false;
document.getElementById('timerStatus').textContent = 'Timer paused';
document.getElementById('timerDisplay').classList.add('paused');
log('Timer paused');
}
function resetTimer() {
pauseTimer();
timerSeconds = 0;
updateTimerDisplay();
document.getElementById('timerStatus').textContent = 'Timer reset';
log('Timer reset');
}
function updateTimerDisplay() {
const hours = Math.floor(timerSeconds / 3600);
const minutes = Math.floor((timerSeconds % 3600) / 60);
const seconds = timerSeconds % 60;
document.getElementById('timerDisplay').textContent =
String(hours).padStart(2, '0') + ':' +
String(minutes).padStart(2, '0') + ':' +
String(seconds).padStart(2, '0');
}
// Video functions
function playVideo() {
videoPlaying = true;
document.getElementById('videoPlaceholder').classList.remove('paused');
document.getElementById('videoStatus').textContent = 'Video playing';
log('Video playing');
}
function pauseVideo() {
videoPlaying = false;
document.getElementById('videoPlaceholder').classList.add('paused');
document.getElementById('videoStatus').textContent = 'Video paused';
log('Video paused');
}
// Visibility change handler
function handleVisibilityChange() {
const now = Date.now();
stateChangeCount++;
if (document.hidden) {
// Page became hidden
hiddenCount++;
hiddenStartTime = now;
// Save accumulated visible time
if (visibleStartTime) {
totalVisibleTime += (now - visibleStartTime) / 1000;
visibleStartTime = null;
}
// Pause timer if running
if (timerRunning) {
timerWasRunning = true;
clearInterval(timerInterval);
timerInterval = null;
document.getElementById('timerStatus').textContent = 'Timer paused (tab hidden)';
document.getElementById('timerDisplay').classList.add('paused');
}
// Pause video if playing
if (videoPlaying) {
videoWasPlaying = true;
document.getElementById('videoPlaceholder').classList.add('paused');
document.getElementById('videoStatus').textContent = 'Video paused (tab hidden)';
}
log('Page HIDDEN - Activities paused');
} else {
// Page became visible
visibleStartTime = now;
// Save accumulated hidden time
if (hiddenStartTime) {
totalHiddenTime += (now - hiddenStartTime) / 1000;
hiddenStartTime = null;
}
// Resume timer if it was running
if (timerWasRunning) {
timerWasRunning = false;
timerRunning = true;
timerInterval = setInterval(() => {
timerSeconds++;
updateTimerDisplay();
}, 1000);
document.getElementById('timerStatus').textContent = 'Timer resumed';
document.getElementById('timerDisplay').classList.remove('paused');
}
// Resume video if it was playing
if (videoWasPlaying) {
videoWasPlaying = false;
document.getElementById('videoPlaceholder').classList.remove('paused');
document.getElementById('videoStatus').textContent = 'Video resumed';
}
log('Page VISIBLE - Activities resumed');
}
updateVisibilityDisplay();
updateStats();
}
// Stats update interval
setInterval(updateStats, 1000);
// Initialize
if (checkSupport()) {
checkFeatures();
updateVisibilityDisplay();
// Listen for visibility changes
document.addEventListener('visibilitychange', handleVisibilityChange);
// Also listen for Page Lifecycle events if available
if ('onfreeze' in document) {
document.addEventListener('freeze', () => {
log('Page FROZEN (Page Lifecycle API)');
});
document.addEventListener('resume', () => {
log('Page RESUMED (Page Lifecycle API)');
});
}
log('Page Visibility API initialized');
log(`Initial state: ${document.visibilityState}`);
}
// Initialize video as paused
document.getElementById('videoPlaceholder').classList.add('paused');
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Page Visibility Demo",
Description: "Tab visibility detection",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Page Visibility Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,537 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Performance API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(233, 69, 96, 0.2); color: #e94560; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary {
background: #60a5fa;
}
button.danger {
background: #e94560;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.metric-box {
background: #0a0a1a;
border-radius: 8px;
padding: 15px;
text-align: center;
}
.metric-box .label {
color: #aaa;
font-size: 0.85rem;
margin-bottom: 5px;
}
.metric-box .value {
color: #4ade80;
font-size: 1.5rem;
font-weight: 600;
font-family: monospace;
}
.metric-box .unit {
color: #888;
font-size: 0.75rem;
}
.timeline {
background: #0a0a1a;
border-radius: 8px;
padding: 20px;
position: relative;
height: 150px;
overflow-x: auto;
}
.timeline-bar {
position: absolute;
height: 30px;
border-radius: 4px;
display: flex;
align-items: center;
padding: 0 8px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
}
.timeline-label {
position: absolute;
font-size: 0.7rem;
color: #aaa;
bottom: 5px;
}
.marks-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.marks-table th, .marks-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.marks-table th {
color: #4ade80;
font-weight: 600;
}
.marks-table td { color: #ddd; }
.marks-table tr:hover { background: rgba(255,255,255,0.05); }
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
input[type="text"] {
background: #0a0a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 10px 15px;
color: #eee;
font-family: monospace;
}
input[type="text"]:focus {
outline: none;
border-color: #4ade80;
}
.progress-bar {
height: 20px;
background: #0a0a1a;
border-radius: 10px;
overflow: hidden;
margin-bottom: 15px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4ade80, #22c55e);
transition: width 0.1s;
border-radius: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>Performance API Demo</h1>
<p class="description">
The Performance API provides access to performance-related information,
enabling precise timing measurements, user timing marks, and performance metrics.
</p>
<div class="status success" id="status">
Performance API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Page Load Timing</h2>
<p style="color: #aaa; margin-bottom: 15px;">Key performance metrics from page navigation:</p>
<div class="metrics-grid" id="loadMetrics">
<div class="metric-box">
<div class="label">DOM Content Loaded</div>
<div class="value" id="domContentLoaded">--</div>
<div class="unit">milliseconds</div>
</div>
<div class="metric-box">
<div class="label">Page Load Time</div>
<div class="value" id="loadTime">--</div>
<div class="unit">milliseconds</div>
</div>
<div class="metric-box">
<div class="label">DNS Lookup</div>
<div class="value" id="dnsTime">--</div>
<div class="unit">milliseconds</div>
</div>
<div class="metric-box">
<div class="label">Time to First Byte</div>
<div class="value" id="ttfb">--</div>
<div class="unit">milliseconds</div>
</div>
</div>
<button onclick="refreshLoadMetrics()">Refresh Metrics</button>
</div>
<div class="card">
<h2>Performance Marks & Measures</h2>
<p style="color: #aaa; margin-bottom: 15px;">Create custom timing marks and measure durations:</p>
<div class="controls">
<input type="text" id="markName" placeholder="Mark name" value="my-mark">
<button onclick="createMark()">Create Mark</button>
<button class="secondary" onclick="createMeasure()">Measure Since Last Mark</button>
<button class="danger" onclick="clearMarks()">Clear All</button>
</div>
<div style="overflow-x: auto;">
<table class="marks-table" id="marksTable">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Start Time</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="marksBody">
<tr><td colspan="4" style="color: #888;">No marks or measures yet</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>Simulated Task Performance</h2>
<p style="color: #aaa; margin-bottom: 15px;">Run simulated tasks and measure their performance:</p>
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width: 0%"></div>
</div>
<div class="controls">
<button onclick="runTask('light')">Light Task (10ms)</button>
<button onclick="runTask('medium')">Medium Task (100ms)</button>
<button onclick="runTask('heavy')">Heavy Task (500ms)</button>
<button class="secondary" onclick="runMultipleTasks()">Run All Tasks</button>
</div>
<div class="metrics-grid">
<div class="metric-box">
<div class="label">Last Task Duration</div>
<div class="value" id="lastTaskDuration">--</div>
<div class="unit">milliseconds</div>
</div>
<div class="metric-box">
<div class="label">Total Tasks Run</div>
<div class="value" id="totalTasks">0</div>
<div class="unit">tasks</div>
</div>
<div class="metric-box">
<div class="label">Average Duration</div>
<div class="value" id="avgDuration">--</div>
<div class="unit">milliseconds</div>
</div>
</div>
</div>
<div class="card">
<h2>Navigation Timeline</h2>
<p style="color: #aaa; margin-bottom: 15px;">Visual timeline of page load phases:</p>
<div class="timeline" id="timeline">
<div style="color: #888; padding: 60px 0; text-align: center;">Loading timeline...</div>
</div>
</div>
<div class="card">
<h2>Resource Timing</h2>
<p style="color: #aaa; margin-bottom: 15px;">Performance entries for loaded resources:</p>
<div class="output" id="resourceOutput">Loading resource timing data...</div>
<button onclick="refreshResources()" style="margin-top: 15px;">Refresh Resources</button>
</div>
<div class="card">
<h2>Event Log</h2>
<div class="output" id="eventLog">Performance events will appear here...</div>
</div>
</div>
<script>
let logEntries = [];
let taskCount = 0;
let totalDuration = 0;
let lastMarkName = null;
function checkSupport() {
const supported = 'performance' in window && 'mark' in performance;
const statusEl = document.getElementById('status');
const supportedEl = document.getElementById('supported');
if (supported) {
supportedEl.textContent = 'Available';
statusEl.classList.remove('error');
statusEl.classList.add('success');
} else {
supportedEl.textContent = 'Not available';
statusEl.classList.remove('success');
statusEl.classList.add('error');
}
return supported;
}
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 20) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function refreshLoadMetrics() {
const timing = performance.timing || {};
const nav = performance.getEntriesByType('navigation')[0] || {};
// DOM Content Loaded
const domContentLoaded = nav.domContentLoadedEventEnd ||
(timing.domContentLoadedEventEnd - timing.navigationStart);
document.getElementById('domContentLoaded').textContent =
domContentLoaded ? Math.round(domContentLoaded) : 'N/A';
// Load Time
const loadTime = nav.loadEventEnd ||
(timing.loadEventEnd - timing.navigationStart);
document.getElementById('loadTime').textContent =
loadTime ? Math.round(loadTime) : 'N/A';
// DNS Lookup
const dnsTime = nav.domainLookupEnd - nav.domainLookupStart ||
(timing.domainLookupEnd - timing.domainLookupStart);
document.getElementById('dnsTime').textContent =
dnsTime >= 0 ? Math.round(dnsTime) : 'N/A';
// Time to First Byte
const ttfb = nav.responseStart - nav.requestStart ||
(timing.responseStart - timing.requestStart);
document.getElementById('ttfb').textContent =
ttfb >= 0 ? Math.round(ttfb) : 'N/A';
log('Load metrics refreshed');
}
function createMark() {
const name = document.getElementById('markName').value || 'mark-' + Date.now();
performance.mark(name);
lastMarkName = name;
log(`Mark created: "${name}" at ${performance.now().toFixed(2)}ms`);
updateMarksTable();
}
function createMeasure() {
if (!lastMarkName) {
log('Error: Create a mark first');
return;
}
const measureName = 'measure-' + Date.now();
try {
performance.measure(measureName, lastMarkName);
const measure = performance.getEntriesByName(measureName)[0];
log(`Measure created: "${measureName}" - ${measure.duration.toFixed(2)}ms`);
updateMarksTable();
} catch (e) {
log('Error creating measure: ' + e.message);
}
}
function clearMarks() {
performance.clearMarks();
performance.clearMeasures();
lastMarkName = null;
log('All marks and measures cleared');
updateMarksTable();
}
function updateMarksTable() {
const marks = performance.getEntriesByType('mark');
const measures = performance.getEntriesByType('measure');
const tbody = document.getElementById('marksBody');
if (marks.length === 0 && measures.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="color: #888;">No marks or measures yet</td></tr>';
return;
}
tbody.innerHTML = '';
[...marks, ...measures]
.sort((a, b) => b.startTime - a.startTime)
.slice(0, 10)
.forEach(entry => {
const row = document.createElement('tr');
row.innerHTML = `
<td style="color: ${entry.entryType === 'mark' ? '#4ade80' : '#60a5fa'}">${entry.entryType}</td>
<td>${entry.name}</td>
<td>${entry.startTime.toFixed(2)}ms</td>
<td>${entry.duration ? entry.duration.toFixed(2) + 'ms' : '-'}</td>
`;
tbody.appendChild(row);
});
}
function simulateWork(duration) {
return new Promise(resolve => {
const start = performance.now();
const update = () => {
const elapsed = performance.now() - start;
const progress = Math.min(100, (elapsed / duration) * 100);
document.getElementById('progressFill').style.width = progress + '%';
if (elapsed < duration) {
requestAnimationFrame(update);
} else {
resolve();
}
};
update();
});
}
async function runTask(type) {
const durations = { light: 10, medium: 100, heavy: 500 };
const duration = durations[type];
const taskName = `task-${type}-${Date.now()}`;
performance.mark(taskName + '-start');
log(`Starting ${type} task...`);
await simulateWork(duration);
performance.mark(taskName + '-end');
performance.measure(taskName, taskName + '-start', taskName + '-end');
const measure = performance.getEntriesByName(taskName)[0];
const actualDuration = measure.duration;
taskCount++;
totalDuration += actualDuration;
document.getElementById('lastTaskDuration').textContent = actualDuration.toFixed(2);
document.getElementById('totalTasks').textContent = taskCount;
document.getElementById('avgDuration').textContent = (totalDuration / taskCount).toFixed(2);
log(`${type.charAt(0).toUpperCase() + type.slice(1)} task completed: ${actualDuration.toFixed(2)}ms`);
document.getElementById('progressFill').style.width = '0%';
}
async function runMultipleTasks() {
await runTask('light');
await runTask('medium');
await runTask('heavy');
log('All tasks completed');
}
function drawTimeline() {
const nav = performance.getEntriesByType('navigation')[0];
if (!nav) {
document.getElementById('timeline').innerHTML =
'<div style="color: #888; padding: 60px 0; text-align: center;">Navigation timing not available</div>';
return;
}
const timeline = document.getElementById('timeline');
const total = nav.loadEventEnd || nav.domComplete || 1;
const width = timeline.clientWidth - 40;
const phases = [
{ name: 'DNS', start: nav.domainLookupStart, end: nav.domainLookupEnd, color: '#60a5fa', row: 0 },
{ name: 'Connect', start: nav.connectStart, end: nav.connectEnd, color: '#a855f7', row: 0 },
{ name: 'Request', start: nav.requestStart, end: nav.responseStart, color: '#fbbf24', row: 1 },
{ name: 'Response', start: nav.responseStart, end: nav.responseEnd, color: '#4ade80', row: 1 },
{ name: 'DOM Processing', start: nav.domInteractive, end: nav.domComplete, color: '#f87171', row: 2 },
{ name: 'Load Event', start: nav.loadEventStart, end: nav.loadEventEnd, color: '#22d3d1', row: 2 }
];
timeline.innerHTML = '';
phases.forEach(phase => {
if (phase.start && phase.end && phase.end > phase.start) {
const left = 20 + (phase.start / total) * width;
const barWidth = Math.max(50, ((phase.end - phase.start) / total) * width);
const top = 15 + phase.row * 40;
const bar = document.createElement('div');
bar.className = 'timeline-bar';
bar.style.cssText = `
left: ${left}px;
width: ${barWidth}px;
top: ${top}px;
background: ${phase.color};
color: #1a1a2e;
`;
bar.textContent = `${phase.name} (${(phase.end - phase.start).toFixed(0)}ms)`;
timeline.appendChild(bar);
}
});
// Add time labels
[0, 0.25, 0.5, 0.75, 1].forEach(pct => {
const label = document.createElement('div');
label.className = 'timeline-label';
label.style.left = (20 + pct * width) + 'px';
label.textContent = Math.round(pct * total) + 'ms';
timeline.appendChild(label);
});
}
function refreshResources() {
const resources = performance.getEntriesByType('resource');
const output = document.getElementById('resourceOutput');
if (resources.length === 0) {
output.textContent = 'No resource entries found';
return;
}
const summary = resources.slice(0, 10).map(r => {
const name = r.name.split('/').pop().substring(0, 30);
return `${r.initiatorType.padEnd(10)} | ${name.padEnd(30)} | ${r.duration.toFixed(0)}ms`;
});
output.textContent = `Found ${resources.length} resources:\n\n` +
'Type | Name | Duration\n' +
'-'.repeat(55) + '\n' +
summary.join('\n');
log(`Resource timing refreshed: ${resources.length} entries`);
}
// Initialize
if (checkSupport()) {
window.addEventListener('load', () => {
setTimeout(() => {
refreshLoadMetrics();
drawTimeline();
refreshResources();
log('Performance API demo initialized');
}, 100);
});
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Performance API Demo",
Description: "Performance timing and metrics",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Performance API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,512 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Permissions API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.small { padding: 6px 12px; font-size: 0.85rem; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.warning { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.permission-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 15px;
}
.permission-card {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
border-left: 4px solid #333;
transition: all 0.3s;
}
.permission-card.granted { border-left-color: #4ade80; }
.permission-card.denied { border-left-color: #f87171; }
.permission-card.prompt { border-left-color: #fbbf24; }
.permission-card .name {
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.permission-card .state {
font-size: 0.85rem;
padding: 4px 10px;
border-radius: 4px;
display: inline-block;
margin-bottom: 10px;
}
.permission-card .state.granted { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.permission-card .state.denied { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.permission-card .state.prompt { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.permission-card .state.unknown { background: rgba(156, 163, 175, 0.2); color: #9ca3af; }
.permission-card .description {
font-size: 0.85rem;
color: #888;
margin-bottom: 10px;
}
.permission-card .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
background: #4ade80;
color: #1a1a2e;
font-weight: 600;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
z-index: 1000;
}
.notification.show {
transform: translateY(0);
opacity: 1;
}
.icon {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.summary-bar {
display: flex;
gap: 20px;
flex-wrap: wrap;
padding: 15px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
margin-bottom: 20px;
}
.summary-item {
display: flex;
align-items: center;
gap: 8px;
}
.summary-item .count {
font-size: 1.5rem;
font-weight: 700;
}
.summary-item .label { color: #888; font-size: 0.85rem; }
.summary-item.granted .count { color: #4ade80; }
.summary-item.denied .count { color: #f87171; }
.summary-item.prompt .count { color: #fbbf24; }
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
</style>
</head>
<body>
<div class="container">
<h1>Permissions API Demo</h1>
<p class="description">
The Permissions API allows you to query and monitor the status of various browser permissions.
It provides a unified way to check whether permissions are granted, denied, or need to be requested.
</p>
<div id="status" class="status">
Permissions API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Permission Summary</h2>
<div class="summary-bar" id="summaryBar">
<div class="summary-item granted">
<span class="count" id="grantedCount">0</span>
<span class="label">Granted</span>
</div>
<div class="summary-item denied">
<span class="count" id="deniedCount">0</span>
<span class="label">Denied</span>
</div>
<div class="summary-item prompt">
<span class="count" id="promptCount">0</span>
<span class="label">Prompt</span>
</div>
</div>
<button onclick="refreshAllPermissions()">Refresh All Permissions</button>
</div>
<div class="card">
<h2>Standard Permissions</h2>
<p style="color: #888; font-size: 0.9rem; margin-bottom: 15px;">
These are the commonly supported permission types across browsers.
</p>
<div class="permission-grid" id="standardPermissions"></div>
</div>
<div class="card">
<h2>Extended Permissions</h2>
<p style="color: #888; font-size: 0.9rem; margin-bottom: 15px;">
These permissions may not be supported in all browsers or WebView implementations.
</p>
<div class="permission-grid" id="extendedPermissions"></div>
</div>
<div class="card">
<h2>Custom Permission Query</h2>
<p style="color: #888; font-size: 0.9rem; margin-bottom: 15px;">
Query any permission by name. Some permissions may require additional parameters.
</p>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px;">
<input type="text" id="customPermission" value="geolocation"
style="flex: 1; min-width: 200px; padding: 10px; border: 1px solid #333; border-radius: 6px; background: #1a1a2e; color: #eee;">
<button onclick="queryCustomPermission()">Query Permission</button>
</div>
<div class="output" id="customResult">Enter a permission name and click Query to check its status...</div>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Event Log</h2>
<div class="output" id="eventLog">Permission events will appear here...</div>
</div>
</div>
<div class="notification" id="notification"></div>
<script>
let logEntries = [];
let permissionStatuses = {};
const standardPermissions = [
{ name: 'geolocation', description: 'Access device location', icon: 'location' },
{ name: 'notifications', description: 'Show system notifications', icon: 'bell' },
{ name: 'camera', description: 'Access camera device', icon: 'camera' },
{ name: 'microphone', description: 'Access microphone', icon: 'mic' },
{ name: 'clipboard-read', description: 'Read from clipboard', icon: 'clipboard' },
{ name: 'clipboard-write', description: 'Write to clipboard', icon: 'clipboard' },
];
const extendedPermissions = [
{ name: 'persistent-storage', description: 'Use persistent storage', icon: 'storage' },
{ name: 'push', description: 'Receive push messages', userVisibleOnly: true, icon: 'push' },
{ name: 'midi', description: 'Access MIDI devices', icon: 'midi' },
{ name: 'background-sync', description: 'Sync in background', icon: 'sync' },
{ name: 'accelerometer', description: 'Access accelerometer', icon: 'sensor' },
{ name: 'gyroscope', description: 'Access gyroscope', icon: 'sensor' },
{ name: 'magnetometer', description: 'Access magnetometer', icon: 'sensor' },
{ name: 'ambient-light-sensor', description: 'Access light sensor', icon: 'sensor' },
{ name: 'screen-wake-lock', description: 'Prevent screen sleep', icon: 'screen' },
];
function log(message, type = 'info') {
const time = new Date().toLocaleTimeString();
const prefix = type === 'error' ? '[ERROR]' : type === 'success' ? '[SUCCESS]' : '[INFO]';
logEntries.unshift(`[${time}] ${prefix} ${message}`);
if (logEntries.length > 50) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function showNotification(message, isError = false) {
const notif = document.getElementById('notification');
notif.textContent = message;
notif.style.background = isError ? '#f87171' : '#4ade80';
notif.classList.add('show');
setTimeout(() => notif.classList.remove('show'), 3000);
}
function checkSupport() {
const hasPermissions = 'permissions' in navigator;
document.getElementById('supported').textContent = hasPermissions ? 'Available' : 'Not available';
document.getElementById('status').className = hasPermissions ? 'status success' : 'status error';
const features = document.getElementById('features');
const checks = [
{ name: 'navigator.permissions', supported: hasPermissions },
{ name: 'permissions.query()', supported: hasPermissions && 'query' in navigator.permissions },
{ name: 'PermissionStatus', supported: typeof PermissionStatus !== 'undefined' },
{ name: 'onchange event', supported: hasPermissions },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
log('API support check completed');
return hasPermissions;
}
async function queryPermission(permName, options = {}) {
if (!navigator.permissions) {
return { state: 'unknown', error: 'Permissions API not supported' };
}
try {
const descriptor = { name: permName, ...options };
const status = await navigator.permissions.query(descriptor);
// Watch for changes
if (!permissionStatuses[permName]) {
status.addEventListener('change', () => {
log(`Permission "${permName}" changed to: ${status.state}`, 'info');
showNotification(`${permName}: ${status.state}`);
renderPermissions();
updateSummary();
});
}
permissionStatuses[permName] = status.state;
return { state: status.state };
} catch (e) {
return { state: 'unknown', error: e.message };
}
}
function getStateClass(state) {
if (state === 'granted') return 'granted';
if (state === 'denied') return 'denied';
if (state === 'prompt') return 'prompt';
return '';
}
function getIcon(iconType) {
const icons = {
location: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>',
bell: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/></svg>',
camera: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z"/><path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/></svg>',
mic: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg>',
clipboard: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm7 18H5V4h2v3h10V4h2v16z"/></svg>',
storage: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M2 20h20v-4H2v4zm2-3h2v2H4v-2zM2 4v4h20V4H2zm4 3H4V5h2v2zm-4 7h20v-4H2v4zm2-3h2v2H4v-2z"/></svg>',
push: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>',
midi: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM5 10.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm0 5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm4-5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm0 5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm4-5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm0 5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm4-5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm0 5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm4-5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5zm0 5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5z"/></svg>',
sync: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>',
sensor: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>',
screen: '<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/></svg>',
};
return icons[iconType] || icons.sensor;
}
async function requestPermission(permName) {
log(`Requesting permission: ${permName}`);
try {
switch (permName) {
case 'geolocation':
await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject);
});
break;
case 'notifications':
await Notification.requestPermission();
break;
case 'camera':
const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true });
cameraStream.getTracks().forEach(t => t.stop());
break;
case 'microphone':
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
micStream.getTracks().forEach(t => t.stop());
break;
case 'clipboard-read':
await navigator.clipboard.readText();
break;
case 'clipboard-write':
await navigator.clipboard.writeText('test');
break;
case 'persistent-storage':
if (navigator.storage && navigator.storage.persist) {
await navigator.storage.persist();
}
break;
case 'screen-wake-lock':
if ('wakeLock' in navigator) {
const lock = await navigator.wakeLock.request('screen');
lock.release();
}
break;
default:
log(`No request handler for: ${permName}`, 'error');
showNotification(`Cannot request ${permName}`, true);
return;
}
log(`Permission request for ${permName} completed`, 'success');
showNotification(`Requested ${permName}`);
// Refresh the permission status
await refreshPermission(permName);
} catch (e) {
log(`Permission request failed: ${e.message}`, 'error');
showNotification(`Request failed: ${e.message}`, true);
await refreshPermission(permName);
}
}
async function refreshPermission(permName) {
const result = await queryPermission(permName);
permissionStatuses[permName] = result.state;
renderPermissions();
updateSummary();
}
async function refreshAllPermissions() {
log('Refreshing all permissions...');
const allPerms = [...standardPermissions, ...extendedPermissions];
for (const perm of allPerms) {
const options = {};
if (perm.userVisibleOnly) {
options.userVisibleOnly = true;
}
const result = await queryPermission(perm.name, options);
permissionStatuses[perm.name] = result.state;
}
renderPermissions();
updateSummary();
log('All permissions refreshed', 'success');
showNotification('Permissions refreshed');
}
function updateSummary() {
const states = Object.values(permissionStatuses);
document.getElementById('grantedCount').textContent = states.filter(s => s === 'granted').length;
document.getElementById('deniedCount').textContent = states.filter(s => s === 'denied').length;
document.getElementById('promptCount').textContent = states.filter(s => s === 'prompt').length;
}
function renderPermissionCard(perm) {
const state = permissionStatuses[perm.name] || 'unknown';
const stateClass = getStateClass(state);
return `
<div class="permission-card ${stateClass}">
<div class="name">
<span class="icon">${getIcon(perm.icon)}</span>
${perm.name}
</div>
<div class="state ${stateClass}">${state}</div>
<div class="description">${perm.description}</div>
<div class="actions">
<button class="small secondary" onclick="refreshPermission('${perm.name}')">Refresh</button>
${state !== 'granted' ? `<button class="small" onclick="requestPermission('${perm.name}')">Request</button>` : ''}
</div>
</div>
`;
}
function renderPermissions() {
document.getElementById('standardPermissions').innerHTML =
standardPermissions.map(p => renderPermissionCard(p)).join('');
document.getElementById('extendedPermissions').innerHTML =
extendedPermissions.map(p => renderPermissionCard(p)).join('');
}
async function queryCustomPermission() {
const permName = document.getElementById('customPermission').value.trim();
const resultEl = document.getElementById('customResult');
if (!permName) {
resultEl.textContent = 'Please enter a permission name';
return;
}
log(`Querying custom permission: ${permName}`);
try {
const status = await navigator.permissions.query({ name: permName });
const result = {
name: permName,
state: status.state,
onchange: status.onchange !== null ? 'supported' : 'not set'
};
resultEl.textContent = JSON.stringify(result, null, 2);
log(`Custom query result: ${permName} = ${status.state}`, 'success');
// Listen for changes
status.addEventListener('change', () => {
log(`Custom permission "${permName}" changed to: ${status.state}`, 'info');
showNotification(`${permName}: ${status.state}`);
});
} catch (e) {
resultEl.textContent = `Error: ${e.message}\n\nThis permission name may not be supported or may require additional parameters.`;
log(`Custom query failed: ${e.message}`, 'error');
}
}
// Initialize
async function init() {
checkSupport();
if (navigator.permissions) {
await refreshAllPermissions();
} else {
renderPermissions();
}
}
init();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Permissions API Demo",
Description: "Permission queries",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Permissions API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,579 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pointer Events API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
min-height: 100px;
max-height: 200px;
overflow-y: auto;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.pointer-area {
background: #0a0a1a;
border-radius: 8px;
height: 300px;
position: relative;
overflow: hidden;
touch-action: none;
cursor: crosshair;
}
.pointer-indicator {
position: absolute;
width: 30px;
height: 30px;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
transition: width 0.1s, height 0.1s;
}
.pointer-trail {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
opacity: 0.5;
pointer-events: none;
}
.pointer-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-top: 15px;
}
.info-item {
background: rgba(0,0,0,0.3);
padding: 10px;
border-radius: 6px;
}
.info-label {
color: #888;
font-size: 0.8rem;
margin-bottom: 3px;
}
.info-value {
color: #4ade80;
font-family: monospace;
font-size: 0.95rem;
}
.drawing-canvas {
background: #0a0a1a;
border-radius: 8px;
touch-action: none;
cursor: crosshair;
}
.controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 15px;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.control-group label { color: #aaa; font-size: 0.9rem; }
input[type="color"] {
width: 40px;
height: 30px;
border: none;
border-radius: 4px;
cursor: pointer;
}
input[type="range"] { width: 100px; }
input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.active-pointers {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 15px;
}
.pointer-badge {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 6px;
}
.pointer-badge.touch { background: rgba(96, 165, 250, 0.2); color: #60a5fa; }
.pointer-badge.pen { background: rgba(233, 69, 96, 0.2); color: #e94560; }
.pointer-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
</style>
</head>
<body>
<div class="container">
<h1>Pointer Events API Demo</h1>
<p class="description">
The Pointer Events API provides a unified way to handle input from mouse, touch, and pen/stylus
devices with a single event model, including pressure and tilt information.
</p>
<div class="status success">
Pointer Events API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Pointer Tracking</h2>
<p style="color: #aaa; margin-bottom: 15px;">Move your mouse, touch, or use a stylus in the area below:</p>
<div class="pointer-area" id="trackingArea"></div>
<div class="pointer-info">
<div class="info-item">
<div class="info-label">Pointer ID</div>
<div class="info-value" id="pointerId">-</div>
</div>
<div class="info-item">
<div class="info-label">Pointer Type</div>
<div class="info-value" id="pointerType">-</div>
</div>
<div class="info-item">
<div class="info-label">Position (X, Y)</div>
<div class="info-value" id="position">-, -</div>
</div>
<div class="info-item">
<div class="info-label">Pressure</div>
<div class="info-value" id="pressure">-</div>
</div>
<div class="info-item">
<div class="info-label">Tilt (X, Y)</div>
<div class="info-value" id="tilt">-, -</div>
</div>
<div class="info-item">
<div class="info-label">Width x Height</div>
<div class="info-value" id="contactSize">-</div>
</div>
<div class="info-item">
<div class="info-label">Twist</div>
<div class="info-value" id="twist">-</div>
</div>
<div class="info-item">
<div class="info-label">Buttons</div>
<div class="info-value" id="buttons">-</div>
</div>
</div>
<div class="active-pointers" id="activePointers"></div>
</div>
<div class="card">
<h2>Pressure-Sensitive Drawing</h2>
<p style="color: #aaa; margin-bottom: 15px;">Draw with pressure sensitivity (size varies with pressure):</p>
<div class="controls">
<div class="control-group">
<label>Color:</label>
<input type="color" id="drawColor" value="#4ade80">
</div>
<div class="control-group">
<label>Base Size:</label>
<input type="range" id="baseSize" min="1" max="30" value="8">
<span id="baseSizeValue">8</span>
</div>
<div class="control-group">
<input type="checkbox" id="pressureEnabled" checked>
<label>Pressure Sensitivity</label>
</div>
</div>
<div class="controls">
<button onclick="clearDrawing()">Clear Canvas</button>
<button class="secondary" onclick="saveDrawing()">Save as PNG</button>
</div>
<canvas id="drawingCanvas" class="drawing-canvas" width="800" height="300"></canvas>
</div>
<div class="card">
<h2>Pointer Capture Demo</h2>
<p style="color: #aaa; margin-bottom: 15px;">Click and drag the box - it captures pointer events even outside itself:</p>
<div style="display: flex; gap: 20px; align-items: center; flex-wrap: wrap;">
<div id="captureBox" style="width: 100px; height: 100px; background: #4ade80; border-radius: 8px; cursor: grab; display: flex; align-items: center; justify-content: center; font-weight: bold; color: #1a1a2e; user-select: none;">
Drag Me
</div>
<div>
<div class="info-item" style="min-width: 150px;">
<div class="info-label">Capture Status</div>
<div class="info-value" id="captureStatus">Not captured</div>
</div>
</div>
</div>
</div>
<div class="card">
<h2>Multi-Touch Points</h2>
<p style="color: #aaa; margin-bottom: 15px;">Touch with multiple fingers (on touch devices):</p>
<div class="pointer-area" id="multiTouchArea" style="height: 200px;"></div>
<p style="color: #888; font-size: 0.85rem; margin-top: 10px;">Active touch points: <span id="touchCount">0</span></p>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Pointer events will appear here...</div>
</div>
</div>
<script>
// Event logging
let logEntries = [];
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 50) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Log cleared.';
}
// Check API support
function checkSupport() {
const hasPointerEvents = 'PointerEvent' in window;
document.getElementById('supported').textContent = hasPointerEvents ? 'Available' : 'Not available';
document.getElementById('supported').parentElement.className = hasPointerEvents ? 'status success' : 'status error';
const features = document.getElementById('features');
const checks = [
{ name: 'PointerEvent', supported: 'PointerEvent' in window },
{ name: 'pointerdown', supported: 'onpointerdown' in window },
{ name: 'pointermove', supported: 'onpointermove' in window },
{ name: 'pointerup', supported: 'onpointerup' in window },
{ name: 'pointerenter/leave', supported: 'onpointerenter' in window },
{ name: 'pointercancel', supported: 'onpointercancel' in window },
{ name: 'setPointerCapture', supported: Element.prototype.setPointerCapture !== undefined },
{ name: 'Touch Support', supported: 'ontouchstart' in window || navigator.maxTouchPoints > 0 },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
log('API support check completed');
}
// Pointer tracking area
const trackingArea = document.getElementById('trackingArea');
const activePointers = new Map();
const pointerColors = {
mouse: '#4ade80',
touch: '#60a5fa',
pen: '#e94560'
};
function updatePointerInfo(e) {
document.getElementById('pointerId').textContent = e.pointerId;
document.getElementById('pointerType').textContent = e.pointerType;
document.getElementById('position').textContent = `${Math.round(e.offsetX)}, ${Math.round(e.offsetY)}`;
document.getElementById('pressure').textContent = e.pressure.toFixed(3);
document.getElementById('tilt').textContent = `${e.tiltX}, ${e.tiltY}`;
document.getElementById('contactSize').textContent = `${e.width.toFixed(1)} x ${e.height.toFixed(1)}`;
document.getElementById('twist').textContent = `${e.twist}deg`;
document.getElementById('buttons').textContent = e.buttons;
}
function updateActivePointersList() {
const container = document.getElementById('activePointers');
container.innerHTML = '';
activePointers.forEach((pointer, id) => {
const badge = document.createElement('div');
badge.className = `pointer-badge ${pointer.type}`;
badge.innerHTML = `<span class="pointer-dot"></span>${pointer.type} #${id}`;
container.appendChild(badge);
});
}
function getOrCreateIndicator(pointerId, pointerType) {
let indicator = trackingArea.querySelector(`[data-pointer-id="${pointerId}"]`);
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'pointer-indicator';
indicator.dataset.pointerId = pointerId;
indicator.style.background = pointerColors[pointerType] || '#fff';
indicator.style.border = `2px solid ${pointerColors[pointerType] || '#fff'}`;
trackingArea.appendChild(indicator);
}
return indicator;
}
trackingArea.addEventListener('pointerenter', (e) => {
log(`pointerenter: ${e.pointerType} #${e.pointerId}`);
activePointers.set(e.pointerId, { type: e.pointerType });
updateActivePointersList();
});
trackingArea.addEventListener('pointerleave', (e) => {
log(`pointerleave: ${e.pointerType} #${e.pointerId}`);
const indicator = trackingArea.querySelector(`[data-pointer-id="${e.pointerId}"]`);
if (indicator) indicator.remove();
activePointers.delete(e.pointerId);
updateActivePointersList();
});
trackingArea.addEventListener('pointerdown', (e) => {
log(`pointerdown: ${e.pointerType} #${e.pointerId} at (${Math.round(e.offsetX)}, ${Math.round(e.offsetY)})`);
});
trackingArea.addEventListener('pointerup', (e) => {
log(`pointerup: ${e.pointerType} #${e.pointerId}`);
});
trackingArea.addEventListener('pointermove', (e) => {
updatePointerInfo(e);
const indicator = getOrCreateIndicator(e.pointerId, e.pointerType);
const rect = trackingArea.getBoundingClientRect();
indicator.style.left = `${e.offsetX}px`;
indicator.style.top = `${e.offsetY}px`;
// Adjust size based on pressure
const size = 20 + (e.pressure * 40);
indicator.style.width = `${size}px`;
indicator.style.height = `${size}px`;
});
trackingArea.addEventListener('pointercancel', (e) => {
log(`pointercancel: ${e.pointerType} #${e.pointerId}`);
const indicator = trackingArea.querySelector(`[data-pointer-id="${e.pointerId}"]`);
if (indicator) indicator.remove();
activePointers.delete(e.pointerId);
updateActivePointersList();
});
// Drawing canvas
const canvas = document.getElementById('drawingCanvas');
const ctx = canvas.getContext('2d');
let isDrawing = false;
let lastX = 0, lastY = 0;
document.getElementById('baseSize').addEventListener('input', (e) => {
document.getElementById('baseSizeValue').textContent = e.target.value;
});
canvas.addEventListener('pointerdown', (e) => {
isDrawing = true;
[lastX, lastY] = [e.offsetX, e.offsetY];
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', (e) => {
if (!isDrawing) return;
const color = document.getElementById('drawColor').value;
const baseSize = parseInt(document.getElementById('baseSize').value);
const usePressure = document.getElementById('pressureEnabled').checked;
// Calculate size based on pressure
const pressure = usePressure ? Math.max(0.1, e.pressure) : 0.5;
const size = baseSize * pressure * 2;
ctx.strokeStyle = color;
ctx.lineWidth = size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
[lastX, lastY] = [e.offsetX, e.offsetY];
});
canvas.addEventListener('pointerup', (e) => {
isDrawing = false;
canvas.releasePointerCapture(e.pointerId);
});
canvas.addEventListener('pointerleave', () => {
isDrawing = false;
});
function clearDrawing() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
log('Drawing canvas cleared');
}
function saveDrawing() {
const link = document.createElement('a');
link.download = 'pointer-drawing.png';
link.href = canvas.toDataURL();
link.click();
log('Drawing saved as PNG');
}
// Pointer capture demo
const captureBox = document.getElementById('captureBox');
let isDragging = false;
let dragOffsetX = 0, dragOffsetY = 0;
let boxX = 0, boxY = 0;
captureBox.addEventListener('pointerdown', (e) => {
isDragging = true;
captureBox.setPointerCapture(e.pointerId);
captureBox.style.cursor = 'grabbing';
captureBox.style.background = '#60a5fa';
const rect = captureBox.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
document.getElementById('captureStatus').textContent = 'Captured!';
log(`Pointer captured: #${e.pointerId}`);
});
captureBox.addEventListener('pointermove', (e) => {
if (!isDragging) return;
const parent = captureBox.parentElement;
const parentRect = parent.getBoundingClientRect();
boxX = e.clientX - parentRect.left - dragOffsetX;
boxY = e.clientY - parentRect.top - dragOffsetY;
captureBox.style.transform = `translate(${boxX}px, ${boxY}px)`;
});
captureBox.addEventListener('pointerup', (e) => {
isDragging = false;
captureBox.releasePointerCapture(e.pointerId);
captureBox.style.cursor = 'grab';
captureBox.style.background = '#4ade80';
document.getElementById('captureStatus').textContent = 'Released';
log(`Pointer released: #${e.pointerId}`);
});
captureBox.addEventListener('gotpointercapture', (e) => {
log(`gotpointercapture event fired`);
});
captureBox.addEventListener('lostpointercapture', (e) => {
log(`lostpointercapture event fired`);
isDragging = false;
captureBox.style.cursor = 'grab';
captureBox.style.background = '#4ade80';
});
// Multi-touch area
const multiTouchArea = document.getElementById('multiTouchArea');
const touchPoints = new Map();
multiTouchArea.addEventListener('pointerdown', (e) => {
const dot = document.createElement('div');
dot.className = 'pointer-indicator';
dot.dataset.pointerId = e.pointerId;
dot.style.background = pointerColors[e.pointerType] || '#fff';
dot.style.left = `${e.offsetX}px`;
dot.style.top = `${e.offsetY}px`;
dot.style.width = '40px';
dot.style.height = '40px';
multiTouchArea.appendChild(dot);
touchPoints.set(e.pointerId, dot);
document.getElementById('touchCount').textContent = touchPoints.size;
});
multiTouchArea.addEventListener('pointermove', (e) => {
const dot = touchPoints.get(e.pointerId);
if (dot) {
dot.style.left = `${e.offsetX}px`;
dot.style.top = `${e.offsetY}px`;
}
});
multiTouchArea.addEventListener('pointerup', (e) => {
const dot = touchPoints.get(e.pointerId);
if (dot) {
dot.remove();
touchPoints.delete(e.pointerId);
document.getElementById('touchCount').textContent = touchPoints.size;
}
});
multiTouchArea.addEventListener('pointercancel', (e) => {
const dot = touchPoints.get(e.pointerId);
if (dot) {
dot.remove();
touchPoints.delete(e.pointerId);
document.getElementById('touchCount').textContent = touchPoints.size;
}
});
// Initialize
checkSupport();
log('Pointer Events demo initialized');
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Pointer Events Demo",
Description: "Unified input handling",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Pointer Events Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,303 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Resize Observer API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.resizable {
resize: both;
overflow: auto;
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
border-radius: 12px;
padding: 20px;
min-width: 150px;
min-height: 100px;
max-width: 100%;
color: #1a1a2e;
font-weight: 600;
}
.resizable-info {
margin-top: 10px;
font-family: monospace;
font-size: 0.9rem;
}
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.responsive-box {
background: #0f3460;
border-radius: 12px;
padding: 20px;
text-align: center;
transition: all 0.3s;
}
.responsive-box.small { background: #f87171; }
.responsive-box.medium { background: #fbbf24; color: #1a1a2e; }
.responsive-box.large { background: #4ade80; color: #1a1a2e; }
.size-indicator {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin: 15px 0;
}
.size-indicator .size {
padding: 15px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
text-align: center;
}
.size-indicator .size.active {
background: #4ade80;
color: #1a1a2e;
}
.aspect-ratio-box {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
transition: all 0.3s;
min-height: 100px;
}
.chart-container {
height: 200px;
background: #0a0a1a;
border-radius: 8px;
position: relative;
overflow: hidden;
}
.chart-bar {
position: absolute;
bottom: 20px;
background: linear-gradient(to top, #4ade80, #22c55e);
border-radius: 4px 4px 0 0;
transition: all 0.3s;
}
.chart-label {
position: absolute;
bottom: 5px;
font-size: 0.7rem;
color: #aaa;
transform: translateX(-50%);
}
</style>
</head>
<body>
<div class="container">
<h1>Resize Observer API Demo</h1>
<p class="description">
The Resize Observer API provides a way to observe changes to the size of elements,
enabling responsive layouts without relying solely on media queries.
</p>
<div class="status success">
Resize Observer: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Resizable Element</h2>
<p style="color: #aaa; margin-bottom: 15px;">Drag the bottom-right corner to resize this element:</p>
<div class="resizable" id="resizable">
<div>Drag to resize me!</div>
<div class="resizable-info" id="resizableInfo">
Width: -- | Height: --
</div>
</div>
</div>
<div class="card">
<h2>Responsive Component</h2>
<p style="color: #aaa; margin-bottom: 15px;">This component changes based on its container size (not viewport):</p>
<div class="size-indicator">
<div class="size" id="sizeSmall">Small<br>&lt; 300px</div>
<div class="size" id="sizeMedium">Medium<br>300-500px</div>
<div class="size" id="sizeLarge">Large<br>&gt; 500px</div>
</div>
<div class="responsive-box" id="responsiveBox">
<div id="responsiveText">Resize the window to see me change</div>
<div style="font-family: monospace; margin-top: 10px;" id="responsiveSize">--</div>
</div>
</div>
<div class="card">
<h2>Aspect Ratio Monitor</h2>
<p style="color: #aaa; margin-bottom: 15px;">This box displays its current aspect ratio:</p>
<div class="aspect-ratio-box" id="aspectBox" style="height: 200px;">
<span id="aspectRatio">--</span>
</div>
</div>
<div class="card">
<h2>Responsive Chart</h2>
<p style="color: #aaa; margin-bottom: 15px;">This chart redraws when its container resizes:</p>
<div class="chart-container" id="chartContainer"></div>
</div>
<div class="card">
<h2>Resize Events Log</h2>
<div class="output" id="eventLog">Resize events will appear here...</div>
</div>
</div>
<script>
let logEntries = [];
function checkSupport() {
const supported = 'ResizeObserver' in window;
document.getElementById('supported').textContent = supported ? 'Available' : 'Not available';
return supported;
}
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 15) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
// Resizable element observer
function setupResizableObserver() {
const resizable = document.getElementById('resizable');
const info = document.getElementById('resizableInfo');
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
info.textContent = `Width: ${Math.round(width)}px | Height: ${Math.round(height)}px`;
log(`Resizable: ${Math.round(width)} x ${Math.round(height)}`);
}
});
observer.observe(resizable);
}
// Responsive component observer
function setupResponsiveObserver() {
const box = document.getElementById('responsiveBox');
const text = document.getElementById('responsiveText');
const size = document.getElementById('responsiveSize');
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const width = entry.contentRect.width;
size.textContent = `${Math.round(width)}px wide`;
document.querySelectorAll('.size').forEach(s => s.classList.remove('active'));
if (width < 300) {
box.className = 'responsive-box small';
text.textContent = 'Small layout - single column';
document.getElementById('sizeSmall').classList.add('active');
} else if (width < 500) {
box.className = 'responsive-box medium';
text.textContent = 'Medium layout - two columns';
document.getElementById('sizeMedium').classList.add('active');
} else {
box.className = 'responsive-box large';
text.textContent = 'Large layout - full width';
document.getElementById('sizeLarge').classList.add('active');
}
}
});
observer.observe(box);
}
// Aspect ratio observer
function setupAspectObserver() {
const box = document.getElementById('aspectBox');
const ratio = document.getElementById('aspectRatio');
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
const aspectRatio = (width / height).toFixed(2);
ratio.textContent = `${Math.round(width)} x ${Math.round(height)} (${aspectRatio}:1)`;
// Change color based on aspect ratio
const hue = Math.min(200, Math.max(0, (aspectRatio - 0.5) * 100));
box.style.background = `linear-gradient(135deg, hsl(${hue}, 70%, 60%), hsl(${hue + 30}, 70%, 50%))`;
}
});
observer.observe(box);
}
// Chart observer
function setupChartObserver() {
const container = document.getElementById('chartContainer');
const data = [65, 45, 80, 35, 55, 70, 40, 90, 50, 75];
function drawChart(width, height) {
container.innerHTML = '';
const barWidth = (width - 40) / data.length - 5;
const maxValue = Math.max(...data);
data.forEach((value, i) => {
const barHeight = (value / maxValue) * (height - 40);
const left = 20 + i * (barWidth + 5);
const bar = document.createElement('div');
bar.className = 'chart-bar';
bar.style.left = left + 'px';
bar.style.width = barWidth + 'px';
bar.style.height = barHeight + 'px';
container.appendChild(bar);
const label = document.createElement('div');
label.className = 'chart-label';
label.style.left = (left + barWidth / 2) + 'px';
label.textContent = value;
container.appendChild(label);
});
}
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
drawChart(width, height);
log(`Chart redrawn: ${Math.round(width)} x ${Math.round(height)}`);
}
});
observer.observe(container);
}
if (checkSupport()) {
setupResizableObserver();
setupResponsiveObserver();
setupAspectObserver();
setupChartObserver();
}
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Resize Observer Demo",
Description: "Demonstrates the Resize Observer API",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Resize Observer Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,548 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Selection API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
min-height: 100px;
max-height: 200px;
overflow-y: auto;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.selectable-content {
background: rgba(0,0,0,0.2);
padding: 20px;
border-radius: 8px;
line-height: 1.8;
font-size: 1.05rem;
margin-bottom: 15px;
}
.selectable-content::selection {
background: rgba(74, 222, 128, 0.4);
color: #fff;
}
.selection-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-top: 15px;
}
.info-item {
background: rgba(0,0,0,0.3);
padding: 10px;
border-radius: 6px;
}
.info-label {
color: #888;
font-size: 0.8rem;
margin-bottom: 3px;
}
.info-value {
color: #4ade80;
font-family: monospace;
font-size: 0.95rem;
word-break: break-word;
}
.highlight {
background: rgba(74, 222, 128, 0.3);
padding: 2px 4px;
border-radius: 3px;
}
.highlight-blue {
background: rgba(96, 165, 250, 0.3);
}
.highlight-red {
background: rgba(233, 69, 96, 0.3);
}
input[type="text"], input[type="number"] {
background: #1a1a2e;
border: 1px solid #333;
color: #eee;
padding: 8px 12px;
border-radius: 6px;
margin-right: 10px;
margin-bottom: 10px;
width: 80px;
}
input[type="text"] { width: 200px; }
.word-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 15px;
}
.word-item {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
}
.word-item:hover {
background: rgba(74, 222, 128, 0.4);
}
.editable-area {
background: rgba(0,0,0,0.2);
padding: 20px;
border-radius: 8px;
min-height: 100px;
outline: none;
line-height: 1.6;
}
.editable-area:focus {
box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.3);
}
</style>
</head>
<body>
<div class="container">
<h1>Selection API Demo</h1>
<p class="description">
The Selection API provides methods to programmatically select text, get the current selection,
and manipulate selected ranges within a document.
</p>
<div class="status success">
Selection API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Text Selection Monitor</h2>
<p style="color: #aaa; margin-bottom: 15px;">Select any text in the paragraph below:</p>
<div class="selectable-content" id="selectableText">
The Selection API is a powerful browser feature that allows JavaScript to programmatically
interact with user text selections. It provides methods to get the currently selected text,
create new selections, modify existing selections, and respond to selection changes.
This API is essential for building rich text editors, annotation tools, and other
interactive text-based applications. Try selecting different parts of this text to see
the API in action!
</div>
<div class="selection-info">
<div class="info-item">
<div class="info-label">Selected Text</div>
<div class="info-value" id="selectedText">-</div>
</div>
<div class="info-item">
<div class="info-label">Character Count</div>
<div class="info-value" id="charCount">0</div>
</div>
<div class="info-item">
<div class="info-label">Range Count</div>
<div class="info-value" id="rangeCount">0</div>
</div>
<div class="info-item">
<div class="info-label">Selection Type</div>
<div class="info-value" id="selectionType">None</div>
</div>
<div class="info-item">
<div class="info-label">Is Collapsed</div>
<div class="info-value" id="isCollapsed">-</div>
</div>
<div class="info-item">
<div class="info-label">Anchor/Focus Offset</div>
<div class="info-value" id="offsets">-, -</div>
</div>
</div>
</div>
<div class="card">
<h2>Programmatic Selection</h2>
<p style="color: #aaa; margin-bottom: 15px;">Select text programmatically by character position:</p>
<div class="selectable-content" id="programmaticText">
ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789
</div>
<div style="margin-top: 15px;">
<label style="color: #aaa;">Start:</label>
<input type="number" id="selStart" value="0" min="0">
<label style="color: #aaa;">End:</label>
<input type="number" id="selEnd" value="10" min="0">
<button onclick="selectRange()">Select Range</button>
<button class="secondary" onclick="selectAll()">Select All</button>
<button class="danger" onclick="clearSelection()">Clear Selection</button>
</div>
</div>
<div class="card">
<h2>Word Selection</h2>
<p style="color: #aaa; margin-bottom: 15px;">Click a word to select it in the text:</p>
<div class="selectable-content" id="wordText">
JavaScript provides powerful APIs for working with text selections in the browser.
The Selection and Range objects give developers fine-grained control over selected content.
</div>
<div class="word-list" id="wordList"></div>
</div>
<div class="card">
<h2>Selection Manipulation</h2>
<p style="color: #aaa; margin-bottom: 15px;">Select text below, then apply actions:</p>
<div class="editable-area" id="editableArea" contenteditable="true">
This is an editable area where you can select text and apply transformations.
Try selecting some text and clicking the buttons below to see the Selection API in action.
You can also type and edit the content directly.
</div>
<div style="margin-top: 15px;">
<button onclick="wrapSelection('highlight')">Highlight</button>
<button onclick="wrapSelection('highlight-blue')" class="secondary">Highlight Blue</button>
<button onclick="wrapSelection('highlight-red')" class="danger">Highlight Red</button>
<button onclick="uppercaseSelection()">UPPERCASE</button>
<button onclick="lowercaseSelection()" class="secondary">lowercase</button>
<button onclick="deleteSelection()" class="danger">Delete</button>
</div>
</div>
<div class="card">
<h2>Find and Select</h2>
<p style="color: #aaa; margin-bottom: 15px;">Find and select text matches:</p>
<div class="selectable-content" id="searchText">
The quick brown fox jumps over the lazy dog. The fox was very quick indeed.
A quick movement of the enemy will jeopardize five gunboats.
The five boxing wizards jump quickly.
</div>
<div style="margin-top: 15px;">
<input type="text" id="searchTerm" placeholder="Enter search term" value="quick">
<button onclick="findAndSelect()">Find First</button>
<button class="secondary" onclick="findAndSelectAll()">Find All (highlight)</button>
<button class="danger" onclick="resetSearchText()">Reset Text</button>
</div>
<div id="searchResults" style="color: #aaa; margin-top: 10px;"></div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Selection events will appear here...</div>
</div>
</div>
<script>
// Event logging
let logEntries = [];
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 50) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Log cleared.';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Check API support
function checkSupport() {
const hasSelection = 'getSelection' in window;
document.getElementById('supported').textContent = hasSelection ? 'Available' : 'Not available';
document.getElementById('supported').parentElement.className = hasSelection ? 'status success' : 'status error';
const features = document.getElementById('features');
const checks = [
{ name: 'window.getSelection()', supported: 'getSelection' in window },
{ name: 'Selection.toString()', supported: window.getSelection && typeof window.getSelection().toString === 'function' },
{ name: 'Selection.getRangeAt()', supported: window.getSelection && typeof window.getSelection().getRangeAt === 'function' },
{ name: 'Selection.addRange()', supported: window.getSelection && typeof window.getSelection().addRange === 'function' },
{ name: 'Selection.removeAllRanges()', supported: window.getSelection && typeof window.getSelection().removeAllRanges === 'function' },
{ name: 'Selection.extend()', supported: window.getSelection && typeof window.getSelection().extend === 'function' },
{ name: 'Document.createRange()', supported: typeof document.createRange === 'function' },
{ name: 'selectionchange event', supported: 'onselectionchange' in document },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
log('API support check completed');
}
// Selection monitoring
function updateSelectionInfo() {
const selection = window.getSelection();
const text = selection.toString();
document.getElementById('selectedText').textContent = text ? `"${text.substring(0, 100)}${text.length > 100 ? '...' : ''}"` : '-';
document.getElementById('charCount').textContent = text.length;
document.getElementById('rangeCount').textContent = selection.rangeCount;
document.getElementById('selectionType').textContent = selection.type || 'Unknown';
document.getElementById('isCollapsed').textContent = selection.isCollapsed ? 'Yes' : 'No';
document.getElementById('offsets').textContent = `${selection.anchorOffset}, ${selection.focusOffset}`;
if (text) {
log(`Selection changed: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}" (${text.length} chars)`);
}
}
document.addEventListener('selectionchange', updateSelectionInfo);
// Programmatic selection
function selectRange() {
const element = document.getElementById('programmaticText');
const textNode = element.firstChild;
const start = parseInt(document.getElementById('selStart').value) || 0;
const end = parseInt(document.getElementById('selEnd').value) || 0;
if (start >= 0 && end <= textNode.length && start <= end) {
const range = document.createRange();
range.setStart(textNode, start);
range.setEnd(textNode, end);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
log(`Programmatic selection: characters ${start} to ${end}`);
} else {
log('Invalid range specified');
}
}
function selectAll() {
const element = document.getElementById('programmaticText');
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
log('Selected all text');
}
function clearSelection() {
window.getSelection().removeAllRanges();
log('Selection cleared');
}
// Word selection
function initWordSelection() {
const element = document.getElementById('wordText');
const text = element.textContent;
const words = text.match(/\b\w+\b/g) || [];
const uniqueWords = [...new Set(words)];
const wordList = document.getElementById('wordList');
wordList.innerHTML = uniqueWords.slice(0, 15).map(word =>
`<span class="word-item" onclick="selectWord('${word}')">${word}</span>`
).join('');
}
function selectWord(word) {
const element = document.getElementById('wordText');
const text = element.textContent;
const textNode = element.firstChild;
const index = text.indexOf(word);
if (index !== -1) {
const range = document.createRange();
range.setStart(textNode, index);
range.setEnd(textNode, index + word.length);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
log(`Selected word: "${word}"`);
}
}
// Selection manipulation
function wrapSelection(className) {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
log('No text selected');
return;
}
const range = selection.getRangeAt(0);
const span = document.createElement('span');
span.className = className;
try {
range.surroundContents(span);
log(`Wrapped selection with class: ${className}`);
} catch (e) {
log('Cannot wrap: selection spans multiple elements');
}
}
function uppercaseSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
log('No text selected');
return;
}
const text = selection.toString();
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text.toUpperCase()));
log(`Converted to uppercase: "${text.toUpperCase()}"`);
}
function lowercaseSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
log('No text selected');
return;
}
const text = selection.toString();
const range = selection.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text.toLowerCase()));
log(`Converted to lowercase: "${text.toLowerCase()}"`);
}
function deleteSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
log('No text selected');
return;
}
const text = selection.toString();
const range = selection.getRangeAt(0);
range.deleteContents();
log(`Deleted: "${text}"`);
}
// Find and select
const originalSearchText = document.getElementById('searchText').innerHTML;
function findAndSelect() {
const searchTerm = document.getElementById('searchTerm').value;
if (!searchTerm) {
log('Enter a search term');
return;
}
resetSearchText();
const element = document.getElementById('searchText');
const text = element.textContent;
const textNode = element.firstChild;
const index = text.toLowerCase().indexOf(searchTerm.toLowerCase());
if (index !== -1) {
const range = document.createRange();
range.setStart(textNode, index);
range.setEnd(textNode, index + searchTerm.length);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
document.getElementById('searchResults').textContent = `Found at position ${index}`;
log(`Found "${searchTerm}" at position ${index}`);
} else {
document.getElementById('searchResults').textContent = 'Not found';
log(`"${searchTerm}" not found`);
}
}
function findAndSelectAll() {
const searchTerm = document.getElementById('searchTerm').value;
if (!searchTerm) {
log('Enter a search term');
return;
}
resetSearchText();
const element = document.getElementById('searchText');
const text = element.textContent;
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const escapedText = escapeHtml(text);
const matches = text.match(regex);
if (matches) {
element.innerHTML = escapedText.replace(regex, '<span class="highlight">$1</span>');
document.getElementById('searchResults').textContent = `Found ${matches.length} match(es)`;
log(`Found ${matches.length} matches for "${searchTerm}"`);
} else {
document.getElementById('searchResults').textContent = 'Not found';
log(`"${searchTerm}" not found`);
}
}
function resetSearchText() {
document.getElementById('searchText').innerHTML = originalSearchText;
document.getElementById('searchResults').textContent = '';
}
// Initialize
checkSupport();
initWordSelection();
log('Selection API demo initialized');
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Selection API Demo",
Description: "Text selection handling",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Selection API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,385 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Storage API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input, textarea {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.items-list { list-style: none; }
.items-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: rgba(0,0,0,0.2);
margin-bottom: 5px;
border-radius: 4px;
}
.items-list .key { color: #4ade80; font-family: monospace; }
.items-list .value { color: #aaa; font-family: monospace; max-width: 300px; overflow: hidden; text-overflow: ellipsis; }
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.comparison-item {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.comparison-item h3 {
font-size: 1rem;
margin-bottom: 10px;
}
.comparison-item.session h3 { color: #4ade80; }
.comparison-item.local h3 { color: #60a5fa; }
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
</style>
</head>
<body>
<div class="container">
<h1>Session Storage API Demo</h1>
<p class="description">
The Web Storage API provides sessionStorage for key-value storage that persists only
for the duration of the page session (until the tab or browser is closed).
</p>
<div id="status" class="status success">
sessionStorage is available: <span id="available">checking...</span>
</div>
<div class="card">
<h2>Store Data</h2>
<div class="form-group">
<label for="key">Key</label>
<input type="text" id="key" placeholder="Enter key name">
</div>
<div class="form-group">
<label for="value">Value</label>
<textarea id="value" rows="3" placeholder="Enter value (can be JSON)"></textarea>
</div>
<button onclick="saveItem()">Save to sessionStorage</button>
<button onclick="loadItem()">Load from sessionStorage</button>
</div>
<div class="card">
<h2>Stored Items (<span id="count">0</span>)</h2>
<button onclick="refreshList()">Refresh List</button>
<button class="danger" onclick="clearAll()">Clear All</button>
<ul class="items-list" id="itemsList"></ul>
</div>
<div class="card">
<h2>Session vs Local Storage Comparison</h2>
<p style="color: #aaa; margin-bottom: 15px;">Save the same key to both storages to compare behavior:</p>
<div class="form-group">
<label for="compareKey">Comparison Key</label>
<input type="text" id="compareKey" value="testKey" placeholder="Key for comparison">
</div>
<button onclick="saveToBoth()">Save to Both</button>
<button class="secondary" onclick="compareStorages()">Compare</button>
<div class="comparison" style="margin-top: 15px;">
<div class="comparison-item session">
<h3>sessionStorage</h3>
<div id="sessionValue">-</div>
</div>
<div class="comparison-item local">
<h3>localStorage</h3>
<div id="localValue">-</div>
</div>
</div>
<p style="color: #888; font-size: 0.85rem; margin-top: 10px;">
Tip: Refresh the page or open a new tab to see the difference - sessionStorage is unique per tab!
</p>
</div>
<div class="card">
<h2>Storage Info</h2>
<div class="output" id="info"></div>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Storage events will appear here...
Note: Storage events fire when data is changed from ANOTHER tab/window of the same origin.</div>
</div>
</div>
<script>
let logEntries = [];
// Check if sessionStorage is available
function checkStorage() {
try {
sessionStorage.setItem('__test__', 'test');
sessionStorage.removeItem('__test__');
document.getElementById('available').textContent = 'Yes';
document.getElementById('status').className = 'status success';
return true;
} catch (e) {
document.getElementById('available').textContent = 'No - ' + e.message;
document.getElementById('status').className = 'status error';
return false;
}
}
function checkFeatures() {
const features = document.getElementById('features');
const checks = [
{ name: 'sessionStorage', supported: 'sessionStorage' in window },
{ name: 'localStorage', supported: 'localStorage' in window },
{ name: 'setItem()', supported: 'sessionStorage' in window && typeof sessionStorage.setItem === 'function' },
{ name: 'getItem()', supported: 'sessionStorage' in window && typeof sessionStorage.getItem === 'function' },
{ name: 'removeItem()', supported: 'sessionStorage' in window && typeof sessionStorage.removeItem === 'function' },
{ name: 'clear()', supported: 'sessionStorage' in window && typeof sessionStorage.clear === 'function' },
{ name: 'key()', supported: 'sessionStorage' in window && typeof sessionStorage.key === 'function' },
{ name: 'Storage Events', supported: 'onstorage' in window },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
}
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 20) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Log cleared.';
}
function saveItem() {
const key = document.getElementById('key').value;
const value = document.getElementById('value').value;
if (!key) {
alert('Please enter a key');
return;
}
try {
sessionStorage.setItem(key, value);
log(`Saved: "${key}" = "${value.substring(0, 50)}${value.length > 50 ? '...' : ''}"`);
refreshList();
updateInfo();
} catch (e) {
alert('Error saving: ' + e.message);
log(`Error saving "${key}": ${e.message}`);
}
}
function loadItem() {
const key = document.getElementById('key').value;
if (!key) {
alert('Please enter a key');
return;
}
const value = sessionStorage.getItem(key);
if (value !== null) {
document.getElementById('value').value = value;
log(`Loaded: "${key}"`);
} else {
alert('Key not found');
log(`Key "${key}" not found`);
}
}
function deleteItem(key) {
sessionStorage.removeItem(key);
log(`Deleted: "${key}"`);
refreshList();
updateInfo();
}
function clearAll() {
if (confirm('Clear all sessionStorage data?')) {
sessionStorage.clear();
log('Cleared all sessionStorage data');
refreshList();
updateInfo();
}
}
function refreshList() {
const list = document.getElementById('itemsList');
list.innerHTML = '';
const count = sessionStorage.length;
document.getElementById('count').textContent = count;
for (let i = 0; i < count; i++) {
const key = sessionStorage.key(i);
const value = sessionStorage.getItem(key);
const li = document.createElement('li');
li.innerHTML = `
<span class="key">${escapeHtml(key)}</span>
<span class="value">${escapeHtml(value.substring(0, 50))}${value.length > 50 ? '...' : ''}</span>
<button onclick='deleteItem(${JSON.stringify(key)})' class="danger" style="padding: 5px 10px; font-size: 0.8rem;">Delete</button>
`;
list.appendChild(li);
}
}
function saveToBoth() {
const key = document.getElementById('compareKey').value || 'testKey';
const timestamp = new Date().toLocaleTimeString();
const value = `Saved at ${timestamp} (Tab ID: ${getTabId()})`;
sessionStorage.setItem(key, value);
localStorage.setItem(key, value);
log(`Saved to both storages: "${key}"`);
compareStorages();
}
function getTabId() {
let tabId = sessionStorage.getItem('__tabId__');
if (!tabId) {
tabId = Math.random().toString(36).substring(2, 8);
sessionStorage.setItem('__tabId__', tabId);
}
return tabId;
}
function compareStorages() {
const key = document.getElementById('compareKey').value || 'testKey';
const sessionVal = sessionStorage.getItem(key);
const localVal = localStorage.getItem(key);
document.getElementById('sessionValue').textContent = sessionVal || '(not set)';
document.getElementById('localValue').textContent = localVal || '(not set)';
log(`Compared storages for key "${key}"`);
}
function updateInfo() {
const info = document.getElementById('info');
let totalSize = 0;
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
totalSize += key.length + sessionStorage.getItem(key).length;
}
info.textContent = `Items: ${sessionStorage.length}
Total size: ~${(totalSize * 2 / 1024).toFixed(2)} KB (UTF-16)
Typical quota: 5-10 MB per origin
Tab ID: ${getTabId()}
Key Differences from localStorage:
- Data persists only for the page session
- Each tab/window has its own sessionStorage
- Data is cleared when tab/window closes
- Not shared between tabs (unlike localStorage)
API Methods:
- sessionStorage.setItem(key, value)
- sessionStorage.getItem(key)
- sessionStorage.removeItem(key)
- sessionStorage.clear()
- sessionStorage.key(index)
- sessionStorage.length`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Listen for storage events (from other tabs)
window.addEventListener('storage', (e) => {
if (e.storageArea === sessionStorage) {
log(`Storage event: "${e.key}" changed from "${e.oldValue}" to "${e.newValue}"`);
refreshList();
updateInfo();
}
});
// Initialize
if (checkStorage()) {
checkFeatures();
refreshList();
updateInfo();
compareStorages();
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Session Storage Demo",
Description: "sessionStorage API demonstration",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Session Storage Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Share API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 800px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
input, textarea {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
margin-bottom: 15px;
}
textarea { min-height: 80px; resize: vertical; }
label { display: block; color: #aaa; font-size: 0.9rem; margin-bottom: 5px; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.warning { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 250px;
overflow-y: auto;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.row { display: flex; gap: 15px; flex-wrap: wrap; }
.row > div { flex: 1; min-width: 200px; }
.file-drop {
border: 2px dashed #333;
border-radius: 8px;
padding: 30px;
text-align: center;
color: #666;
transition: all 0.3s;
margin-bottom: 15px;
}
.file-drop.dragover {
border-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
color: #4ade80;
}
.file-list {
list-style: none;
margin-top: 15px;
}
.file-list li {
background: rgba(0,0,0,0.2);
padding: 10px 15px;
border-radius: 6px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.file-list .file-info { color: #aaa; font-size: 0.85rem; }
.file-list .remove-btn {
background: transparent;
color: #f87171;
padding: 5px 10px;
margin: 0;
}
.notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
background: #4ade80;
color: #1a1a2e;
font-weight: 600;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
}
.notification.show {
transform: translateY(0);
opacity: 1;
}
.share-preview {
background: #2d2d3a;
border-radius: 8px;
padding: 15px;
margin-top: 15px;
}
.share-preview .preview-title { font-weight: 600; margin-bottom: 5px; color: #4ade80; }
.share-preview .preview-text { color: #aaa; font-size: 0.9rem; margin-bottom: 5px; }
.share-preview .preview-url { color: #60a5fa; font-size: 0.85rem; word-break: break-all; }
</style>
</head>
<body>
<div class="container">
<h1>Web Share API Demo</h1>
<p class="description">
The Web Share API allows web applications to invoke the native sharing capabilities
of the device, enabling users to share text, URLs, and files to other apps.
</p>
<div id="status" class="status">
Web Share API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Share Text and URL</h2>
<div class="row">
<div>
<label for="shareTitle">Title</label>
<input type="text" id="shareTitle" value="Check out Wails!" placeholder="Share title">
</div>
</div>
<label for="shareText">Text</label>
<textarea id="shareText" placeholder="Text to share...">Wails is a framework for building desktop apps using Go and web technologies.</textarea>
<label for="shareUrl">URL</label>
<input type="url" id="shareUrl" value="https://wails.io" placeholder="https://example.com">
<div class="share-preview" id="sharePreview">
<div class="preview-title" id="previewTitle">Check out Wails!</div>
<div class="preview-text" id="previewText">Wails is a framework for building desktop apps...</div>
<div class="preview-url" id="previewUrl">https://wails.io</div>
</div>
<div style="margin-top: 15px;">
<button onclick="shareText()">Share Content</button>
<button class="secondary" onclick="shareUrlOnly()">Share URL Only</button>
</div>
</div>
<div class="card">
<h2>Share Files</h2>
<div class="file-drop" id="fileDrop">
<p>Drag and drop files here or click to select</p>
<input type="file" id="fileInput" multiple style="display: none;">
</div>
<ul class="file-list" id="fileList"></ul>
<button onclick="shareFiles()" id="shareFilesBtn" disabled>Share Selected Files</button>
<button class="secondary" onclick="clearFiles()">Clear Files</button>
<p style="color: #666; font-size: 0.85rem; margin-top: 10px;">
Note: File sharing requires the canShare() API to verify file types are supported.
</p>
</div>
<div class="card">
<h2>Quick Share Examples</h2>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button onclick="shareExample('link')">Share a Link</button>
<button onclick="shareExample('contact')">Share Contact</button>
<button onclick="shareExample('location')">Share Location</button>
<button onclick="shareExample('code')">Share Code Snippet</button>
</div>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Event Log</h2>
<div class="output" id="eventLog">Share events will appear here...</div>
</div>
</div>
<div class="notification" id="notification"></div>
<script>
let selectedFiles = [];
let logEntries = [];
function log(message, type = 'info') {
const time = new Date().toLocaleTimeString();
const prefix = type === 'error' ? '[ERROR]' : type === 'success' ? '[SUCCESS]' : '[INFO]';
logEntries.unshift(`[${time}] ${prefix} ${message}`);
if (logEntries.length > 30) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function showNotification(message, isError = false) {
const notif = document.getElementById('notification');
notif.textContent = message;
notif.style.background = isError ? '#f87171' : '#4ade80';
notif.classList.add('show');
setTimeout(() => notif.classList.remove('show'), 3000);
}
function checkSupport() {
const hasShare = 'share' in navigator;
const hasCanShare = 'canShare' in navigator;
document.getElementById('supported').textContent = hasShare ? 'Available' : 'Not available';
document.getElementById('status').className = hasShare ? 'status success' : 'status error';
const features = document.getElementById('features');
const checks = [
{ name: 'navigator.share()', supported: hasShare },
{ name: 'navigator.canShare()', supported: hasCanShare },
{ name: 'Share title/text/url', supported: hasShare },
{ name: 'Share files', supported: hasCanShare && checkFileShareSupport() },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
log('API support check completed');
return hasShare;
}
function checkFileShareSupport() {
if (!navigator.canShare) return false;
try {
const testFile = new File(['test'], 'test.txt', { type: 'text/plain' });
return navigator.canShare({ files: [testFile] });
} catch (e) {
return false;
}
}
function updatePreview() {
const title = document.getElementById('shareTitle').value;
const text = document.getElementById('shareText').value;
const url = document.getElementById('shareUrl').value;
document.getElementById('previewTitle').textContent = title || '(No title)';
document.getElementById('previewText').textContent = text ? (text.substring(0, 100) + (text.length > 100 ? '...' : '')) : '(No text)';
document.getElementById('previewUrl').textContent = url || '(No URL)';
}
async function shareText() {
const title = document.getElementById('shareTitle').value;
const text = document.getElementById('shareText').value;
const url = document.getElementById('shareUrl').value;
const shareData = {};
if (title) shareData.title = title;
if (text) shareData.text = text;
if (url) shareData.url = url;
if (Object.keys(shareData).length === 0) {
showNotification('Please enter some content to share', true);
log('Share aborted: no content provided', 'error');
return;
}
log(`Attempting to share: ${JSON.stringify(shareData)}`);
try {
if (navigator.canShare && !navigator.canShare(shareData)) {
throw new Error('Content cannot be shared');
}
await navigator.share(shareData);
showNotification('Content shared successfully!');
log('Share completed successfully', 'success');
} catch (e) {
if (e.name === 'AbortError') {
log('Share was cancelled by user');
showNotification('Share cancelled', true);
} else {
log(`Share failed: ${e.message}`, 'error');
showNotification(`Share failed: ${e.message}`, true);
}
}
}
async function shareUrlOnly() {
const url = document.getElementById('shareUrl').value;
if (!url) {
showNotification('Please enter a URL', true);
return;
}
log(`Sharing URL: ${url}`);
try {
await navigator.share({ url });
showNotification('URL shared!');
log('URL share completed', 'success');
} catch (e) {
if (e.name !== 'AbortError') {
log(`URL share failed: ${e.message}`, 'error');
showNotification(`Failed: ${e.message}`, true);
} else {
log('URL share cancelled');
}
}
}
async function shareFiles() {
if (selectedFiles.length === 0) {
showNotification('No files selected', true);
return;
}
const shareData = {
files: selectedFiles,
title: 'Shared Files',
text: `Sharing ${selectedFiles.length} file(s)`
};
log(`Attempting to share ${selectedFiles.length} file(s)`);
try {
if (navigator.canShare && !navigator.canShare(shareData)) {
throw new Error('These files cannot be shared');
}
await navigator.share(shareData);
showNotification('Files shared successfully!');
log('File share completed', 'success');
} catch (e) {
if (e.name !== 'AbortError') {
log(`File share failed: ${e.message}`, 'error');
showNotification(`Failed: ${e.message}`, true);
} else {
log('File share cancelled');
}
}
}
async function shareExample(type) {
const examples = {
link: {
title: 'Interesting Article',
text: 'I found this really interesting!',
url: 'https://wails.io/docs'
},
contact: {
title: 'Contact Info',
text: 'John Doe\nEmail: john@example.com\nPhone: +1-555-0123'
},
location: {
title: 'Meeting Location',
text: 'Let\'s meet here!',
url: 'https://maps.google.com/?q=51.5074,-0.1278'
},
code: {
title: 'Code Snippet',
text: 'func main() {\n fmt.Println("Hello, Wails!")\n}'
}
};
const data = examples[type];
log(`Sharing ${type} example`);
try {
await navigator.share(data);
showNotification(`${type} shared!`);
log(`${type} share completed`, 'success');
} catch (e) {
if (e.name !== 'AbortError') {
log(`${type} share failed: ${e.message}`, 'error');
showNotification(`Failed: ${e.message}`, true);
}
}
}
function updateFileList() {
const list = document.getElementById('fileList');
const btn = document.getElementById('shareFilesBtn');
if (selectedFiles.length === 0) {
list.innerHTML = '<li style="color: #666; background: transparent; padding: 10px 0;">No files selected</li>';
btn.disabled = true;
} else {
list.innerHTML = selectedFiles.map((file, i) => `
<li>
<span>
<strong>${escapeHtml(file.name)}</strong>
<span class="file-info"> - ${escapeHtml(formatFileSize(file.size))} (${escapeHtml(file.type || 'unknown type')})</span>
</span>
<button class="remove-btn" onclick="removeFile(${i})">Remove</button>
</li>
`).join('');
btn.disabled = false;
}
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function removeFile(index) {
selectedFiles.splice(index, 1);
updateFileList();
log(`File removed, ${selectedFiles.length} file(s) remaining`);
}
function clearFiles() {
selectedFiles = [];
updateFileList();
log('All files cleared');
}
// File drop handling
const fileDrop = document.getElementById('fileDrop');
const fileInput = document.getElementById('fileInput');
fileDrop.addEventListener('click', () => fileInput.click());
fileDrop.addEventListener('dragover', (e) => {
e.preventDefault();
fileDrop.classList.add('dragover');
});
fileDrop.addEventListener('dragleave', () => {
fileDrop.classList.remove('dragover');
});
fileDrop.addEventListener('drop', (e) => {
e.preventDefault();
fileDrop.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
selectedFiles = selectedFiles.concat(files);
updateFileList();
log(`Added ${files.length} file(s) via drag & drop`);
});
fileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
selectedFiles = selectedFiles.concat(files);
updateFileList();
log(`Added ${files.length} file(s) via file picker`);
});
// Preview updates
document.getElementById('shareTitle').addEventListener('input', updatePreview);
document.getElementById('shareText').addEventListener('input', updatePreview);
document.getElementById('shareUrl').addEventListener('input', updatePreview);
// Initialize
checkSupport();
updateFileList();
updatePreview();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Web Share API Demo",
Description: "Native sharing",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Web Share API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,656 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Speech Synthesis API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
button.warning { background: #fbbf24; color: #1a1a2e; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
textarea {
width: 100%;
padding: 15px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
min-height: 120px;
resize: vertical;
margin-bottom: 15px;
}
select {
padding: 8px 12px;
background: #1a1a2e;
color: #eee;
border: 1px solid #333;
border-radius: 4px;
margin-bottom: 10px;
min-width: 200px;
}
.controls {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: flex-start;
margin-bottom: 15px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 150px;
}
.control-group label {
color: #aaa;
font-size: 0.85rem;
}
input[type="range"] {
width: 100%;
accent-color: #4ade80;
}
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
max-height: 200px;
overflow-y: auto;
}
.log-entry { margin-bottom: 5px; }
.log-entry .time { color: #666; }
.log-entry .info { color: #60a5fa; }
.log-entry .success { color: #4ade80; }
.log-entry .error { color: #f87171; }
.log-entry .warn { color: #fbbf24; }
.speaking-indicator {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 15px;
border-radius: 20px;
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
margin-bottom: 15px;
}
.speaking-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #4ade80;
animation: pulse 0.5s infinite alternate;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.3); opacity: 0.5; }
}
.hidden { display: none !important; }
.voice-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
margin-top: 15px;
max-height: 300px;
overflow-y: auto;
}
.voice-item {
background: rgba(0,0,0,0.3);
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.voice-item:hover { background: rgba(0,0,0,0.5); }
.voice-item.selected {
background: rgba(74, 222, 128, 0.2);
border: 1px solid #4ade80;
}
.voice-item .voice-name { font-weight: 600; color: #4ade80; }
.voice-item .voice-lang { color: #aaa; font-size: 0.85rem; }
.voice-item .voice-local {
font-size: 0.75rem;
padding: 2px 6px;
border-radius: 3px;
background: #60a5fa;
color: #1a1a2e;
margin-left: 8px;
}
.quick-phrases {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.phrase-btn {
padding: 8px 15px;
background: rgba(0,0,0,0.3);
border: 1px solid #333;
color: #eee;
border-radius: 20px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.phrase-btn:hover {
background: #4ade80;
color: #1a1a2e;
border-color: #4ade80;
}
.progress-bar {
width: 100%;
height: 8px;
background: #1a1a2e;
border-radius: 4px;
overflow: hidden;
margin: 15px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4ade80, #22c55e);
width: 0%;
transition: width 0.1s;
}
.char-highlight {
display: inline;
}
.char-highlight.spoken {
color: #4ade80;
}
</style>
</head>
<body>
<div class="container">
<h1>Speech Synthesis API Demo</h1>
<p class="description">
The Speech Synthesis API (Web Speech API) enables text-to-speech functionality,
allowing web applications to read text aloud using different voices and settings.
</p>
<div class="status success" id="apiStatus">
Speech Synthesis API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Text to Speech</h2>
<div id="speakingIndicator" class="speaking-indicator hidden">
<div class="speaking-dot"></div>
<span>Speaking...</span>
</div>
<textarea id="textInput" placeholder="Enter text to speak...">Hello! Welcome to the Speech Synthesis API demo. This powerful browser API allows web applications to convert text into spoken audio. You can adjust the voice, rate, pitch, and volume to customize the speech output.</textarea>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="controls">
<div class="control-group">
<label>Voice</label>
<select id="voiceSelect">
<option value="">Loading voices...</option>
</select>
</div>
<div class="control-group">
<label>Rate: <span id="rateValue">1.0</span>x</label>
<input type="range" id="rate" min="0.1" max="2" step="0.1" value="1">
</div>
<div class="control-group">
<label>Pitch: <span id="pitchValue">1.0</span></label>
<input type="range" id="pitch" min="0" max="2" step="0.1" value="1">
</div>
<div class="control-group">
<label>Volume: <span id="volumeValue">100</span>%</label>
<input type="range" id="volume" min="0" max="1" step="0.1" value="1">
</div>
</div>
<button id="speakBtn" onclick="speak()">Speak</button>
<button id="pauseBtn" class="warning hidden" onclick="pauseSpeech()">Pause</button>
<button id="resumeBtn" class="secondary hidden" onclick="resumeSpeech()">Resume</button>
<button id="stopBtn" class="danger" onclick="stopSpeech()">Stop</button>
</div>
<div class="card">
<h2>Quick Phrases</h2>
<p style="color: #aaa; margin-bottom: 15px;">Click a phrase to speak it immediately:</p>
<div class="quick-phrases" id="quickPhrases">
<button class="phrase-btn" onclick="speakPhrase('Hello, how are you today?')">Hello!</button>
<button class="phrase-btn" onclick="speakPhrase('The quick brown fox jumps over the lazy dog.')">Pangram</button>
<button class="phrase-btn" onclick="speakPhrase('Welcome to our application!')">Welcome</button>
<button class="phrase-btn" onclick="speakPhrase('Thank you for your patience.')">Thank you</button>
<button class="phrase-btn" onclick="speakPhrase('Please wait while we process your request.')">Please wait</button>
<button class="phrase-btn" onclick="speakPhrase('Your order has been confirmed.')">Order confirmed</button>
<button class="phrase-btn" onclick="speakPhrase('Error: Something went wrong. Please try again.')">Error message</button>
<button class="phrase-btn" onclick="speakPhrase('One, two, three, four, five, six, seven, eight, nine, ten.')">Counting</button>
</div>
</div>
<div class="card">
<h2>Available Voices (<span id="voiceCount">0</span>)</h2>
<div style="margin-bottom: 15px;">
<input type="text" id="voiceFilter" placeholder="Filter voices by name or language..." style="width: 100%; padding: 10px; background: #1a1a2e; color: #eee; border: 1px solid #333; border-radius: 6px;">
</div>
<div class="voice-list" id="voiceList">
<p style="color: #aaa;">Loading voices...</p>
</div>
</div>
<div class="card">
<h2>Speech Queue Demo</h2>
<p style="color: #aaa; margin-bottom: 15px;">Queue multiple utterances to be spoken in sequence:</p>
<button onclick="queueDemo()">Run Queue Demo</button>
<button class="secondary" onclick="addToQueue()">Add Current Text to Queue</button>
<button class="danger" onclick="clearQueue()">Clear Queue</button>
<div style="margin-top: 15px; color: #aaa;">
Queue length: <span id="queueLength">0</span> utterances
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<button class="danger" onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Events will be logged here...</div>
</div>
</div>
<script>
let voices = [];
let selectedVoice = null;
let currentUtterance = null;
let speechQueue = [];
let isSpeaking = false;
let isPaused = false;
// Check API Support
function checkSupport() {
const hasSpeechSynthesis = 'speechSynthesis' in window;
const hasUtterance = typeof SpeechSynthesisUtterance !== 'undefined';
const supported = document.getElementById('supported');
const apiStatus = document.getElementById('apiStatus');
if (hasSpeechSynthesis && hasUtterance) {
supported.textContent = 'Available';
apiStatus.className = 'status success';
} else {
supported.textContent = 'Not available';
apiStatus.className = 'status error';
}
const features = document.getElementById('features');
const checks = [
{ name: 'speechSynthesis', supported: hasSpeechSynthesis },
{ name: 'SpeechSynthesisUtterance', supported: hasUtterance },
{ name: 'getVoices()', supported: hasSpeechSynthesis && 'getVoices' in speechSynthesis },
{ name: 'speak()', supported: hasSpeechSynthesis && 'speak' in speechSynthesis },
{ name: 'pause()', supported: hasSpeechSynthesis && 'pause' in speechSynthesis },
{ name: 'resume()', supported: hasSpeechSynthesis && 'resume' in speechSynthesis },
{ name: 'cancel()', supported: hasSpeechSynthesis && 'cancel' in speechSynthesis },
{ name: 'onvoiceschanged', supported: hasSpeechSynthesis && 'onvoiceschanged' in speechSynthesis },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
return hasSpeechSynthesis && hasUtterance;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Logging
function log(message, type = 'info') {
const logDiv = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = `<span class="time">[${escapeHtml(time)}]</span> <span class="${escapeHtml(type)}">${escapeHtml(message)}</span>`;
logDiv.insertBefore(entry, logDiv.firstChild);
}
function clearLog() {
document.getElementById('eventLog').innerHTML = '<div class="log-entry"><span class="info">Log cleared</span></div>';
}
// Load Voices
function loadVoices() {
voices = speechSynthesis.getVoices();
if (voices.length === 0) {
log('No voices loaded yet, waiting...', 'warn');
return;
}
log(`Loaded ${voices.length} voices`, 'success');
document.getElementById('voiceCount').textContent = voices.length;
// Populate voice select
const voiceSelect = document.getElementById('voiceSelect');
voiceSelect.innerHTML = voices.map((voice, i) =>
`<option value="${i}">${voice.name} (${voice.lang})${voice.localService ? ' - Local' : ''}</option>`
).join('');
// Set default voice
const defaultVoice = voices.find(v => v.default) || voices[0];
selectedVoice = defaultVoice;
voiceSelect.value = voices.indexOf(defaultVoice);
// Populate voice list
updateVoiceList();
}
function updateVoiceList(filter = '') {
const voiceList = document.getElementById('voiceList');
const filterLower = filter.toLowerCase();
const filteredVoices = voices.filter(voice =>
voice.name.toLowerCase().includes(filterLower) ||
voice.lang.toLowerCase().includes(filterLower)
);
if (filteredVoices.length === 0) {
voiceList.innerHTML = '<p style="color: #aaa;">No voices match your filter.</p>';
return;
}
voiceList.innerHTML = filteredVoices.map((voice, i) => {
const originalIndex = voices.indexOf(voice);
const isSelected = selectedVoice === voice;
return `
<div class="voice-item ${isSelected ? 'selected' : ''}" onclick="selectVoice(${originalIndex})">
<div class="voice-name">
${voice.name}
${voice.localService ? '<span class="voice-local">Local</span>' : ''}
${voice.default ? '<span class="voice-local" style="background: #4ade80;">Default</span>' : ''}
</div>
<div class="voice-lang">${voice.lang}</div>
</div>
`;
}).join('');
}
function selectVoice(index) {
selectedVoice = voices[index];
document.getElementById('voiceSelect').value = index;
updateVoiceList(document.getElementById('voiceFilter').value);
log(`Selected voice: ${selectedVoice.name}`, 'info');
// Speak a sample
speakPhrase(`Hello, I am ${selectedVoice.name.split(' ')[0]}`);
}
// Event Listeners for controls
document.getElementById('rate').addEventListener('input', (e) => {
document.getElementById('rateValue').textContent = parseFloat(e.target.value).toFixed(1);
});
document.getElementById('pitch').addEventListener('input', (e) => {
document.getElementById('pitchValue').textContent = parseFloat(e.target.value).toFixed(1);
});
document.getElementById('volume').addEventListener('input', (e) => {
document.getElementById('volumeValue').textContent = Math.round(e.target.value * 100);
});
document.getElementById('voiceSelect').addEventListener('change', (e) => {
selectedVoice = voices[e.target.value];
updateVoiceList(document.getElementById('voiceFilter').value);
log(`Selected voice: ${selectedVoice.name}`, 'info');
});
document.getElementById('voiceFilter').addEventListener('input', (e) => {
updateVoiceList(e.target.value);
});
// Speech functions
function createUtterance(text) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.voice = selectedVoice;
utterance.rate = parseFloat(document.getElementById('rate').value);
utterance.pitch = parseFloat(document.getElementById('pitch').value);
utterance.volume = parseFloat(document.getElementById('volume').value);
utterance.onstart = () => {
log(`Started speaking: "${text.substring(0, 50)}..."`, 'success');
isSpeaking = true;
updateUI();
};
utterance.onend = () => {
log('Speech ended', 'info');
isSpeaking = false;
isPaused = false;
document.getElementById('progressFill').style.width = '100%';
setTimeout(() => {
document.getElementById('progressFill').style.width = '0%';
}, 500);
updateUI();
};
utterance.onpause = () => {
log('Speech paused', 'warn');
isPaused = true;
updateUI();
};
utterance.onresume = () => {
log('Speech resumed', 'success');
isPaused = false;
updateUI();
};
utterance.onerror = (e) => {
log(`Speech error: ${e.error}`, 'error');
isSpeaking = false;
isPaused = false;
updateUI();
};
utterance.onboundary = (e) => {
if (e.name === 'word') {
const progress = (e.charIndex / text.length) * 100;
document.getElementById('progressFill').style.width = progress + '%';
}
};
return utterance;
}
function speak() {
const text = document.getElementById('textInput').value.trim();
if (!text) {
log('No text to speak', 'warn');
return;
}
stopSpeech();
currentUtterance = createUtterance(text);
speechSynthesis.speak(currentUtterance);
}
function speakPhrase(text) {
stopSpeech();
currentUtterance = createUtterance(text);
speechSynthesis.speak(currentUtterance);
}
function pauseSpeech() {
if (speechSynthesis.speaking && !speechSynthesis.paused) {
speechSynthesis.pause();
}
}
function resumeSpeech() {
if (speechSynthesis.paused) {
speechSynthesis.resume();
}
}
function stopSpeech() {
speechSynthesis.cancel();
isSpeaking = false;
isPaused = false;
document.getElementById('progressFill').style.width = '0%';
updateUI();
}
function updateUI() {
const speakBtn = document.getElementById('speakBtn');
const pauseBtn = document.getElementById('pauseBtn');
const resumeBtn = document.getElementById('resumeBtn');
const indicator = document.getElementById('speakingIndicator');
if (isSpeaking && !isPaused) {
indicator.classList.remove('hidden');
pauseBtn.classList.remove('hidden');
resumeBtn.classList.add('hidden');
} else if (isPaused) {
indicator.classList.remove('hidden');
pauseBtn.classList.add('hidden');
resumeBtn.classList.remove('hidden');
} else {
indicator.classList.add('hidden');
pauseBtn.classList.add('hidden');
resumeBtn.classList.add('hidden');
}
}
// Queue functions
function addToQueue() {
const text = document.getElementById('textInput').value.trim();
if (!text) {
log('No text to add to queue', 'warn');
return;
}
const utterance = createUtterance(text);
speechQueue.push(utterance);
speechSynthesis.speak(utterance);
document.getElementById('queueLength').textContent = speechQueue.length;
log(`Added to queue: "${text.substring(0, 30)}..."`, 'success');
}
function clearQueue() {
speechSynthesis.cancel();
speechQueue = [];
document.getElementById('queueLength').textContent = 0;
log('Queue cleared', 'warn');
}
function queueDemo() {
clearQueue();
const phrases = [
'First, let me introduce myself.',
'I am a speech synthesis demonstration.',
'I can speak in multiple languages and voices.',
'This is the end of the queue demo.'
];
phrases.forEach((phrase, i) => {
const utterance = createUtterance(phrase);
utterance.onend = () => {
speechQueue.shift();
document.getElementById('queueLength').textContent = speechQueue.length;
};
speechQueue.push(utterance);
speechSynthesis.speak(utterance);
});
document.getElementById('queueLength').textContent = speechQueue.length;
log('Queue demo started with ' + phrases.length + ' utterances', 'success');
}
// Initialize
function init() {
if (!checkSupport()) {
log('Speech Synthesis not supported in this browser', 'error');
return;
}
// Load voices
loadVoices();
// Some browsers load voices asynchronously
if (speechSynthesis.onvoiceschanged !== undefined) {
speechSynthesis.onvoiceschanged = loadVoices;
}
// Fallback: try loading voices after a delay
setTimeout(() => {
if (voices.length === 0) {
loadVoices();
}
}, 100);
}
init();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Speech Synthesis Demo",
Description: "Text-to-speech API",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Speech Synthesis Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,580 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Streams API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input, textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
textarea { min-height: 80px; resize: vertical; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
word-break: break-all;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.event-log {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.8rem;
max-height: 200px;
overflow-y: auto;
}
.event-log .event { margin-bottom: 5px; padding: 5px; border-radius: 4px; }
.event-log .event.info { background: rgba(96, 165, 250, 0.2); color: #60a5fa; }
.event-log .event.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.event-log .event.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.event-log .event.data { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.progress-bar {
width: 100%;
height: 20px;
background: #0a0a1a;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
.progress-bar .fill {
height: 100%;
background: linear-gradient(90deg, #4ade80, #60a5fa);
transition: width 0.3s;
}
.demo-row { display: flex; gap: 10px; align-items: center; margin-bottom: 15px; }
.demo-row input { flex: 1; }
</style>
</head>
<body>
<div class="container">
<h1>Streams API Demo</h1>
<p class="description">
The Streams API allows JavaScript to programmatically access streams of data,
enabling efficient processing of large data sets chunk by chunk without loading everything into memory.
</p>
<div id="status" class="status success">
Streams API available: <span id="available">checking...</span>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>ReadableStream Demo</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Create a custom ReadableStream that generates data chunks on demand.
</p>
<div class="demo-row">
<label style="white-space: nowrap;">Chunk count:</label>
<input type="number" id="chunkCount" value="5" min="1" max="20">
<label style="white-space: nowrap;">Delay (ms):</label>
<input type="number" id="chunkDelay" value="500" min="100" max="2000">
</div>
<button onclick="demoReadableStream()">Start ReadableStream</button>
<button class="secondary" onclick="demoReadableStreamCancel()" id="cancelReadable" disabled>Cancel</button>
<div class="progress-bar"><div class="fill" id="readableProgress" style="width: 0%"></div></div>
<div class="output" id="readableOutput">Click "Start ReadableStream" to begin...</div>
</div>
<div class="card">
<h2>WritableStream Demo</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Create a WritableStream that processes and logs each written chunk.
</p>
<div class="form-group">
<label for="writeInput">Text to write (each line becomes a chunk):</label>
<textarea id="writeInput">Hello World
This is chunk 2
Another chunk here
Final chunk!</textarea>
</div>
<button onclick="demoWritableStream()">Write to Stream</button>
<div class="output" id="writableOutput">Click "Write to Stream" to begin...</div>
</div>
<div class="card">
<h2>TransformStream Demo</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Transform data as it flows through a stream pipeline.
</p>
<div class="form-group">
<label for="transformInput">Input text:</label>
<input type="text" id="transformInput" value="hello world from streams api">
</div>
<div class="form-group">
<label>Transform type:</label>
<select id="transformType">
<option value="uppercase">Uppercase</option>
<option value="reverse">Reverse Characters</option>
<option value="double">Double Each Character</option>
<option value="rot13">ROT13 Cipher</option>
</select>
</div>
<button onclick="demoTransformStream()">Transform</button>
<div class="output" id="transformOutput">Click "Transform" to see the result...</div>
</div>
<div class="card">
<h2>Pipe Chain Demo</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Chain multiple transforms together using pipeThrough and pipeTo.
</p>
<div class="form-group">
<label for="pipeInput">Input text:</label>
<input type="text" id="pipeInput" value="stream piping is powerful">
</div>
<button onclick="demoPipeChain()">Run Pipe Chain</button>
<p style="color: #aaa; margin: 10px 0; font-size: 0.9rem;">
Pipeline: Input -> Split into chars -> Uppercase -> Add index -> Collect
</p>
<div class="output" id="pipeOutput">Click "Run Pipe Chain" to see the result...</div>
</div>
<div class="card">
<h2>Fetch with Streams</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Use streams to process fetch response body progressively.
</p>
<div class="form-group">
<label for="fetchUrl">URL to fetch:</label>
<input type="text" id="fetchUrl" value="https://jsonplaceholder.typicode.com/posts">
</div>
<button onclick="demoFetchStream()">Fetch with Stream</button>
<button class="secondary" onclick="demoFetchStreamAbort()" id="abortFetch" disabled>Abort</button>
<div class="progress-bar"><div class="fill" id="fetchProgress" style="width: 0%"></div></div>
<div class="output" id="fetchOutput">Click "Fetch with Stream" to begin...</div>
</div>
<div class="card">
<h2>Event Log</h2>
<button class="secondary" onclick="clearLog()">Clear Log</button>
<div class="event-log" id="eventLog">
<div class="event info">Ready to log stream events...</div>
</div>
</div>
</div>
<script>
let readableController = null;
let fetchAbortController = null;
function checkSupport() {
const hasReadable = typeof ReadableStream !== 'undefined';
const hasWritable = typeof WritableStream !== 'undefined';
const hasTransform = typeof TransformStream !== 'undefined';
const available = hasReadable && hasWritable && hasTransform;
document.getElementById('available').textContent = available ? 'Yes' : 'Partial';
document.getElementById('status').className = available ? 'status success' : 'status error';
const features = document.getElementById('features');
const checks = [
{ name: 'ReadableStream', supported: hasReadable },
{ name: 'WritableStream', supported: hasWritable },
{ name: 'TransformStream', supported: hasTransform },
{ name: 'ReadableStreamDefaultReader', supported: typeof ReadableStreamDefaultReader !== 'undefined' },
{ name: 'WritableStreamDefaultWriter', supported: typeof WritableStreamDefaultWriter !== 'undefined' },
{ name: 'ByteLengthQueuingStrategy', supported: typeof ByteLengthQueuingStrategy !== 'undefined' },
{ name: 'CountQueuingStrategy', supported: typeof CountQueuingStrategy !== 'undefined' },
{ name: 'TextEncoderStream', supported: typeof TextEncoderStream !== 'undefined' },
{ name: 'TextDecoderStream', supported: typeof TextDecoderStream !== 'undefined' },
{ name: 'CompressionStream', supported: typeof CompressionStream !== 'undefined' },
{ name: 'DecompressionStream', supported: typeof DecompressionStream !== 'undefined' },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
}
function log(message, type = 'info') {
const logEl = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = `event ${type}`;
div.textContent = `[${time}] ${message}`;
logEl.insertBefore(div, logEl.firstChild);
}
function clearLog() {
document.getElementById('eventLog').innerHTML = '<div class="event info">Log cleared</div>';
}
async function demoReadableStream() {
const chunkCount = parseInt(document.getElementById('chunkCount').value);
const delay = parseInt(document.getElementById('chunkDelay').value);
const output = document.getElementById('readableOutput');
const progress = document.getElementById('readableProgress');
const cancelBtn = document.getElementById('cancelReadable');
output.textContent = 'Starting ReadableStream...\n';
progress.style.width = '0%';
cancelBtn.disabled = false;
let cancelled = false;
const stream = new ReadableStream({
start(controller) {
readableController = controller;
log('ReadableStream started', 'info');
},
async pull(controller) {
for (let i = 0; i < chunkCount && !cancelled; i++) {
await new Promise(r => setTimeout(r, delay));
if (cancelled) break;
const chunk = `Chunk ${i + 1}: Data generated at ${new Date().toISOString()}`;
controller.enqueue(chunk);
log(`Enqueued: ${chunk}`, 'data');
output.textContent += `Received: ${chunk}\n`;
progress.style.width = `${((i + 1) / chunkCount) * 100}%`;
}
controller.close();
log('ReadableStream closed', 'success');
},
cancel(reason) {
cancelled = true;
log(`ReadableStream cancelled: ${reason}`, 'error');
}
});
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
output.textContent += '\nStream completed!';
break;
}
}
} catch (e) {
output.textContent += `\nError: ${e.message}`;
log(`Read error: ${e.message}`, 'error');
} finally {
cancelBtn.disabled = true;
reader.releaseLock();
}
}
function demoReadableStreamCancel() {
if (readableController) {
readableController.error(new Error('Cancelled by user'));
document.getElementById('cancelReadable').disabled = true;
document.getElementById('readableOutput').textContent += '\n\nStream cancelled!';
}
}
async function demoWritableStream() {
const input = document.getElementById('writeInput').value;
const output = document.getElementById('writableOutput');
const chunks = input.split('\n').filter(c => c.trim());
output.textContent = 'Starting WritableStream...\n\n';
let processedChunks = [];
const stream = new WritableStream({
start(controller) {
log('WritableStream started', 'info');
},
write(chunk, controller) {
return new Promise(resolve => {
setTimeout(() => {
const processed = `[PROCESSED] ${chunk.toUpperCase()}`;
processedChunks.push(processed);
output.textContent += `Written: "${chunk}"\n`;
output.textContent += `Processed: "${processed}"\n\n`;
log(`Wrote chunk: ${chunk}`, 'data');
resolve();
}, 300);
});
},
close() {
output.textContent += 'WritableStream closed successfully!\n';
output.textContent += `\nTotal chunks processed: ${processedChunks.length}`;
log('WritableStream closed', 'success');
},
abort(reason) {
log(`WritableStream aborted: ${reason}`, 'error');
}
});
const writer = stream.getWriter();
try {
for (const chunk of chunks) {
await writer.write(chunk);
}
await writer.close();
} catch (e) {
output.textContent += `\nError: ${e.message}`;
log(`Write error: ${e.message}`, 'error');
}
}
async function demoTransformStream() {
const input = document.getElementById('transformInput').value;
const type = document.getElementById('transformType').value;
const output = document.getElementById('transformOutput');
const transforms = {
uppercase: (chunk) => chunk.toUpperCase(),
reverse: (chunk) => chunk.split('').reverse().join(''),
double: (chunk) => chunk.split('').map(c => c + c).join(''),
rot13: (chunk) => chunk.replace(/[a-zA-Z]/g, c => {
const base = c <= 'Z' ? 65 : 97;
return String.fromCharCode((c.charCodeAt(0) - base + 13) % 26 + base);
})
};
output.textContent = `Transform type: ${type}\n`;
output.textContent += `Input: "${input}"\n\n`;
log(`Starting ${type} transform`, 'info');
const transform = new TransformStream({
transform(chunk, controller) {
const result = transforms[type](chunk);
controller.enqueue(result);
log(`Transformed: "${chunk}" -> "${result}"`, 'data');
}
});
const readable = new ReadableStream({
start(controller) {
controller.enqueue(input);
controller.close();
}
});
const reader = readable.pipeThrough(transform).getReader();
let result = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += value;
}
output.textContent += `Output: "${result}"`;
log('Transform complete', 'success');
}
async function demoPipeChain() {
const input = document.getElementById('pipeInput').value;
const output = document.getElementById('pipeOutput');
output.textContent = `Input: "${input}"\n\nProcessing through pipe chain...\n\n`;
log('Starting pipe chain demo', 'info');
// Transform 1: Split into characters
const splitTransform = new TransformStream({
transform(chunk, controller) {
for (const char of chunk) {
controller.enqueue(char);
}
}
});
// Transform 2: Uppercase
const upperTransform = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
}
});
// Transform 3: Add index
let index = 0;
const indexTransform = new TransformStream({
transform(chunk, controller) {
controller.enqueue(`[${index++}]${chunk}`);
}
});
// Source stream
const source = new ReadableStream({
start(controller) {
controller.enqueue(input);
controller.close();
}
});
// Collect results
const results = [];
const sink = new WritableStream({
write(chunk) {
results.push(chunk);
}
});
// Run the pipe chain
await source
.pipeThrough(splitTransform)
.pipeThrough(upperTransform)
.pipeThrough(indexTransform)
.pipeTo(sink);
output.textContent += `Stage 1 (Split): "${input}" -> [${input.split('').map(c => `"${c}"`).join(', ')}]\n`;
output.textContent += `Stage 2 (Upper): [${input.toUpperCase().split('').map(c => `"${c}"`).join(', ')}]\n`;
output.textContent += `Stage 3 (Index): [${results.slice(0, 10).join(', ')}${results.length > 10 ? '...' : ''}]\n\n`;
output.textContent += `Final result: ${results.join(' ')}`;
log(`Pipe chain complete: ${results.length} items processed`, 'success');
}
async function demoFetchStream() {
const url = document.getElementById('fetchUrl').value;
const output = document.getElementById('fetchOutput');
const progress = document.getElementById('fetchProgress');
const abortBtn = document.getElementById('abortFetch');
output.textContent = `Fetching: ${url}\n\n`;
progress.style.width = '0%';
abortBtn.disabled = false;
fetchAbortController = new AbortController();
try {
log(`Fetching: ${url}`, 'info');
const response = await fetch(url, { signal: fetchAbortController.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength) : 0;
let received = 0;
output.textContent += `Content-Length: ${total || 'unknown'}\n`;
output.textContent += `Status: ${response.status} ${response.statusText}\n\n`;
output.textContent += 'Reading stream chunks:\n';
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
log('Fetch stream complete', 'success');
break;
}
received += value.length;
chunks.push(value);
const percent = total ? (received / total) * 100 : 50;
progress.style.width = `${Math.min(percent, 100)}%`;
output.textContent += `Chunk received: ${value.length} bytes (total: ${received})\n`;
log(`Received chunk: ${value.length} bytes`, 'data');
}
progress.style.width = '100%';
// Combine chunks and decode
const allChunks = new Uint8Array(received);
let position = 0;
for (const chunk of chunks) {
allChunks.set(chunk, position);
position += chunk.length;
}
const text = new TextDecoder().decode(allChunks);
output.textContent += `\nTotal received: ${received} bytes\n`;
output.textContent += `\nDecoded content (first 500 chars):\n${text.substring(0, 500)}${text.length > 500 ? '...' : ''}`;
} catch (e) {
if (e.name === 'AbortError') {
output.textContent += '\n\nFetch aborted by user!';
log('Fetch aborted', 'error');
} else {
output.textContent += `\nError: ${e.message}`;
log(`Fetch error: ${e.message}`, 'error');
}
} finally {
abortBtn.disabled = true;
}
}
function demoFetchStreamAbort() {
if (fetchAbortController) {
fetchAbortController.abort();
}
}
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Streams API Demo",
Description: "Readable and writable streams",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Streams API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,581 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vibration API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
transition: transform 0.1s;
}
button:hover { opacity: 0.9; }
button:active { transform: scale(0.95); }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.status.warning { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.preset-btn {
background: rgba(0,0,0,0.2);
border: 2px solid #333;
color: #eee;
padding: 15px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.preset-btn:hover {
border-color: #4ade80;
background: rgba(74, 222, 128, 0.1);
}
.preset-btn.active {
border-color: #4ade80;
background: rgba(74, 222, 128, 0.2);
}
.preset-btn .icon { font-size: 1.5rem; margin-bottom: 5px; }
.preset-btn .name { font-weight: 600; }
.preset-btn .pattern { font-size: 0.75rem; color: #666; margin-top: 5px; }
.pattern-builder {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 20px;
}
.pattern-display {
display: flex;
gap: 5px;
flex-wrap: wrap;
min-height: 60px;
background: rgba(0,0,0,0.3);
border-radius: 6px;
padding: 10px;
margin-bottom: 15px;
align-items: center;
}
.pattern-segment {
padding: 8px 12px;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
cursor: pointer;
transition: transform 0.1s;
}
.pattern-segment:hover { transform: scale(1.05); }
.pattern-segment.vibrate { background: #4ade80; color: #1a1a2e; }
.pattern-segment.pause { background: #333; color: #aaa; }
.pattern-segment.remove::after {
content: ' x';
opacity: 0.5;
}
.control-row {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 15px;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.control-group label { color: #aaa; font-size: 0.9rem; }
input[type="number"], input[type="range"] {
background: #1a1a2e;
border: 1px solid #333;
color: #eee;
padding: 8px;
border-radius: 4px;
width: 80px;
}
input[type="range"] {
width: 120px;
padding: 0;
}
.timeline {
position: relative;
height: 60px;
background: rgba(0,0,0,0.3);
border-radius: 6px;
overflow: hidden;
margin-top: 15px;
}
.timeline-bar {
position: absolute;
top: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: rgba(255,255,255,0.7);
transition: left 0.1s linear;
}
.timeline-bar.vibrate { background: rgba(74, 222, 128, 0.6); }
.timeline-bar.pause { background: rgba(100, 100, 100, 0.3); }
.timeline-playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: #f87171;
left: 0;
transition: left 0.05s linear;
}
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
max-height: 200px;
overflow-y: auto;
}
.log-entry {
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.log-entry .time { color: #666; margin-right: 10px; }
.log-entry.event { color: #60a5fa; }
.log-entry.data { color: #4ade80; }
.log-entry.error { color: #f87171; }
.haptic-pad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
max-width: 300px;
margin: 0 auto;
}
.haptic-btn {
aspect-ratio: 1;
background: linear-gradient(135deg, #333 0%, #222 100%);
border: 2px solid #444;
border-radius: 12px;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.1s;
display: flex;
align-items: center;
justify-content: center;
}
.haptic-btn:hover {
border-color: #4ade80;
box-shadow: 0 0 20px rgba(74, 222, 128, 0.3);
}
.haptic-btn:active {
transform: scale(0.95);
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
}
</style>
</head>
<body>
<div class="container">
<h1>Vibration API Demo</h1>
<p class="description">
The Vibration API provides access to the device's vibration hardware.
It allows triggering single vibrations or complex patterns of vibrations and pauses.
</p>
<div id="status" class="status">
Vibration API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Quick Vibrations</h2>
<button onclick="vibrate(50)">Short (50ms)</button>
<button onclick="vibrate(200)">Medium (200ms)</button>
<button onclick="vibrate(500)">Long (500ms)</button>
<button onclick="vibrate(1000)">Very Long (1s)</button>
<button onclick="stopVibration()" class="danger">Stop</button>
</div>
<div class="card">
<h2>Vibration Presets</h2>
<div class="preset-grid">
<div class="preset-btn" onclick="playPreset('notification')">
<div class="icon">*</div>
<div class="name">Notification</div>
<div class="pattern">[100, 50, 100]</div>
</div>
<div class="preset-btn" onclick="playPreset('success')">
<div class="icon">+</div>
<div class="name">Success</div>
<div class="pattern">[50, 50, 50, 50, 200]</div>
</div>
<div class="preset-btn" onclick="playPreset('error')">
<div class="icon">!</div>
<div class="name">Error</div>
<div class="pattern">[200, 100, 200, 100, 200]</div>
</div>
<div class="preset-btn" onclick="playPreset('warning')">
<div class="icon">?</div>
<div class="name">Warning</div>
<div class="pattern">[100, 100, 100]</div>
</div>
<div class="preset-btn" onclick="playPreset('heartbeat')">
<div class="icon">&lt;3</div>
<div class="name">Heartbeat</div>
<div class="pattern">[100, 100, 100, 400]</div>
</div>
<div class="preset-btn" onclick="playPreset('sos')">
<div class="icon">SOS</div>
<div class="name">SOS</div>
<div class="pattern">[100,50,100,50,100,200,...]</div>
</div>
<div class="preset-btn" onclick="playPreset('ringtone')">
<div class="icon">~</div>
<div class="name">Ringtone</div>
<div class="pattern">[300, 200, 300, 200, ...]</div>
</div>
<div class="preset-btn" onclick="playPreset('countdown')">
<div class="icon">3-2-1</div>
<div class="name">Countdown</div>
<div class="pattern">[100, 900, 100, 900, ...]</div>
</div>
</div>
</div>
<div class="card">
<h2>Pattern Builder</h2>
<div class="pattern-builder">
<div class="pattern-display" id="patternDisplay">
<span style="color: #666;">Click buttons below to build a pattern...</span>
</div>
<div class="control-row">
<div class="control-group">
<label>Duration:</label>
<input type="number" id="segmentDuration" value="100" min="10" max="2000" step="10">
<span style="color: #666;">ms</span>
</div>
<button onclick="addVibrate()">+ Vibrate</button>
<button onclick="addPause()" class="secondary">+ Pause</button>
<button onclick="clearPattern()" class="danger">Clear</button>
</div>
<div class="control-row">
<button onclick="playPattern()">Play Pattern</button>
<button onclick="loopPattern()" class="secondary" id="loopBtn">Loop Pattern</button>
<button onclick="exportPattern()" class="secondary">Copy to Clipboard</button>
</div>
<div class="timeline" id="timeline">
<div class="timeline-playhead" id="playhead"></div>
</div>
</div>
</div>
<div class="card">
<h2>Haptic Pad</h2>
<p style="color: #aaa; margin-bottom: 15px; text-align: center;">
Tap the buttons for different haptic feedback
</p>
<div class="haptic-pad">
<button class="haptic-btn" onclick="haptic(1)">1</button>
<button class="haptic-btn" onclick="haptic(2)">2</button>
<button class="haptic-btn" onclick="haptic(3)">3</button>
<button class="haptic-btn" onclick="haptic(4)">4</button>
<button class="haptic-btn" onclick="haptic(5)">5</button>
<button class="haptic-btn" onclick="haptic(6)">6</button>
<button class="haptic-btn" onclick="haptic(7)">7</button>
<button class="haptic-btn" onclick="haptic(8)">8</button>
<button class="haptic-btn" onclick="haptic(9)">9</button>
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<div class="output" id="eventLog">
<div class="log-entry">Waiting for vibration events...</div>
</div>
</div>
</div>
<script>
let pattern = [];
let looping = false;
let loopTimeout = null;
const presets = {
notification: [100, 50, 100],
success: [50, 50, 50, 50, 200],
error: [200, 100, 200, 100, 200],
warning: [100, 100, 100],
heartbeat: [100, 100, 100, 400, 100, 100, 100, 400],
sos: [100, 50, 100, 50, 100, 200, 300, 50, 300, 50, 300, 200, 100, 50, 100, 50, 100],
ringtone: [300, 200, 300, 200, 300, 1000, 300, 200, 300, 200, 300],
countdown: [100, 900, 100, 900, 100, 900, 500]
};
function checkSupport() {
const supported = 'vibrate' in navigator;
document.getElementById('supported').textContent = supported ? 'Supported' : 'Not supported';
document.getElementById('status').className = supported ? 'status success' : 'status error';
if (!supported) {
log('Vibration API not supported on this device/browser', 'error');
}
return supported;
}
function log(message, type = 'event') {
const logEl = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.innerHTML = `<span class="time">${time}</span>${message}`;
logEl.insertBefore(entry, logEl.firstChild);
while (logEl.children.length > 50) {
logEl.removeChild(logEl.lastChild);
}
}
function vibrate(duration) {
if (!checkSupport()) return;
const result = navigator.vibrate(duration);
log(`Vibrating for ${duration}ms - result: ${result}`, 'data');
}
function stopVibration() {
if (!checkSupport()) return;
looping = false;
if (loopTimeout) {
clearTimeout(loopTimeout);
loopTimeout = null;
}
document.getElementById('loopBtn').textContent = 'Loop Pattern';
document.getElementById('loopBtn').classList.remove('danger');
navigator.vibrate(0);
log('Vibration stopped', 'event');
}
function playPreset(name) {
if (!checkSupport()) return;
const p = presets[name];
navigator.vibrate(p);
log(`Playing preset "${name}": [${p.join(', ')}]`, 'data');
// Highlight active preset
document.querySelectorAll('.preset-btn').forEach(btn => btn.classList.remove('active'));
event.target.closest('.preset-btn').classList.add('active');
animateTimeline(p);
}
function addVibrate() {
const duration = parseInt(document.getElementById('segmentDuration').value) || 100;
pattern.push({ type: 'vibrate', duration });
updatePatternDisplay();
log(`Added vibrate segment: ${duration}ms`, 'event');
}
function addPause() {
const duration = parseInt(document.getElementById('segmentDuration').value) || 100;
pattern.push({ type: 'pause', duration });
updatePatternDisplay();
log(`Added pause segment: ${duration}ms`, 'event');
}
function removeSegment(index) {
pattern.splice(index, 1);
updatePatternDisplay();
}
function updatePatternDisplay() {
const display = document.getElementById('patternDisplay');
if (pattern.length === 0) {
display.innerHTML = '<span style="color: #666;">Click buttons below to build a pattern...</span>';
return;
}
display.innerHTML = pattern.map((seg, i) => `
<div class="pattern-segment ${seg.type}" onclick="removeSegment(${i})">
${seg.type === 'vibrate' ? '~' : '_'} ${seg.duration}ms
</div>
`).join('');
updateTimeline();
}
function updateTimeline() {
const timeline = document.getElementById('timeline');
const totalDuration = pattern.reduce((sum, seg) => sum + seg.duration, 0);
// Clear previous bars
timeline.querySelectorAll('.timeline-bar').forEach(bar => bar.remove());
let offset = 0;
pattern.forEach((seg, i) => {
const bar = document.createElement('div');
bar.className = `timeline-bar ${seg.type}`;
bar.style.left = (offset / totalDuration * 100) + '%';
bar.style.width = (seg.duration / totalDuration * 100) + '%';
bar.textContent = seg.duration + 'ms';
timeline.appendChild(bar);
offset += seg.duration;
});
}
function getPatternArray() {
// Convert pattern objects to vibration API format
// [vibrate, pause, vibrate, pause, ...]
const arr = [];
pattern.forEach((seg, i) => {
if (seg.type === 'vibrate') {
arr.push(seg.duration);
// Add 0 pause if next segment is also vibrate
if (pattern[i + 1]?.type === 'vibrate') {
arr.push(0);
}
} else {
// It's a pause - if previous was vibrate, this becomes the pause
// If we're starting with a pause, we need to add a 0 vibrate first
if (arr.length === 0 || arr.length % 2 === 0) {
arr.push(0); // Add 0ms vibrate
}
arr.push(seg.duration);
}
});
return arr;
}
function playPattern() {
if (!checkSupport()) return;
if (pattern.length === 0) {
log('No pattern to play - add some segments first', 'error');
return;
}
const arr = getPatternArray();
navigator.vibrate(arr);
log(`Playing pattern: [${arr.join(', ')}]`, 'data');
animateTimeline(arr);
}
function loopPattern() {
if (looping) {
stopVibration();
return;
}
if (pattern.length === 0) {
log('No pattern to loop - add some segments first', 'error');
return;
}
looping = true;
document.getElementById('loopBtn').textContent = 'Stop Loop';
document.getElementById('loopBtn').classList.add('danger');
const arr = getPatternArray();
const totalDuration = arr.reduce((sum, val) => sum + val, 0);
function loop() {
if (!looping) return;
navigator.vibrate(arr);
animateTimeline(arr);
loopTimeout = setTimeout(loop, totalDuration + 100);
}
loop();
log(`Looping pattern: [${arr.join(', ')}]`, 'data');
}
function animateTimeline(arr) {
const playhead = document.getElementById('playhead');
const totalDuration = arr.reduce((sum, val) => sum + val, 0);
playhead.style.transition = 'none';
playhead.style.left = '0%';
requestAnimationFrame(() => {
playhead.style.transition = `left ${totalDuration}ms linear`;
playhead.style.left = '100%';
});
}
function clearPattern() {
pattern = [];
updatePatternDisplay();
document.getElementById('timeline').querySelectorAll('.timeline-bar').forEach(bar => bar.remove());
log('Pattern cleared', 'event');
}
function exportPattern() {
const arr = getPatternArray();
const text = `navigator.vibrate([${arr.join(', ')}]);`;
navigator.clipboard.writeText(text).then(() => {
log('Pattern copied to clipboard!', 'data');
}).catch(err => {
log('Failed to copy: ' + err.message, 'error');
});
}
function haptic(num) {
if (!checkSupport()) return;
// Different vibration patterns for each button
const patterns = {
1: [20],
2: [30],
3: [40],
4: [50],
5: [60],
6: [80],
7: [100],
8: [30, 30, 30],
9: [50, 50, 50, 50, 50]
};
navigator.vibrate(patterns[num]);
log(`Haptic ${num}: [${patterns[num].join(', ')}]`, 'data');
}
// Initialize
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Vibration API Demo",
Description: "Device vibration patterns",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Vibration API Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,518 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Components API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
min-height: 100px;
max-height: 200px;
overflow-y: auto;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
.component-demo {
background: rgba(0,0,0,0.2);
padding: 20px;
border-radius: 8px;
margin-bottom: 15px;
}
.demo-label {
color: #aaa;
font-size: 0.85rem;
margin-bottom: 10px;
}
input[type="text"], input[type="color"], select {
background: #1a1a2e;
border: 1px solid #333;
color: #eee;
padding: 8px 12px;
border-radius: 6px;
margin-right: 10px;
margin-bottom: 10px;
}
input[type="color"] { padding: 2px; height: 36px; width: 60px; }
.code-block {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.8rem;
overflow-x: auto;
color: #60a5fa;
}
.code-block .keyword { color: #e94560; }
.code-block .string { color: #4ade80; }
.code-block .comment { color: #666; }
</style>
</head>
<body>
<div class="container">
<h1>Web Components API Demo</h1>
<p class="description">
Web Components is a suite of APIs allowing you to create reusable custom elements with encapsulated
functionality using Custom Elements, Shadow DOM, and HTML Templates.
</p>
<div class="status success">
Web Components API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
<div class="card">
<h2>Custom Element Demo: &lt;wails-card&gt;</h2>
<p class="demo-label">A custom card element with Shadow DOM encapsulation:</p>
<div class="component-demo">
<wails-card title="Welcome to Wails" theme="green">
This is content inside a custom Web Component. The styling is encapsulated via Shadow DOM.
</wails-card>
</div>
<div class="component-demo">
<wails-card title="Another Card" theme="blue">
Different theme variant using the same component.
</wails-card>
</div>
<div style="margin-top: 15px;">
<input type="text" id="cardTitle" placeholder="Card title" value="Dynamic Card">
<select id="cardTheme">
<option value="green">Green</option>
<option value="blue">Blue</option>
<option value="red">Red</option>
</select>
<button onclick="addCard()">Add Card</button>
</div>
<div id="dynamicCards"></div>
</div>
<div class="card">
<h2>Custom Element Demo: &lt;wails-counter&gt;</h2>
<p class="demo-label">A stateful counter component with reactive attributes:</p>
<div class="component-demo">
<wails-counter value="0" step="1"></wails-counter>
</div>
<div class="component-demo">
<wails-counter value="100" step="10"></wails-counter>
</div>
</div>
<div class="card">
<h2>HTML Template Demo</h2>
<p class="demo-label">Using &lt;template&gt; for efficient cloning:</p>
<template id="item-template">
<div class="template-item" style="background: rgba(74, 222, 128, 0.1); padding: 10px; margin: 5px 0; border-radius: 6px; display: flex; justify-content: space-between; align-items: center;">
<span class="item-text">Template item</span>
<button class="remove-btn" style="background: #f87171; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Remove</button>
</div>
</template>
<input type="text" id="templateText" placeholder="Enter item text" value="New Item">
<button onclick="addFromTemplate()">Add from Template</button>
<button class="secondary" onclick="clearTemplateItems()">Clear All</button>
<div id="templateItems" style="margin-top: 15px;"></div>
</div>
<div class="card">
<h2>Slot Demo: &lt;wails-panel&gt;</h2>
<p class="demo-label">Named slots for flexible content composition:</p>
<div class="component-demo">
<wails-panel>
<span slot="header">Panel Header</span>
<p>This is the default slot content - the main body of the panel.</p>
<span slot="footer">Panel Footer - Created with Web Components</span>
</wails-panel>
</div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="output" id="eventLog">Component events will appear here...</div>
</div>
</div>
<script>
// Event logging
let logEntries = [];
function log(message) {
const time = new Date().toLocaleTimeString();
logEntries.unshift(`[${time}] ${message}`);
if (logEntries.length > 50) logEntries.pop();
document.getElementById('eventLog').textContent = logEntries.join('\n');
}
function clearLog() {
logEntries = [];
document.getElementById('eventLog').textContent = 'Log cleared.';
}
// Check API support
function checkSupport() {
const hasCustomElements = 'customElements' in window;
const hasShadowDOM = !!HTMLElement.prototype.attachShadow;
const hasTemplate = 'content' in document.createElement('template');
const hasSlot = 'HTMLSlotElement' in window;
const allSupported = hasCustomElements && hasShadowDOM && hasTemplate;
document.getElementById('supported').textContent = allSupported ? 'Fully Supported' : 'Partial Support';
document.getElementById('supported').parentElement.className = allSupported ? 'status success' : 'status error';
const features = document.getElementById('features');
const checks = [
{ name: 'Custom Elements', supported: hasCustomElements },
{ name: 'Shadow DOM', supported: hasShadowDOM },
{ name: 'HTML Templates', supported: hasTemplate },
{ name: 'Slots', supported: hasSlot },
{ name: 'customElements.define()', supported: hasCustomElements && typeof customElements.define === 'function' },
{ name: 'Autonomous Elements', supported: hasCustomElements },
{ name: 'Built-in Extends', supported: hasCustomElements && typeof customElements.upgrade === 'function' },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
log('API support check completed');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Define wails-card custom element
class WailsCard extends HTMLElement {
static get observedAttributes() {
return ['title', 'theme'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
log('WailsCard: constructor called');
}
connectedCallback() {
this.render();
log(`WailsCard: connected - title="${this.getAttribute('title')}"`);
}
disconnectedCallback() {
log('WailsCard: disconnected');
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
log(`WailsCard: attribute "${name}" changed from "${oldValue}" to "${newValue}"`);
this.render();
}
}
render() {
const theme = this.getAttribute('theme') || 'green';
const title = this.getAttribute('title') || 'Card';
const colors = {
green: '#4ade80',
blue: '#60a5fa',
red: '#e94560'
};
const color = colors[theme] || colors.green;
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
margin: 10px 0;
}
.card {
background: rgba(0, 0, 0, 0.3);
border-left: 4px solid ${escapeHtml(color)};
border-radius: 8px;
padding: 15px;
}
.title {
color: ${escapeHtml(color)};
font-weight: bold;
font-size: 1.1rem;
margin-bottom: 10px;
}
.content {
color: #ccc;
line-height: 1.5;
}
</style>
<div class="card">
<div class="title">${escapeHtml(title)}</div>
<div class="content">
<slot></slot>
</div>
</div>
`;
}
}
// Define wails-counter custom element
class WailsCounter extends HTMLElement {
static get observedAttributes() {
return ['value', 'step'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._value = 0;
this._step = 1;
log('WailsCounter: constructor called');
}
connectedCallback() {
this._value = parseInt(this.getAttribute('value')) || 0;
this._step = parseInt(this.getAttribute('step')) || 1;
this.render();
log(`WailsCounter: connected - value=${this._value}, step=${this._step}`);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'value') this._value = parseInt(newValue) || 0;
if (name === 'step') this._step = parseInt(newValue) || 1;
this.render();
}
increment() {
this._value += this._step;
this.setAttribute('value', this._value);
this.dispatchEvent(new CustomEvent('change', { detail: { value: this._value } }));
log(`WailsCounter: incremented to ${this._value}`);
}
decrement() {
this._value -= this._step;
this.setAttribute('value', this._value);
this.dispatchEvent(new CustomEvent('change', { detail: { value: this._value } }));
log(`WailsCounter: decremented to ${this._value}`);
}
reset() {
this._value = 0;
this.setAttribute('value', this._value);
log(`WailsCounter: reset to 0`);
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-block; }
.counter {
display: flex;
align-items: center;
gap: 15px;
background: rgba(0, 0, 0, 0.3);
padding: 15px 20px;
border-radius: 8px;
}
button {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.decrement { background: #f87171; color: white; }
.increment { background: #4ade80; color: #1a1a2e; }
.reset { background: #60a5fa; color: white; font-size: 0.8rem; width: auto; padding: 0 15px; border-radius: 6px; }
button:hover { opacity: 0.9; }
.value {
font-size: 2rem;
font-weight: bold;
color: #4ade80;
min-width: 80px;
text-align: center;
}
.step {
color: #aaa;
font-size: 0.8rem;
}
</style>
<div class="counter">
<button class="decrement" id="dec">-</button>
<div class="value">${this._value}</div>
<button class="increment" id="inc">+</button>
<button class="reset" id="reset">Reset</button>
<span class="step">Step: ${this._step}</span>
</div>
`;
this.shadowRoot.getElementById('inc').addEventListener('click', () => this.increment());
this.shadowRoot.getElementById('dec').addEventListener('click', () => this.decrement());
this.shadowRoot.getElementById('reset').addEventListener('click', () => this.reset());
}
}
// Define wails-panel with slots
class WailsPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
log('WailsPanel: constructor called');
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.panel {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
overflow: hidden;
}
.header {
background: rgba(74, 222, 128, 0.2);
padding: 12px 15px;
font-weight: bold;
color: #4ade80;
border-bottom: 1px solid rgba(74, 222, 128, 0.3);
}
.body {
padding: 15px;
color: #ccc;
}
.footer {
background: rgba(0, 0, 0, 0.2);
padding: 10px 15px;
font-size: 0.85rem;
color: #888;
border-top: 1px solid #333;
}
::slotted(*) {
margin: 0;
}
</style>
<div class="panel">
<div class="header">
<slot name="header">Default Header</slot>
</div>
<div class="body">
<slot>Default body content</slot>
</div>
<div class="footer">
<slot name="footer">Default Footer</slot>
</div>
</div>
`;
log('WailsPanel: connected with slots');
// Listen for slot changes
this.shadowRoot.querySelectorAll('slot').forEach(slot => {
slot.addEventListener('slotchange', (e) => {
const nodes = slot.assignedNodes();
log(`WailsPanel: slot "${slot.name || 'default'}" changed, ${nodes.length} node(s)`);
});
});
}
}
// Register custom elements
customElements.define('wails-card', WailsCard);
customElements.define('wails-counter', WailsCounter);
customElements.define('wails-panel', WailsPanel);
// Template functionality
let itemCounter = 0;
function addFromTemplate() {
const template = document.getElementById('item-template');
const text = document.getElementById('templateText').value || `Item ${++itemCounter}`;
const clone = template.content.cloneNode(true);
clone.querySelector('.item-text').textContent = text;
clone.querySelector('.remove-btn').addEventListener('click', function() {
this.parentElement.remove();
log(`Template item removed: "${text}"`);
});
document.getElementById('templateItems').appendChild(clone);
log(`Template item added: "${text}"`);
}
function clearTemplateItems() {
document.getElementById('templateItems').innerHTML = '';
log('All template items cleared');
}
// Dynamic card creation
function addCard() {
const title = document.getElementById('cardTitle').value || 'Untitled';
const theme = document.getElementById('cardTheme').value;
const card = document.createElement('wails-card');
card.setAttribute('title', title);
card.setAttribute('theme', theme);
card.innerHTML = `Dynamically created at ${new Date().toLocaleTimeString()}`;
document.getElementById('dynamicCards').appendChild(card);
}
// Initialize
checkSupport();
log('Web Components demo initialized');
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Web Components Demo",
Description: "Custom elements and Shadow DOM",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Web Components Demo",
Width: 900,
Height: 700,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,402 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Audio API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
.controls {
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 15px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
color: #aaa;
font-size: 0.85rem;
}
input[type="range"] {
width: 150px;
}
select {
padding: 8px;
background: #1a1a2e;
color: #eee;
border: 1px solid #333;
border-radius: 4px;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.piano {
display: flex;
gap: 2px;
margin: 20px 0;
}
.key {
width: 50px;
height: 150px;
background: white;
border: 1px solid #333;
border-radius: 0 0 5px 5px;
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 10px;
color: #333;
font-size: 0.8rem;
}
.key:hover { background: #ddd; }
.key:active { background: #4ade80; }
.key.black {
background: #1a1a2e;
color: #aaa;
width: 35px;
height: 100px;
margin: 0 -17px;
z-index: 1;
}
.key.black:hover { background: #333; }
.key.black:active { background: #4ade80; }
canvas {
width: 100%;
height: 100px;
background: #0a0a1a;
border-radius: 8px;
}
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
}
</style>
</head>
<body>
<div class="container">
<h1>Web Audio API Demo</h1>
<p class="description">
The Web Audio API provides a powerful system for controlling audio,
allowing developers to generate, process, and analyze audio in real time.
</p>
<div class="status success">
Web Audio API supported: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Oscillator Synthesizer</h2>
<div class="controls">
<div class="control-group">
<label>Waveform</label>
<select id="waveform">
<option value="sine">Sine</option>
<option value="square">Square</option>
<option value="sawtooth">Sawtooth</option>
<option value="triangle">Triangle</option>
</select>
</div>
<div class="control-group">
<label>Frequency: <span id="freqValue">440</span> Hz</label>
<input type="range" id="frequency" min="100" max="2000" value="440">
</div>
<div class="control-group">
<label>Volume: <span id="volValue">50</span>%</label>
<input type="range" id="volume" min="0" max="100" value="50">
</div>
</div>
<button onclick="playTone()">Play Tone</button>
<button class="danger" onclick="stopTone()">Stop</button>
</div>
<div class="card">
<h2>Piano Keyboard</h2>
<p style="color: #aaa; margin-bottom: 15px;">Click keys or use keyboard: A S D F G H J K</p>
<div class="piano" id="piano">
<div class="key" data-note="C4" data-key="a">C</div>
<div class="key black" data-note="C#4" data-key="w">C#</div>
<div class="key" data-note="D4" data-key="s">D</div>
<div class="key black" data-note="D#4" data-key="e">D#</div>
<div class="key" data-note="E4" data-key="d">E</div>
<div class="key" data-note="F4" data-key="f">F</div>
<div class="key black" data-note="F#4" data-key="t">F#</div>
<div class="key" data-note="G4" data-key="g">G</div>
<div class="key black" data-note="G#4" data-key="y">G#</div>
<div class="key" data-note="A4" data-key="h">A</div>
<div class="key black" data-note="A#4" data-key="u">A#</div>
<div class="key" data-note="B4" data-key="j">B</div>
<div class="key" data-note="C5" data-key="k">C</div>
</div>
</div>
<div class="card">
<h2>Audio Visualizer</h2>
<button onclick="startVisualizer()">Start Mic Visualizer</button>
<button class="danger" onclick="stopVisualizer()">Stop</button>
<canvas id="visualizer"></canvas>
</div>
<div class="card">
<h2>Sound Effects</h2>
<button onclick="playEffect('beep')">Beep</button>
<button onclick="playEffect('laser')">Laser</button>
<button onclick="playEffect('explosion')">Explosion</button>
<button onclick="playEffect('coin')">Coin</button>
</div>
</div>
<script>
let audioContext = null;
let oscillator = null;
let gainNode = null;
let analyser = null;
let micStream = null;
let animationId = null;
// Note frequencies
const noteFrequencies = {
'C4': 261.63, 'C#4': 277.18, 'D4': 293.66, 'D#4': 311.13,
'E4': 329.63, 'F4': 349.23, 'F#4': 369.99, 'G4': 392.00,
'G#4': 415.30, 'A4': 440.00, 'A#4': 466.16, 'B4': 493.88,
'C5': 523.25
};
// Check Web Audio support
function checkSupport() {
const supported = typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined';
document.getElementById('supported').textContent = supported ? 'Yes' : 'No';
return supported;
}
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
// Oscillator controls
document.getElementById('frequency').addEventListener('input', (e) => {
document.getElementById('freqValue').textContent = e.target.value;
if (oscillator) oscillator.frequency.value = e.target.value;
});
document.getElementById('volume').addEventListener('input', (e) => {
document.getElementById('volValue').textContent = e.target.value;
if (gainNode) gainNode.gain.value = e.target.value / 100;
});
function playTone() {
stopTone();
const ctx = getAudioContext();
oscillator = ctx.createOscillator();
gainNode = ctx.createGain();
oscillator.type = document.getElementById('waveform').value;
oscillator.frequency.value = document.getElementById('frequency').value;
gainNode.gain.value = document.getElementById('volume').value / 100;
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.start();
}
function stopTone() {
if (oscillator) {
oscillator.stop();
oscillator = null;
}
}
// Piano
function playNote(note) {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = noteFrequencies[note];
osc.type = 'triangle';
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.5);
}
document.querySelectorAll('.key').forEach(key => {
key.addEventListener('mousedown', () => playNote(key.dataset.note));
});
document.addEventListener('keydown', (e) => {
const key = document.querySelector(`.key[data-key="${e.key.toLowerCase()}"]`);
if (key && !e.repeat) {
key.classList.add('active');
playNote(key.dataset.note);
}
});
document.addEventListener('keyup', (e) => {
const key = document.querySelector(`.key[data-key="${e.key.toLowerCase()}"]`);
if (key) key.classList.remove('active');
});
// Visualizer
async function startVisualizer() {
try {
const ctx = getAudioContext();
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = ctx.createMediaStreamSource(micStream);
analyser = ctx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const canvas = document.getElementById('visualizer');
const canvasCtx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
animationId = requestAnimationFrame(draw);
analyser.getByteFrequencyData(dataArray);
canvasCtx.fillStyle = '#0a0a1a';
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
const hue = (i / bufferLength) * 120 + 100;
canvasCtx.fillStyle = `hsl(${hue}, 70%, 50%)`;
canvasCtx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
}
draw();
} catch (e) {
alert('Could not access microphone: ' + e.message);
}
}
function stopVisualizer() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
if (micStream) {
micStream.getTracks().forEach(track => track.stop());
micStream = null;
}
}
// Sound effects
function playEffect(type) {
const ctx = getAudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
switch (type) {
case 'beep':
osc.frequency.value = 800;
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1);
osc.start();
osc.stop(ctx.currentTime + 0.1);
break;
case 'laser':
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(1000, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.2);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2);
osc.start();
osc.stop(ctx.currentTime + 0.2);
break;
case 'explosion':
const noise = ctx.createBufferSource();
const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
noise.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(1000, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.5);
const expGain = ctx.createGain();
expGain.gain.setValueAtTime(0.5, ctx.currentTime);
expGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
noise.connect(filter);
filter.connect(expGain);
expGain.connect(ctx.destination);
noise.start();
return;
case 'coin':
osc.type = 'square';
osc.frequency.setValueAtTime(987.77, ctx.currentTime);
osc.frequency.setValueAtTime(1318.51, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.setValueAtTime(0.2, ctx.currentTime + 0.1);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.start();
osc.stop(ctx.currentTime + 0.3);
break;
}
}
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Web Audio Demo",
Description: "Demonstrates the Web Audio API for audio processing",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Web Audio Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,354 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Crypto API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
textarea, input[type="text"], input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
font-family: monospace;
margin-bottom: 15px;
}
textarea { min-height: 80px; resize: vertical; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
white-space: pre-wrap;
}
label { display: block; color: #aaa; font-size: 0.9rem; margin-bottom: 5px; }
select {
padding: 10px;
background: #1a1a2e;
color: #eee;
border: 1px solid #333;
border-radius: 6px;
margin-bottom: 15px;
}
.row { display: flex; gap: 15px; flex-wrap: wrap; }
.row > div { flex: 1; min-width: 200px; }
</style>
</head>
<body>
<div class="container">
<h1>Web Crypto API Demo</h1>
<p class="description">
The Web Cryptography API provides cryptographic operations including hashing,
signature generation, encryption/decryption, and key management.
</p>
<div class="status success">
Web Crypto API: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Random Values</h2>
<button onclick="generateRandom(16)">16 bytes</button>
<button onclick="generateRandom(32)">32 bytes</button>
<button onclick="generateRandom(64)">64 bytes</button>
<button onclick="generateUUID()">Generate UUID</button>
<div class="output" id="randomOutput">Click a button to generate random values...</div>
</div>
<div class="card">
<h2>Hash Functions</h2>
<label for="hashInput">Text to hash:</label>
<textarea id="hashInput" placeholder="Enter text to hash...">Hello, World!</textarea>
<div class="row">
<div>
<label>Algorithm:</label>
<select id="hashAlgo">
<option value="SHA-1">SHA-1</option>
<option value="SHA-256" selected>SHA-256</option>
<option value="SHA-384">SHA-384</option>
<option value="SHA-512">SHA-512</option>
</select>
</div>
</div>
<button onclick="computeHash()">Compute Hash</button>
<div class="output" id="hashOutput">Hash will appear here...</div>
</div>
<div class="card">
<h2>Symmetric Encryption (AES-GCM)</h2>
<label for="encryptInput">Text to encrypt:</label>
<textarea id="encryptInput" placeholder="Enter text to encrypt...">Secret message!</textarea>
<label for="encryptPassword">Password:</label>
<input type="password" id="encryptPassword" placeholder="Enter password..." value="my-secret-key">
<button onclick="encryptData()">Encrypt</button>
<button class="secondary" onclick="decryptData()">Decrypt</button>
<div class="output" id="encryptOutput">Encrypted/decrypted data will appear here...</div>
</div>
<div class="card">
<h2>Digital Signatures (ECDSA)</h2>
<label for="signInput">Message to sign:</label>
<textarea id="signInput" placeholder="Enter message to sign...">This is an authentic message.</textarea>
<button onclick="generateSigningKey()">Generate Key Pair</button>
<button onclick="signMessage()">Sign Message</button>
<button class="secondary" onclick="verifySignature()">Verify Signature</button>
<div class="output" id="signOutput">Generate a key pair first...</div>
</div>
<div class="card">
<h2>HMAC</h2>
<label for="hmacInput">Message:</label>
<textarea id="hmacInput" placeholder="Enter message...">Message to authenticate</textarea>
<label for="hmacKey">Secret Key:</label>
<input type="text" id="hmacKey" placeholder="Enter secret key..." value="my-hmac-secret">
<button onclick="computeHMAC()">Compute HMAC</button>
<div class="output" id="hmacOutput">HMAC will appear here...</div>
</div>
</div>
<script>
let signingKeyPair = null;
let lastSignature = null;
function checkSupport() {
const supported = crypto && crypto.subtle;
document.getElementById('supported').textContent = supported ? 'Available' : 'Not available';
return supported;
}
function arrayBufferToHex(buffer) {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
function arrayBufferToBase64(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
function base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
// Random values
function generateRandom(length) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
document.getElementById('randomOutput').textContent =
`Hex: ${arrayBufferToHex(array.buffer)}\n\nBase64: ${arrayBufferToBase64(array.buffer)}`;
}
function generateUUID() {
const uuid = crypto.randomUUID();
document.getElementById('randomOutput').textContent = `UUID: ${uuid}`;
}
// Hashing
async function computeHash() {
const text = document.getElementById('hashInput').value;
const algo = document.getElementById('hashAlgo').value;
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hash = await crypto.subtle.digest(algo, data);
document.getElementById('hashOutput').textContent =
`Algorithm: ${algo}\nInput: "${text}"\n\nHash (hex): ${arrayBufferToHex(hash)}\n\nHash (base64): ${arrayBufferToBase64(hash)}`;
}
// Encryption
async function deriveKey(password, salt) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
async function encryptData() {
const text = document.getElementById('encryptInput').value;
const password = document.getElementById('encryptPassword').value;
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(password, salt);
const encoder = new TextEncoder();
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoder.encode(text)
);
// Combine salt + iv + ciphertext
const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(encrypted), salt.length + iv.length);
const base64 = arrayBufferToBase64(combined.buffer);
document.getElementById('encryptOutput').textContent =
`Encrypted (base64):\n${base64}\n\n(Salt: 16 bytes, IV: 12 bytes, then ciphertext)`;
// Store for decryption demo
document.getElementById('encryptInput').dataset.encrypted = base64;
}
async function decryptData() {
const base64 = document.getElementById('encryptInput').dataset.encrypted;
if (!base64) {
document.getElementById('encryptOutput').textContent = 'Encrypt something first!';
return;
}
const password = document.getElementById('encryptPassword').value;
const combined = new Uint8Array(base64ToArrayBuffer(base64));
const salt = combined.slice(0, 16);
const iv = combined.slice(16, 28);
const ciphertext = combined.slice(28);
try {
const key = await deriveKey(password, salt);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
const decoder = new TextDecoder();
document.getElementById('encryptOutput').textContent =
`Decrypted:\n${decoder.decode(decrypted)}`;
} catch (e) {
document.getElementById('encryptOutput').textContent = `Decryption failed: ${e.message}\n(Wrong password or corrupted data)`;
}
}
// Digital signatures
async function generateSigningKey() {
signingKeyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign', 'verify']
);
const publicKeyJWK = await crypto.subtle.exportKey('jwk', signingKeyPair.publicKey);
document.getElementById('signOutput').textContent =
`Key pair generated!\n\nPublic Key (JWK):\n${JSON.stringify(publicKeyJWK, null, 2)}`;
}
async function signMessage() {
if (!signingKeyPair) {
document.getElementById('signOutput').textContent = 'Generate a key pair first!';
return;
}
const message = document.getElementById('signInput').value;
const encoder = new TextEncoder();
lastSignature = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
signingKeyPair.privateKey,
encoder.encode(message)
);
document.getElementById('signOutput').textContent =
`Message signed!\n\nSignature (base64):\n${arrayBufferToBase64(lastSignature)}\n\nSignature (hex):\n${arrayBufferToHex(lastSignature)}`;
}
async function verifySignature() {
if (!signingKeyPair || !lastSignature) {
document.getElementById('signOutput').textContent = 'Sign a message first!';
return;
}
const message = document.getElementById('signInput').value;
const encoder = new TextEncoder();
const valid = await crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
signingKeyPair.publicKey,
lastSignature,
encoder.encode(message)
);
document.getElementById('signOutput').textContent =
`Signature verification: ${valid ? 'VALID' : 'INVALID'}\n\nMessage: "${message}"`;
}
// HMAC
async function computeHMAC() {
const message = document.getElementById('hmacInput').value;
const secretKey = document.getElementById('hmacKey').value;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secretKey),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message));
document.getElementById('hmacOutput').textContent =
`HMAC-SHA256:\n\nHex: ${arrayBufferToHex(signature)}\n\nBase64: ${arrayBufferToBase64(signature)}`;
}
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Web Crypto Demo",
Description: "Demonstrates the Web Cryptography API",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Web Crypto Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,386 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGL API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
canvas {
width: 100%;
max-width: 600px;
height: 400px;
background: #000;
border-radius: 8px;
display: block;
margin: 0 auto;
}
.controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 15px;
justify-content: center;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label { color: #aaa; font-size: 0.85rem; }
input[type="range"] { width: 120px; }
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.info-item {
background: rgba(0,0,0,0.2);
padding: 10px;
border-radius: 6px;
}
.info-item .label { color: #aaa; font-size: 0.8rem; }
.info-item .value { color: #4ade80; font-family: monospace; }
</style>
</head>
<body>
<div class="container">
<h1>WebGL API Demo</h1>
<p class="description">
WebGL enables rendering interactive 2D and 3D graphics in the browser
using the GPU for hardware-accelerated rendering.
</p>
<div id="status" class="status">
WebGL: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>3D Rotating Cube</h2>
<div class="controls">
<div class="control-group">
<label>Rotation X: <span id="rotXVal">0</span>°</label>
<input type="range" id="rotX" min="-180" max="180" value="0">
</div>
<div class="control-group">
<label>Rotation Y: <span id="rotYVal">0</span>°</label>
<input type="range" id="rotY" min="-180" max="180" value="0">
</div>
<div class="control-group">
<label>Zoom: <span id="zoomVal">5</span></label>
<input type="range" id="zoom" min="2" max="10" value="5" step="0.1">
</div>
</div>
<div class="controls">
<button onclick="toggleAnimation()">Toggle Animation</button>
<button onclick="resetView()">Reset View</button>
</div>
<canvas id="glCanvas" width="600" height="400"></canvas>
</div>
<div class="card">
<h2>WebGL Info</h2>
<div class="info-grid" id="glInfo"></div>
</div>
</div>
<script>
let gl = null;
let program = null;
let animating = true;
let rotationX = 0, rotationY = 0;
let autoRotateX = 0, autoRotateY = 0;
// Vertex shader
const vsSource = `
attribute vec4 aVertexPosition;
attribute vec4 aVertexColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
varying lowp vec4 vColor;
void main() {
gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition;
vColor = aVertexColor;
}
`;
// Fragment shader
const fsSource = `
varying lowp vec4 vColor;
void main() {
gl_FragColor = vColor;
}
`;
function checkWebGL() {
const canvas = document.getElementById('glCanvas');
gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) {
document.getElementById('supported').textContent = 'Not supported';
document.getElementById('status').className = 'status error';
return false;
}
const webgl2 = canvas.getContext('webgl2');
document.getElementById('supported').textContent = webgl2 ? 'WebGL 2.0' : 'WebGL 1.0';
document.getElementById('status').className = 'status success';
return true;
}
function showGLInfo() {
if (!gl) return;
const info = document.getElementById('glInfo');
const renderer = gl.getParameter(gl.RENDERER);
const vendor = gl.getParameter(gl.VENDOR);
const version = gl.getParameter(gl.VERSION);
const glslVersion = gl.getParameter(gl.SHADING_LANGUAGE_VERSION);
const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
const maxViewportDims = gl.getParameter(gl.MAX_VIEWPORT_DIMS);
info.innerHTML = `
<div class="info-item"><span class="label">Renderer</span><div class="value">${renderer}</div></div>
<div class="info-item"><span class="label">Vendor</span><div class="value">${vendor}</div></div>
<div class="info-item"><span class="label">Version</span><div class="value">${version}</div></div>
<div class="info-item"><span class="label">GLSL Version</span><div class="value">${glslVersion}</div></div>
<div class="info-item"><span class="label">Max Texture Size</span><div class="value">${maxTextureSize}px</div></div>
<div class="info-item"><span class="label">Max Viewport</span><div class="value">${maxViewportDims[0]}x${maxViewportDims[1]}</div></div>
`;
}
function initShaderProgram(vsSource, fsSource) {
const vertexShader = loadShader(gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl.FRAGMENT_SHADER, fsSource);
program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Shader program failed:', gl.getProgramInfoLog(program));
return null;
}
return program;
}
function loadShader(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function initBuffers() {
// Cube vertices
const positions = [
// Front face
-1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1,
// Back face
-1, -1, -1, -1, 1, -1, 1, 1, -1, 1, -1, -1,
// Top face
-1, 1, -1, -1, 1, 1, 1, 1, 1, 1, 1, -1,
// Bottom face
-1, -1, -1, 1, -1, -1, 1, -1, 1, -1, -1, 1,
// Right face
1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1,
// Left face
-1, -1, -1, -1, -1, 1, -1, 1, 1, -1, 1, -1,
];
const colors = [
// Front - green
0.3, 0.85, 0.5, 1, 0.3, 0.85, 0.5, 1, 0.3, 0.85, 0.5, 1, 0.3, 0.85, 0.5, 1,
// Back - red
0.9, 0.27, 0.38, 1, 0.9, 0.27, 0.38, 1, 0.9, 0.27, 0.38, 1, 0.9, 0.27, 0.38, 1,
// Top - blue
0.38, 0.65, 0.98, 1, 0.38, 0.65, 0.98, 1, 0.38, 0.65, 0.98, 1, 0.38, 0.65, 0.98, 1,
// Bottom - yellow
0.98, 0.75, 0.15, 1, 0.98, 0.75, 0.15, 1, 0.98, 0.75, 0.15, 1, 0.98, 0.75, 0.15, 1,
// Right - purple
0.7, 0.4, 0.9, 1, 0.7, 0.4, 0.9, 1, 0.7, 0.4, 0.9, 1, 0.7, 0.4, 0.9, 1,
// Left - cyan
0.2, 0.8, 0.8, 1, 0.2, 0.8, 0.8, 1, 0.2, 0.8, 0.8, 1, 0.2, 0.8, 0.8, 1,
];
const indices = [
0, 1, 2, 0, 2, 3, // front
4, 5, 6, 4, 6, 7, // back
8, 9, 10, 8, 10, 11, // top
12, 13, 14, 12, 14, 15, // bottom
16, 17, 18, 16, 18, 19, // right
20, 21, 22, 20, 22, 23, // left
];
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);
return { position: positionBuffer, color: colorBuffer, indices: indexBuffer };
}
function drawScene(buffers) {
gl.clearColor(0.04, 0.04, 0.1, 1.0);
gl.clearDepth(1.0);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const zoom = parseFloat(document.getElementById('zoom').value);
const projectionMatrix = perspectiveMatrix(45 * Math.PI / 180, 600/400, 0.1, 100.0);
const modelViewMatrix = mat4Identity();
mat4Translate(modelViewMatrix, 0, 0, -zoom);
mat4RotateX(modelViewMatrix, (rotationX + autoRotateX) * Math.PI / 180);
mat4RotateY(modelViewMatrix, (rotationY + autoRotateY) * Math.PI / 180);
// Bind position buffer
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.position);
const vertexPosition = gl.getAttribLocation(program, 'aVertexPosition');
gl.vertexAttribPointer(vertexPosition, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vertexPosition);
// Bind color buffer
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.color);
const vertexColor = gl.getAttribLocation(program, 'aVertexColor');
gl.vertexAttribPointer(vertexColor, 4, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vertexColor);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.indices);
gl.useProgram(program);
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uProjectionMatrix'), false, projectionMatrix);
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uModelViewMatrix'), false, modelViewMatrix);
gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);
}
// Simple matrix functions
function mat4Identity() {
return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]);
}
function mat4Translate(m, x, y, z) {
m[12] += x; m[13] += y; m[14] += z;
}
function mat4RotateX(m, rad) {
const c = Math.cos(rad), s = Math.sin(rad);
const m4 = m[4], m5 = m[5], m6 = m[6], m7 = m[7];
const m8 = m[8], m9 = m[9], m10 = m[10], m11 = m[11];
m[4] = m4*c + m8*s; m[5] = m5*c + m9*s; m[6] = m6*c + m10*s; m[7] = m7*c + m11*s;
m[8] = m8*c - m4*s; m[9] = m9*c - m5*s; m[10] = m10*c - m6*s; m[11] = m11*c - m7*s;
}
function mat4RotateY(m, rad) {
const c = Math.cos(rad), s = Math.sin(rad);
const m0 = m[0], m1 = m[1], m2 = m[2], m3 = m[3];
const m8 = m[8], m9 = m[9], m10 = m[10], m11 = m[11];
m[0] = m0*c - m8*s; m[1] = m1*c - m9*s; m[2] = m2*c - m10*s; m[3] = m3*c - m11*s;
m[8] = m0*s + m8*c; m[9] = m1*s + m9*c; m[10] = m2*s + m10*c; m[11] = m3*s + m11*c;
}
function perspectiveMatrix(fov, aspect, near, far) {
const f = 1.0 / Math.tan(fov / 2);
const nf = 1 / (near - far);
return new Float32Array([
f/aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far+near)*nf, -1,
0, 0, 2*far*near*nf, 0
]);
}
// Controls
document.getElementById('rotX').addEventListener('input', (e) => {
rotationX = parseFloat(e.target.value);
document.getElementById('rotXVal').textContent = rotationX;
});
document.getElementById('rotY').addEventListener('input', (e) => {
rotationY = parseFloat(e.target.value);
document.getElementById('rotYVal').textContent = rotationY;
});
document.getElementById('zoom').addEventListener('input', (e) => {
document.getElementById('zoomVal').textContent = e.target.value;
});
function toggleAnimation() {
animating = !animating;
}
function resetView() {
rotationX = rotationY = autoRotateX = autoRotateY = 0;
document.getElementById('rotX').value = 0;
document.getElementById('rotY').value = 0;
document.getElementById('zoom').value = 5;
document.getElementById('rotXVal').textContent = '0';
document.getElementById('rotYVal').textContent = '0';
document.getElementById('zoomVal').textContent = '5';
}
// Main
if (checkWebGL()) {
showGLInfo();
initShaderProgram(vsSource, fsSource);
const buffers = initBuffers();
function render() {
if (animating) {
autoRotateX += 0.5;
autoRotateY += 0.7;
}
drawScene(buffers);
requestAnimationFrame(render);
}
render();
}
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "WebGL Demo",
Description: "Demonstrates the WebGL API for 3D graphics",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "WebGL Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
.form-row { display: flex; gap: 10px; align-items: end; }
.form-row .form-group { flex: 1; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
max-height: 400px;
overflow-y: auto;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.status.warning { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
.indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: #666;
}
.indicator.connected { background: #4ade80; }
.indicator.connecting { background: #fbbf24; animation: pulse 1s infinite; }
.indicator.disconnected { background: #f87171; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.message { padding: 8px 12px; margin: 5px 0; border-radius: 6px; }
.message.sent { background: rgba(74, 222, 128, 0.1); border-left: 3px solid #4ade80; }
.message.received { background: rgba(96, 165, 250, 0.1); border-left: 3px solid #60a5fa; }
.message.system { background: rgba(251, 191, 36, 0.1); border-left: 3px solid #fbbf24; color: #aaa; font-style: italic; }
.message .time { color: #666; font-size: 0.75rem; }
.message .content { margin-top: 4px; word-break: break-all; }
</style>
</head>
<body>
<div class="container">
<h1>WebSocket API Demo</h1>
<p class="description">
WebSocket provides full-duplex communication channels over a single TCP connection,
enabling real-time data exchange between client and server.
</p>
<div id="status" class="status">
<div class="indicator" id="indicator"></div>
<span>WebSocket: <span id="connectionStatus">Disconnected</span></span>
</div>
<div class="card">
<h2>Connection</h2>
<div class="form-row">
<div class="form-group">
<label for="wsUrl">WebSocket URL</label>
<input type="text" id="wsUrl" value="wss://echo.websocket.org">
</div>
</div>
<button id="connectBtn" onclick="connect()">Connect</button>
<button id="disconnectBtn" onclick="disconnect()" disabled class="danger">Disconnect</button>
<p style="color: #666; font-size: 0.85rem; margin-top: 10px;">
Try: wss://echo.websocket.org (echo server) or wss://ws.postman-echo.com/raw
</p>
</div>
<div class="card">
<h2>Send Message</h2>
<div class="form-row">
<div class="form-group">
<label for="message">Message</label>
<input type="text" id="message" placeholder="Type a message..." onkeypress="if(event.key==='Enter')sendMessage()">
</div>
<button id="sendBtn" onclick="sendMessage()" disabled style="margin-bottom: 15px;">Send</button>
</div>
</div>
<div class="card">
<h2>Messages</h2>
<button onclick="clearMessages()">Clear</button>
<div class="output" id="messages">
<div class="message system">
<span class="content">Connect to a WebSocket server to start...</span>
</div>
</div>
</div>
<div class="card">
<h2>WebSocket API Reference</h2>
<div class="output" style="white-space: pre-wrap;">
// Create connection
const ws = new WebSocket('wss://example.com/socket');
// Connection opened
ws.onopen = (event) => {
console.log('Connected');
ws.send('Hello Server!');
};
// Listen for messages
ws.onmessage = (event) => {
console.log('Received:', event.data);
};
// Connection closed
ws.onclose = (event) => {
console.log('Disconnected', event.code, event.reason);
};
// Error handling
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
// Send data
ws.send('text message');
ws.send(new Blob(['binary data']));
ws.send(new ArrayBuffer(8));
// Close connection
ws.close(1000, 'Normal closure');
// Properties
ws.readyState // 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
ws.bufferedAmount // Bytes queued for transmission
ws.protocol // Selected sub-protocol
ws.url // WebSocket URL</div>
</div>
</div>
<script>
let ws = null;
function updateStatus(state) {
const indicator = document.getElementById('indicator');
const statusText = document.getElementById('connectionStatus');
const status = document.getElementById('status');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const sendBtn = document.getElementById('sendBtn');
switch (state) {
case 'connecting':
indicator.className = 'indicator connecting';
statusText.textContent = 'Connecting...';
status.className = 'status warning';
connectBtn.disabled = true;
disconnectBtn.disabled = true;
sendBtn.disabled = true;
break;
case 'connected':
indicator.className = 'indicator connected';
statusText.textContent = 'Connected';
status.className = 'status success';
connectBtn.disabled = true;
disconnectBtn.disabled = false;
sendBtn.disabled = false;
break;
case 'disconnected':
indicator.className = 'indicator disconnected';
statusText.textContent = 'Disconnected';
status.className = 'status error';
connectBtn.disabled = false;
disconnectBtn.disabled = true;
sendBtn.disabled = true;
break;
}
}
function addMessage(type, content) {
const messages = document.getElementById('messages');
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = `message ${type}`;
div.innerHTML = `
<span class="time">${time}</span>
<div class="content">${escapeHtml(content)}</div>
`;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function connect() {
const url = document.getElementById('wsUrl').value;
if (!url) {
alert('Please enter a WebSocket URL');
return;
}
updateStatus('connecting');
addMessage('system', `Connecting to ${url}...`);
try {
ws = new WebSocket(url);
ws.onopen = () => {
updateStatus('connected');
addMessage('system', 'Connection established');
};
ws.onmessage = (event) => {
addMessage('received', event.data);
};
ws.onclose = (event) => {
updateStatus('disconnected');
addMessage('system', `Connection closed (code: ${event.code}, reason: ${event.reason || 'none'})`);
ws = null;
};
ws.onerror = (error) => {
addMessage('system', 'WebSocket error occurred');
console.error('WebSocket error:', error);
};
} catch (e) {
updateStatus('disconnected');
addMessage('system', `Error: ${e.message}`);
}
}
function disconnect() {
if (ws) {
ws.close(1000, 'User disconnected');
}
}
function sendMessage() {
const input = document.getElementById('message');
const message = input.value.trim();
if (!message || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(message);
addMessage('sent', message);
input.value = '';
}
function clearMessages() {
document.getElementById('messages').innerHTML = '';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Check WebSocket support
if (typeof WebSocket === 'undefined') {
document.getElementById('status').className = 'status error';
document.getElementById('connectionStatus').textContent = 'Not supported';
} else {
updateStatus('disconnected');
}
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "WebSocket Demo",
Description: "Demonstrates the WebSocket API for real-time communication",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "WebSocket Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,373 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Workers API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
input {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
margin-bottom: 15px;
}
label { display: block; color: #aaa; font-size: 0.9rem; margin-bottom: 5px; }
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.danger { background: #f87171; }
button.secondary { background: #60a5fa; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
.progress-bar {
height: 20px;
background: #1a1a2e;
border-radius: 10px;
overflow: hidden;
margin: 15px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4ade80, #60a5fa);
transition: width 0.1s;
width: 0%;
}
.animation-box {
width: 50px;
height: 50px;
background: #4ade80;
border-radius: 8px;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.row { display: flex; gap: 20px; align-items: center; }
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.feature {
background: rgba(0,0,0,0.2);
padding: 15px;
border-radius: 8px;
}
.feature .name { font-weight: 600; margin-bottom: 5px; }
.feature .supported { color: #4ade80; }
.feature .unsupported { color: #f87171; }
</style>
</head>
<body>
<div class="container">
<h1>Web Workers API Demo</h1>
<p class="description">
Web Workers allow JavaScript to run in background threads,
enabling CPU-intensive tasks without blocking the UI.
</p>
<div class="status success">
Web Workers: <span id="supported">checking...</span>
</div>
<div class="card">
<h2>Prime Number Calculator</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Calculate prime numbers up to a given limit. Try with/without a worker to see the difference.
</p>
<label for="primeLimit">Calculate primes up to:</label>
<input type="number" id="primeLimit" value="100000">
<div class="row" style="margin-bottom: 15px;">
<div class="animation-box" id="animationBox"></div>
<span style="color: #aaa;">This animation should stay smooth when using a worker</span>
</div>
<button onclick="calculateWithWorker()">Calculate with Worker</button>
<button class="secondary" onclick="calculateWithoutWorker()">Calculate WITHOUT Worker (blocks UI)</button>
<button class="danger" onclick="terminateWorker()">Terminate Worker</button>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="output" id="primeOutput">Results will appear here...</div>
</div>
<div class="card">
<h2>Fibonacci Calculator</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Calculate Fibonacci numbers using a dedicated worker.
</p>
<label for="fibN">Calculate Fibonacci(n):</label>
<input type="number" id="fibN" value="40">
<button onclick="calculateFibonacci()">Calculate</button>
<div class="output" id="fibOutput">Result will appear here...</div>
</div>
<div class="card">
<h2>Message Passing</h2>
<p style="color: #aaa; margin-bottom: 15px;">
Send custom messages to a worker and receive responses.
</p>
<label for="workerMessage">Message to send:</label>
<input type="text" id="workerMessage" value="Hello, Worker!">
<button onclick="sendMessage()">Send to Worker</button>
<div class="output" id="messageOutput">Messages will appear here...</div>
</div>
<div class="card">
<h2>API Support</h2>
<div class="feature-grid" id="features"></div>
</div>
</div>
<script>
let primeWorker = null;
let fibWorker = null;
let messageWorker = null;
function checkSupport() {
const supported = typeof Worker !== 'undefined';
document.getElementById('supported').textContent = supported ? 'Available' : 'Not available';
const features = document.getElementById('features');
const checks = [
{ name: 'Web Workers', supported: typeof Worker !== 'undefined' },
{ name: 'Shared Workers', supported: typeof SharedWorker !== 'undefined' },
{ name: 'Service Workers', supported: 'serviceWorker' in navigator },
{ name: 'Worker from Blob', supported: typeof Blob !== 'undefined' && typeof URL.createObjectURL !== 'undefined' },
{ name: 'Transferable Objects', supported: true },
];
features.innerHTML = checks.map(c => `
<div class="feature">
<div class="name">${c.name}</div>
<div class="${c.supported ? 'supported' : 'unsupported'}">${c.supported ? 'Supported' : 'Not supported'}</div>
</div>
`).join('');
}
// Create worker from inline code
function createWorker(code) {
const blob = new Blob([code], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
return new Worker(url);
}
// Prime number worker
const primeWorkerCode = `
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false;
}
return true;
}
self.onmessage = function(e) {
const limit = e.data;
const primes = [];
const reportEvery = Math.max(1, Math.floor(limit / 100));
for (let i = 2; i <= limit; i++) {
if (isPrime(i)) {
primes.push(i);
}
if (i % reportEvery === 0) {
self.postMessage({ type: 'progress', value: (i / limit) * 100 });
}
}
self.postMessage({ type: 'complete', primes });
};
`;
function calculateWithWorker() {
const limit = parseInt(document.getElementById('primeLimit').value);
const output = document.getElementById('primeOutput');
const progress = document.getElementById('progressFill');
output.textContent = 'Starting worker...';
progress.style.width = '0%';
if (primeWorker) primeWorker.terminate();
primeWorker = createWorker(primeWorkerCode);
const startTime = performance.now();
primeWorker.onmessage = function(e) {
if (e.data.type === 'progress') {
progress.style.width = e.data.value + '%';
} else if (e.data.type === 'complete') {
const duration = ((performance.now() - startTime) / 1000).toFixed(2);
const primes = e.data.primes;
output.textContent = `Found ${primes.length} primes up to ${limit}\n` +
`Time: ${duration}s\n\n` +
`First 20: ${primes.slice(0, 20).join(', ')}...\n` +
`Last 20: ...${primes.slice(-20).join(', ')}`;
progress.style.width = '100%';
}
};
primeWorker.postMessage(limit);
}
function calculateWithoutWorker() {
const limit = parseInt(document.getElementById('primeLimit').value);
const output = document.getElementById('primeOutput');
const progress = document.getElementById('progressFill');
output.textContent = 'Calculating (UI will freeze)...';
progress.style.width = '0%';
// Give UI time to update
setTimeout(() => {
const startTime = performance.now();
const primes = [];
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false;
}
return true;
}
for (let i = 2; i <= limit; i++) {
if (isPrime(i)) primes.push(i);
}
const duration = ((performance.now() - startTime) / 1000).toFixed(2);
output.textContent = `Found ${primes.length} primes up to ${limit}\n` +
`Time: ${duration}s (UI was blocked!)\n\n` +
`First 20: ${primes.slice(0, 20).join(', ')}...\n` +
`Last 20: ...${primes.slice(-20).join(', ')}`;
progress.style.width = '100%';
}, 50);
}
function terminateWorker() {
if (primeWorker) {
primeWorker.terminate();
primeWorker = null;
document.getElementById('primeOutput').textContent = 'Worker terminated.';
document.getElementById('progressFill').style.width = '0%';
}
}
// Fibonacci worker
const fibWorkerCode = `
function fibonacci(n) {
if (n <= 1) return n;
let a = 0, b = 1;
for (let i = 2; i <= n; i++) {
const temp = a + b;
a = b;
b = temp;
}
return b;
}
self.onmessage = function(e) {
const n = e.data;
const startTime = Date.now();
const result = fibonacci(n);
const duration = Date.now() - startTime;
self.postMessage({ n, result: result.toString(), duration });
};
`;
function calculateFibonacci() {
const n = parseInt(document.getElementById('fibN').value);
const output = document.getElementById('fibOutput');
output.textContent = 'Calculating...';
if (fibWorker) fibWorker.terminate();
fibWorker = createWorker(fibWorkerCode);
fibWorker.onmessage = function(e) {
output.textContent = `Fibonacci(${e.data.n}) = ${e.data.result}\nTime: ${e.data.duration}ms`;
};
fibWorker.postMessage(n);
}
// Message passing worker
const messageWorkerCode = `
self.onmessage = function(e) {
const message = e.data;
// Echo back with transformation
self.postMessage({
original: message,
reversed: message.split('').reverse().join(''),
uppercase: message.toUpperCase(),
length: message.length,
timestamp: new Date().toISOString()
});
};
`;
let messageLog = [];
function sendMessage() {
const message = document.getElementById('workerMessage').value;
const output = document.getElementById('messageOutput');
if (!messageWorker) {
messageWorker = createWorker(messageWorkerCode);
messageWorker.onmessage = function(e) {
messageLog.unshift(`Received: ${JSON.stringify(e.data, null, 2)}`);
if (messageLog.length > 5) messageLog.pop();
output.textContent = messageLog.join('\n\n');
};
}
messageLog.unshift(`Sent: "${message}"`);
messageWorker.postMessage(message);
}
checkSupport();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed frontend/*
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "Web Workers Demo",
Description: "Demonstrates Web Workers for background processing",
Assets: application.AssetOptions{
Handler: application.BundledAssetFileServer(assets),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Web Workers Demo",
Width: 800,
Height: 600,
URL: "/",
})
app.Run()
}

View file

@ -0,0 +1,496 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XMLHttpRequest API Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
h1 { color: #4ade80; margin-bottom: 10px; }
.description { color: #aaa; margin-bottom: 30px; }
.card {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
h2 { font-size: 1.2rem; margin-bottom: 15px; color: #e94560; }
.form-group { margin-bottom: 15px; }
.form-row { display: flex; gap: 10px; align-items: end; }
.form-row .form-group { flex: 1; }
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 0.9rem; }
input, textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a2e;
color: #eee;
font-size: 1rem;
}
button {
background: #4ade80;
color: #1a1a2e;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover { opacity: 0.9; }
button.secondary { background: #60a5fa; }
button.danger { background: #f87171; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.output {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
word-break: break-all;
}
.status { padding: 10px; border-radius: 6px; margin-bottom: 20px; }
.status.success { background: rgba(74, 222, 128, 0.2); color: #4ade80; }
.status.error { background: rgba(248, 113, 113, 0.2); color: #f87171; }
.status.loading { background: rgba(96, 165, 250, 0.2); color: #60a5fa; }
.progress-bar {
width: 100%;
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
margin: 10px 0;
}
.progress-fill {
height: 100%;
background: #4ade80;
width: 0%;
transition: width 0.3s;
}
.event-log {
background: #0a0a1a;
border-radius: 6px;
padding: 15px;
font-family: monospace;
font-size: 0.8rem;
max-height: 250px;
overflow-y: auto;
}
.log-entry {
padding: 4px 8px;
margin: 2px 0;
border-radius: 4px;
display: flex;
gap: 10px;
}
.log-entry.event { background: rgba(96, 165, 250, 0.1); }
.log-entry.state { background: rgba(251, 191, 36, 0.1); }
.log-entry.error { background: rgba(248, 113, 113, 0.1); }
.log-entry.success { background: rgba(74, 222, 128, 0.1); }
.log-time { color: #666; flex-shrink: 0; }
.log-type { color: #60a5fa; flex-shrink: 0; width: 80px; }
.log-message { color: #ccc; }
.response-info { display: flex; gap: 20px; margin-bottom: 15px; flex-wrap: wrap; }
.response-info span { padding: 5px 10px; background: rgba(0,0,0,0.3); border-radius: 4px; }
.tabs { display: flex; gap: 5px; margin-bottom: 15px; }
.tab {
padding: 8px 16px;
background: transparent;
border: 1px solid #333;
color: #aaa;
cursor: pointer;
}
.tab.active { background: #4ade80; color: #1a1a2e; border-color: #4ade80; }
</style>
</head>
<body>
<div class="container">
<h1>XMLHttpRequest API Demo</h1>
<p class="description">
XMLHttpRequest (XHR) is the classic API for making HTTP requests in JavaScript.
It provides detailed control over request/response handling with event-based progress tracking.
</p>
<div id="status" class="status success">
XMLHttpRequest API available: <span id="available">checking...</span>
</div>
<div class="card">
<h2>Make Request</h2>
<div class="form-row">
<div class="form-group" style="flex: 0 0 120px;">
<label for="method">Method</label>
<select id="method">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="form-group">
<label for="url">URL</label>
<input type="text" id="url" value="https://jsonplaceholder.typicode.com/posts/1">
</div>
</div>
<div class="form-group">
<label for="headers">Headers (JSON)</label>
<textarea id="headers" rows="2" placeholder='{"Content-Type": "application/json"}'></textarea>
</div>
<div class="form-group">
<label for="body">Body (for POST/PUT)</label>
<textarea id="body" rows="3" placeholder='{"title": "foo", "body": "bar", "userId": 1}'></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="async" checked> Asynchronous
</label>
</div>
<div class="progress-bar" id="progressBar" style="display: none;">
<div class="progress-fill" id="progressFill"></div>
</div>
<button onclick="makeRequest()">Send Request</button>
<button id="abortBtn" class="danger" onclick="abortRequest()" disabled>Abort</button>
<button class="secondary" onclick="loadExample('get')">GET Posts</button>
<button class="secondary" onclick="loadExample('post')">POST Create</button>
<button class="secondary" onclick="loadExample('large')">Large Response</button>
</div>
<div class="card">
<h2>Response</h2>
<div id="responseStatus" class="status" style="display: none;"></div>
<div class="response-info" id="responseInfo" style="display: none;">
<span>Status: <strong id="resStatus"></strong></span>
<span>Ready State: <strong id="resReadyState"></strong></span>
<span>Time: <strong id="resTime"></strong></span>
</div>
<div class="tabs">
<button class="tab active" onclick="showTab('body')">Body</button>
<button class="tab" onclick="showTab('headers')">Headers</button>
</div>
<div id="responseBody" class="output">Make a request to see the response...</div>
<div id="responseHeaders" class="output" style="display: none;"></div>
</div>
<div class="card">
<h2>Event Log</h2>
<button onclick="clearLog()">Clear Log</button>
<div class="event-log" id="eventLog">
<div class="log-entry state">
<span class="log-time">--:--:--</span>
<span class="log-type">INFO</span>
<span class="log-message">Ready to make requests...</span>
</div>
</div>
</div>
<div class="card">
<h2>XMLHttpRequest API Reference</h2>
<div class="output">
// Create request
const xhr = new XMLHttpRequest();
// Configure request
xhr.open('GET', 'https://api.example.com/data', true); // async=true
// Set headers
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('X-Custom-Header', 'value');
// Event handlers
xhr.onreadystatechange = () => {
// 0=UNSENT, 1=OPENED, 2=HEADERS_RECEIVED, 3=LOADING, 4=DONE
console.log('Ready state:', xhr.readyState);
};
xhr.onload = () => console.log('Load complete');
xhr.onerror = () => console.log('Network error');
xhr.onprogress = (e) => console.log(`Progress: ${e.loaded}/${e.total}`);
xhr.onabort = () => console.log('Request aborted');
xhr.ontimeout = () => console.log('Request timed out');
// Response properties
xhr.status // HTTP status code (200, 404, etc.)
xhr.statusText // Status text ("OK", "Not Found", etc.)
xhr.responseText // Response as text
xhr.responseXML // Response as XML document
xhr.response // Response based on responseType
xhr.responseType // '', 'arraybuffer', 'blob', 'document', 'json', 'text'
// Send request
xhr.send(); // GET/DELETE
xhr.send('data'); // POST/PUT with string
xhr.send(new FormData(formElement)); // POST with form data
xhr.send(JSON.stringify({key: 'value'})); // POST with JSON
// Abort request
xhr.abort();</div>
</div>
</div>
<script>
let currentXhr = null;
let currentTab = 'body';
const readyStates = {
0: 'UNSENT',
1: 'OPENED',
2: 'HEADERS_RECEIVED',
3: 'LOADING',
4: 'DONE'
};
function checkXHR() {
const available = typeof XMLHttpRequest !== 'undefined';
document.getElementById('available').textContent = available ? 'Yes' : 'No';
document.getElementById('status').className = available ? 'status success' : 'status error';
return available;
}
function logEvent(type, message, className = 'event') {
const log = document.getElementById('eventLog');
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = `log-entry ${className}`;
entry.innerHTML = `
<span class="log-time">${time}</span>
<span class="log-type">${type}</span>
<span class="log-message">${message}</span>
`;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function clearLog() {
document.getElementById('eventLog').innerHTML = '';
logEvent('INFO', 'Log cleared', 'state');
}
function loadExample(type) {
const method = document.getElementById('method');
const url = document.getElementById('url');
const headers = document.getElementById('headers');
const body = document.getElementById('body');
switch (type) {
case 'get':
method.value = 'GET';
url.value = 'https://jsonplaceholder.typicode.com/posts?_limit=5';
headers.value = '';
body.value = '';
break;
case 'post':
method.value = 'POST';
url.value = 'https://jsonplaceholder.typicode.com/posts';
headers.value = '{"Content-Type": "application/json"}';
body.value = JSON.stringify({ title: 'XHR Test', body: 'Hello from XMLHttpRequest!', userId: 1 }, null, 2);
break;
case 'large':
method.value = 'GET';
url.value = 'https://jsonplaceholder.typicode.com/photos';
headers.value = '';
body.value = '';
break;
}
logEvent('INFO', `Loaded example: ${type}`, 'state');
}
function makeRequest() {
const method = document.getElementById('method').value;
const url = document.getElementById('url').value;
const headersStr = document.getElementById('headers').value;
const bodyStr = document.getElementById('body').value;
const async = document.getElementById('async').checked;
const statusEl = document.getElementById('responseStatus');
const infoEl = document.getElementById('responseInfo');
const bodyEl = document.getElementById('responseBody');
const headersEl = document.getElementById('responseHeaders');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const abortBtn = document.getElementById('abortBtn');
// Reset UI
statusEl.style.display = 'block';
statusEl.className = 'status loading';
statusEl.textContent = 'Loading...';
infoEl.style.display = 'none';
progressBar.style.display = 'block';
progressFill.style.width = '0%';
abortBtn.disabled = false;
const startTime = performance.now();
logEvent('INFO', `Starting ${method} request to ${url}`, 'state');
try {
currentXhr = new XMLHttpRequest();
// Ready state change handler
currentXhr.onreadystatechange = function() {
const state = readyStates[this.readyState];
logEvent('STATE', `readyState changed to ${this.readyState} (${state})`, 'state');
document.getElementById('resReadyState').textContent = `${this.readyState} (${state})`;
};
// Progress handler
currentXhr.onprogress = function(e) {
if (e.lengthComputable) {
const percent = (e.loaded / e.total * 100).toFixed(1);
progressFill.style.width = percent + '%';
logEvent('PROGRESS', `Loaded ${e.loaded} of ${e.total} bytes (${percent}%)`, 'event');
} else {
logEvent('PROGRESS', `Loaded ${e.loaded} bytes (total unknown)`, 'event');
}
};
// Load handler
currentXhr.onload = function() {
const endTime = performance.now();
progressFill.style.width = '100%';
abortBtn.disabled = true;
logEvent('LOAD', `Request completed with status ${this.status}`, 'success');
// Display response info
infoEl.style.display = 'flex';
document.getElementById('resStatus').textContent = `${this.status} ${this.statusText}`;
document.getElementById('resTime').textContent = `${(endTime - startTime).toFixed(0)}ms`;
// Display headers
headersEl.textContent = this.getAllResponseHeaders() || 'No headers';
// Display body
const contentType = this.getResponseHeader('content-type') || '';
let bodyText = this.responseText;
if (contentType.includes('application/json')) {
try {
bodyText = JSON.stringify(JSON.parse(this.responseText), null, 2);
} catch (e) {}
}
bodyEl.textContent = bodyText;
statusEl.className = this.status >= 200 && this.status < 300 ? 'status success' : 'status error';
statusEl.textContent = this.status >= 200 && this.status < 300 ? 'Request successful' : `Request failed: ${this.status}`;
currentXhr = null;
};
// Error handler
currentXhr.onerror = function() {
const endTime = performance.now();
abortBtn.disabled = true;
progressBar.style.display = 'none';
logEvent('ERROR', 'Network error occurred', 'error');
statusEl.className = 'status error';
statusEl.textContent = 'Network Error';
bodyEl.textContent = 'A network error occurred. This could be due to CORS, network issues, or an invalid URL.';
document.getElementById('resTime').textContent = `${(endTime - startTime).toFixed(0)}ms`;
infoEl.style.display = 'flex';
currentXhr = null;
};
// Abort handler
currentXhr.onabort = function() {
abortBtn.disabled = true;
progressBar.style.display = 'none';
logEvent('ABORT', 'Request was aborted', 'error');
statusEl.className = 'status error';
statusEl.textContent = 'Request Aborted';
bodyEl.textContent = 'The request was aborted by the user.';
currentXhr = null;
};
// Timeout handler
currentXhr.ontimeout = function() {
abortBtn.disabled = true;
progressBar.style.display = 'none';
logEvent('TIMEOUT', 'Request timed out', 'error');
statusEl.className = 'status error';
statusEl.textContent = 'Request Timeout';
bodyEl.textContent = 'The request timed out.';
currentXhr = null;
};
// Load start handler
currentXhr.onloadstart = function() {
logEvent('LOADSTART', 'Request started', 'event');
};
// Load end handler
currentXhr.onloadend = function() {
logEvent('LOADEND', 'Request finished (success or failure)', 'event');
};
// Open connection
currentXhr.open(method, url, async);
logEvent('EVENT', `Connection opened (${async ? 'async' : 'sync'})`, 'event');
// Set headers
if (headersStr) {
const headers = JSON.parse(headersStr);
for (const [key, value] of Object.entries(headers)) {
currentXhr.setRequestHeader(key, value);
logEvent('HEADER', `Set header: ${key}: ${value}`, 'event');
}
}
// Send request
if (bodyStr && (method === 'POST' || method === 'PUT')) {
currentXhr.send(bodyStr);
logEvent('SEND', `Request sent with body (${bodyStr.length} chars)`, 'event');
} else {
currentXhr.send();
logEvent('SEND', 'Request sent (no body)', 'event');
}
} catch (e) {
statusEl.className = 'status error';
statusEl.textContent = 'Error: ' + e.message;
bodyEl.textContent = e.stack || e.message;
logEvent('ERROR', e.message, 'error');
abortBtn.disabled = true;
progressBar.style.display = 'none';
}
}
function abortRequest() {
if (currentXhr) {
currentXhr.abort();
logEvent('ACTION', 'Abort requested by user', 'state');
}
}
function showTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelector(`.tab[onclick="showTab('${tab}')"]`).classList.add('active');
document.getElementById('responseBody').style.display = tab === 'body' ? 'block' : 'none';
document.getElementById('responseHeaders').style.display = tab === 'headers' ? 'block' : 'none';
}
checkXHR();
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show more