wails/v3/internal/fileexplorer/desktopfile_test.go
Lea Anthony a06d55804c
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>
2026-01-02 07:03:36 +11:00

532 lines
11 KiB
Go

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