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:
Lea Anthony 2026-01-02 07:03:36 +11:00 committed by GitHub
commit a06d55804c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 4579 additions and 199 deletions

View file

@ -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 -->

View file

@ -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

View file

@ -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=

View 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)
}
})
}

View file

@ -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...)
}
}
}

View file

@ -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()
}
}
}

View file

@ -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
}
}

View 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)
}
})
}
}

View file

@ -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>`

View file

@ -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>`

View file

@ -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{}

View file

@ -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{}

View file

@ -1,6 +1,6 @@
package capabilities
import "encoding/json"
import json "github.com/goccy/go-json"
type Capabilities struct {
HasNativeDrag bool

View 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
}

View 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)
}
}
}

View file

@ -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
}
}

View file

@ -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||{};`

View 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
}

View 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)
}
}

View file

@ -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"
)

View file

@ -3,10 +3,11 @@
package application
import (
"encoding/json"
"fmt"
"sync"
"unsafe"
json "github.com/goccy/go-json"
)
var (

View file

@ -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"
)

View file

@ -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)
}
}

View file

@ -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() != ""
}
}

View 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)
}
})
}
}

View 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)
}
})
}

View file

@ -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()
}
}

View file

@ -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
})
}
}

View 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)
}
})
}

View file

@ -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)) }

View 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)
}
}

View 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)
}
}

View file

@ -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 {

View file

@ -1,8 +1,9 @@
package application
import (
"encoding/json"
"fmt"
json "github.com/goccy/go-json"
)
type Args struct {

View file

@ -2,9 +2,10 @@ package application
import (
"context"
"encoding/json"
"errors"
json "github.com/goccy/go-json"
"github.com/wailsapp/wails/v3/pkg/errs"
)

View file

@ -1,7 +1,7 @@
package application
import (
"encoding/json"
json "github.com/goccy/go-json"
"github.com/wailsapp/wails/v3/pkg/errs"
)

View file

@ -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")

View 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()
}
})
}

View file

@ -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

View file

@ -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
})
}
}

View file

@ -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)
}

View 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()
}()
}
})
}

View file

@ -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"
)

View file

@ -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"
)

View file

@ -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"
)

View file

@ -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"

View file

@ -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
}
}