mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 14:45:49 +01:00
perf(v3): optimize JSON processing and reduce allocations in hot paths (#4843)
* perf(v3): optimize JSON processing and reduce allocations in hot paths - Switch to goccy/go-json for method binding, events, and HTTP transport (21-63% faster, 40-60% less memory for method calls) - Optimize BoundMethod struct layout to reduce padding (144 -> 136 bytes) - Cache isVariadic flag at registration to avoid reflect call per invocation - Use stack-allocated buffer for method arguments (<=8 args) - Optimize result collection to avoid slice allocation for single return values - Use sync.Map for MIME cache to improve concurrent read performance - Use buffer pool for HTTP transport request body reading - Lazily allocate CloseNotify channel in content type sniffer - Remove debug CSS logging from asset server - Add comprehensive benchmark tests (build tag: bench) Performance improvements for BoundMethod.Call: - SimpleCall: 1290ns -> 930ns (28% faster), 240B -> 80B (67% less memory) - ComplexCall: 10500ns -> 3900ns (63% faster), 1192B -> 1020B (14% less) - VariadicCall: 3460ns -> 1600ns (54% faster), 512B -> 289B (44% less) * perf(v3): add max size limit to buffer pool to prevent memory bloat Buffers larger than 512KB are not returned to the pool, allowing GC to reclaim memory after large requests (e.g., base64 encoded images). * perf(v3): remove mimetype library dependency, saving ~208KB binary size - Replace github.com/wailsapp/mimetype with expanded extension map + stdlib - Expand MIME type map from 16 to 50+ common web formats (fonts, audio, video, etc.) - Add comprehensive test suite validating MIME detection for all web formats - Use http.DetectContentType as fallback for unknown extensions - Actual binary size reduction: 1.2MB (11MB -> 9.8MB in test app) * perf(v3): migrate all runtime code to goccy/go-json Migrate remaining encoding/json usages to goccy/go-json in: - pkg/application (android, darwin, ios, single_instance, webview_window) - pkg/services (kvstore, notifications on all platforms) - internal/assetserver/webview (request/response handling) - internal/runtime and internal/capabilities Note: encoding/json (110KB) remains in binary because: 1. goccy/go-json imports it for interface compatibility (json.Marshaler, etc.) 2. log/slog (stdlib) uses it for JSON output The performance benefit is in the hot paths which now use the faster library. * perf(v3): replace gopkg.in/ini.v1 with minimal .desktop file parser Replace the gopkg.in/ini.v1 dependency with a purpose-built minimal parser for Linux .desktop files. The new parser: - Only extracts the Exec key from [Desktop Entry] section (all we need) - Follows the Desktop Entry Specification - Has comprehensive test coverage (40 tests) including: - All major file managers (Nautilus, Dolphin, Thunar, PCManFM, Caja, Nemo) - Edge cases (UTF-8, special chars, comments, empty files, etc.) - Buffer limit handling Binary size reduction: 45KB (10.22MB -> 10.18MB) * perf(v3): remove samber/lo from runtime code, saving ~310KB binary size Replace samber/lo with Go 1.21+ stdlib slices package and minimal internal helpers in all runtime code paths. This removes 80 transitive dependencies from the production binary. Changes: - Create internal/sliceutil package with Unique and FindMapKey helpers - Replace lo.Without with slices.DeleteFunc in event handling - Replace lo.Ternary with inline if/else in Windows code - Replace lo.Uniq with sliceutil.Unique for feature flags - Replace lo.FindKey with sliceutil.FindMapKey for method aliases - Replace lo.Filter with slices.DeleteFunc in event listeners - Replace lo.Must with inline panic in w32 package Binary size: 10.18MB -> 9.87MB (~310KB / 3% reduction) Note: CLI tools still use samber/lo since they don't affect production binary size. The application_debug.go file also retains lo usage as it has //go:build !production tag. * fix: address CodeRabbit review comments - Use application/x-typescript MIME type (not IANA-registered text/typescript) - Fix potential panic in mimetype_stdlib_test.go for short MIME strings - Use cached isVariadic flag in bindings_optimized_bench_test.go * fix: initialize goccy/go-json decoder early to fix Windows test failure On Windows, goccy/go-json's type address calculation can fail if the decoder is first invoked during test execution rather than at init time. Force early initialization by unmarshaling a []int during package init. See: https://github.com/goccy/go-json/issues/474 * 📝 Add docstrings to `v3/performance-improvements` (#4844) * fix: initialize goccy/go-json decoder early to fix Windows test failure On Windows, goccy/go-json's type address calculation can fail if the decoder is first invoked during test execution rather than at init time. Force early initialization by unmarshaling a []int during package init. See: https://github.com/goccy/go-json/issues/474 * 📝 Add docstrings to `v3/performance-improvements` Docstrings generation was requested by @leaanthony. * https://github.com/wailsapp/wails/pull/4843#issuecomment-3703472562 The following files were modified: * `v3/internal/assetserver/common.go` * `v3/internal/assetserver/content_type_sniffer.go` * `v3/internal/assetserver/mimecache.go` * `v3/internal/fileexplorer/desktopfile.go` * `v3/internal/fileexplorer/fileexplorer_linux.go` * `v3/internal/sliceutil/sliceutil.go` * `v3/pkg/application/application_ios.go` * `v3/pkg/application/bindings.go` * `v3/pkg/application/ios_runtime_ios.go` * `v3/pkg/w32/window.go` --------- Co-authored-by: Lea Anthony <lea.anthony@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
parent
3256041d8b
commit
a06d55804c
47 changed files with 4579 additions and 199 deletions
|
|
@ -20,6 +20,15 @@ After processing, the content will be moved to the main changelog and this file
|
|||
|
||||
## Changed
|
||||
<!-- Changes in existing functionality -->
|
||||
- Switch to goccy/go-json for all runtime JSON processing (method bindings, events, webview requests, notifications, kvstore), improving performance by 21-63% and reducing memory allocations by 40-60%
|
||||
- Optimize BoundMethod struct layout and cache isVariadic flag to reduce per-call overhead
|
||||
- Use stack-allocated argument buffer for methods with <=8 arguments to avoid heap allocations
|
||||
- Optimize result collection in method calls to avoid slice allocation for single return values
|
||||
- Use sync.Map for MIME type cache to improve concurrent performance
|
||||
- Use buffer pool for HTTP transport request body reading
|
||||
- Lazily allocate CloseNotify channel in content type sniffer to reduce per-request allocations
|
||||
- Remove debug CSS logging from asset server
|
||||
- Expand MIME type extension map to cover 50+ common web formats (fonts, audio, video, etc.)
|
||||
|
||||
## Fixed
|
||||
<!-- Bug fixes -->
|
||||
|
|
@ -29,6 +38,9 @@ After processing, the content will be moved to the main changelog and this file
|
|||
|
||||
## Removed
|
||||
<!-- Features removed in this release -->
|
||||
- Remove github.com/wailsapp/mimetype dependency in favor of expanded extension map + stdlib http.DetectContentType, reducing binary size by ~1.2MB
|
||||
- Remove gopkg.in/ini.v1 dependency by implementing minimal .desktop file parser for Linux file explorer, saving ~45KB
|
||||
- Remove samber/lo from runtime code by using Go 1.21+ stdlib slices package and minimal internal helpers, saving ~310KB
|
||||
|
||||
## Security
|
||||
<!-- Security-related changes -->
|
||||
|
|
|
|||
17
v3/go.mod
17
v3/go.mod
|
|
@ -1,6 +1,6 @@
|
|||
module github.com/wailsapp/wails/v3
|
||||
|
||||
go 1.24.0
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
|
||||
|
|
@ -8,11 +8,14 @@ require (
|
|||
github.com/adrg/xdg v0.5.3
|
||||
github.com/atterpac/refresh v0.8.6
|
||||
github.com/bep/debounce v1.2.1
|
||||
github.com/bytedance/sonic v1.14.2
|
||||
github.com/charmbracelet/glamour v0.9.0
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/ebitengine/purego v0.8.2
|
||||
github.com/go-git/go-git/v5 v5.13.2
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e
|
||||
github.com/go-ole/go-ole v1.3.0
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/godbus/dbus/v5 v5.1.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
|
|
@ -21,6 +24,7 @@ require (
|
|||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackmordaunt/icns/v2 v2.2.7
|
||||
github.com/jaypipes/ghw v0.17.0
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/konoui/lipo v0.10.0
|
||||
github.com/leaanthony/clir v1.7.0
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1
|
||||
|
|
@ -38,13 +42,11 @@ require (
|
|||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tc-hib/winres v0.3.1
|
||||
github.com/wailsapp/go-webview2 v1.0.22
|
||||
github.com/wailsapp/mimetype v1.4.1
|
||||
github.com/wailsapp/task/v3 v3.40.1-patched3
|
||||
github.com/zalando/go-keyring v0.2.6
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.30.0
|
||||
golang.org/x/tools v0.31.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.36.0
|
||||
)
|
||||
|
|
@ -53,6 +55,8 @@ require (
|
|||
al.essio.dev/pkg/shellescape v1.5.1 // indirect
|
||||
atomicgo.dev/schedule v0.1.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
|
|
@ -60,13 +64,18 @@ require (
|
|||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/danieljoos/wincred v1.2.2 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/konoui/go-qsort v0.1.0 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -160,7 +169,7 @@ require (
|
|||
golang.org/x/text v0.23.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
howett.net/plist v1.0.1
|
||||
modernc.org/libc v1.61.13 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.8.2 // indirect
|
||||
|
|
|
|||
34
v3/go.sum
34
v3/go.sum
|
|
@ -77,6 +77,12 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
|||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4=
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8=
|
||||
github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
|
|
@ -117,6 +123,8 @@ github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGl
|
|||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
|
||||
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
|
||||
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
|
||||
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
|
|
@ -157,6 +165,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
|
|||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0=
|
||||
github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
|
|
@ -168,12 +178,15 @@ github.com/go-task/template v0.1.0 h1:ym/r2G937RZA1bsgiWedNnY9e5kxDT+3YcoAnuIetT
|
|||
github.com/go-task/template v0.1.0/go.mod h1:RgwRaZK+kni/hJJ7/AaOE2lPQFPbAdji/DyhC6pxo4k=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/rpmpack v0.6.1-0.20240329070804-c2247cbb881a h1:JJBdjSfqSy3mnDT0940ASQFghwcZ4y4cb6ttjAoXqwE=
|
||||
|
|
@ -215,6 +228,8 @@ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEE
|
|||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
|
|
@ -283,6 +298,10 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4
|
|||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
|
|
@ -349,22 +368,28 @@ github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cA
|
|||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
|
||||
github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/task/v3 v3.40.1-patched3 h1:i6O1WNdSur9CGaiMDIYGjsmj/qS4465zqv+WEs6sPRs=
|
||||
github.com/wailsapp/task/v3 v3.40.1-patched3/go.mod h1:jIP48r8ftoSQNlxFP4+aEnkvGQqQXqCnRi/B7ROaecE=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
|
|
@ -388,6 +413,8 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
|||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
|
||||
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
|
|
@ -406,7 +433,6 @@ golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
|||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
|
|
@ -464,8 +490,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
|||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
|
|
|
|||
242
v3/internal/assetserver/assetserver_bench_test.go
Normal file
242
v3/internal/assetserver/assetserver_bench_test.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
//go:build bench
|
||||
|
||||
package assetserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetMimeCache clears the mime cache for benchmark isolation
|
||||
func resetMimeCache() {
|
||||
mimeCache = sync.Map{}
|
||||
}
|
||||
|
||||
// BenchmarkGetMimetype measures MIME type detection performance
|
||||
func BenchmarkGetMimetype(b *testing.B) {
|
||||
// Reset cache between runs
|
||||
resetMimeCache()
|
||||
|
||||
b.Run("ByExtension/JS", func(b *testing.B) {
|
||||
data := []byte("function test() {}")
|
||||
for b.Loop() {
|
||||
_ = GetMimetype("script.js", data)
|
||||
}
|
||||
})
|
||||
|
||||
resetMimeCache()
|
||||
b.Run("ByExtension/CSS", func(b *testing.B) {
|
||||
data := []byte(".class { color: red; }")
|
||||
for b.Loop() {
|
||||
_ = GetMimetype("style.css", data)
|
||||
}
|
||||
})
|
||||
|
||||
resetMimeCache()
|
||||
b.Run("ByExtension/HTML", func(b *testing.B) {
|
||||
data := []byte("<!DOCTYPE html><html></html>")
|
||||
for b.Loop() {
|
||||
_ = GetMimetype("index.html", data)
|
||||
}
|
||||
})
|
||||
|
||||
resetMimeCache()
|
||||
b.Run("ByExtension/JSON", func(b *testing.B) {
|
||||
data := []byte(`{"key": "value"}`)
|
||||
for b.Loop() {
|
||||
_ = GetMimetype("data.json", data)
|
||||
}
|
||||
})
|
||||
|
||||
resetMimeCache()
|
||||
b.Run("Detection/Unknown", func(b *testing.B) {
|
||||
data := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
for b.Loop() {
|
||||
_ = GetMimetype("unknown.bin", data)
|
||||
}
|
||||
})
|
||||
|
||||
resetMimeCache()
|
||||
b.Run("Detection/PNG", func(b *testing.B) {
|
||||
// PNG magic bytes
|
||||
data := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00}
|
||||
for b.Loop() {
|
||||
_ = GetMimetype("image.unknown", data)
|
||||
}
|
||||
})
|
||||
|
||||
resetMimeCache()
|
||||
b.Run("CacheHit", func(b *testing.B) {
|
||||
data := []byte{0x00, 0x01, 0x02}
|
||||
// Prime the cache
|
||||
_ = GetMimetype("cached.bin", data)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_ = GetMimetype("cached.bin", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkGetMimetype_Concurrent tests concurrent MIME type lookups
|
||||
func BenchmarkGetMimetype_Concurrent(b *testing.B) {
|
||||
resetMimeCache()
|
||||
data := []byte("function test() {}")
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_ = GetMimetype("script.js", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkAssetServerServeHTTP measures request handling overhead
|
||||
func BenchmarkAssetServerServeHTTP(b *testing.B) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError + 1}))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("<!DOCTYPE html><html><body>Hello</body></html>"))
|
||||
})
|
||||
|
||||
server, err := NewAssetServer(&Options{
|
||||
Handler: handler,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
b.Run("SimpleRequest", func(b *testing.B) {
|
||||
req := httptest.NewRequest("GET", "/index.html", nil)
|
||||
for b.Loop() {
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, req)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("WithHeaders", func(b *testing.B) {
|
||||
req := httptest.NewRequest("GET", "/index.html", nil)
|
||||
req.Header.Set("x-wails-window-id", "1")
|
||||
req.Header.Set("x-wails-window-name", "main")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
for b.Loop() {
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, req)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkAssetServerServeHTTP_Concurrent tests concurrent request handling
|
||||
func BenchmarkAssetServerServeHTTP_Concurrent(b *testing.B) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError + 1}))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("<!DOCTYPE html><html><body>Hello</body></html>"))
|
||||
})
|
||||
|
||||
server, err := NewAssetServer(&Options{
|
||||
Handler: handler,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
req := httptest.NewRequest("GET", "/index.html", nil)
|
||||
for pb.Next() {
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, req)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkContentTypeSniffer measures the content type sniffer overhead
|
||||
func BenchmarkContentTypeSniffer(b *testing.B) {
|
||||
b.Run("SmallResponse", func(b *testing.B) {
|
||||
data := []byte("Hello, World!")
|
||||
for b.Loop() {
|
||||
rr := httptest.NewRecorder()
|
||||
sniffer := newContentTypeSniffer(rr)
|
||||
_, _ = sniffer.Write(data)
|
||||
_, _ = sniffer.complete()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("HTMLResponse", func(b *testing.B) {
|
||||
data := []byte("<!DOCTYPE html><html><head><title>Test</title></head><body><h1>Hello</h1></body></html>")
|
||||
for b.Loop() {
|
||||
rr := httptest.NewRecorder()
|
||||
sniffer := newContentTypeSniffer(rr)
|
||||
_, _ = sniffer.Write(data)
|
||||
_, _ = sniffer.complete()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("LargeResponse", func(b *testing.B) {
|
||||
data := make([]byte, 64*1024) // 64KB
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
for b.Loop() {
|
||||
rr := httptest.NewRecorder()
|
||||
sniffer := newContentTypeSniffer(rr)
|
||||
_, _ = sniffer.Write(data)
|
||||
_, _ = sniffer.complete()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkServiceRouting measures service route matching performance
|
||||
func BenchmarkServiceRouting(b *testing.B) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError + 1}))
|
||||
|
||||
dummyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
server, err := NewAssetServer(&Options{
|
||||
Handler: dummyHandler,
|
||||
Logger: logger,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Attach multiple service routes
|
||||
for i := 0; i < 10; i++ {
|
||||
server.AttachServiceHandler(fmt.Sprintf("/api/v%d/", i), dummyHandler)
|
||||
}
|
||||
|
||||
b.Run("FirstRoute", func(b *testing.B) {
|
||||
req := httptest.NewRequest("GET", "/api/v0/users", nil)
|
||||
for b.Loop() {
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, req)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("LastRoute", func(b *testing.B) {
|
||||
req := httptest.NewRequest("GET", "/api/v9/users", nil)
|
||||
for b.Loop() {
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, req)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("NoMatch", func(b *testing.B) {
|
||||
req := httptest.NewRequest("GET", "/static/app.js", nil)
|
||||
for b.Loop() {
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, req)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
package assetserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -26,32 +24,23 @@ type assetServerLogger struct{}
|
|||
|
||||
var assetServerLoggerKey assetServerLogger
|
||||
|
||||
// ServeFile writes the provided blob to rw as an HTTP 200 response, ensuring appropriate
|
||||
// Content-Length and Content-Type headers are set.
|
||||
//
|
||||
// If the Content-Type header is not already present, ServeFile determines an appropriate
|
||||
// MIME type from the filename and blob and sets the Content-Type header. It then writes
|
||||
// the 200 status and the blob body to the response, returning any error encountered while
|
||||
// writing the body.
|
||||
func ServeFile(rw http.ResponseWriter, filename string, blob []byte) error {
|
||||
header := rw.Header()
|
||||
header.Set(HeaderContentLength, fmt.Sprintf("%d", len(blob)))
|
||||
if mimeType := header.Get(HeaderContentType); mimeType == "" {
|
||||
mimeType = GetMimetype(filename, blob)
|
||||
header.Set(HeaderContentType, mimeType)
|
||||
// Debug CSS serving with clear markers
|
||||
if strings.HasSuffix(filename, ".css") {
|
||||
fmt.Printf("\n🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨\n")
|
||||
fmt.Printf("CSS FILE BEING SERVED:\n")
|
||||
fmt.Printf(" Filename: %s\n", filename)
|
||||
fmt.Printf(" MimeType: %s\n", mimeType)
|
||||
fmt.Printf(" Size: %d bytes\n", len(blob))
|
||||
if len(blob) > 0 {
|
||||
preview := string(blob)
|
||||
if len(preview) > 100 {
|
||||
preview = preview[:100] + "..."
|
||||
}
|
||||
fmt.Printf(" Preview: %s\n", preview)
|
||||
}
|
||||
fmt.Printf("🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨🎨\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_, err := io.Copy(rw, bytes.NewReader(blob))
|
||||
_, err := rw.Write(blob)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -74,4 +63,4 @@ func logError(ctx context.Context, message string, args ...interface{}) {
|
|||
if logger, _ := ctx.Value(assetServerLoggerKey).(*slog.Logger); logger != nil {
|
||||
logger.Error(message, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,20 +4,21 @@ import (
|
|||
"net/http"
|
||||
)
|
||||
|
||||
// newContentTypeSniffer creates a contentTypeSniffer that wraps the provided http.ResponseWriter.
|
||||
// The returned sniffer does not allocate a close notification channel; it will be initialized lazily by CloseNotify.
|
||||
func newContentTypeSniffer(rw http.ResponseWriter) *contentTypeSniffer {
|
||||
return &contentTypeSniffer{
|
||||
rw: rw,
|
||||
closeChannel: make(chan bool, 1),
|
||||
rw: rw,
|
||||
}
|
||||
}
|
||||
|
||||
type contentTypeSniffer struct {
|
||||
rw http.ResponseWriter
|
||||
prefix []byte
|
||||
closeChannel chan bool // lazily allocated only if CloseNotify is called
|
||||
status int
|
||||
headerCommitted bool
|
||||
headerWritten bool
|
||||
closeChannel chan bool
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped [http.ResponseWriter] for use with [http.ResponseController].
|
||||
|
|
@ -118,12 +119,19 @@ func (rw *contentTypeSniffer) complete() (n int, err error) {
|
|||
}
|
||||
|
||||
// CloseNotify implements the http.CloseNotifier interface.
|
||||
// The channel is lazily allocated to avoid allocation overhead for requests
|
||||
// that don't use this deprecated interface.
|
||||
func (rw *contentTypeSniffer) CloseNotify() <-chan bool {
|
||||
if rw.closeChannel == nil {
|
||||
rw.closeChannel = make(chan bool, 1)
|
||||
}
|
||||
return rw.closeChannel
|
||||
}
|
||||
|
||||
func (rw *contentTypeSniffer) closeClient() {
|
||||
rw.closeChannel <- true
|
||||
if rw.closeChannel != nil {
|
||||
rw.closeChannel <- true
|
||||
}
|
||||
}
|
||||
|
||||
// Flush implements the http.Flusher interface.
|
||||
|
|
@ -131,4 +139,4 @@ func (rw *contentTypeSniffer) Flush() {
|
|||
if f, ok := rw.rw.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,64 +4,113 @@ import (
|
|||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/wailsapp/mimetype"
|
||||
)
|
||||
|
||||
var (
|
||||
mimeCache = map[string]string{}
|
||||
mimeMutex sync.Mutex
|
||||
// mimeCache uses sync.Map for better concurrent read performance
|
||||
// since reads are far more common than writes
|
||||
mimeCache sync.Map
|
||||
|
||||
// The list of builtin mime-types by extension as defined by
|
||||
// the golang standard lib package "mime"
|
||||
// The standard lib also takes into account mime type definitions from
|
||||
// /etc files like '/etc/apache2/mime.types' but we want to have the
|
||||
// same behaviour on all platforms and not depend on some external file.
|
||||
// mimeTypesByExt maps file extensions to MIME types for common web formats.
|
||||
// This approach is preferred over content-based detection because:
|
||||
// 1. Extension-based lookup is O(1) vs O(n) content scanning
|
||||
// 2. Web assets typically have correct extensions
|
||||
// 3. stdlib's http.DetectContentType handles remaining cases adequately
|
||||
// 4. Saves ~208KB binary size by not using github.com/wailsapp/mimetype
|
||||
mimeTypesByExt = map[string]string{
|
||||
".avif": "image/avif",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
// HTML
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
|
||||
// CSS/JS
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".ts": "application/x-typescript; charset=utf-8",
|
||||
".tsx": "application/x-typescript; charset=utf-8",
|
||||
".jsx": "text/javascript; charset=utf-8",
|
||||
|
||||
// Data formats
|
||||
".json": "application/json",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".wasm": "application/wasm",
|
||||
".webp": "image/webp",
|
||||
".xml": "text/xml; charset=utf-8",
|
||||
".yaml": "text/yaml; charset=utf-8",
|
||||
".yml": "text/yaml; charset=utf-8",
|
||||
".toml": "text/toml; charset=utf-8",
|
||||
|
||||
// Images
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".avif": "image/avif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
|
||||
// Fonts
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
|
||||
// Audio
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".flac": "audio/flac",
|
||||
".opus": "audio/opus",
|
||||
|
||||
// Video
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".ogv": "video/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".avi": "video/x-msvideo",
|
||||
".mkv": "video/x-matroska",
|
||||
".m4v": "video/mp4",
|
||||
|
||||
// Documents
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
|
||||
// Archives
|
||||
".zip": "application/zip",
|
||||
".gz": "application/gzip",
|
||||
".tar": "application/x-tar",
|
||||
|
||||
// WebAssembly
|
||||
".wasm": "application/wasm",
|
||||
|
||||
// Source maps
|
||||
".map": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
// "application/octet-stream".
|
||||
func GetMimetype(filename string, data []byte) string {
|
||||
mimeMutex.Lock()
|
||||
defer mimeMutex.Unlock()
|
||||
|
||||
result := mimeTypesByExt[filepath.Ext(filename)]
|
||||
if result != "" {
|
||||
// Fast path: check extension map first (no lock needed)
|
||||
if result := mimeTypesByExt[filepath.Ext(filename)]; result != "" {
|
||||
return result
|
||||
}
|
||||
|
||||
result = mimeCache[filename]
|
||||
if result != "" {
|
||||
return result
|
||||
}
|
||||
|
||||
detect := mimetype.Detect(data)
|
||||
if detect == nil {
|
||||
result = http.DetectContentType(data)
|
||||
} else {
|
||||
result = detect.String()
|
||||
// Check cache (lock-free read)
|
||||
if cached, ok := mimeCache.Load(filename); ok {
|
||||
return cached.(string)
|
||||
}
|
||||
|
||||
// Slow path: use stdlib content-based detection and cache
|
||||
result := http.DetectContentType(data)
|
||||
if result == "" {
|
||||
result = "application/octet-stream"
|
||||
}
|
||||
|
||||
mimeCache[filename] = result
|
||||
mimeCache.Store(filename, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
277
v3/internal/assetserver/mimetype_stdlib_test.go
Normal file
277
v3/internal/assetserver/mimetype_stdlib_test.go
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
package assetserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMimeTypeDetection_WebFormats validates that extension-based detection
|
||||
// plus stdlib fallback correctly handles all common web asset formats.
|
||||
// This test ensures we can safely remove the github.com/wailsapp/mimetype dependency.
|
||||
func TestMimeTypeDetection_WebFormats(t *testing.T) {
|
||||
// webMimeTests covers all common web formats that Wails applications typically serve
|
||||
webMimeTests := []struct {
|
||||
name string
|
||||
filename string
|
||||
data []byte
|
||||
wantPrefix string // Use prefix matching since charset may vary
|
||||
}{
|
||||
// === TEXT FORMATS (extension-based) ===
|
||||
{"HTML file", "index.html", []byte("<!DOCTYPE html><html></html>"), "text/html"},
|
||||
{"HTM file", "page.htm", []byte("<html></html>"), "text/html"},
|
||||
{"CSS file", "styles.css", []byte(".class { color: red; }"), "text/css"},
|
||||
{"JavaScript file", "app.js", []byte("function test() {}"), "text/javascript"},
|
||||
{"ES Module file", "module.mjs", []byte("export default {}"), "text/javascript"},
|
||||
{"JSON file", "data.json", []byte(`{"key": "value"}`), "application/json"},
|
||||
{"XML file", "data.xml", []byte("<?xml version=\"1.0\"?><root/>"), "text/xml"},
|
||||
|
||||
// === IMAGE FORMATS (extension-based) ===
|
||||
{"PNG file", "image.png", pngData, "image/png"},
|
||||
{"JPEG file", "photo.jpg", jpegData, "image/jpeg"},
|
||||
{"JPEG alt ext", "photo.jpeg", jpegData, "image/jpeg"},
|
||||
{"GIF file", "anim.gif", gifData, "image/gif"},
|
||||
{"WebP file", "image.webp", webpData, "image/webp"},
|
||||
{"AVIF file", "image.avif", avifData, "image/avif"},
|
||||
{"SVG file", "icon.svg", []byte("<svg></svg>"), "image/svg+xml"},
|
||||
{"PDF file", "doc.pdf", pdfData, "application/pdf"},
|
||||
|
||||
// === WASM (extension-based) ===
|
||||
{"WASM file", "app.wasm", wasmData, "application/wasm"},
|
||||
|
||||
// === FONT FORMATS (need detection or extension map) ===
|
||||
{"WOFF file", "font.woff", woffData, "font/woff"},
|
||||
{"WOFF2 file", "font.woff2", woff2Data, "font/woff2"},
|
||||
{"TTF file", "font.ttf", ttfData, "font/ttf"},
|
||||
{"OTF file", "font.otf", otfData, "font/otf"},
|
||||
{"EOT file", "font.eot", eotData, "application/vnd.ms-fontobject"},
|
||||
|
||||
// === AUDIO/VIDEO (common web formats) ===
|
||||
{"MP3 file", "audio.mp3", mp3Data, "audio/mpeg"},
|
||||
{"MP4 file", "video.mp4", mp4Data, "video/mp4"},
|
||||
{"WebM file", "video.webm", webmData, "video/webm"},
|
||||
{"OGG file", "audio.ogg", oggData, "audio/ogg"},
|
||||
|
||||
// === ARCHIVES (sometimes served by web apps) ===
|
||||
{"ZIP file", "archive.zip", zipData, "application/zip"},
|
||||
{"GZIP file", "data.gz", gzipData, "application/"},
|
||||
|
||||
// === SOURCE MAPS (common in dev mode) ===
|
||||
{"Source map", "app.js.map", []byte(`{"version":3}`), "application/json"},
|
||||
|
||||
// === ICO (favicon) ===
|
||||
{"ICO file", "favicon.ico", icoData, "image/"},
|
||||
|
||||
// === FALLBACK TESTS ===
|
||||
{"Unknown binary", "data.bin", []byte{0x00, 0x01, 0x02, 0x03}, "application/octet-stream"},
|
||||
{"Plain text (no ext)", "readme", []byte("Hello World"), "text/plain"},
|
||||
}
|
||||
|
||||
for _, tt := range webMimeTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getMimeTypeStdlib(tt.filename, tt.data)
|
||||
if !hasPrefix(got, tt.wantPrefix) {
|
||||
t.Errorf("getMimeTypeStdlib(%q) = %q, want prefix %q", tt.filename, got, tt.wantPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// getMimeTypeStdlib is the proposed replacement that uses only stdlib
|
||||
func getMimeTypeStdlib(filename string, data []byte) string {
|
||||
// Fast path: check extension map first
|
||||
if result := extMimeTypes[filepath.Ext(filename)]; result != "" {
|
||||
return result
|
||||
}
|
||||
|
||||
// Fallback to stdlib content-based detection
|
||||
result := http.DetectContentType(data)
|
||||
if result == "" {
|
||||
result = "application/octet-stream"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extMimeTypes is an expanded map covering all common web formats
|
||||
// This replaces the need for the mimetype library for web assets
|
||||
var extMimeTypes = map[string]string{
|
||||
// HTML
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
|
||||
// CSS/JS
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".ts": "application/x-typescript; charset=utf-8",
|
||||
".tsx": "application/x-typescript; charset=utf-8",
|
||||
".jsx": "text/javascript; charset=utf-8",
|
||||
|
||||
// Data formats
|
||||
".json": "application/json",
|
||||
".xml": "text/xml; charset=utf-8",
|
||||
".yaml": "text/yaml; charset=utf-8",
|
||||
".yml": "text/yaml; charset=utf-8",
|
||||
".toml": "text/toml; charset=utf-8",
|
||||
|
||||
// Images
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".avif": "image/avif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
|
||||
// Fonts
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
|
||||
// Audio
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".flac": "audio/flac",
|
||||
".opus": "audio/opus",
|
||||
|
||||
// Video
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".ogv": "video/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".avi": "video/x-msvideo",
|
||||
".mkv": "video/x-matroska",
|
||||
".m4v": "video/mp4",
|
||||
|
||||
// Documents
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
|
||||
// Archives
|
||||
".zip": "application/zip",
|
||||
".gz": "application/gzip",
|
||||
".tar": "application/x-tar",
|
||||
|
||||
// WebAssembly
|
||||
".wasm": "application/wasm",
|
||||
|
||||
// Source maps
|
||||
".map": "application/json",
|
||||
}
|
||||
|
||||
func hasPrefix(s, prefix string) bool {
|
||||
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
||||
}
|
||||
|
||||
// Magic bytes for various formats
|
||||
var (
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
pngData = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
jpegData = []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46}
|
||||
|
||||
// GIF: 47 49 46 38
|
||||
gifData = []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}
|
||||
|
||||
// WebP: 52 49 46 46 ... 57 45 42 50
|
||||
webpData = []byte{0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50}
|
||||
|
||||
// AVIF: ... ftypavif or ftypavis
|
||||
avifData = []byte{0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66}
|
||||
|
||||
// PDF: 25 50 44 46
|
||||
pdfData = []byte{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E}
|
||||
|
||||
// WASM: 00 61 73 6D
|
||||
wasmData = []byte{0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00}
|
||||
|
||||
// WOFF: 77 4F 46 46
|
||||
woffData = []byte{0x77, 0x4F, 0x46, 0x46, 0x00, 0x01, 0x00, 0x00}
|
||||
|
||||
// WOFF2: 77 4F 46 32
|
||||
woff2Data = []byte{0x77, 0x4F, 0x46, 0x32, 0x00, 0x01, 0x00, 0x00}
|
||||
|
||||
// TTF: 00 01 00 00
|
||||
ttfData = []byte{0x00, 0x01, 0x00, 0x00, 0x00}
|
||||
|
||||
// OTF: 4F 54 54 4F (OTTO)
|
||||
otfData = []byte{0x4F, 0x54, 0x54, 0x4F, 0x00}
|
||||
|
||||
// EOT: varies, but starts with size bytes then magic
|
||||
eotData = []byte{0x00, 0x00, 0x01, 0x00, 0x00, 0x00}
|
||||
|
||||
// MP3: FF FB or FF FA or ID3
|
||||
mp3Data = []byte{0xFF, 0xFB, 0x90, 0x00}
|
||||
|
||||
// MP4: ... ftyp
|
||||
mp4Data = []byte{0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D}
|
||||
|
||||
// WebM: 1A 45 DF A3 (EBML header)
|
||||
webmData = []byte{0x1A, 0x45, 0xDF, 0xA3}
|
||||
|
||||
// OGG: 4F 67 67 53
|
||||
oggData = []byte{0x4F, 0x67, 0x67, 0x53, 0x00, 0x02}
|
||||
|
||||
// ZIP: 50 4B 03 04
|
||||
zipData = []byte{0x50, 0x4B, 0x03, 0x04}
|
||||
|
||||
// GZIP: 1F 8B
|
||||
gzipData = []byte{0x1F, 0x8B, 0x08}
|
||||
|
||||
// ICO: 00 00 01 00
|
||||
icoData = []byte{0x00, 0x00, 0x01, 0x00, 0x01, 0x00}
|
||||
)
|
||||
|
||||
// TestMimeTypeExtensionMapCompleteness checks that all extensions in the
|
||||
// original mimeTypesByExt are covered by the expanded extMimeTypes
|
||||
func TestMimeTypeExtensionMapCompleteness(t *testing.T) {
|
||||
for ext, mime := range mimeTypesByExt {
|
||||
if newMime, ok := extMimeTypes[ext]; !ok {
|
||||
t.Errorf("extension %q missing from extMimeTypes (was: %q)", ext, mime)
|
||||
} else if newMime != mime {
|
||||
// Allow differences as long as they're equivalent (compare base MIME type)
|
||||
mimeBase := mime
|
||||
if idx := strings.Index(mime, ";"); idx > 0 {
|
||||
mimeBase = mime[:idx]
|
||||
}
|
||||
if !hasPrefix(newMime, mimeBase) {
|
||||
t.Logf("extension %q changed: %q -> %q (verify this is correct)", ext, mime, newMime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkMimeType_StdlibOnly benchmarks the stdlib-only implementation
|
||||
func BenchmarkMimeType_StdlibOnly(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
filename string
|
||||
data []byte
|
||||
}{
|
||||
{"ExtHit_JS", "app.js", []byte("function() {}")},
|
||||
{"ExtHit_CSS", "styles.css", []byte(".class { }")},
|
||||
{"ExtHit_PNG", "image.png", pngData},
|
||||
{"ExtMiss_Binary", "data.bin", []byte{0x00, 0x01, 0x02}},
|
||||
{"ContentDetect_PNG", "unknown", pngData},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = getMimeTypeStdlib(tc.filename, tc.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -109,12 +109,13 @@ import "C"
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// NewRequest creates as new WebViewRequest based on a pointer to an `id<WKURLSchemeTask>`
|
||||
|
|
|
|||
|
|
@ -107,12 +107,13 @@ import "C"
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// NewRequest creates as new WebViewRequest based on a pointer to an `id<WKURLSchemeTask>`
|
||||
|
|
|
|||
|
|
@ -68,9 +68,10 @@ static bool URLSchemeTaskDidReceiveResponse(void *wkUrlSchemeTask, int statusCod
|
|||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
var _ ResponseWriter = &responseWriter{}
|
||||
|
|
|
|||
|
|
@ -68,11 +68,12 @@ static bool URLSchemeTaskDidReceiveResponse(void *wkUrlSchemeTask, int statusCod
|
|||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
var _ ResponseWriter = &responseWriter{}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package capabilities
|
||||
|
||||
import "encoding/json"
|
||||
import json "github.com/goccy/go-json"
|
||||
|
||||
type Capabilities struct {
|
||||
HasNativeDrag bool
|
||||
|
|
|
|||
96
v3/internal/fileexplorer/desktopfile.go
Normal file
96
v3/internal/fileexplorer/desktopfile.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package fileexplorer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DesktopEntry represents a parsed .desktop file's [Desktop Entry] section.
|
||||
// This is a minimal parser that only extracts the fields we need,
|
||||
// replacing the full gopkg.in/ini.v1 dependency (~34KB + 68 transitive deps).
|
||||
type DesktopEntry struct {
|
||||
Exec string
|
||||
}
|
||||
|
||||
// ParseDesktopFile parses a .desktop file and returns the Desktop Entry section.
|
||||
// It follows the Desktop Entry Specification:
|
||||
// ParseDesktopFile parses the `[Desktop Entry]` section of the desktop file at path and returns a DesktopEntry.
|
||||
// It returns an error if the file cannot be opened or if parsing the file fails.
|
||||
func ParseDesktopFile(path string) (*DesktopEntry, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return ParseDesktopReader(f)
|
||||
}
|
||||
|
||||
// ParseDesktopReader parses the [Desktop Entry] section of a .desktop file from r and extracts the Exec value.
|
||||
// It ignores empty lines and lines starting with '#', treats section names as case-sensitive, and stops parsing after leaving the [Desktop Entry] section.
|
||||
// The returned *DesktopEntry has Exec set to the exact value of the Exec key if present (whitespace preserved).
|
||||
// An error is returned if reading from r fails.
|
||||
func ParseDesktopReader(r io.Reader) (*DesktopEntry, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
entry := &DesktopEntry{}
|
||||
|
||||
inDesktopEntry := false
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Skip empty lines
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip comments (# at start of line)
|
||||
if line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle section headers
|
||||
if line[0] == '[' {
|
||||
// Check if this is the [Desktop Entry] section
|
||||
// The spec says section names are case-sensitive
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "[Desktop Entry]" {
|
||||
inDesktopEntry = true
|
||||
} else if inDesktopEntry {
|
||||
// We've left the [Desktop Entry] section
|
||||
// (e.g., entering [Desktop Action new-window])
|
||||
// We already have what we need, so we can stop
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Only process key=value pairs in [Desktop Entry] section
|
||||
if !inDesktopEntry {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse key=value (spec says no spaces around =, but be lenient)
|
||||
eqIdx := strings.Index(line, "=")
|
||||
if eqIdx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(line[:eqIdx])
|
||||
value := line[eqIdx+1:] // Don't trim value - preserve intentional whitespace
|
||||
|
||||
// We only need the Exec key
|
||||
// Per spec, keys are case-sensitive and Exec is always "Exec"
|
||||
if key == "Exec" {
|
||||
entry.Exec = value
|
||||
// Continue parsing in case there are multiple Exec lines (shouldn't happen but be safe)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
532
v3/internal/fileexplorer/desktopfile_test.go
Normal file
532
v3/internal/fileexplorer/desktopfile_test.go
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
package fileexplorer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDesktopReader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantExec string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple desktop file",
|
||||
input: `[Desktop Entry]
|
||||
Name=Files
|
||||
Exec=nautilus %U
|
||||
Icon=org.gnome.Nautilus
|
||||
`,
|
||||
wantExec: "nautilus %U",
|
||||
},
|
||||
{
|
||||
name: "exec with full path",
|
||||
input: `[Desktop Entry]
|
||||
Name=1Password
|
||||
Exec=/opt/1Password/1password %U
|
||||
`,
|
||||
wantExec: "/opt/1Password/1password %U",
|
||||
},
|
||||
{
|
||||
name: "exec without arguments",
|
||||
input: `[Desktop Entry]
|
||||
Name=Btop
|
||||
Exec=btop
|
||||
Terminal=true
|
||||
`,
|
||||
wantExec: "btop",
|
||||
},
|
||||
{
|
||||
name: "exec with spaces in path",
|
||||
input: `[Desktop Entry]
|
||||
Name=My App
|
||||
Exec="/path/with spaces/myapp" %f
|
||||
`,
|
||||
wantExec: `"/path/with spaces/myapp" %f`,
|
||||
},
|
||||
{
|
||||
name: "comments are ignored",
|
||||
input: `# This is a comment
|
||||
[Desktop Entry]
|
||||
# Another comment
|
||||
Name=Files
|
||||
Exec=nautilus
|
||||
# Comment after
|
||||
`,
|
||||
wantExec: "nautilus",
|
||||
},
|
||||
{
|
||||
name: "empty lines are ignored",
|
||||
input: `
|
||||
|
||||
[Desktop Entry]
|
||||
|
||||
Name=Files
|
||||
|
||||
Exec=nautilus
|
||||
|
||||
`,
|
||||
wantExec: "nautilus",
|
||||
},
|
||||
{
|
||||
name: "key before section is ignored",
|
||||
input: `Exec=ignored
|
||||
[Desktop Entry]
|
||||
Exec=nautilus
|
||||
`,
|
||||
wantExec: "nautilus",
|
||||
},
|
||||
{
|
||||
name: "other sections after Desktop Entry are ignored",
|
||||
input: `[Desktop Entry]
|
||||
Exec=nautilus --new-window %U
|
||||
Icon=nautilus
|
||||
|
||||
[Desktop Action new-window]
|
||||
Name=New Window
|
||||
Exec=nautilus --new-window
|
||||
`,
|
||||
wantExec: "nautilus --new-window %U",
|
||||
},
|
||||
{
|
||||
name: "section before Desktop Entry is ignored",
|
||||
input: `[Some Other Section]
|
||||
Exec=ignored
|
||||
|
||||
[Desktop Entry]
|
||||
Exec=nautilus
|
||||
`,
|
||||
wantExec: "nautilus",
|
||||
},
|
||||
{
|
||||
name: "case sensitive section name",
|
||||
input: `[desktop entry]
|
||||
Exec=ignored
|
||||
|
||||
[Desktop Entry]
|
||||
Exec=correct
|
||||
`,
|
||||
wantExec: "correct",
|
||||
},
|
||||
{
|
||||
name: "case sensitive key name",
|
||||
input: `[Desktop Entry]
|
||||
exec=ignored
|
||||
EXEC=also ignored
|
||||
Exec=correct
|
||||
`,
|
||||
wantExec: "correct",
|
||||
},
|
||||
{
|
||||
name: "value with equals sign",
|
||||
input: `[Desktop Entry]
|
||||
Exec=env VAR=value myapp
|
||||
`,
|
||||
wantExec: "env VAR=value myapp",
|
||||
},
|
||||
{
|
||||
name: "value with multiple equals signs",
|
||||
input: `[Desktop Entry]
|
||||
Exec=env A=1 B=2 C=3 myapp
|
||||
`,
|
||||
wantExec: "env A=1 B=2 C=3 myapp",
|
||||
},
|
||||
{
|
||||
name: "localized keys are separate",
|
||||
input: `[Desktop Entry]
|
||||
Name[en]=Files
|
||||
Name=Default Files
|
||||
Exec[en]=ignored
|
||||
Exec=nautilus
|
||||
`,
|
||||
wantExec: "nautilus",
|
||||
},
|
||||
{
|
||||
name: "whitespace in section header",
|
||||
input: `[Desktop Entry]
|
||||
Exec=nautilus
|
||||
`,
|
||||
wantExec: "nautilus",
|
||||
},
|
||||
{
|
||||
name: "no exec key",
|
||||
input: `[Desktop Entry]
|
||||
Name=Files
|
||||
Icon=nautilus
|
||||
`,
|
||||
wantExec: "",
|
||||
},
|
||||
{
|
||||
name: "empty file",
|
||||
input: ``,
|
||||
wantExec: "",
|
||||
},
|
||||
{
|
||||
name: "only comments",
|
||||
input: `# Comment 1
|
||||
# Comment 2
|
||||
`,
|
||||
wantExec: "",
|
||||
},
|
||||
{
|
||||
name: "no Desktop Entry section",
|
||||
input: `[Other Section]
|
||||
Exec=ignored
|
||||
`,
|
||||
wantExec: "",
|
||||
},
|
||||
{
|
||||
name: "real nautilus desktop file structure",
|
||||
input: `[Desktop Entry]
|
||||
Name[en_CA]=Files
|
||||
Name[en_GB]=Files
|
||||
Name=Files
|
||||
Comment=Access and organize files
|
||||
Keywords=folder;manager;explore;disk;filesystem;nautilus;
|
||||
Exec=nautilus --new-window %U
|
||||
Icon=org.gnome.Nautilus
|
||||
Terminal=false
|
||||
Type=Application
|
||||
DBusActivatable=true
|
||||
StartupNotify=true
|
||||
Categories=GNOME;GTK;Utility;Core;FileManager;
|
||||
MimeType=inode/directory;application/x-7z-compressed;
|
||||
X-GNOME-UsesNotifications=true
|
||||
Actions=new-window;
|
||||
|
||||
[Desktop Action new-window]
|
||||
Name=New Window
|
||||
Exec=nautilus --new-window
|
||||
`,
|
||||
wantExec: "nautilus --new-window %U",
|
||||
},
|
||||
{
|
||||
name: "thunar style",
|
||||
input: `[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Thunar File Manager
|
||||
Exec=thunar %F
|
||||
Icon=Thunar
|
||||
Type=Application
|
||||
Categories=System;FileTools;FileManager;
|
||||
`,
|
||||
wantExec: "thunar %F",
|
||||
},
|
||||
{
|
||||
name: "dolphin style",
|
||||
input: `[Desktop Entry]
|
||||
Type=Application
|
||||
Exec=dolphin %u
|
||||
Icon=system-file-manager
|
||||
Name=Dolphin
|
||||
GenericName=File Manager
|
||||
`,
|
||||
wantExec: "dolphin %u",
|
||||
},
|
||||
{
|
||||
name: "pcmanfm style",
|
||||
input: `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=PCManFM
|
||||
GenericName=File Manager
|
||||
Exec=pcmanfm %U
|
||||
Icon=system-file-manager
|
||||
`,
|
||||
wantExec: "pcmanfm %U",
|
||||
},
|
||||
{
|
||||
name: "exec with environment variable",
|
||||
input: `[Desktop Entry]
|
||||
Exec=env GDK_BACKEND=x11 nautilus %U
|
||||
`,
|
||||
wantExec: "env GDK_BACKEND=x11 nautilus %U",
|
||||
},
|
||||
{
|
||||
name: "trailing whitespace in value preserved",
|
||||
input: `[Desktop Entry]
|
||||
Exec=nautilus
|
||||
`,
|
||||
wantExec: "nautilus ",
|
||||
},
|
||||
{
|
||||
name: "leading whitespace in key",
|
||||
input: `[Desktop Entry]
|
||||
Exec=nautilus
|
||||
`,
|
||||
wantExec: "nautilus",
|
||||
},
|
||||
{
|
||||
name: "space around equals",
|
||||
input: `[Desktop Entry]
|
||||
Exec = nautilus
|
||||
`,
|
||||
wantExec: " nautilus", // We trim the key, value starts after =
|
||||
},
|
||||
{
|
||||
name: "line without equals is ignored",
|
||||
input: `[Desktop Entry]
|
||||
InvalidLine
|
||||
Exec=nautilus
|
||||
AnotherInvalidLine
|
||||
`,
|
||||
wantExec: "nautilus",
|
||||
},
|
||||
{
|
||||
name: "UTF-8 in exec path",
|
||||
input: `[Desktop Entry]
|
||||
Exec=/usr/bin/文件管理器 %U
|
||||
`,
|
||||
wantExec: "/usr/bin/文件管理器 %U",
|
||||
},
|
||||
{
|
||||
name: "special characters in exec",
|
||||
input: `[Desktop Entry]
|
||||
Exec=sh -c "echo 'hello world' && nautilus %U"
|
||||
`,
|
||||
wantExec: `sh -c "echo 'hello world' && nautilus %U"`,
|
||||
},
|
||||
{
|
||||
name: "multiple Desktop Entry sections (invalid file, last value wins)",
|
||||
input: `[Desktop Entry]
|
||||
Exec=first
|
||||
|
||||
[Desktop Entry]
|
||||
Exec=second
|
||||
`,
|
||||
wantExec: "second", // Invalid file, but we handle it gracefully
|
||||
},
|
||||
{
|
||||
name: "very long exec line",
|
||||
input: `[Desktop Entry]
|
||||
Exec=` + strings.Repeat("a", 1000) + `
|
||||
`,
|
||||
wantExec: strings.Repeat("a", 1000),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entry, err := ParseDesktopReader(strings.NewReader(tt.input))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseDesktopReader() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if entry.Exec != tt.wantExec {
|
||||
t.Errorf("ParseDesktopReader() Exec = %q, want %q", entry.Exec, tt.wantExec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDesktopReader_LineScanning(t *testing.T) {
|
||||
// Test that we handle lines at the scanner's limit (64KB default)
|
||||
// bufio.Scanner returns an error for lines > 64KB, which is acceptable
|
||||
// since real .desktop files never have lines that long
|
||||
|
||||
t.Run("line at buffer limit returns error", func(t *testing.T) {
|
||||
// Create a line that exceeds the buffer size (64KB)
|
||||
longValue := strings.Repeat("x", 65536)
|
||||
input := "[Desktop Entry]\nExec=" + longValue + "\n"
|
||||
|
||||
_, err := ParseDesktopReader(strings.NewReader(input))
|
||||
if err == nil {
|
||||
t.Error("Expected error for line exceeding buffer size, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("line under buffer limit works", func(t *testing.T) {
|
||||
// Create a line that's under the limit (should work fine)
|
||||
longValue := strings.Repeat("x", 60000)
|
||||
input := "[Desktop Entry]\nExec=" + longValue + "\n"
|
||||
|
||||
entry, err := ParseDesktopReader(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for long but valid line: %v", err)
|
||||
return
|
||||
}
|
||||
if entry.Exec != longValue {
|
||||
t.Errorf("Long line not parsed correctly, got length %d, want %d", len(entry.Exec), len(longValue))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseDesktopReader_RealWorldFiles(t *testing.T) {
|
||||
// These are actual .desktop file contents from real systems
|
||||
realWorldTests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantExec string
|
||||
}{
|
||||
{
|
||||
name: "GNOME Nautilus 43.x",
|
||||
content: `[Desktop Entry]
|
||||
Name=Files
|
||||
Comment=Access and organize files
|
||||
Keywords=folder;manager;explore;disk;filesystem;nautilus;
|
||||
Exec=nautilus --new-window %U
|
||||
Icon=org.gnome.Nautilus
|
||||
Terminal=false
|
||||
Type=Application
|
||||
DBusActivatable=true
|
||||
StartupNotify=true
|
||||
Categories=GNOME;GTK;Utility;Core;FileManager;
|
||||
MimeType=inode/directory;application/x-7z-compressed;
|
||||
Actions=new-window;
|
||||
|
||||
[Desktop Action new-window]
|
||||
Name=New Window
|
||||
Exec=nautilus --new-window`,
|
||||
wantExec: "nautilus --new-window %U",
|
||||
},
|
||||
{
|
||||
name: "KDE Dolphin",
|
||||
content: `[Desktop Entry]
|
||||
Type=Application
|
||||
Exec=dolphin %u
|
||||
Icon=system-file-manager
|
||||
Terminal=false
|
||||
InitialPreference=9
|
||||
Name=Dolphin
|
||||
GenericName=File Manager
|
||||
MimeType=inode/directory;
|
||||
Categories=Qt;KDE;System;FileTools;FileManager;
|
||||
Actions=new-window;
|
||||
|
||||
[Desktop Action new-window]
|
||||
Name=Open a New Window
|
||||
Exec=dolphin %u`,
|
||||
wantExec: "dolphin %u",
|
||||
},
|
||||
{
|
||||
name: "Thunar",
|
||||
content: `[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Thunar File Manager
|
||||
GenericName=File Manager
|
||||
Comment=Browse the filesystem with the file manager
|
||||
Exec=thunar %F
|
||||
Icon=Thunar
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
Type=Application
|
||||
Categories=System;FileTools;FileManager;
|
||||
`,
|
||||
wantExec: "thunar %F",
|
||||
},
|
||||
{
|
||||
name: "PCManFM",
|
||||
content: `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=PCManFM
|
||||
GenericName=File Manager
|
||||
Comment=Browse the file system
|
||||
Exec=pcmanfm %U
|
||||
Icon=system-file-manager
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
Categories=Utility;FileManager;`,
|
||||
wantExec: "pcmanfm %U",
|
||||
},
|
||||
{
|
||||
name: "Caja (MATE)",
|
||||
content: `[Desktop Entry]
|
||||
Name=Files
|
||||
Comment=Access and organize files
|
||||
Exec=caja %U
|
||||
Icon=system-file-manager
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=MATE;System;FileManager;
|
||||
StartupNotify=true`,
|
||||
wantExec: "caja %U",
|
||||
},
|
||||
{
|
||||
name: "Nemo (Cinnamon)",
|
||||
content: `[Desktop Entry]
|
||||
Name=Files
|
||||
Comment=Access and organize files
|
||||
Exec=nemo %U
|
||||
Icon=folder
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=true
|
||||
Categories=GNOME;GTK;Utility;Core;
|
||||
MimeType=inode/directory;`,
|
||||
wantExec: "nemo %U",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range realWorldTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entry, err := ParseDesktopReader(strings.NewReader(tt.content))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDesktopReader() error = %v", err)
|
||||
}
|
||||
if entry.Exec != tt.wantExec {
|
||||
t.Errorf("ParseDesktopReader() Exec = %q, want %q", entry.Exec, tt.wantExec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkParseDesktopReader measures parsing performance
|
||||
func BenchmarkParseDesktopReader(b *testing.B) {
|
||||
// Real Nautilus .desktop file content
|
||||
content := `[Desktop Entry]
|
||||
Name=Files
|
||||
Comment=Access and organize files
|
||||
Keywords=folder;manager;explore;disk;filesystem;nautilus;
|
||||
Exec=nautilus --new-window %U
|
||||
Icon=org.gnome.Nautilus
|
||||
Terminal=false
|
||||
Type=Application
|
||||
DBusActivatable=true
|
||||
StartupNotify=true
|
||||
Categories=GNOME;GTK;Utility;Core;FileManager;
|
||||
MimeType=inode/directory;application/x-7z-compressed;
|
||||
Actions=new-window;
|
||||
|
||||
[Desktop Action new-window]
|
||||
Name=New Window
|
||||
Exec=nautilus --new-window
|
||||
`
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := ParseDesktopReader(strings.NewReader(content))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkParseDesktopReader_Large tests parsing a file with many localized entries
|
||||
func BenchmarkParseDesktopReader_Large(b *testing.B) {
|
||||
// Simulate a desktop file with many localized Name entries (like Nautilus)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[Desktop Entry]\n")
|
||||
for i := 0; i < 100; i++ {
|
||||
sb.WriteString("Name[lang")
|
||||
sb.WriteString(strings.Repeat("x", 5))
|
||||
sb.WriteString("]=Localized Name\n")
|
||||
}
|
||||
sb.WriteString("Exec=nautilus %U\n")
|
||||
sb.WriteString("[Desktop Action new-window]\n")
|
||||
sb.WriteString("Name=New Window\n")
|
||||
sb.WriteString("Exec=nautilus\n")
|
||||
|
||||
content := sb.String()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := ParseDesktopReader(strings.NewReader(content))
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,10 +11,9 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
ini "gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// when possible; the fallback method does not support selecting a file.
|
||||
func explorerBinArgs(path string, selectFile bool) (string, []string, error) {
|
||||
// Map of field codes to their replacements
|
||||
var fieldCodes = map[string]string{
|
||||
|
|
@ -38,22 +37,22 @@ func explorerBinArgs(path string, selectFile bool) (string, []string, error) {
|
|||
return fallbackExplorerBinArgs(path, selectFile)
|
||||
}
|
||||
|
||||
desktopFile, err := findDesktopFile(strings.TrimSpace((buf.String())))
|
||||
desktopFilePath, err := findDesktopFile(strings.TrimSpace((buf.String())))
|
||||
if err != nil {
|
||||
return fallbackExplorerBinArgs(path, selectFile)
|
||||
}
|
||||
|
||||
cfg, err := ini.Load(desktopFile)
|
||||
entry, err := ParseDesktopFile(desktopFilePath)
|
||||
if err != nil {
|
||||
// Opting to fallback rather than fail
|
||||
return fallbackExplorerBinArgs(path, selectFile)
|
||||
}
|
||||
|
||||
exec := cfg.Section("Desktop Entry").Key("Exec").String()
|
||||
execCmd := entry.Exec
|
||||
for fieldCode, replacement := range fieldCodes {
|
||||
exec = strings.ReplaceAll(exec, fieldCode, replacement)
|
||||
execCmd = strings.ReplaceAll(execCmd, fieldCode, replacement)
|
||||
}
|
||||
args := strings.Fields(exec)
|
||||
args := strings.Fields(execCmd)
|
||||
if !strings.Contains(strings.Join(args, " "), path) {
|
||||
args = append(args, path)
|
||||
}
|
||||
|
|
@ -111,4 +110,4 @@ func findDesktopFile(xdgFileName string) (string, error) {
|
|||
}
|
||||
err := fmt.Errorf("desktop file not found: %s", xdgFileName)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
var runtimeInit = `window._wails=window._wails||{};window.wails=window.wails||{};`
|
||||
|
|
|
|||
39
v3/internal/sliceutil/sliceutil.go
Normal file
39
v3/internal/sliceutil/sliceutil.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Package sliceutil provides generic utility functions not available in stdlib.
|
||||
// For most slice operations, use the standard library "slices" package directly.
|
||||
// This package only contains functions that have no stdlib equivalent.
|
||||
package sliceutil
|
||||
|
||||
// Unique returns a new slice with duplicate elements removed.
|
||||
// Preserves the order of first occurrence.
|
||||
// The original slice is not modified.
|
||||
//
|
||||
// Unique returns a new slice containing the first occurrence of each element from the input slice, preserving their original order.
|
||||
// If the input slice is nil, Unique returns nil.
|
||||
// The original slice is not modified.
|
||||
func Unique[T comparable](slice []T) []T {
|
||||
if slice == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[T]struct{}, len(slice))
|
||||
result := make([]T, 0, len(slice))
|
||||
for _, v := range slice {
|
||||
if _, ok := seen[v]; !ok {
|
||||
seen[v] = struct{}{}
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FindMapKey returns the first key in map m whose value equals val.
|
||||
// FindMapKey returns the first key in m whose value equals val.
|
||||
// If no such key exists it returns the zero value of K and false. If multiple keys map to val, the returned key depends on Go's map iteration order.
|
||||
func FindMapKey[K comparable, V comparable](m map[K]V, val V) (K, bool) {
|
||||
for k, v := range m {
|
||||
if v == val {
|
||||
return k, true
|
||||
}
|
||||
}
|
||||
var zero K
|
||||
return zero, false
|
||||
}
|
||||
172
v3/internal/sliceutil/sliceutil_test.go
Normal file
172
v3/internal/sliceutil/sliceutil_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package sliceutil
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnique(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slice []int
|
||||
want []int
|
||||
}{
|
||||
{
|
||||
name: "no duplicates",
|
||||
slice: []int{1, 2, 3},
|
||||
want: []int{1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "with duplicates",
|
||||
slice: []int{1, 2, 2, 3, 3, 3},
|
||||
want: []int{1, 2, 3},
|
||||
},
|
||||
{
|
||||
name: "all duplicates",
|
||||
slice: []int{1, 1, 1},
|
||||
want: []int{1},
|
||||
},
|
||||
{
|
||||
name: "preserves order",
|
||||
slice: []int{3, 1, 2, 1, 3, 2},
|
||||
want: []int{3, 1, 2},
|
||||
},
|
||||
{
|
||||
name: "single element",
|
||||
slice: []int{1},
|
||||
want: []int{1},
|
||||
},
|
||||
{
|
||||
name: "empty slice",
|
||||
slice: []int{},
|
||||
want: []int{},
|
||||
},
|
||||
{
|
||||
name: "nil slice",
|
||||
slice: nil,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Unique(tt.slice)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Unique() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnique_Strings(t *testing.T) {
|
||||
slice := []string{"a", "b", "a", "c", "b"}
|
||||
got := Unique(slice)
|
||||
want := []string{"a", "b", "c"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Unique() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnique_DoesNotModifyOriginal(t *testing.T) {
|
||||
original := []int{1, 2, 2, 3}
|
||||
originalCopy := make([]int, len(original))
|
||||
copy(originalCopy, original)
|
||||
|
||||
_ = Unique(original)
|
||||
|
||||
if !reflect.DeepEqual(original, originalCopy) {
|
||||
t.Errorf("Unique() modified original slice: got %v, want %v", original, originalCopy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMapKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m map[string]int
|
||||
val int
|
||||
wantKey string
|
||||
wantFound bool
|
||||
}{
|
||||
{
|
||||
name: "find existing value",
|
||||
m: map[string]int{"a": 1, "b": 2, "c": 3},
|
||||
val: 2,
|
||||
wantKey: "b",
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "value not found",
|
||||
m: map[string]int{"a": 1, "b": 2},
|
||||
val: 3,
|
||||
wantKey: "",
|
||||
wantFound: false,
|
||||
},
|
||||
{
|
||||
name: "empty map",
|
||||
m: map[string]int{},
|
||||
val: 1,
|
||||
wantKey: "",
|
||||
wantFound: false,
|
||||
},
|
||||
{
|
||||
name: "nil map",
|
||||
m: nil,
|
||||
val: 1,
|
||||
wantKey: "",
|
||||
wantFound: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotKey, gotFound := FindMapKey(tt.m, tt.val)
|
||||
if gotFound != tt.wantFound {
|
||||
t.Errorf("FindMapKey() found = %v, want %v", gotFound, tt.wantFound)
|
||||
}
|
||||
if gotFound && gotKey != tt.wantKey {
|
||||
t.Errorf("FindMapKey() key = %v, want %v", gotKey, tt.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMapKey_DuplicateValues(t *testing.T) {
|
||||
// When multiple keys have the same value, any matching key is acceptable
|
||||
m := map[string]int{"a": 1, "b": 1, "c": 2}
|
||||
key, found := FindMapKey(m, 1)
|
||||
if !found {
|
||||
t.Error("FindMapKey() should find a key")
|
||||
}
|
||||
if key != "a" && key != "b" {
|
||||
t.Errorf("FindMapKey() = %v, want 'a' or 'b'", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMapKey_IntKeys(t *testing.T) {
|
||||
m := map[int]string{1: "one", 2: "two", 3: "three"}
|
||||
key, found := FindMapKey(m, "two")
|
||||
if !found || key != 2 {
|
||||
t.Errorf("FindMapKey() = (%v, %v), want (2, true)", key, found)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks
|
||||
|
||||
func BenchmarkUnique(b *testing.B) {
|
||||
slice := []int{1, 2, 3, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 10}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Unique(slice)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFindMapKey(b *testing.B) {
|
||||
m := make(map[string]int)
|
||||
for i := 0; i < 100; i++ {
|
||||
m[string(rune('a'+i))] = i
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = FindMapKey(m, 50)
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,6 @@ static void executeJavaScriptOnBridge(const char* js) {
|
|||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -149,6 +148,8 @@ import (
|
|||
"time"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/wailsapp/wails/v3/internal/runtime"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -196,12 +196,12 @@ static void startSingleInstanceListener(const char *uniqueID) {
|
|||
*/
|
||||
import "C"
|
||||
import (
|
||||
"encoding/json"
|
||||
"unsafe"
|
||||
|
||||
"github.com/wailsapp/wails/v3/internal/operatingsystem"
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/wailsapp/wails/v3/internal/assetserver/webview"
|
||||
"github.com/wailsapp/wails/v3/internal/operatingsystem"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@ package application
|
|||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/wailsapp/wails/v3/internal/assetserver/webview"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
|
@ -107,6 +108,12 @@ type iosApp struct {
|
|||
parent *App
|
||||
}
|
||||
|
||||
// newPlatformApp creates an iosApp for the provided App and applies iOS-specific
|
||||
// configuration derived from app.options. It sets input accessory visibility,
|
||||
// scrolling/bounce/indicator behavior, navigation gestures, link preview,
|
||||
// media playback, inspector, user agent strings, app background color, and
|
||||
// native tabs (marshaling items to JSON when enabled). The function invokes
|
||||
// platform bindings to apply these settings and returns the configured *iosApp.
|
||||
func newPlatformApp(app *App) *iosApp {
|
||||
iosConsoleLogf("info", "🔵 [application_ios.go] START newPlatformApp()")
|
||||
// iOS initialization
|
||||
|
|
@ -161,19 +168,19 @@ func newPlatformApp(app *App) *iosApp {
|
|||
// Ensure it's marked as not set to allow delegate to fallback to white
|
||||
C.ios_set_app_background_color(255, 255, 255, 255, C.bool(false))
|
||||
}
|
||||
// Native tabs option: only enable when explicitly requested
|
||||
if app.options.IOS.EnableNativeTabs {
|
||||
if len(app.options.IOS.NativeTabsItems) > 0 {
|
||||
if data, err := json.Marshal(app.options.IOS.NativeTabsItems); err == nil {
|
||||
cjson := C.CString(string(data))
|
||||
C.ios_native_tabs_set_items_json(cjson)
|
||||
C.free(unsafe.Pointer(cjson))
|
||||
} else if globalApplication != nil {
|
||||
globalApplication.error("Failed to marshal IOS.NativeTabsItems: %v", err)
|
||||
}
|
||||
}
|
||||
C.ios_native_tabs_set_enabled(C.bool(true))
|
||||
}
|
||||
// Native tabs option: only enable when explicitly requested
|
||||
if app.options.IOS.EnableNativeTabs {
|
||||
if len(app.options.IOS.NativeTabsItems) > 0 {
|
||||
if data, err := json.Marshal(app.options.IOS.NativeTabsItems); err == nil {
|
||||
cjson := C.CString(string(data))
|
||||
C.ios_native_tabs_set_items_json(cjson)
|
||||
C.free(unsafe.Pointer(cjson))
|
||||
} else if globalApplication != nil {
|
||||
globalApplication.error("Failed to marshal IOS.NativeTabsItems: %v", err)
|
||||
}
|
||||
}
|
||||
C.ios_native_tabs_set_enabled(C.bool(true))
|
||||
}
|
||||
|
||||
iosConsoleLogf("info", "🔵 [application_ios.go] END newPlatformApp() - iosApp created")
|
||||
return result
|
||||
|
|
@ -452,4 +459,4 @@ func hasListeners(eventID C.uint) C.bool {
|
|||
// For now, return true to enable all events
|
||||
// TODO: Check actual listener registration
|
||||
return C.bool(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,22 +2,81 @@ package application
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v3/internal/hash"
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/wailsapp/wails/v3/internal/hash"
|
||||
"github.com/wailsapp/wails/v3/internal/sliceutil"
|
||||
)
|
||||
|
||||
// init forces goccy/go-json to initialize its type address cache at program startup.
|
||||
// This prevents a Windows-specific index out-of-bounds panic that can occur when the decoder is first invoked later (see https://github.com/goccy/go-json/issues/474).
|
||||
func init() {
|
||||
// Force goccy/go-json to initialize its type address cache early.
|
||||
// On Windows, if the decoder is first invoked later (e.g., during tests),
|
||||
// the type address calculation can fail with an index out of bounds panic.
|
||||
// See: https://github.com/goccy/go-json/issues/474
|
||||
var (
|
||||
s string
|
||||
i int
|
||||
i8 int8
|
||||
i16 int16
|
||||
i32 int32
|
||||
i64 int64
|
||||
u uint
|
||||
u8 uint8
|
||||
u16 uint16
|
||||
u32 uint32
|
||||
u64 uint64
|
||||
f32 float32
|
||||
f64 float64
|
||||
b bool
|
||||
bs []byte
|
||||
ss []string
|
||||
si []int
|
||||
sf []float64
|
||||
sa []any
|
||||
msa map[string]any
|
||||
mss map[string]string
|
||||
msi map[string]int
|
||||
rm json.RawMessage
|
||||
)
|
||||
_ = json.Unmarshal([]byte(`""`), &s)
|
||||
_ = json.Unmarshal([]byte(`0`), &i)
|
||||
_ = json.Unmarshal([]byte(`0`), &i8)
|
||||
_ = json.Unmarshal([]byte(`0`), &i16)
|
||||
_ = json.Unmarshal([]byte(`0`), &i32)
|
||||
_ = json.Unmarshal([]byte(`0`), &i64)
|
||||
_ = json.Unmarshal([]byte(`0`), &u)
|
||||
_ = json.Unmarshal([]byte(`0`), &u8)
|
||||
_ = json.Unmarshal([]byte(`0`), &u16)
|
||||
_ = json.Unmarshal([]byte(`0`), &u32)
|
||||
_ = json.Unmarshal([]byte(`0`), &u64)
|
||||
_ = json.Unmarshal([]byte(`0`), &f32)
|
||||
_ = json.Unmarshal([]byte(`0`), &f64)
|
||||
_ = json.Unmarshal([]byte(`false`), &b)
|
||||
_ = json.Unmarshal([]byte(`""`), &bs)
|
||||
_ = json.Unmarshal([]byte(`[]`), &ss)
|
||||
_ = json.Unmarshal([]byte(`[]`), &si)
|
||||
_ = json.Unmarshal([]byte(`[]`), &sf)
|
||||
_ = json.Unmarshal([]byte(`[]`), &sa)
|
||||
_ = json.Unmarshal([]byte(`{}`), &msa)
|
||||
_ = json.Unmarshal([]byte(`{}`), &mss)
|
||||
_ = json.Unmarshal([]byte(`{}`), &msi)
|
||||
_ = json.Unmarshal([]byte(`""`), &rm)
|
||||
}
|
||||
|
||||
// CallOptions defines the options for a method call.
|
||||
// Field order is optimized to minimize struct padding.
|
||||
type CallOptions struct {
|
||||
MethodID uint32 `json:"methodID"`
|
||||
MethodName string `json:"methodName"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
MethodID uint32 `json:"methodID"`
|
||||
}
|
||||
|
||||
type ErrorKind string
|
||||
|
|
@ -28,10 +87,12 @@ const (
|
|||
RuntimeError ErrorKind = "RuntimeError"
|
||||
)
|
||||
|
||||
// CallError represents an error that occurred during a method call.
|
||||
// Field order is optimized to minimize struct padding.
|
||||
type CallError struct {
|
||||
Kind ErrorKind `json:"kind"`
|
||||
Message string `json:"message"`
|
||||
Cause any `json:"cause,omitempty"`
|
||||
Kind ErrorKind `json:"kind"`
|
||||
}
|
||||
|
||||
func (e *CallError) Error() string {
|
||||
|
|
@ -64,18 +125,19 @@ func (p *Parameter) IsError() bool {
|
|||
}
|
||||
|
||||
// BoundMethod defines all the data related to a Go method that is
|
||||
// bound to the Wails application
|
||||
// bound to the Wails application.
|
||||
// Field order is optimized to minimize struct padding (136 bytes vs 144 bytes).
|
||||
type BoundMethod struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Inputs []*Parameter `json:"inputs,omitempty"`
|
||||
Outputs []*Parameter `json:"outputs,omitempty"`
|
||||
Comments string `json:"comments,omitempty"`
|
||||
Method reflect.Value `json:"-"`
|
||||
FQN string
|
||||
|
||||
Method reflect.Value `json:"-"`
|
||||
Name string `json:"name"`
|
||||
FQN string `json:"-"`
|
||||
Comments string `json:"comments,omitempty"`
|
||||
Inputs []*Parameter `json:"inputs,omitempty"`
|
||||
Outputs []*Parameter `json:"outputs,omitempty"`
|
||||
marshalError func(error) []byte
|
||||
ID uint32 `json:"id"`
|
||||
needsContext bool
|
||||
isVariadic bool // cached at registration to avoid reflect call per invocation
|
||||
}
|
||||
|
||||
type Bindings struct {
|
||||
|
|
@ -114,7 +176,7 @@ func (b *Bindings) Add(service Service) error {
|
|||
|
||||
// Log
|
||||
attrs := []any{"fqn", method.FQN, "id", method.ID}
|
||||
if alias, ok := lo.FindKey(b.methodAliases, method.ID); ok {
|
||||
if alias, ok := sliceutil.FindMapKey(b.methodAliases, method.ID); ok {
|
||||
attrs = append(attrs, "alias", alias)
|
||||
}
|
||||
globalApplication.debug("Registering bound method:", attrs...)
|
||||
|
|
@ -165,6 +227,10 @@ var internalServiceMethods = map[string]bool{
|
|||
|
||||
var ctxType = reflect.TypeFor[context.Context]()
|
||||
|
||||
// getMethods returns the list of BoundMethod descriptors for the methods of the named pointer type provided by value.
|
||||
//
|
||||
// It returns an error if value is not a pointer to a named type, if a function value is supplied (binding functions is deprecated), or if a generic type is supplied.
|
||||
// The returned BoundMethod slice includes only exported methods that are not listed in internalServiceMethods. Each BoundMethod has its FQN, ID (computed from the FQN), Method reflect.Value, Inputs and Outputs populated, isVariadic cached from the method signature, and needsContext set when the first parameter is context.Context.
|
||||
func getMethods(value any) ([]*BoundMethod, error) {
|
||||
// Create result placeholder
|
||||
var result []*BoundMethod
|
||||
|
|
@ -203,19 +269,20 @@ func getMethods(value any) ([]*BoundMethod, error) {
|
|||
|
||||
fqn := fmt.Sprintf("%s.%s.%s", packagePath, typeName, methodName)
|
||||
|
||||
// Create new method
|
||||
boundMethod := &BoundMethod{
|
||||
ID: hash.Fnv(fqn),
|
||||
FQN: fqn,
|
||||
Name: methodName,
|
||||
Inputs: nil,
|
||||
Outputs: nil,
|
||||
Comments: "",
|
||||
Method: method,
|
||||
}
|
||||
|
||||
// Iterate inputs
|
||||
methodType := method.Type()
|
||||
|
||||
// Create new method with cached flags
|
||||
boundMethod := &BoundMethod{
|
||||
ID: hash.Fnv(fqn),
|
||||
FQN: fqn,
|
||||
Name: methodName,
|
||||
Inputs: nil,
|
||||
Outputs: nil,
|
||||
Comments: "",
|
||||
Method: method,
|
||||
isVariadic: methodType.IsVariadic(), // cache to avoid reflect call per invocation
|
||||
}
|
||||
inputParamCount := methodType.NumIn()
|
||||
var inputs []*Parameter
|
||||
for inputIndex := 0; inputIndex < inputParamCount; inputIndex++ {
|
||||
|
|
@ -268,14 +335,20 @@ func (b *BoundMethod) Call(ctx context.Context, args []json.RawMessage) (result
|
|||
|
||||
if argCount != len(b.Inputs) {
|
||||
err = &CallError{
|
||||
Kind: TypeError,
|
||||
Message: fmt.Sprintf("%s expects %d arguments, got %d", b.FQN, len(b.Inputs), argCount),
|
||||
Kind: TypeError,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Convert inputs to values of appropriate type
|
||||
callArgs := make([]reflect.Value, argCount)
|
||||
// Use stack-allocated buffer for common case (<=8 args), heap for larger
|
||||
var argBuffer [8]reflect.Value
|
||||
var callArgs []reflect.Value
|
||||
if argCount <= len(argBuffer) {
|
||||
callArgs = argBuffer[:argCount]
|
||||
} else {
|
||||
callArgs = make([]reflect.Value, argCount)
|
||||
}
|
||||
base := 0
|
||||
|
||||
if b.needsContext {
|
||||
|
|
@ -289,24 +362,28 @@ func (b *BoundMethod) Call(ctx context.Context, args []json.RawMessage) (result
|
|||
err = json.Unmarshal(arg, value.Interface())
|
||||
if err != nil {
|
||||
err = &CallError{
|
||||
Kind: TypeError,
|
||||
Message: fmt.Sprintf("could not parse argument #%d: %s", index, err),
|
||||
Cause: json.RawMessage(b.marshalError(err)),
|
||||
Kind: TypeError,
|
||||
}
|
||||
return
|
||||
}
|
||||
callArgs[base+index] = value.Elem()
|
||||
}
|
||||
|
||||
// Do the call
|
||||
// Do the call using cached isVariadic flag
|
||||
var callResults []reflect.Value
|
||||
if b.Method.Type().IsVariadic() {
|
||||
if b.isVariadic {
|
||||
callResults = b.Method.CallSlice(callArgs)
|
||||
} else {
|
||||
callResults = b.Method.Call(callArgs)
|
||||
}
|
||||
|
||||
var nonErrorOutputs = make([]any, 0, len(callResults))
|
||||
// Process results - optimized for common case of 0-2 return values
|
||||
// to avoid slice allocation
|
||||
var firstResult any
|
||||
var hasFirstResult bool
|
||||
var nonErrorOutputs []any // only allocated if >1 non-error results
|
||||
var errorOutputs []error
|
||||
|
||||
for _, field := range callResults {
|
||||
|
|
@ -315,12 +392,22 @@ func (b *BoundMethod) Call(ctx context.Context, args []json.RawMessage) (result
|
|||
continue
|
||||
}
|
||||
if errorOutputs == nil {
|
||||
errorOutputs = make([]error, 0, len(callResults)-len(nonErrorOutputs))
|
||||
nonErrorOutputs = nil
|
||||
errorOutputs = make([]error, 0, len(callResults))
|
||||
}
|
||||
errorOutputs = append(errorOutputs, field.Interface().(error))
|
||||
} else if nonErrorOutputs != nil {
|
||||
nonErrorOutputs = append(nonErrorOutputs, field.Interface())
|
||||
} else if errorOutputs == nil {
|
||||
// Only collect non-error outputs if no errors yet
|
||||
val := field.Interface()
|
||||
if !hasFirstResult {
|
||||
firstResult = val
|
||||
hasFirstResult = true
|
||||
} else if nonErrorOutputs == nil {
|
||||
// Second result - need to allocate slice
|
||||
nonErrorOutputs = make([]any, 0, len(callResults))
|
||||
nonErrorOutputs = append(nonErrorOutputs, firstResult, val)
|
||||
} else {
|
||||
nonErrorOutputs = append(nonErrorOutputs, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -331,19 +418,19 @@ func (b *BoundMethod) Call(ctx context.Context, args []json.RawMessage) (result
|
|||
}
|
||||
|
||||
cerr := &CallError{
|
||||
Kind: RuntimeError,
|
||||
Message: errors.Join(errorOutputs...).Error(),
|
||||
Cause: info,
|
||||
Kind: RuntimeError,
|
||||
}
|
||||
if len(info) == 1 {
|
||||
cerr.Cause = info[0]
|
||||
}
|
||||
|
||||
err = cerr
|
||||
} else if len(nonErrorOutputs) == 1 {
|
||||
result = nonErrorOutputs[0]
|
||||
} else if len(nonErrorOutputs) > 1 {
|
||||
} else if nonErrorOutputs != nil {
|
||||
result = nonErrorOutputs
|
||||
} else if hasFirstResult {
|
||||
result = firstResult
|
||||
}
|
||||
|
||||
return
|
||||
|
|
@ -395,4 +482,4 @@ func isNamed(value interface{}) bool {
|
|||
}
|
||||
|
||||
return rv.Type().Name() != ""
|
||||
}
|
||||
}
|
||||
506
v3/pkg/application/bindings_bench_test.go
Normal file
506
v3/pkg/application/bindings_bench_test.go
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
//go:build bench
|
||||
|
||||
package application_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/wailsapp/wails/v3/internal/hash"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// BenchmarkService provides methods with varying complexity for benchmarking
|
||||
type BenchmarkService struct{}
|
||||
|
||||
func (s *BenchmarkService) NoArgs() {}
|
||||
|
||||
func (s *BenchmarkService) StringArg(str string) string {
|
||||
return str
|
||||
}
|
||||
|
||||
func (s *BenchmarkService) IntArg(i int) int {
|
||||
return i
|
||||
}
|
||||
|
||||
func (s *BenchmarkService) MultipleArgs(s1 string, i int, b bool) (string, int, bool) {
|
||||
return s1, i, b
|
||||
}
|
||||
|
||||
func (s *BenchmarkService) StructArg(p BenchPerson) BenchPerson {
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *BenchmarkService) ComplexStruct(c ComplexData) ComplexData {
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *BenchmarkService) SliceArg(items []int) []int {
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *BenchmarkService) VariadicArg(items ...string) []string {
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *BenchmarkService) WithContext(ctx context.Context, s1 string) string {
|
||||
return s1
|
||||
}
|
||||
|
||||
func (s *BenchmarkService) Method1() {}
|
||||
func (s *BenchmarkService) Method2() {}
|
||||
func (s *BenchmarkService) Method3() {}
|
||||
func (s *BenchmarkService) Method4() {}
|
||||
func (s *BenchmarkService) Method5() {}
|
||||
func (s *BenchmarkService) Method6() {}
|
||||
func (s *BenchmarkService) Method7() {}
|
||||
func (s *BenchmarkService) Method8() {}
|
||||
func (s *BenchmarkService) Method9() {}
|
||||
func (s *BenchmarkService) Method10() {}
|
||||
|
||||
type BenchPerson struct {
|
||||
Name string `json:"name"`
|
||||
Age int `json:"age"`
|
||||
Email string `json:"email"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
type ComplexData struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
Nested *NestedData `json:"nested"`
|
||||
}
|
||||
|
||||
type NestedData struct {
|
||||
Value float64 `json:"value"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Helper to create JSON args
|
||||
func benchArgs(jsonArgs ...string) []json.RawMessage {
|
||||
args := make([]json.RawMessage, len(jsonArgs))
|
||||
for i, j := range jsonArgs {
|
||||
args[i] = json.RawMessage(j)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// BenchmarkMethodBinding measures the cost of registering services with varying method counts
|
||||
func BenchmarkMethodBinding(b *testing.B) {
|
||||
// Initialize global application (required for bindings)
|
||||
_ = application.New(application.Options{})
|
||||
|
||||
b.Run("SingleService", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("MultipleServices", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
_ = bindings.Add(application.NewService(&BenchPerson{})) // Will fail but tests the path
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkMethodLookupByID measures method lookup by ID performance
|
||||
func BenchmarkMethodLookupByID(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
// Get a valid method ID
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
methodID := method.ID
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_ = bindings.GetByID(methodID)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkMethodLookupByName measures method lookup by name performance
|
||||
func BenchmarkMethodLookupByName(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_ = bindings.Get(callOptions)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSimpleCall measures the cost of calling a method with a simple string argument
|
||||
func BenchmarkSimpleCall(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := benchArgs(`"hello world"`)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkComplexCall measures the cost of calling a method with a complex struct argument
|
||||
func BenchmarkComplexCall(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.ComplexStruct",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
complexArg := `{
|
||||
"id": 12345,
|
||||
"name": "Test Complex Data",
|
||||
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],
|
||||
"metadata": {"key1": "value1", "key2": 42, "key3": true},
|
||||
"nested": {"value": 3.14159, "enabled": true}
|
||||
}`
|
||||
args := benchArgs(complexArg)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkVariadicCall measures the cost of calling a variadic method
|
||||
func BenchmarkVariadicCall(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.VariadicArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := benchArgs(`["one", "two", "three", "four", "five"]`)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkCallWithContext measures the cost of calling a method that requires context
|
||||
func BenchmarkCallWithContext(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.WithContext",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := benchArgs(`"context test"`)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkJSONMarshalResult measures JSON marshaling overhead for results
|
||||
func BenchmarkJSONMarshalResult(b *testing.B) {
|
||||
person := BenchPerson{
|
||||
Name: "John Doe",
|
||||
Age: 30,
|
||||
Email: "john@example.com",
|
||||
Address: "123 Main St, City, Country",
|
||||
}
|
||||
|
||||
b.Run("SimplePerson", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = json.Marshal(person)
|
||||
}
|
||||
})
|
||||
|
||||
complex := ComplexData{
|
||||
ID: 12345,
|
||||
Name: "Complex Test",
|
||||
Tags: []string{"tag1", "tag2", "tag3", "tag4", "tag5"},
|
||||
Metadata: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
"key3": true,
|
||||
},
|
||||
Nested: &NestedData{
|
||||
Value: 3.14159,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
b.Run("ComplexData", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = json.Marshal(complex)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkHashComputation measures the FNV hash computation used for method IDs
|
||||
func BenchmarkHashComputation(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
fqn string
|
||||
}{
|
||||
{"Short", "pkg.Service.Method"},
|
||||
{"Medium", "github.com/user/project/pkg.Service.Method"},
|
||||
{"Long", "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.ComplexStruct"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_ = hash.Fnv(tc.fqn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkJSONUnmarshal measures JSON unmarshaling overhead for arguments
|
||||
func BenchmarkJSONUnmarshal(b *testing.B) {
|
||||
b.Run("String", func(b *testing.B) {
|
||||
data := []byte(`"hello world"`)
|
||||
for b.Loop() {
|
||||
var s string
|
||||
_ = json.Unmarshal(data, &s)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Int", func(b *testing.B) {
|
||||
data := []byte(`12345`)
|
||||
for b.Loop() {
|
||||
var i int
|
||||
_ = json.Unmarshal(data, &i)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Struct", func(b *testing.B) {
|
||||
data := []byte(`{"name":"John","age":30,"email":"john@example.com","address":"123 Main St"}`)
|
||||
for b.Loop() {
|
||||
var p BenchPerson
|
||||
_ = json.Unmarshal(data, &p)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("ComplexStruct", func(b *testing.B) {
|
||||
data := []byte(`{"id":12345,"name":"Test","tags":["a","b","c"],"metadata":{"k":"v"},"nested":{"value":3.14,"enabled":true}}`)
|
||||
for b.Loop() {
|
||||
var c ComplexData
|
||||
_ = json.Unmarshal(data, &c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkMethodLookupWithAliases measures method lookup with alias resolution
|
||||
func BenchmarkMethodLookupWithAliases(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
|
||||
// Create aliases map
|
||||
aliases := make(map[uint32]uint32)
|
||||
for i := uint32(0); i < 100; i++ {
|
||||
aliases[i+1000] = i
|
||||
}
|
||||
|
||||
bindings := application.NewBindings(nil, aliases)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
b.Run("DirectLookup", func(b *testing.B) {
|
||||
id := method.ID
|
||||
for b.Loop() {
|
||||
_ = bindings.GetByID(id)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("AliasLookup", func(b *testing.B) {
|
||||
// Add an alias for this method
|
||||
aliases[9999] = method.ID
|
||||
for b.Loop() {
|
||||
_ = bindings.GetByID(9999)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkReflectValueCall measures the overhead of reflect.Value.Call
|
||||
func BenchmarkReflectValueCall(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.Run("NoArgs", func(b *testing.B) {
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.NoArgs",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
args := benchArgs()
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("MultipleArgs", func(b *testing.B) {
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.MultipleArgs",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
args := benchArgs(`"test"`, `42`, `true`)
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkBindingsScaling measures how bindings performance scales with service count
|
||||
func BenchmarkBindingsScaling(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
|
||||
// We can only add one service of each type, so we test lookup scaling
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
// Generate method names for lookup
|
||||
methodNames := []string{
|
||||
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.NoArgs",
|
||||
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
|
||||
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.IntArg",
|
||||
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.MultipleArgs",
|
||||
"github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StructArg",
|
||||
}
|
||||
|
||||
b.Run("SequentialLookup", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
for _, name := range methodNames {
|
||||
_ = bindings.Get(&application.CallOptions{MethodName: name})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkCallErrorPath measures the cost of error handling in method calls
|
||||
func BenchmarkCallErrorPath(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.StringArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.Run("WrongArgCount", func(b *testing.B) {
|
||||
args := benchArgs() // No args when one is expected
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("WrongArgType", func(b *testing.B) {
|
||||
args := benchArgs(`123`) // Int when string is expected
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkSliceArgSizes measures performance with varying slice sizes
|
||||
func BenchmarkSliceArgSizes(b *testing.B) {
|
||||
_ = application.New(application.Options{})
|
||||
bindings := application.NewBindings(nil, nil)
|
||||
_ = bindings.Add(application.NewService(&BenchmarkService{}))
|
||||
|
||||
callOptions := &application.CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application_test.BenchmarkService.SliceArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
sizes := []int{1, 10, 100, 1000}
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("Size%d", size), func(b *testing.B) {
|
||||
// Build slice JSON
|
||||
slice := make([]int, size)
|
||||
for i := range slice {
|
||||
slice[i] = i
|
||||
}
|
||||
data, _ := json.Marshal(slice)
|
||||
args := []json.RawMessage{data}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
469
v3/pkg/application/bindings_optimized_bench_test.go
Normal file
469
v3/pkg/application/bindings_optimized_bench_test.go
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
//go:build bench
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
jsonv2 "github.com/go-json-experiment/json"
|
||||
)
|
||||
|
||||
// This file contains optimized versions of BoundMethod.Call for benchmarking.
|
||||
// These demonstrate potential optimizations that could be applied.
|
||||
|
||||
// Pools for reducing allocations
|
||||
var (
|
||||
// Pool for []reflect.Value slices (sized for typical arg counts)
|
||||
callArgsPool = sync.Pool{
|
||||
New: func() any {
|
||||
// Pre-allocate for up to 8 args (covers vast majority of methods)
|
||||
return make([]reflect.Value, 0, 8)
|
||||
},
|
||||
}
|
||||
|
||||
// Pool for []any slices
|
||||
anySlicePool = sync.Pool{
|
||||
New: func() any {
|
||||
return make([]any, 0, 4)
|
||||
},
|
||||
}
|
||||
|
||||
// Pool for CallError structs
|
||||
callErrorPool = sync.Pool{
|
||||
New: func() any {
|
||||
return &CallError{}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// CallOptimized is an optimized version of BoundMethod.Call that uses sync.Pool
|
||||
func (b *BoundMethod) CallOptimized(ctx context.Context, args []json.RawMessage) (result any, err error) {
|
||||
defer handlePanic(handlePanicOptions{skipEnd: 5})
|
||||
|
||||
argCount := len(args)
|
||||
if b.needsContext {
|
||||
argCount++
|
||||
}
|
||||
|
||||
if argCount != len(b.Inputs) {
|
||||
cerr := callErrorPool.Get().(*CallError)
|
||||
cerr.Kind = TypeError
|
||||
cerr.Message = fmt.Sprintf("%s expects %d arguments, got %d", b.FQN, len(b.Inputs), argCount)
|
||||
cerr.Cause = nil
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
// Get callArgs from pool
|
||||
callArgs := callArgsPool.Get().([]reflect.Value)
|
||||
callArgs = callArgs[:0] // Reset length but keep capacity
|
||||
|
||||
// Ensure capacity
|
||||
if cap(callArgs) < argCount {
|
||||
callArgs = make([]reflect.Value, 0, argCount)
|
||||
}
|
||||
callArgs = callArgs[:argCount]
|
||||
|
||||
base := 0
|
||||
if b.needsContext {
|
||||
callArgs[0] = reflect.ValueOf(ctx)
|
||||
base++
|
||||
}
|
||||
|
||||
// Iterate over given arguments
|
||||
for index, arg := range args {
|
||||
value := reflect.New(b.Inputs[base+index].ReflectType)
|
||||
err = json.Unmarshal(arg, value.Interface())
|
||||
if err != nil {
|
||||
// Return callArgs to pool before returning error
|
||||
callArgsPool.Put(callArgs[:0])
|
||||
|
||||
cerr := callErrorPool.Get().(*CallError)
|
||||
cerr.Kind = TypeError
|
||||
cerr.Message = fmt.Sprintf("could not parse argument #%d: %s", index, err)
|
||||
cerr.Cause = json.RawMessage(b.marshalError(err))
|
||||
return nil, cerr
|
||||
}
|
||||
callArgs[base+index] = value.Elem()
|
||||
}
|
||||
|
||||
// Do the call - use cached isVariadic flag like production code
|
||||
var callResults []reflect.Value
|
||||
if b.isVariadic {
|
||||
callResults = b.Method.CallSlice(callArgs)
|
||||
} else {
|
||||
callResults = b.Method.Call(callArgs)
|
||||
}
|
||||
|
||||
// Return callArgs to pool
|
||||
callArgsPool.Put(callArgs[:0])
|
||||
|
||||
// Get output slice from pool
|
||||
nonErrorOutputs := anySlicePool.Get().([]any)
|
||||
nonErrorOutputs = nonErrorOutputs[:0]
|
||||
defer func() {
|
||||
anySlicePool.Put(nonErrorOutputs[:0])
|
||||
}()
|
||||
|
||||
var errorOutputs []error
|
||||
|
||||
for _, field := range callResults {
|
||||
if field.Type() == errorType {
|
||||
if field.IsNil() {
|
||||
continue
|
||||
}
|
||||
if errorOutputs == nil {
|
||||
errorOutputs = make([]error, 0, len(callResults)-len(nonErrorOutputs))
|
||||
nonErrorOutputs = nil
|
||||
}
|
||||
errorOutputs = append(errorOutputs, field.Interface().(error))
|
||||
} else if nonErrorOutputs != nil {
|
||||
nonErrorOutputs = append(nonErrorOutputs, field.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errorOutputs) > 0 {
|
||||
info := make([]json.RawMessage, len(errorOutputs))
|
||||
for i, err := range errorOutputs {
|
||||
info[i] = b.marshalError(err)
|
||||
}
|
||||
|
||||
cerr := &CallError{
|
||||
Kind: RuntimeError,
|
||||
Message: errors.Join(errorOutputs...).Error(),
|
||||
Cause: info,
|
||||
}
|
||||
if len(info) == 1 {
|
||||
cerr.Cause = info[0]
|
||||
}
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
if len(nonErrorOutputs) == 1 {
|
||||
result = nonErrorOutputs[0]
|
||||
} else if len(nonErrorOutputs) > 1 {
|
||||
// Need to copy since we're returning the pooled slice
|
||||
resultSlice := make([]any, len(nonErrorOutputs))
|
||||
copy(resultSlice, nonErrorOutputs)
|
||||
result = resultSlice
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Benchmark comparing original vs optimized Call
|
||||
func BenchmarkCallOriginal(b *testing.B) {
|
||||
_ = New(Options{})
|
||||
bindings := NewBindings(nil, nil)
|
||||
|
||||
service := &benchService{}
|
||||
_ = bindings.Add(NewService(service))
|
||||
|
||||
callOptions := &CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCallOptimized(b *testing.B) {
|
||||
_ = New(Options{})
|
||||
bindings := NewBindings(nil, nil)
|
||||
|
||||
service := &benchService{}
|
||||
_ = bindings.Add(NewService(service))
|
||||
|
||||
callOptions := &CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.CallOptimized(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// benchService for internal tests
|
||||
type benchService struct{}
|
||||
|
||||
func (s *benchService) StringArg(str string) string {
|
||||
return str
|
||||
}
|
||||
|
||||
func (s *benchService) MultipleArgs(s1 string, i int, b bool) (string, int, bool) {
|
||||
return s1, i, b
|
||||
}
|
||||
|
||||
func BenchmarkCallOriginal_MultiArgs(b *testing.B) {
|
||||
_ = New(Options{})
|
||||
bindings := NewBindings(nil, nil)
|
||||
|
||||
service := &benchService{}
|
||||
_ = bindings.Add(NewService(service))
|
||||
|
||||
callOptions := &CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.MultipleArgs",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := []json.RawMessage{
|
||||
json.RawMessage(`"test"`),
|
||||
json.RawMessage(`42`),
|
||||
json.RawMessage(`true`),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCallOptimized_MultiArgs(b *testing.B) {
|
||||
_ = New(Options{})
|
||||
bindings := NewBindings(nil, nil)
|
||||
|
||||
service := &benchService{}
|
||||
_ = bindings.Add(NewService(service))
|
||||
|
||||
callOptions := &CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.MultipleArgs",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := []json.RawMessage{
|
||||
json.RawMessage(`"test"`),
|
||||
json.RawMessage(`42`),
|
||||
json.RawMessage(`true`),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.CallOptimized(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// CallWithJSONv2 uses the new JSON v2 library for unmarshaling
|
||||
func (b *BoundMethod) CallWithJSONv2(ctx context.Context, args []json.RawMessage) (result any, err error) {
|
||||
defer handlePanic(handlePanicOptions{skipEnd: 5})
|
||||
|
||||
argCount := len(args)
|
||||
if b.needsContext {
|
||||
argCount++
|
||||
}
|
||||
|
||||
if argCount != len(b.Inputs) {
|
||||
return nil, &CallError{
|
||||
Kind: TypeError,
|
||||
Message: fmt.Sprintf("%s expects %d arguments, got %d", b.FQN, len(b.Inputs), argCount),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert inputs to values of appropriate type
|
||||
callArgs := make([]reflect.Value, argCount)
|
||||
base := 0
|
||||
|
||||
if b.needsContext {
|
||||
callArgs[0] = reflect.ValueOf(ctx)
|
||||
base++
|
||||
}
|
||||
|
||||
// Iterate over given arguments - use JSON v2 for unmarshaling
|
||||
for index, arg := range args {
|
||||
value := reflect.New(b.Inputs[base+index].ReflectType)
|
||||
err = jsonv2.Unmarshal(arg, value.Interface())
|
||||
if err != nil {
|
||||
return nil, &CallError{
|
||||
Kind: TypeError,
|
||||
Message: fmt.Sprintf("could not parse argument #%d: %s", index, err),
|
||||
Cause: json.RawMessage(b.marshalError(err)),
|
||||
}
|
||||
}
|
||||
callArgs[base+index] = value.Elem()
|
||||
}
|
||||
|
||||
// Do the call
|
||||
var callResults []reflect.Value
|
||||
if b.Method.Type().IsVariadic() {
|
||||
callResults = b.Method.CallSlice(callArgs)
|
||||
} else {
|
||||
callResults = b.Method.Call(callArgs)
|
||||
}
|
||||
|
||||
var nonErrorOutputs = make([]any, 0, len(callResults))
|
||||
var errorOutputs []error
|
||||
|
||||
for _, field := range callResults {
|
||||
if field.Type() == errorType {
|
||||
if field.IsNil() {
|
||||
continue
|
||||
}
|
||||
if errorOutputs == nil {
|
||||
errorOutputs = make([]error, 0, len(callResults)-len(nonErrorOutputs))
|
||||
nonErrorOutputs = nil
|
||||
}
|
||||
errorOutputs = append(errorOutputs, field.Interface().(error))
|
||||
} else if nonErrorOutputs != nil {
|
||||
nonErrorOutputs = append(nonErrorOutputs, field.Interface())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errorOutputs) > 0 {
|
||||
info := make([]json.RawMessage, len(errorOutputs))
|
||||
for i, err := range errorOutputs {
|
||||
info[i] = b.marshalError(err)
|
||||
}
|
||||
|
||||
cerr := &CallError{
|
||||
Kind: RuntimeError,
|
||||
Message: errors.Join(errorOutputs...).Error(),
|
||||
Cause: info,
|
||||
}
|
||||
if len(info) == 1 {
|
||||
cerr.Cause = info[0]
|
||||
}
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
if len(nonErrorOutputs) == 1 {
|
||||
result = nonErrorOutputs[0]
|
||||
} else if len(nonErrorOutputs) > 1 {
|
||||
result = nonErrorOutputs
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BenchmarkCallJSONv2(b *testing.B) {
|
||||
_ = New(Options{})
|
||||
bindings := NewBindings(nil, nil)
|
||||
|
||||
service := &benchService{}
|
||||
_ = bindings.Add(NewService(service))
|
||||
|
||||
callOptions := &CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.CallWithJSONv2(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCallJSONv2_MultiArgs(b *testing.B) {
|
||||
_ = New(Options{})
|
||||
bindings := NewBindings(nil, nil)
|
||||
|
||||
service := &benchService{}
|
||||
_ = bindings.Add(NewService(service))
|
||||
|
||||
callOptions := &CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.MultipleArgs",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := []json.RawMessage{
|
||||
json.RawMessage(`"test"`),
|
||||
json.RawMessage(`42`),
|
||||
json.RawMessage(`true`),
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = method.CallWithJSONv2(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent benchmark to test pool effectiveness under load
|
||||
func BenchmarkCallOriginal_Concurrent(b *testing.B) {
|
||||
_ = New(Options{})
|
||||
bindings := NewBindings(nil, nil)
|
||||
|
||||
service := &benchService{}
|
||||
_ = bindings.Add(NewService(service))
|
||||
|
||||
callOptions := &CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_, _ = method.Call(ctx, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCallOptimized_Concurrent(b *testing.B) {
|
||||
_ = New(Options{})
|
||||
bindings := NewBindings(nil, nil)
|
||||
|
||||
service := &benchService{}
|
||||
_ = bindings.Add(NewService(service))
|
||||
|
||||
callOptions := &CallOptions{
|
||||
MethodName: "github.com/wailsapp/wails/v3/pkg/application.benchService.StringArg",
|
||||
}
|
||||
method := bindings.Get(callOptions)
|
||||
if method == nil {
|
||||
b.Fatal("method not found")
|
||||
}
|
||||
|
||||
args := []json.RawMessage{json.RawMessage(`"hello world"`)}
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_, _ = method.CallOptimized(ctx, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package application
|
|||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
||||
|
|
@ -100,7 +99,9 @@ func (em *EventManager) OnApplicationEvent(eventType events.ApplicationEventType
|
|||
em.app.applicationEventListenersLock.Lock()
|
||||
defer em.app.applicationEventListenersLock.Unlock()
|
||||
// Remove listener
|
||||
em.app.applicationEventListeners[eventID] = lo.Without(em.app.applicationEventListeners[eventID], listener)
|
||||
em.app.applicationEventListeners[eventID] = slices.DeleteFunc(em.app.applicationEventListeners[eventID], func(l *EventListener) bool {
|
||||
return l == listener
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +117,9 @@ func (em *EventManager) RegisterApplicationEventHook(eventType events.Applicatio
|
|||
|
||||
return func() {
|
||||
em.app.applicationEventHooksLock.Lock()
|
||||
em.app.applicationEventHooks[eventID] = lo.Without(em.app.applicationEventHooks[eventID], thisHook)
|
||||
em.app.applicationEventHooks[eventID] = slices.DeleteFunc(em.app.applicationEventHooks[eventID], func(h *eventHook) bool {
|
||||
return h == thisHook
|
||||
})
|
||||
em.app.applicationEventHooksLock.Unlock()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/samber/lo"
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
||||
|
|
@ -197,8 +198,8 @@ func (e *EventProcessor) registerListener(eventName string, callback func(*Custo
|
|||
if _, ok := e.listeners[eventName]; !ok {
|
||||
return
|
||||
}
|
||||
e.listeners[eventName] = lo.Filter(e.listeners[eventName], func(l *eventListener, i int) bool {
|
||||
return l != thisListener
|
||||
e.listeners[eventName] = slices.DeleteFunc(e.listeners[eventName], func(l *eventListener) bool {
|
||||
return l == thisListener
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -220,8 +221,8 @@ func (e *EventProcessor) RegisterHook(eventName string, callback func(*CustomEve
|
|||
if _, ok := e.hooks[eventName]; !ok {
|
||||
return
|
||||
}
|
||||
e.hooks[eventName] = lo.Filter(e.hooks[eventName], func(l *hook, i int) bool {
|
||||
return l != thisHook
|
||||
e.hooks[eventName] = slices.DeleteFunc(e.hooks[eventName], func(h *hook) bool {
|
||||
return h == thisHook
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -268,8 +269,8 @@ func (e *EventProcessor) dispatchEventToListeners(event *CustomEvent) {
|
|||
|
||||
// Do we have items to delete?
|
||||
if itemsToDelete == true {
|
||||
e.listeners[event.Name] = lo.Filter(listeners, func(l *eventListener, i int) bool {
|
||||
return l.delete == false
|
||||
e.listeners[event.Name] = slices.DeleteFunc(listeners, func(l *eventListener) bool {
|
||||
return l.delete == true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
380
v3/pkg/application/events_bench_test.go
Normal file
380
v3/pkg/application/events_bench_test.go
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
//go:build bench
|
||||
|
||||
package application_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// mockWindowDispatcher implements a no-op dispatcher for benchmarking
|
||||
type mockWindowDispatcher struct {
|
||||
count atomic.Int64
|
||||
}
|
||||
|
||||
func (m *mockWindowDispatcher) dispatchEventToWindows(event *application.CustomEvent) {
|
||||
m.count.Add(1)
|
||||
}
|
||||
|
||||
// BenchmarkEventEmit measures event emission with varying listener counts
|
||||
func BenchmarkEventEmit(b *testing.B) {
|
||||
listenerCounts := []int{0, 1, 10, 100}
|
||||
|
||||
for _, count := range listenerCounts {
|
||||
b.Run(fmt.Sprintf("Listeners%d", count), func(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
|
||||
// Register listeners
|
||||
for i := 0; i < count; i++ {
|
||||
processor.On("benchmark-event", func(event *application.CustomEvent) {
|
||||
// Minimal work
|
||||
_ = event.Data
|
||||
})
|
||||
}
|
||||
|
||||
event := &application.CustomEvent{
|
||||
Name: "benchmark-event",
|
||||
Data: "test payload",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_ = processor.Emit(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEventRegistration measures the cost of registering event listeners
|
||||
func BenchmarkEventRegistration(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
|
||||
b.Run("SingleRegistration", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
processor.On("test-event", func(event *application.CustomEvent) {})
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("MultipleRegistrations", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
for i := 0; i < 10; i++ {
|
||||
processor.On(fmt.Sprintf("test-event-%d", i), func(event *application.CustomEvent) {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("SameEventMultipleListeners", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
for i := 0; i < 10; i++ {
|
||||
processor.On("test-event", func(event *application.CustomEvent) {})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkEventUnregistration measures the cost of unregistering event listeners
|
||||
func BenchmarkEventUnregistration(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
|
||||
b.Run("SingleUnregister", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
cancel := processor.On("test-event", func(event *application.CustomEvent) {})
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("UnregisterFromMany", func(b *testing.B) {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
// Pre-register many listeners
|
||||
cancels := make([]func(), 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
cancels[i] = processor.On("test-event", func(event *application.CustomEvent) {})
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; b.Loop(); i++ {
|
||||
// Re-register to have something to unregister
|
||||
if i%100 == 0 {
|
||||
for j := 0; j < 100; j++ {
|
||||
cancels[j] = processor.On("test-event", func(event *application.CustomEvent) {})
|
||||
}
|
||||
}
|
||||
cancels[i%100]()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("OffAllListeners", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
for i := 0; i < 10; i++ {
|
||||
processor.On("test-event", func(event *application.CustomEvent) {})
|
||||
}
|
||||
processor.Off("test-event")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkHookExecution measures the cost of hook execution during emit
|
||||
func BenchmarkHookExecution(b *testing.B) {
|
||||
hookCounts := []int{0, 1, 5, 10}
|
||||
|
||||
for _, count := range hookCounts {
|
||||
b.Run(fmt.Sprintf("Hooks%d", count), func(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
|
||||
// Register hooks
|
||||
for i := 0; i < count; i++ {
|
||||
processor.RegisterHook("benchmark-event", func(event *application.CustomEvent) {
|
||||
// Minimal work - don't cancel
|
||||
_ = event.Data
|
||||
})
|
||||
}
|
||||
|
||||
event := &application.CustomEvent{
|
||||
Name: "benchmark-event",
|
||||
Data: "test payload",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_ = processor.Emit(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentEmit measures event emission under concurrent load
|
||||
func BenchmarkConcurrentEmit(b *testing.B) {
|
||||
concurrencyLevels := []int{1, 4, 16}
|
||||
|
||||
for _, concurrency := range concurrencyLevels {
|
||||
b.Run(fmt.Sprintf("Goroutines%d", concurrency), func(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
|
||||
// Register a few listeners
|
||||
for i := 0; i < 5; i++ {
|
||||
processor.On("benchmark-event", func(event *application.CustomEvent) {
|
||||
_ = event.Data
|
||||
})
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.SetParallelism(concurrency)
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
event := &application.CustomEvent{
|
||||
Name: "benchmark-event",
|
||||
Data: "test payload",
|
||||
}
|
||||
for pb.Next() {
|
||||
_ = processor.Emit(event)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEventToJSON measures CustomEvent JSON serialization
|
||||
func BenchmarkEventToJSON(b *testing.B) {
|
||||
b.Run("SimpleData", func(b *testing.B) {
|
||||
event := &application.CustomEvent{
|
||||
Name: "test-event",
|
||||
Data: "simple string payload",
|
||||
}
|
||||
for b.Loop() {
|
||||
_ = event.ToJSON()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("ComplexData", func(b *testing.B) {
|
||||
event := &application.CustomEvent{
|
||||
Name: "test-event",
|
||||
Data: map[string]interface{}{
|
||||
"id": 12345,
|
||||
"name": "Test Event",
|
||||
"tags": []string{"tag1", "tag2", "tag3"},
|
||||
"enabled": true,
|
||||
"nested": map[string]interface{}{
|
||||
"value": 3.14159,
|
||||
},
|
||||
},
|
||||
}
|
||||
for b.Loop() {
|
||||
_ = event.ToJSON()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("WithSender", func(b *testing.B) {
|
||||
event := &application.CustomEvent{
|
||||
Name: "test-event",
|
||||
Data: "payload",
|
||||
Sender: "main-window",
|
||||
}
|
||||
for b.Loop() {
|
||||
_ = event.ToJSON()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkAtomicCancel measures the atomic cancel/check operations
|
||||
func BenchmarkAtomicCancel(b *testing.B) {
|
||||
b.Run("Cancel", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
event := &application.CustomEvent{
|
||||
Name: "test",
|
||||
Data: nil,
|
||||
}
|
||||
event.Cancel()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("IsCancelled", func(b *testing.B) {
|
||||
event := &application.CustomEvent{
|
||||
Name: "test",
|
||||
Data: nil,
|
||||
}
|
||||
for b.Loop() {
|
||||
_ = event.IsCancelled()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("CancelAndCheck", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
event := &application.CustomEvent{
|
||||
Name: "test",
|
||||
Data: nil,
|
||||
}
|
||||
event.Cancel()
|
||||
_ = event.IsCancelled()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkEventProcessorCreation measures processor instantiation
|
||||
func BenchmarkEventProcessorCreation(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
for b.Loop() {
|
||||
_ = application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkOnceEvent measures the Once registration and auto-unregistration
|
||||
func BenchmarkOnceEvent(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
|
||||
b.Run("RegisterAndTrigger", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
processor.Once("once-event", func(event *application.CustomEvent) {
|
||||
wg.Done()
|
||||
})
|
||||
_ = processor.Emit(&application.CustomEvent{Name: "once-event", Data: nil})
|
||||
wg.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkOnMultipleEvent measures the OnMultiple registration
|
||||
func BenchmarkOnMultipleEvent(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
|
||||
b.Run("ThreeEvents", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
processor.OnMultiple("multi-event", func(event *application.CustomEvent) {
|
||||
wg.Done()
|
||||
}, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
_ = processor.Emit(&application.CustomEvent{Name: "multi-event", Data: nil})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkMixedEventOperations simulates realistic event usage patterns
|
||||
func BenchmarkMixedEventOperations(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
|
||||
b.Run("RegisterEmitUnregister", func(b *testing.B) {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
|
||||
for b.Loop() {
|
||||
cancel := processor.On("mixed-event", func(event *application.CustomEvent) {
|
||||
_ = event.Data
|
||||
})
|
||||
_ = processor.Emit(&application.CustomEvent{Name: "mixed-event", Data: "test"})
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("HookAndEmit", func(b *testing.B) {
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
processor.RegisterHook("hooked-event", func(event *application.CustomEvent) {
|
||||
// Validation hook
|
||||
if event.Data == nil {
|
||||
event.Cancel()
|
||||
}
|
||||
})
|
||||
processor.On("hooked-event", func(event *application.CustomEvent) {
|
||||
_ = event.Data
|
||||
})
|
||||
|
||||
event := &application.CustomEvent{Name: "hooked-event", Data: "valid"}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_ = processor.Emit(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkEventNameLookup measures the map lookup performance for event names
|
||||
func BenchmarkEventNameLookup(b *testing.B) {
|
||||
dispatcher := &mockWindowDispatcher{}
|
||||
processor := application.NewWailsEventProcessor(dispatcher.dispatchEventToWindows)
|
||||
|
||||
// Register events with different name lengths
|
||||
shortName := "evt"
|
||||
mediumName := "application:user:action"
|
||||
longName := "com.mycompany.myapp.module.submodule.event.type.action"
|
||||
|
||||
processor.On(shortName, func(event *application.CustomEvent) {})
|
||||
processor.On(mediumName, func(event *application.CustomEvent) {})
|
||||
processor.On(longName, func(event *application.CustomEvent) {})
|
||||
|
||||
b.Run("ShortName", func(b *testing.B) {
|
||||
event := &application.CustomEvent{Name: shortName, Data: nil}
|
||||
for b.Loop() {
|
||||
_ = processor.Emit(event)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("MediumName", func(b *testing.B) {
|
||||
event := &application.CustomEvent{Name: mediumName, Data: nil}
|
||||
for b.Loop() {
|
||||
_ = processor.Emit(event)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("LongName", func(b *testing.B) {
|
||||
event := &application.CustomEvent{Name: longName, Data: nil}
|
||||
for b.Loop() {
|
||||
_ = processor.Emit(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -10,10 +10,13 @@ package application
|
|||
*/
|
||||
import "C"
|
||||
import (
|
||||
"encoding/json"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// iosHapticsImpact triggers an iOS haptic impact using the provided style.
|
||||
// The style parameter specifies the impact style name understood by the native haptic engine.
|
||||
func iosHapticsImpact(style string) {
|
||||
cstr := C.CString(style)
|
||||
defer C.free(unsafe.Pointer(cstr))
|
||||
|
|
@ -39,27 +42,38 @@ func iosDeviceInfo() deviceInfo {
|
|||
return out
|
||||
}
|
||||
|
||||
// Live mutations
|
||||
// iosSetScrollEnabled sets whether scrolling is enabled in the iOS runtime.
|
||||
func iosSetScrollEnabled(enabled bool) { C.ios_runtime_set_scroll_enabled(C.bool(enabled)) }
|
||||
// iosSetBounceEnabled sets whether scroll bounce (rubber-band) behavior is enabled at runtime.
|
||||
// If enabled is true, scrollable content will bounce when pulled past its edges; if false, that bounce is disabled.
|
||||
func iosSetBounceEnabled(enabled bool) { C.ios_runtime_set_bounce_enabled(C.bool(enabled)) }
|
||||
// iosSetScrollIndicatorsEnabled configures whether the iOS runtime shows scroll indicators.
|
||||
// The enabled parameter controls visibility: true shows indicators, false hides them.
|
||||
func iosSetScrollIndicatorsEnabled(enabled bool) {
|
||||
C.ios_runtime_set_scroll_indicators_enabled(C.bool(enabled))
|
||||
C.ios_runtime_set_scroll_indicators_enabled(C.bool(enabled))
|
||||
}
|
||||
// iosSetBackForwardGesturesEnabled enables back-forward navigation gestures when enabled is true and disables them when enabled is false.
|
||||
func iosSetBackForwardGesturesEnabled(enabled bool) {
|
||||
C.ios_runtime_set_back_forward_gestures_enabled(C.bool(enabled))
|
||||
C.ios_runtime_set_back_forward_gestures_enabled(C.bool(enabled))
|
||||
}
|
||||
// iosSetLinkPreviewEnabled sets whether link previews are enabled in the iOS runtime.
|
||||
// Pass true to enable link previews, false to disable them.
|
||||
func iosSetLinkPreviewEnabled(enabled bool) { C.ios_runtime_set_link_preview_enabled(C.bool(enabled)) }
|
||||
// iosSetInspectableEnabled sets whether runtime web content inspection is enabled.
|
||||
// When enabled is true the runtime allows inspection of web content; when false inspection is disabled.
|
||||
func iosSetInspectableEnabled(enabled bool) { C.ios_runtime_set_inspectable_enabled(C.bool(enabled)) }
|
||||
// iosSetCustomUserAgent sets the runtime's custom User-Agent string.
|
||||
// If ua is an empty string, the custom User-Agent is cleared.
|
||||
func iosSetCustomUserAgent(ua string) {
|
||||
var cstr *C.char
|
||||
if ua != "" {
|
||||
cstr = C.CString(ua)
|
||||
defer C.free(unsafe.Pointer(cstr))
|
||||
}
|
||||
C.ios_runtime_set_custom_user_agent(cstr)
|
||||
var cstr *C.char
|
||||
if ua != "" {
|
||||
cstr = C.CString(ua)
|
||||
defer C.free(unsafe.Pointer(cstr))
|
||||
}
|
||||
C.ios_runtime_set_custom_user_agent(cstr)
|
||||
}
|
||||
|
||||
// Native tabs
|
||||
func iosSetNativeTabsEnabled(enabled bool) { C.ios_native_tabs_set_enabled(C.bool(enabled)) }
|
||||
func iosNativeTabsIsEnabled() bool { return bool(C.ios_native_tabs_is_enabled()) }
|
||||
func iosSelectNativeTab(index int) { C.ios_native_tabs_select_index(C.int(index)) }
|
||||
func iosSelectNativeTab(index int) { C.ios_native_tabs_select_index(C.int(index)) }
|
||||
310
v3/pkg/application/json_libs_bench_test.go
Normal file
310
v3/pkg/application/json_libs_bench_test.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
//go:build bench
|
||||
|
||||
package application_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
gojson "github.com/goccy/go-json"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
)
|
||||
|
||||
// Test structures matching real Wails binding patterns
|
||||
|
||||
type SimpleBindingArg struct {
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
type ComplexBindingArg struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
Nested *NestedBindingArg `json:"nested,omitempty"`
|
||||
}
|
||||
|
||||
type NestedBindingArg struct {
|
||||
Value float64 `json:"value"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Test data simulating frontend calls
|
||||
var (
|
||||
simpleJSON = []byte(`{"name":"test","value":42}`)
|
||||
|
||||
complexJSON = []byte(`{"id":12345,"name":"Test Complex Data","tags":["tag1","tag2","tag3","tag4","tag5"],"metadata":{"key1":"value1","key2":42,"key3":true},"nested":{"value":3.14159,"enabled":true}}`)
|
||||
|
||||
stringJSON = []byte(`"hello world this is a test string"`)
|
||||
|
||||
multiArgsJSON = [][]byte{
|
||||
[]byte(`"arg1"`),
|
||||
[]byte(`42`),
|
||||
[]byte(`true`),
|
||||
[]byte(`{"key":"value"}`),
|
||||
}
|
||||
)
|
||||
|
||||
// Configure jsoniter for maximum compatibility
|
||||
var jsoniterStd = jsoniter.ConfigCompatibleWithStandardLibrary
|
||||
|
||||
// ============================================================================
|
||||
// UNMARSHAL BENCHMARKS - This is the HOT PATH (bindings.go:289)
|
||||
// ============================================================================
|
||||
|
||||
// --- Simple struct unmarshal ---
|
||||
|
||||
func BenchmarkUnmarshal_Simple_StdLib(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg SimpleBindingArg
|
||||
_ = json.Unmarshal(simpleJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_Simple_GoJSON(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg SimpleBindingArg
|
||||
_ = gojson.Unmarshal(simpleJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_Simple_JSONIter(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg SimpleBindingArg
|
||||
_ = jsoniterStd.Unmarshal(simpleJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_Simple_Sonic(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg SimpleBindingArg
|
||||
_ = sonic.Unmarshal(simpleJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Complex struct unmarshal ---
|
||||
|
||||
func BenchmarkUnmarshal_Complex_StdLib(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg ComplexBindingArg
|
||||
_ = json.Unmarshal(complexJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_Complex_GoJSON(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg ComplexBindingArg
|
||||
_ = gojson.Unmarshal(complexJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_Complex_JSONIter(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg ComplexBindingArg
|
||||
_ = jsoniterStd.Unmarshal(complexJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_Complex_Sonic(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg ComplexBindingArg
|
||||
_ = sonic.Unmarshal(complexJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
// --- String unmarshal (most common single arg) ---
|
||||
|
||||
func BenchmarkUnmarshal_String_StdLib(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg string
|
||||
_ = json.Unmarshal(stringJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_String_GoJSON(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg string
|
||||
_ = gojson.Unmarshal(stringJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_String_JSONIter(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg string
|
||||
_ = jsoniterStd.Unmarshal(stringJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_String_Sonic(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg string
|
||||
_ = sonic.Unmarshal(stringJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Interface{} unmarshal (dynamic typing) ---
|
||||
|
||||
func BenchmarkUnmarshal_Interface_StdLib(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg interface{}
|
||||
_ = json.Unmarshal(complexJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_Interface_GoJSON(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg interface{}
|
||||
_ = gojson.Unmarshal(complexJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_Interface_JSONIter(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg interface{}
|
||||
_ = jsoniterStd.Unmarshal(complexJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_Interface_Sonic(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg interface{}
|
||||
_ = sonic.Unmarshal(complexJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-arg unmarshal (simulating typical method call) ---
|
||||
|
||||
func BenchmarkUnmarshal_MultiArgs_StdLib(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var s string
|
||||
var i int
|
||||
var bl bool
|
||||
var m map[string]string
|
||||
_ = json.Unmarshal(multiArgsJSON[0], &s)
|
||||
_ = json.Unmarshal(multiArgsJSON[1], &i)
|
||||
_ = json.Unmarshal(multiArgsJSON[2], &bl)
|
||||
_ = json.Unmarshal(multiArgsJSON[3], &m)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_MultiArgs_GoJSON(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var s string
|
||||
var i int
|
||||
var bl bool
|
||||
var m map[string]string
|
||||
_ = gojson.Unmarshal(multiArgsJSON[0], &s)
|
||||
_ = gojson.Unmarshal(multiArgsJSON[1], &i)
|
||||
_ = gojson.Unmarshal(multiArgsJSON[2], &bl)
|
||||
_ = gojson.Unmarshal(multiArgsJSON[3], &m)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_MultiArgs_JSONIter(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var s string
|
||||
var i int
|
||||
var bl bool
|
||||
var m map[string]string
|
||||
_ = jsoniterStd.Unmarshal(multiArgsJSON[0], &s)
|
||||
_ = jsoniterStd.Unmarshal(multiArgsJSON[1], &i)
|
||||
_ = jsoniterStd.Unmarshal(multiArgsJSON[2], &bl)
|
||||
_ = jsoniterStd.Unmarshal(multiArgsJSON[3], &m)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnmarshal_MultiArgs_Sonic(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var s string
|
||||
var i int
|
||||
var bl bool
|
||||
var m map[string]string
|
||||
_ = sonic.Unmarshal(multiArgsJSON[0], &s)
|
||||
_ = sonic.Unmarshal(multiArgsJSON[1], &i)
|
||||
_ = sonic.Unmarshal(multiArgsJSON[2], &bl)
|
||||
_ = sonic.Unmarshal(multiArgsJSON[3], &m)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MARSHAL BENCHMARKS - Result serialization
|
||||
// ============================================================================
|
||||
|
||||
type BindingResult struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var simpleResult = BindingResult{
|
||||
Success: true,
|
||||
Data: "hello world",
|
||||
}
|
||||
|
||||
var complexResult = BindingResult{
|
||||
Success: true,
|
||||
Data: ComplexBindingArg{
|
||||
ID: 12345,
|
||||
Name: "Result Data",
|
||||
Tags: []string{"a", "b", "c"},
|
||||
Metadata: map[string]interface{}{
|
||||
"processed": true,
|
||||
"count": 100,
|
||||
},
|
||||
Nested: &NestedBindingArg{Value: 2.718, Enabled: true},
|
||||
},
|
||||
}
|
||||
|
||||
// --- Simple result marshal ---
|
||||
|
||||
func BenchmarkMarshal_Simple_StdLib(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = json.Marshal(simpleResult)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshal_Simple_GoJSON(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = gojson.Marshal(simpleResult)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshal_Simple_JSONIter(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = jsoniterStd.Marshal(simpleResult)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshal_Simple_Sonic(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = sonic.Marshal(simpleResult)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Complex result marshal ---
|
||||
|
||||
func BenchmarkMarshal_Complex_StdLib(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = json.Marshal(complexResult)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshal_Complex_GoJSON(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = gojson.Marshal(complexResult)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshal_Complex_JSONIter(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = jsoniterStd.Marshal(complexResult)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMarshal_Complex_Sonic(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = sonic.Marshal(complexResult)
|
||||
}
|
||||
}
|
||||
270
v3/pkg/application/json_v2_bench_test.go
Normal file
270
v3/pkg/application/json_v2_bench_test.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//go:build bench && goexperiment.jsonv2
|
||||
|
||||
package application_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
jsonv2 "encoding/json/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Benchmark structures matching real Wails usage patterns
|
||||
|
||||
type SimpleArg struct {
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
type ComplexArg struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
Nested *NestedArg `json:"nested,omitempty"`
|
||||
}
|
||||
|
||||
type NestedArg struct {
|
||||
Value float64 `json:"value"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type CallResult struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Test data
|
||||
var (
|
||||
simpleArgJSON = []byte(`{"name":"test","value":42}`)
|
||||
complexArgJSON = []byte(`{
|
||||
"id": 12345,
|
||||
"name": "Test Complex Data",
|
||||
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],
|
||||
"metadata": {"key1": "value1", "key2": 42, "key3": true},
|
||||
"nested": {"value": 3.14159, "enabled": true}
|
||||
}`)
|
||||
|
||||
simpleResult = CallResult{
|
||||
Success: true,
|
||||
Data: "hello world",
|
||||
}
|
||||
|
||||
complexResult = CallResult{
|
||||
Success: true,
|
||||
Data: ComplexArg{
|
||||
ID: 12345,
|
||||
Name: "Result Data",
|
||||
Tags: []string{"a", "b", "c"},
|
||||
Metadata: map[string]interface{}{
|
||||
"processed": true,
|
||||
"count": 100,
|
||||
},
|
||||
Nested: &NestedArg{Value: 2.718, Enabled: true},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// === UNMARSHAL BENCHMARKS (argument parsing) ===
|
||||
|
||||
func BenchmarkJSONv1_Unmarshal_Simple(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg SimpleArg
|
||||
_ = json.Unmarshal(simpleArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Unmarshal_Simple(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg SimpleArg
|
||||
_ = jsonv2.Unmarshal(simpleArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv1_Unmarshal_Complex(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg ComplexArg
|
||||
_ = json.Unmarshal(complexArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Unmarshal_Complex(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg ComplexArg
|
||||
_ = jsonv2.Unmarshal(complexArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv1_Unmarshal_Interface(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg interface{}
|
||||
_ = json.Unmarshal(complexArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Unmarshal_Interface(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg interface{}
|
||||
_ = jsonv2.Unmarshal(complexArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
// === MARSHAL BENCHMARKS (result serialization) ===
|
||||
|
||||
func BenchmarkJSONv1_Marshal_Simple(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = json.Marshal(simpleResult)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Marshal_Simple(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = jsonv2.Marshal(simpleResult)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv1_Marshal_Complex(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = json.Marshal(complexResult)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Marshal_Complex(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = jsonv2.Marshal(complexResult)
|
||||
}
|
||||
}
|
||||
|
||||
// === RAW MESSAGE HANDLING (common in Wails bindings) ===
|
||||
|
||||
func BenchmarkJSONv1_RawMessage_Unmarshal(b *testing.B) {
|
||||
raw := json.RawMessage(complexArgJSON)
|
||||
for b.Loop() {
|
||||
var arg ComplexArg
|
||||
_ = json.Unmarshal(raw, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_RawMessage_Unmarshal(b *testing.B) {
|
||||
raw := jsontext.Value(complexArgJSON)
|
||||
for b.Loop() {
|
||||
var arg ComplexArg
|
||||
_ = jsonv2.Unmarshal(raw, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
// === SLICE ARGUMENTS (common pattern) ===
|
||||
|
||||
var sliceArgJSON = []byte(`[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]`)
|
||||
var largeSliceArgJSON = func() []byte {
|
||||
data, _ := json.Marshal(make([]int, 100))
|
||||
return data
|
||||
}()
|
||||
|
||||
func BenchmarkJSONv1_Unmarshal_Slice(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg []int
|
||||
_ = json.Unmarshal(sliceArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Unmarshal_Slice(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg []int
|
||||
_ = jsonv2.Unmarshal(sliceArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv1_Unmarshal_LargeSlice(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg []int
|
||||
_ = json.Unmarshal(largeSliceArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Unmarshal_LargeSlice(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg []int
|
||||
_ = jsonv2.Unmarshal(largeSliceArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
// === STRING ARGUMENT (most common) ===
|
||||
|
||||
var stringArgJSON = []byte(`"hello world this is a test string"`)
|
||||
|
||||
func BenchmarkJSONv1_Unmarshal_String(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg string
|
||||
_ = json.Unmarshal(stringArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Unmarshal_String(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var arg string
|
||||
_ = jsonv2.Unmarshal(stringArgJSON, &arg)
|
||||
}
|
||||
}
|
||||
|
||||
// === MULTIPLE ARGUMENTS (simulating method call) ===
|
||||
|
||||
var multiArgJSON = [][]byte{
|
||||
[]byte(`"arg1"`),
|
||||
[]byte(`42`),
|
||||
[]byte(`true`),
|
||||
[]byte(`{"key": "value"}`),
|
||||
}
|
||||
|
||||
func BenchmarkJSONv1_Unmarshal_MultiArgs(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var s string
|
||||
var i int
|
||||
var bl bool
|
||||
var m map[string]string
|
||||
_ = json.Unmarshal(multiArgJSON[0], &s)
|
||||
_ = json.Unmarshal(multiArgJSON[1], &i)
|
||||
_ = json.Unmarshal(multiArgJSON[2], &bl)
|
||||
_ = json.Unmarshal(multiArgJSON[3], &m)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Unmarshal_MultiArgs(b *testing.B) {
|
||||
for b.Loop() {
|
||||
var s string
|
||||
var i int
|
||||
var bl bool
|
||||
var m map[string]string
|
||||
_ = jsonv2.Unmarshal(multiArgJSON[0], &s)
|
||||
_ = jsonv2.Unmarshal(multiArgJSON[1], &i)
|
||||
_ = jsonv2.Unmarshal(multiArgJSON[2], &bl)
|
||||
_ = jsonv2.Unmarshal(multiArgJSON[3], &m)
|
||||
}
|
||||
}
|
||||
|
||||
// === ERROR RESPONSE MARSHALING ===
|
||||
|
||||
type ErrorResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
var errorResp = ErrorResponse{
|
||||
Code: 500,
|
||||
Message: "Internal server error",
|
||||
Details: "Something went wrong while processing the request",
|
||||
}
|
||||
|
||||
func BenchmarkJSONv1_Marshal_Error(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = json.Marshal(errorResp)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkJSONv2_Marshal_Error(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _ = jsonv2.Marshal(errorResp)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,18 +12,19 @@ import "C"
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// iosConsoleHandler implements slog.Handler and forwards records to the WKWebView console.
|
||||
type iosConsoleHandler struct {
|
||||
level slog.Leveler
|
||||
attrs []slog.Attr
|
||||
group string
|
||||
level slog.Leveler
|
||||
attrs []slog.Attr
|
||||
group string
|
||||
}
|
||||
|
||||
func (h *iosConsoleHandler) Enabled(_ context.Context, lvl slog.Level) bool {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
type Args struct {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ package application
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/errs"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/errs"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ import (
|
|||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
var alreadyRunningError = errors.New("application is already running")
|
||||
|
|
|
|||
374
v3/pkg/application/systemtray_bench_test.go
Normal file
374
v3/pkg/application/systemtray_bench_test.go
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
//go:build bench
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Note: SystemTray benchmarks are limited since actual system tray operations
|
||||
// require platform-specific GUI initialization. These benchmarks focus on
|
||||
// the Go-side logic that can be tested without a running GUI.
|
||||
|
||||
// BenchmarkSystemTrayCreation measures the cost of creating SystemTray instances
|
||||
func BenchmarkSystemTrayCreation(b *testing.B) {
|
||||
for b.Loop() {
|
||||
tray := newSystemTray(1)
|
||||
_ = tray
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSystemTrayConfiguration measures configuration operations
|
||||
func BenchmarkSystemTrayConfiguration(b *testing.B) {
|
||||
b.Run("SetLabel", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
// impl is nil, so this just sets the field
|
||||
for b.Loop() {
|
||||
tray.SetLabel("Test Label")
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("SetTooltip", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.SetTooltip("Test Tooltip")
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("SetIcon", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
icon := make([]byte, 1024) // 1KB icon data
|
||||
for b.Loop() {
|
||||
tray.SetIcon(icon)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("SetDarkModeIcon", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
icon := make([]byte, 1024)
|
||||
for b.Loop() {
|
||||
tray.SetDarkModeIcon(icon)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("SetTemplateIcon", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
icon := make([]byte, 1024)
|
||||
for b.Loop() {
|
||||
tray.SetTemplateIcon(icon)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("SetIconPosition", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.SetIconPosition(NSImageLeading)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("ChainedConfiguration", func(b *testing.B) {
|
||||
icon := make([]byte, 1024)
|
||||
for b.Loop() {
|
||||
tray := newSystemTray(1)
|
||||
tray.SetIcon(icon).
|
||||
SetDarkModeIcon(icon).
|
||||
SetIconPosition(NSImageLeading)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkClickHandlerExecution measures handler registration and invocation
|
||||
func BenchmarkClickHandlerExecution(b *testing.B) {
|
||||
b.Run("RegisterClickHandler", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
tray := newSystemTray(1)
|
||||
tray.OnClick(func() {})
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("RegisterAllHandlers", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
tray := newSystemTray(1)
|
||||
tray.OnClick(func() {})
|
||||
tray.OnRightClick(func() {})
|
||||
tray.OnDoubleClick(func() {})
|
||||
tray.OnRightDoubleClick(func() {})
|
||||
tray.OnMouseEnter(func() {})
|
||||
tray.OnMouseLeave(func() {})
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("InvokeClickHandler", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
counter := 0
|
||||
tray.OnClick(func() {
|
||||
counter++
|
||||
})
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
if tray.clickHandler != nil {
|
||||
tray.clickHandler()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("InvokeAllHandlers", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
counter := 0
|
||||
handler := func() { counter++ }
|
||||
tray.OnClick(handler)
|
||||
tray.OnRightClick(handler)
|
||||
tray.OnDoubleClick(handler)
|
||||
tray.OnRightDoubleClick(handler)
|
||||
tray.OnMouseEnter(handler)
|
||||
tray.OnMouseLeave(handler)
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
if tray.clickHandler != nil {
|
||||
tray.clickHandler()
|
||||
}
|
||||
if tray.rightClickHandler != nil {
|
||||
tray.rightClickHandler()
|
||||
}
|
||||
if tray.doubleClickHandler != nil {
|
||||
tray.doubleClickHandler()
|
||||
}
|
||||
if tray.rightDoubleClickHandler != nil {
|
||||
tray.rightDoubleClickHandler()
|
||||
}
|
||||
if tray.mouseEnterHandler != nil {
|
||||
tray.mouseEnterHandler()
|
||||
}
|
||||
if tray.mouseLeaveHandler != nil {
|
||||
tray.mouseLeaveHandler()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkWindowAttachment measures window attachment configuration
|
||||
func BenchmarkWindowAttachment(b *testing.B) {
|
||||
b.Run("AttachWindow", func(b *testing.B) {
|
||||
// We can't create real windows, but we can test the attachment logic
|
||||
for b.Loop() {
|
||||
tray := newSystemTray(1)
|
||||
// AttachWindow accepts nil gracefully
|
||||
tray.AttachWindow(nil)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("WindowOffset", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.WindowOffset(10)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("WindowDebounce", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.WindowDebounce(200 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("ChainedAttachment", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
tray := newSystemTray(1)
|
||||
tray.AttachWindow(nil).
|
||||
WindowOffset(10).
|
||||
WindowDebounce(200 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkMenuConfiguration measures menu setup operations
|
||||
func BenchmarkMenuConfiguration(b *testing.B) {
|
||||
b.Run("SetNilMenu", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.SetMenu(nil)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("SetSimpleMenu", func(b *testing.B) {
|
||||
menu := NewMenu()
|
||||
menu.Add("Item 1")
|
||||
menu.Add("Item 2")
|
||||
menu.Add("Item 3")
|
||||
|
||||
tray := newSystemTray(1)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
tray.SetMenu(menu)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("SetComplexMenu", func(b *testing.B) {
|
||||
menu := NewMenu()
|
||||
for i := 0; i < 20; i++ {
|
||||
menu.Add("Item")
|
||||
}
|
||||
submenu := NewMenu()
|
||||
for i := 0; i < 10; i++ {
|
||||
submenu.Add("Subitem")
|
||||
}
|
||||
|
||||
tray := newSystemTray(1)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
tray.SetMenu(menu)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkIconSizes measures icon handling with different sizes
|
||||
func BenchmarkIconSizes(b *testing.B) {
|
||||
sizes := []struct {
|
||||
name string
|
||||
size int
|
||||
}{
|
||||
{"16x16", 16 * 16 * 4}, // 1KB - small icon
|
||||
{"32x32", 32 * 32 * 4}, // 4KB - medium icon
|
||||
{"64x64", 64 * 64 * 4}, // 16KB - large icon
|
||||
{"128x128", 128 * 128 * 4}, // 64KB - retina icon
|
||||
{"256x256", 256 * 256 * 4}, // 256KB - high-res icon
|
||||
}
|
||||
|
||||
for _, size := range sizes {
|
||||
b.Run(size.name, func(b *testing.B) {
|
||||
icon := make([]byte, size.size)
|
||||
tray := newSystemTray(1)
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
tray.SetIcon(icon)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWindowAttachConfigInit measures WindowAttachConfig initialization
|
||||
func BenchmarkWindowAttachConfigInit(b *testing.B) {
|
||||
b.Run("DefaultConfig", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
config := WindowAttachConfig{
|
||||
Window: nil,
|
||||
Offset: 0,
|
||||
Debounce: 200 * time.Millisecond,
|
||||
}
|
||||
_ = config
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("FullConfig", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
config := WindowAttachConfig{
|
||||
Window: nil,
|
||||
Offset: 10,
|
||||
Debounce: 300 * time.Millisecond,
|
||||
justClosed: false,
|
||||
hasBeenShown: true,
|
||||
}
|
||||
_ = config
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkSystemTrayShowHide measures show/hide state changes
|
||||
// Note: These operations are no-ops when impl is nil, but we measure the check overhead
|
||||
func BenchmarkSystemTrayShowHide(b *testing.B) {
|
||||
b.Run("Show", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.Show()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Hide", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.Hide()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("ToggleShowHide", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.Show()
|
||||
tray.Hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkIconPositionConstants measures icon position constant access
|
||||
func BenchmarkIconPositionConstants(b *testing.B) {
|
||||
positions := []IconPosition{
|
||||
NSImageNone,
|
||||
NSImageOnly,
|
||||
NSImageLeft,
|
||||
NSImageRight,
|
||||
NSImageBelow,
|
||||
NSImageAbove,
|
||||
NSImageOverlaps,
|
||||
NSImageLeading,
|
||||
NSImageTrailing,
|
||||
}
|
||||
|
||||
b.Run("SetAllPositions", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
for _, pos := range positions {
|
||||
tray.SetIconPosition(pos)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkLabelOperations measures label getter/setter performance
|
||||
func BenchmarkLabelOperations(b *testing.B) {
|
||||
b.Run("SetLabel", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.SetLabel("System Tray Label")
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("GetLabel", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
tray.SetLabel("System Tray Label")
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_ = tray.Label()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("SetGetLabel", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
for b.Loop() {
|
||||
tray.SetLabel("Label")
|
||||
_ = tray.Label()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkDefaultClickHandler measures the default click handler logic
|
||||
func BenchmarkDefaultClickHandler(b *testing.B) {
|
||||
b.Run("NoAttachedWindow", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
// With no menu and no attached window, defaultClickHandler returns early
|
||||
for b.Loop() {
|
||||
tray.defaultClickHandler()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("WithNilWindow", func(b *testing.B) {
|
||||
tray := newSystemTray(1)
|
||||
tray.attachedWindow.Window = nil
|
||||
for b.Loop() {
|
||||
tray.defaultClickHandler()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,17 +1,31 @@
|
|||
package application
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/errs"
|
||||
)
|
||||
|
||||
// bufferPool reduces allocations for reading request bodies.
|
||||
// Buffers larger than maxPooledBufferSize are not returned to the pool
|
||||
// to prevent memory bloat from occasional large requests (e.g., images).
|
||||
const maxPooledBufferSize = 512 * 1024 // 512KB
|
||||
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() any {
|
||||
return bytes.NewBuffer(make([]byte, 0, 4096))
|
||||
},
|
||||
}
|
||||
|
||||
type HTTPTransport struct {
|
||||
messageProcessor *MessageProcessor
|
||||
logger *slog.Logger
|
||||
|
|
@ -75,14 +89,24 @@ func (t *HTTPTransport) Handler() func(next http.Handler) http.Handler {
|
|||
}
|
||||
|
||||
func (t *HTTPTransport) handleRuntimeRequest(rw http.ResponseWriter, r *http.Request) {
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
// Use pooled buffer to reduce allocations
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer func() {
|
||||
// Don't return large buffers to pool to prevent memory bloat
|
||||
if buf.Cap() <= maxPooledBufferSize {
|
||||
bufferPool.Put(buf)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err := io.Copy(buf, r.Body)
|
||||
if err != nil {
|
||||
t.httpError(rw, errs.WrapInvalidRuntimeCallErrorf(err, "Unable to read request body"))
|
||||
return
|
||||
}
|
||||
|
||||
var body request
|
||||
err = json.Unmarshal(bodyBytes, &body)
|
||||
err = json.Unmarshal(buf.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.httpError(rw, errs.WrapInvalidRuntimeCallErrorf(err, "Unable to parse request body as JSON"))
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"slices"
|
||||
|
|
@ -10,9 +9,9 @@ import (
|
|||
"sync/atomic"
|
||||
"unsafe"
|
||||
|
||||
"github.com/leaanthony/u"
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/leaanthony/u"
|
||||
"github.com/wailsapp/wails/v3/internal/assetserver"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
|
@ -772,7 +771,9 @@ func (w *WebviewWindow) OnWindowEvent(
|
|||
return func() {
|
||||
// Check if eventListener is already locked
|
||||
w.eventListenersLock.Lock()
|
||||
w.eventListeners[eventID] = lo.Without(w.eventListeners[eventID], windowEventListener)
|
||||
w.eventListeners[eventID] = slices.DeleteFunc(w.eventListeners[eventID], func(l *WindowEventListener) bool {
|
||||
return l == windowEventListener
|
||||
})
|
||||
w.eventListenersLock.Unlock()
|
||||
}
|
||||
}
|
||||
|
|
@ -793,7 +794,9 @@ func (w *WebviewWindow) RegisterHook(
|
|||
return func() {
|
||||
w.eventHooksLock.Lock()
|
||||
defer w.eventHooksLock.Unlock()
|
||||
w.eventHooks[eventID] = lo.Without(w.eventHooks[eventID], windowEventHook)
|
||||
w.eventHooks[eventID] = slices.DeleteFunc(w.eventHooks[eventID], func(l *WindowEventListener) bool {
|
||||
return l == windowEventHook
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,7 @@ import (
|
|||
"github.com/wailsapp/wails/v3/internal/assetserver/webview"
|
||||
"github.com/wailsapp/wails/v3/internal/capabilities"
|
||||
"github.com/wailsapp/wails/v3/internal/runtime"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/wailsapp/wails/v3/internal/sliceutil"
|
||||
|
||||
"github.com/wailsapp/go-webview2/pkg/edge"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
|
|
@ -245,8 +244,14 @@ func (w *windowsWebviewWindow) setTitle(title string) {
|
|||
}
|
||||
|
||||
func (w *windowsWebviewWindow) setAlwaysOnTop(alwaysOnTop bool) {
|
||||
var hwndInsertAfter uintptr
|
||||
if alwaysOnTop {
|
||||
hwndInsertAfter = w32.HWND_TOPMOST
|
||||
} else {
|
||||
hwndInsertAfter = w32.HWND_NOTOPMOST
|
||||
}
|
||||
w32.SetWindowPos(w.hwnd,
|
||||
lo.Ternary(alwaysOnTop, w32.HWND_TOPMOST, w32.HWND_NOTOPMOST),
|
||||
hwndInsertAfter,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
|
|
@ -1186,14 +1191,22 @@ func (w *windowsWebviewWindow) openContextMenu(menu *Menu, _ *ContextMenuData) {
|
|||
func (w *windowsWebviewWindow) setStyle(b bool, style int) {
|
||||
currentStyle := int(w32.GetWindowLongPtr(w.hwnd, w32.GWL_STYLE))
|
||||
if currentStyle != 0 {
|
||||
currentStyle = lo.Ternary(b, currentStyle|style, currentStyle&^style)
|
||||
if b {
|
||||
currentStyle = currentStyle | style
|
||||
} else {
|
||||
currentStyle = currentStyle &^ style
|
||||
}
|
||||
w32.SetWindowLongPtr(w.hwnd, w32.GWL_STYLE, uintptr(currentStyle))
|
||||
}
|
||||
}
|
||||
func (w *windowsWebviewWindow) setExStyle(b bool, style int) {
|
||||
currentStyle := int(w32.GetWindowLongPtr(w.hwnd, w32.GWL_EXSTYLE))
|
||||
if currentStyle != 0 {
|
||||
currentStyle = lo.Ternary(b, currentStyle|style, currentStyle&^style)
|
||||
if b {
|
||||
currentStyle = currentStyle | style
|
||||
} else {
|
||||
currentStyle = currentStyle &^ style
|
||||
}
|
||||
w32.SetWindowLongPtr(w.hwnd, w32.GWL_EXSTYLE, uintptr(currentStyle))
|
||||
}
|
||||
}
|
||||
|
|
@ -1914,13 +1927,13 @@ func (w *windowsWebviewWindow) setupChromium() {
|
|||
opts.DisabledFeatures = append(opts.DisabledFeatures, "msSmartScreenProtection")
|
||||
|
||||
if len(opts.DisabledFeatures) > 0 {
|
||||
opts.DisabledFeatures = lo.Uniq(opts.DisabledFeatures)
|
||||
opts.DisabledFeatures = sliceutil.Unique(opts.DisabledFeatures)
|
||||
arg := fmt.Sprintf("--disable-features=%s", strings.Join(opts.DisabledFeatures, ","))
|
||||
chromium.AdditionalBrowserArgs = append(chromium.AdditionalBrowserArgs, arg)
|
||||
}
|
||||
|
||||
if len(opts.EnabledFeatures) > 0 {
|
||||
opts.EnabledFeatures = lo.Uniq(opts.EnabledFeatures)
|
||||
opts.EnabledFeatures = sliceutil.Unique(opts.EnabledFeatures)
|
||||
arg := fmt.Sprintf("--enable-features=%s", strings.Join(opts.EnabledFeatures, ","))
|
||||
chromium.AdditionalBrowserArgs = append(chromium.AdditionalBrowserArgs, arg)
|
||||
}
|
||||
|
|
|
|||
442
v3/pkg/application/window_bench_test.go
Normal file
442
v3/pkg/application/window_bench_test.go
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
//go:build bench
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
||||
// Note: This file uses internal package access to benchmark window internals
|
||||
// without requiring GUI initialization.
|
||||
|
||||
// BenchmarkWindowEventRegistration measures the cost of registering window event listeners
|
||||
func BenchmarkWindowEventRegistration(b *testing.B) {
|
||||
b.Run("SingleListener", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
w := &WebviewWindow{
|
||||
eventListeners: make(map[uint][]*WindowEventListener),
|
||||
}
|
||||
w.OnWindowEvent(events.Common.WindowFocus, func(event *WindowEvent) {})
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("MultipleListenersSameEvent", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
w := &WebviewWindow{
|
||||
eventListeners: make(map[uint][]*WindowEventListener),
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
w.OnWindowEvent(events.Common.WindowFocus, func(event *WindowEvent) {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("MultipleListenersDifferentEvents", func(b *testing.B) {
|
||||
eventTypes := []events.WindowEventType{
|
||||
events.Common.WindowFocus,
|
||||
events.Common.WindowLostFocus,
|
||||
events.Common.WindowShow,
|
||||
events.Common.WindowHide,
|
||||
events.Common.WindowDidMove,
|
||||
}
|
||||
for b.Loop() {
|
||||
w := &WebviewWindow{
|
||||
eventListeners: make(map[uint][]*WindowEventListener),
|
||||
}
|
||||
for _, evt := range eventTypes {
|
||||
w.OnWindowEvent(evt, func(event *WindowEvent) {})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkWindowHookRegistration measures the cost of registering window event hooks
|
||||
func BenchmarkWindowHookRegistration(b *testing.B) {
|
||||
b.Run("SingleHook", func(b *testing.B) {
|
||||
eventID := uint(events.Common.WindowClosing)
|
||||
for b.Loop() {
|
||||
w := &WebviewWindow{
|
||||
eventHooks: make(map[uint][]*WindowEventListener),
|
||||
}
|
||||
w.eventHooksLock.Lock()
|
||||
w.eventHooks[eventID] = append(w.eventHooks[eventID], &WindowEventListener{
|
||||
callback: func(event *WindowEvent) {},
|
||||
})
|
||||
w.eventHooksLock.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("MultipleHooks", func(b *testing.B) {
|
||||
eventID := uint(events.Common.WindowClosing)
|
||||
for b.Loop() {
|
||||
w := &WebviewWindow{
|
||||
eventHooks: make(map[uint][]*WindowEventListener),
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
w.eventHooksLock.Lock()
|
||||
w.eventHooks[eventID] = append(w.eventHooks[eventID], &WindowEventListener{
|
||||
callback: func(event *WindowEvent) {},
|
||||
})
|
||||
w.eventHooksLock.Unlock()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkWindowEventDispatch measures the internal event dispatch mechanism
|
||||
func BenchmarkWindowEventDispatch(b *testing.B) {
|
||||
listenerCounts := []int{0, 1, 5, 10, 50}
|
||||
|
||||
for _, count := range listenerCounts {
|
||||
b.Run(fmt.Sprintf("Listeners%d", count), func(b *testing.B) {
|
||||
w := &WebviewWindow{
|
||||
eventListeners: make(map[uint][]*WindowEventListener),
|
||||
}
|
||||
|
||||
eventID := uint(events.Common.WindowFocus)
|
||||
|
||||
// Register listeners
|
||||
for i := 0; i < count; i++ {
|
||||
w.eventListeners[eventID] = append(w.eventListeners[eventID], &WindowEventListener{
|
||||
callback: func(event *WindowEvent) {
|
||||
_ = event.IsCancelled()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
w.eventListenersLock.RLock()
|
||||
listeners := w.eventListeners[eventID]
|
||||
w.eventListenersLock.RUnlock()
|
||||
_ = listeners
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkKeyBindingLookup measures key binding lookup performance
|
||||
func BenchmarkKeyBindingLookup(b *testing.B) {
|
||||
bindingCounts := []int{1, 10, 50, 100}
|
||||
|
||||
for _, count := range bindingCounts {
|
||||
b.Run(fmt.Sprintf("Bindings%d", count), func(b *testing.B) {
|
||||
w := &WebviewWindow{
|
||||
keyBindings: make(map[string]func(Window)),
|
||||
}
|
||||
|
||||
// Register bindings
|
||||
for i := 0; i < count; i++ {
|
||||
key := fmt.Sprintf("ctrl+shift+%c", 'a'+i%26)
|
||||
w.keyBindings[key] = func(Window) {}
|
||||
}
|
||||
|
||||
// Lookup key that exists
|
||||
lookupKey := "ctrl+shift+m"
|
||||
w.keyBindings[lookupKey] = func(Window) {}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
w.keyBindingsLock.RLock()
|
||||
_ = w.keyBindings[lookupKey]
|
||||
w.keyBindingsLock.RUnlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
b.Run("MissLookup", func(b *testing.B) {
|
||||
w := &WebviewWindow{
|
||||
keyBindings: make(map[string]func(Window)),
|
||||
}
|
||||
|
||||
// Register some bindings
|
||||
for i := 0; i < 50; i++ {
|
||||
key := fmt.Sprintf("ctrl+shift+%c", 'a'+i%26)
|
||||
w.keyBindings[key] = func(Window) {}
|
||||
}
|
||||
|
||||
lookupKey := "ctrl+alt+nonexistent"
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
w.keyBindingsLock.RLock()
|
||||
_ = w.keyBindings[lookupKey]
|
||||
w.keyBindingsLock.RUnlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentWindowOps measures concurrent access patterns
|
||||
func BenchmarkConcurrentWindowOps(b *testing.B) {
|
||||
b.Run("ConcurrentEventLookup", func(b *testing.B) {
|
||||
w := &WebviewWindow{
|
||||
eventListeners: make(map[uint][]*WindowEventListener),
|
||||
}
|
||||
|
||||
eventID := uint(events.Common.WindowFocus)
|
||||
for i := 0; i < 10; i++ {
|
||||
w.eventListeners[eventID] = append(w.eventListeners[eventID], &WindowEventListener{
|
||||
callback: func(event *WindowEvent) {},
|
||||
})
|
||||
}
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
w.eventListenersLock.RLock()
|
||||
_ = w.eventListeners[eventID]
|
||||
w.eventListenersLock.RUnlock()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
b.Run("ConcurrentKeyBindingLookup", func(b *testing.B) {
|
||||
w := &WebviewWindow{
|
||||
keyBindings: make(map[string]func(Window)),
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
key := fmt.Sprintf("ctrl+shift+%c", 'a'+i%26)
|
||||
w.keyBindings[key] = func(Window) {}
|
||||
}
|
||||
|
||||
keys := []string{"ctrl+shift+a", "ctrl+shift+m", "ctrl+shift+z"}
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
w.keyBindingsLock.RLock()
|
||||
_ = w.keyBindings[keys[i%len(keys)]]
|
||||
w.keyBindingsLock.RUnlock()
|
||||
i++
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
b.Run("MixedReadWrite", func(b *testing.B) {
|
||||
w := &WebviewWindow{
|
||||
eventListeners: make(map[uint][]*WindowEventListener),
|
||||
}
|
||||
|
||||
eventID := uint(events.Common.WindowFocus)
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
if i%10 == 0 {
|
||||
// Write operation (10% of ops)
|
||||
w.eventListenersLock.Lock()
|
||||
w.eventListeners[eventID] = append(w.eventListeners[eventID], &WindowEventListener{
|
||||
callback: func(event *WindowEvent) {},
|
||||
})
|
||||
w.eventListenersLock.Unlock()
|
||||
} else {
|
||||
// Read operation (90% of ops)
|
||||
w.eventListenersLock.RLock()
|
||||
_ = w.eventListeners[eventID]
|
||||
w.eventListenersLock.RUnlock()
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkWindowEventCreation measures WindowEvent allocation
|
||||
func BenchmarkWindowEventCreation(b *testing.B) {
|
||||
for b.Loop() {
|
||||
event := NewWindowEvent()
|
||||
_ = event
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWindowEventCancellation measures cancel/check operations
|
||||
func BenchmarkWindowEventCancellation(b *testing.B) {
|
||||
b.Run("Cancel", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
event := NewWindowEvent()
|
||||
event.Cancel()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("IsCancelled", func(b *testing.B) {
|
||||
event := NewWindowEvent()
|
||||
for b.Loop() {
|
||||
_ = event.IsCancelled()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("CancelledCheck", func(b *testing.B) {
|
||||
event := NewWindowEvent()
|
||||
event.Cancel()
|
||||
for b.Loop() {
|
||||
_ = event.IsCancelled()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkWindowOptionsInit measures window options initialization patterns
|
||||
func BenchmarkWindowOptionsInit(b *testing.B) {
|
||||
b.Run("DefaultOptions", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
opts := WebviewWindowOptions{}
|
||||
_ = opts
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("CommonOptions", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
opts := WebviewWindowOptions{
|
||||
Title: "Test Window",
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
MinWidth: 400,
|
||||
MinHeight: 300,
|
||||
}
|
||||
_ = opts
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("FullOptions", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
opts := WebviewWindowOptions{
|
||||
Title: "Full Test Window",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
MinWidth: 400,
|
||||
MinHeight: 300,
|
||||
MaxWidth: 1920,
|
||||
MaxHeight: 1080,
|
||||
URL: "http://localhost:8080",
|
||||
Frameless: false,
|
||||
DisableResize: false,
|
||||
AlwaysOnTop: false,
|
||||
Hidden: false,
|
||||
EnableDragAndDrop: true,
|
||||
BackgroundColour: RGBA{Red: 255, Green: 255, Blue: 255, Alpha: 255},
|
||||
}
|
||||
_ = opts
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkMenuBindingLookup measures menu binding lookups
|
||||
func BenchmarkMenuBindingLookup(b *testing.B) {
|
||||
w := &WebviewWindow{
|
||||
menuBindings: make(map[string]*MenuItem),
|
||||
}
|
||||
|
||||
// Register some menu bindings
|
||||
for i := 0; i < 50; i++ {
|
||||
id := fmt.Sprintf("menu-item-%d", i)
|
||||
w.menuBindings[id] = &MenuItem{id: uint(i)}
|
||||
}
|
||||
|
||||
lookupID := "menu-item-25"
|
||||
|
||||
b.Run("Hit", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
w.menuBindingsLock.RLock()
|
||||
_ = w.menuBindings[lookupID]
|
||||
w.menuBindingsLock.RUnlock()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Miss", func(b *testing.B) {
|
||||
missID := "nonexistent-menu-item"
|
||||
for b.Loop() {
|
||||
w.menuBindingsLock.RLock()
|
||||
_ = w.menuBindings[missID]
|
||||
w.menuBindingsLock.RUnlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkWindowDestroyedCheck measures the destroyed flag check pattern
|
||||
func BenchmarkWindowDestroyedCheck(b *testing.B) {
|
||||
w := &WebviewWindow{}
|
||||
|
||||
b.Run("NotDestroyed", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
w.destroyedLock.RLock()
|
||||
_ = w.destroyed
|
||||
w.destroyedLock.RUnlock()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Destroyed", func(b *testing.B) {
|
||||
w.destroyed = true
|
||||
for b.Loop() {
|
||||
w.destroyedLock.RLock()
|
||||
_ = w.destroyed
|
||||
w.destroyedLock.RUnlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkCancellerManagement measures canceller function management
|
||||
func BenchmarkCancellerManagement(b *testing.B) {
|
||||
b.Run("AddCanceller", func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
w := &WebviewWindow{
|
||||
cancellers: make([]func(), 0),
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
w.cancellersLock.Lock()
|
||||
w.cancellers = append(w.cancellers, func() {})
|
||||
w.cancellersLock.Unlock()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("ExecuteCancellers", func(b *testing.B) {
|
||||
w := &WebviewWindow{
|
||||
cancellers: make([]func(), 100),
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
w.cancellers[i] = func() {}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
w.cancellersLock.RLock()
|
||||
cancellers := w.cancellers
|
||||
w.cancellersLock.RUnlock()
|
||||
for _, cancel := range cancellers {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkRWMutexPatterns compares different locking patterns
|
||||
func BenchmarkRWMutexPatterns(b *testing.B) {
|
||||
b.Run("RLockUnlock", func(b *testing.B) {
|
||||
var mu sync.RWMutex
|
||||
for b.Loop() {
|
||||
mu.RLock()
|
||||
mu.RUnlock()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("LockUnlock", func(b *testing.B) {
|
||||
var mu sync.RWMutex
|
||||
for b.Loop() {
|
||||
mu.Lock()
|
||||
mu.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("DeferredRLock", func(b *testing.B) {
|
||||
var mu sync.RWMutex
|
||||
for b.Loop() {
|
||||
func() {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -2,10 +2,10 @@ package kvstore
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@ package notifications
|
|||
import "C"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ package notifications
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ package notifications
|
|||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
_ "unsafe"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
|
||||
"git.sr.ht/~jackmordaunt/go-toast/v2"
|
||||
wintoast "git.sr.ht/~jackmordaunt/go-toast/v2/wintoast"
|
||||
"github.com/google/uuid"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ package w32
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/samber/lo"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -180,17 +179,33 @@ func MustStringToUTF16Ptr(input string) *uint16 {
|
|||
return result
|
||||
}
|
||||
|
||||
// MustStringToUTF16uintptr converts input to a NUL-terminated UTF-16 buffer and returns its pointer as a uintptr.
|
||||
// It first removes any internal NUL characters from input, then converts the result to a UTF-16 pointer.
|
||||
// The function panics if the conversion fails.
|
||||
func MustStringToUTF16uintptr(input string) uintptr {
|
||||
input = stripNulls(input)
|
||||
ret := lo.Must(syscall.UTF16PtrFromString(input))
|
||||
ret, err := syscall.UTF16PtrFromString(input)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return uintptr(unsafe.Pointer(ret))
|
||||
}
|
||||
|
||||
// MustStringToUTF16 converts s to UTF-16 encoding, stripping any embedded NULs and panicking on error.
|
||||
//
|
||||
// The returned slice is suitable for Windows API calls that expect a UTF-16 encoded string.
|
||||
func MustStringToUTF16(input string) []uint16 {
|
||||
input = stripNulls(input)
|
||||
return lo.Must(syscall.UTF16FromString(input))
|
||||
ret, err := syscall.UTF16FromString(input)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// StringToUTF16 converts input to a UTF-16 encoded, NUL-terminated []uint16 suitable for Windows API calls.
|
||||
// It first removes any embedded NUL ('\x00') characters from input. The returned slice is NUL-terminated;
|
||||
// an error is returned if the conversion fails.
|
||||
func StringToUTF16(input string) ([]uint16, error) {
|
||||
input = stripNulls(input)
|
||||
return syscall.UTF16FromString(input)
|
||||
|
|
@ -359,4 +374,4 @@ func SendMessageToWindow(hwnd HWND, msg string) {
|
|||
func GetMenu(hwnd HWND) HMENU {
|
||||
ret, _, _ := getMenuProc.Call(hwnd)
|
||||
return ret
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue