diff --git a/docs/src/content/docs/reference/application.mdx b/docs/src/content/docs/reference/application.mdx index a590cd17a65..8b467af108b 100644 --- a/docs/src/content/docs/reference/application.mdx +++ b/docs/src/content/docs/reference/application.mdx @@ -463,12 +463,59 @@ app := application.New(application.Options{ app := application.New(application.Options{ Name: "My App", Linux: application.LinuxOptions{ + ApplicationID: "com.myapp.myapplication", ProgramName: "my-app", DisableQuitOnLastWindowClosed: false, }, }) ``` +- `ApplicationID` - The GTK application id. Defaults to `org.wails.` followed by a sanitised `Name`. +- `ProgramName` - Sets the program name for the window manager via `g_set_prgname()`. Defaults to `ApplicationID` when that is set. +- `DisableQuitOnLastWindowClosed` - Keeps the application running after its last window closes. + +Set `ApplicationID` to match the identity your packaging declares. It should be +the same reverse-DNS id you use for the `.desktop` file, so the desktop +environment can associate the application's windows with its launcher. + +The id has to satisfy [`g_application_id_is_valid()`](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html): +two or more non-empty elements separated by a `.`, each holding only the ASCII +characters `A-Z`, `a-z`, `0-9`, `_` and `-`, none of them starting with a digit, +and at most 255 characters in total. So `com.example.MyApp` is fine, while +`MyApp`, `com.example.2ndApp` and `com.example.My App` are not. An id that GTK +would reject is reported through the application's error handler and replaced +with the derived default, because GTK only asserts on the id and would otherwise +abort the process during startup. + +On Wayland, GTK takes the surface `app_id` from the program name rather than the +application id, so `ProgramName` defaults to `ApplicationID` when you set one. +Setting the same string twice is not needed, and setting `ProgramName` +explicitly still wins: + +```go +app := application.New(application.Options{ + Name: "My App", + Linux: application.LinuxOptions{ + ApplicationID: "com.example.MyApp", // also becomes the program name + }, +}) +``` + +:::note +Sandboxed builds must set this. A flatpak may only own D-Bus names prefixed with +its own app id, and WebKit asks the portal to own +`.Sandboxed.WebProcess-` for the accessibility bus. With +the default id that request is refused and the web process aborts, taking the +application down during startup: + +``` +Portal call failed: Invalid sandbox a11y own name: + 'org.wails.myapp.Sandboxed.WebProcess-' doesn't match app id +``` + +Setting `ApplicationID` to the `app-id` in your flatpak manifest resolves it. +::: + ## Complete Application Example ```go diff --git a/v3/pkg/application/application_linux.go b/v3/pkg/application/application_linux.go index 6ecd09c03f4..62cd5a47f2e 100644 --- a/v3/pkg/application/application_linux.go +++ b/v3/pkg/application/application_linux.go @@ -17,7 +17,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "slices" "strings" "sync" @@ -27,22 +26,6 @@ import ( "github.com/wailsapp/wails/v3/pkg/events" ) -var invalidAppNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`) -var leadingDigits = regexp.MustCompile(`^[0-9]+`) - -func sanitizeAppName(name string) string { - name = invalidAppNameChars.ReplaceAllString(name, "_") - name = leadingDigits.ReplaceAllString(name, "_$0") - for strings.Contains(name, "__") { - name = strings.ReplaceAll(name, "__", "_") - } - name = strings.Trim(name, "_") - if name == "" { - name = "wailsapp" - } - return strings.ToLower(name) -} - func init() { // Disable DMA-BUF renderer on any session type with NVIDIA to prevent blank windows and // "Error 71 (Protocol error)" crashes. NVIDIA proprietary drivers fail gbm_bo_map() when @@ -232,16 +215,20 @@ func (a *linuxApp) unregisterWindow(window windowPointer) { } func newPlatformApp(parent *App) *linuxApp { - name := sanitizeAppName(parent.options.Name) + appID, err := applicationID(parent.options) + if err != nil { + parent.error("invalid Linux.ApplicationID: %w; falling back to %q", err, appID) + } + app := &linuxApp{ parent: parent, - application: appNew(name), + application: appNew(appID), activated: make(chan struct{}), windowMap: map[windowPointer]uint{}, } - if parent.options.Linux.ProgramName != "" { - setProgramName(parent.options.Linux.ProgramName) + if name := programName(parent.options, appID); name != "" { + setProgramName(name) } return app diff --git a/v3/pkg/application/application_linux_appid.go b/v3/pkg/application/application_linux_appid.go new file mode 100644 index 00000000000..4945a90777b --- /dev/null +++ b/v3/pkg/application/application_linux_appid.go @@ -0,0 +1,136 @@ +//go:build linux && cgo && !android && !server + +package application + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +var invalidAppNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`) +var leadingDigits = regexp.MustCompile(`^[0-9]+`) + +// sanitizeAppName sanitizes the application name into a single element of a +// GTK/D-Bus application id: only alphanumeric characters, hyphens and +// underscores, and never a leading digit. +func sanitizeAppName(name string) string { + // Replace invalid characters with underscores + name = invalidAppNameChars.ReplaceAllString(name, "_") + // Remove consecutive underscores + for strings.Contains(name, "__") { + name = strings.ReplaceAll(name, "__", "_") + } + // Trim leading/trailing underscores + name = strings.Trim(name, "_") + if name == "" { + name = "wailsapp" + } + // Prefix with underscore if starts with digit. This has to happen after the + // trim, which would otherwise strip the prefix again and leave an element + // GTK refuses, e.g. "1Password" -> "org.wails.1password". + name = leadingDigits.ReplaceAllString(name, "_$0") + return strings.ToLower(name) +} + +// maxApplicationIDLength is the longest id GTK accepts, inherited from the +// D-Bus bus name limit. +const maxApplicationIDLength = 255 + +// validateApplicationID returns an error describing why GTK would refuse id, +// following the same contract as g_application_id_is_valid(): +// +// - the id is composed of two or more elements separated by a '.', and every +// element holds at least one character; +// - every element contains only the ASCII characters A-Z, a-z, 0-9, '_' and +// '-', and does not begin with a digit; +// - the id is at most 255 characters long. +// +// GTK only asserts on this, so an invalid id makes gtk_application_new() return +// NULL and takes the process down later, far away from the option that caused it. +// +// See: https://docs.gtk.org/gio/type_func.Application.id_is_valid.html +func validateApplicationID(id string) error { + if id == "" { + return errors.New("application id is empty") + } + if len(id) > maxApplicationIDLength { + return fmt.Errorf("application id %q is %d characters long, the maximum is %d", id, len(id), maxApplicationIDLength) + } + + elements := strings.Split(id, ".") + if len(elements) < 2 { + return fmt.Errorf("application id %q needs at least two elements separated by a '.', for example \"com.example.MyApp\"", id) + } + + for _, element := range elements { + if element == "" { + return fmt.Errorf("application id %q has an empty element: it must not start or end with a '.', or contain \"..\"", id) + } + if element[0] >= '0' && element[0] <= '9' { + return fmt.Errorf("application id %q has the element %q starting with a digit", id, element) + } + for i := 0; i < len(element); i++ { + if !isApplicationIDChar(element[i]) { + return fmt.Errorf("application id %q contains the invalid character %q: only A-Z, a-z, 0-9, '_' and '-' are allowed", id, rune(element[i])) + } + } + } + + return nil +} + +func isApplicationIDChar(c byte) bool { + return c >= 'A' && c <= 'Z' || + c >= 'a' && c <= 'z' || + c >= '0' && c <= '9' || + c == '_' || c == '-' +} + +// applicationID returns the id to build the GtkApplication with. Options.Linux +// wins when it sets one, so sandboxed builds can match the id their runtime +// expects; everything else keeps the derived "org.wails.". +// +// An id GTK would reject is reported as an error together with the derived id, +// so callers can carry on with an id that works instead of crashing inside GTK. +func applicationID(options Options) (string, error) { + derived := "org.wails." + sanitizeAppName(options.Name) + if len(derived) > maxApplicationIDLength { + // sanitizeAppName never emits a '.', so cutting the tail can only leave + // characters that are legal in the middle of an element. + derived = derived[:maxApplicationIDLength] + } + + id := options.Linux.ApplicationID + if id == "" { + return derived, nil + } + if err := validateApplicationID(id); err != nil { + return derived, err + } + return id, nil +} + +// programName returns the name to hand to g_set_prgname, or "" to leave the +// program name at whatever GTK picked up from the executable. +// +// GTK takes the Wayland surface app_id from g_get_prgname(), so a window is only +// matched with its .desktop file when the program name carries the application +// id as well. An application that sets Options.Linux.ApplicationID inherits it +// here rather than having to repeat the same string in ProgramName. +// +// What is inherited is appID, the id the GtkApplication was built with, so the +// program name cannot disagree with it: an ApplicationID that validation +// rejected falls back to the derived id in both places. Without an +// ApplicationID nothing is derived, keeping the program name of applications +// that set neither option as it was. +func programName(options Options, appID string) string { + if options.Linux.ProgramName != "" { + return options.Linux.ProgramName + } + if options.Linux.ApplicationID != "" { + return appID + } + return "" +} diff --git a/v3/pkg/application/application_linux_appid_test.go b/v3/pkg/application/application_linux_appid_test.go new file mode 100644 index 00000000000..72cd977e91e --- /dev/null +++ b/v3/pkg/application/application_linux_appid_test.go @@ -0,0 +1,228 @@ +//go:build linux && cgo && !android && !server + +package application + +import ( + "strings" + "testing" +) + +// Both backends derive the id the same way, so this covers the GTK4 and GTK3 +// builds alike. +func TestApplicationID(t *testing.T) { + tests := []struct { + name string + options Options + want string + }{ + { + name: "derived from Name when unset", + options: Options{Name: "My App"}, + want: "org.wails.my_app", + }, + { + name: "hyphens are kept when derived", + options: Options{Name: "koofr-stage"}, + want: "org.wails.koofr-stage", + }, + { + name: "empty Name falls back to wailsapp", + options: Options{}, + want: "org.wails.wailsapp", + }, + { + name: "used verbatim when set", + options: Options{ + Name: "My App", + Linux: LinuxOptions{ApplicationID: "com.myapp.myapplication"}, + }, + want: "com.myapp.myapplication", + }, + { + // Sandboxed builds depend on this: the id has to be exactly the one + // the packaging declares, with nothing derived from Name mixed in. + name: "set id wins over Name entirely", + options: Options{ + Name: "something else", + Linux: LinuxOptions{ApplicationID: "com.example.WailsFlatpakAppId"}, + }, + want: "com.example.WailsFlatpakAppId", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := applicationID(tt.options) + if err != nil { + t.Fatalf("applicationID() returned an unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("applicationID() = %q, want %q", got, tt.want) + } + }) + } +} + +// The option is opt-in, so an application that does not set it has to keep the +// id it had before the option existed. +func TestApplicationIDUnsetIsBackwardCompatible(t *testing.T) { + options := Options{Name: "My App"} + + got, err := applicationID(options) + if err != nil { + t.Fatalf("applicationID() returned an unexpected error: %v", err) + } + if want := "org.wails." + sanitizeAppName(options.Name); got != want { + t.Errorf("applicationID() = %q, want the derived id %q", got, want) + } +} + +// An id GTK would reject has to be reported rather than handed to +// gtk_application_new(), and the application still needs an id to start with. +func TestApplicationIDInvalidFallsBackToDerived(t *testing.T) { + options := Options{ + Name: "My App", + Linux: LinuxOptions{ApplicationID: "yeehaw"}, + } + + got, err := applicationID(options) + if err == nil { + t.Fatal("applicationID() accepted an id without a '.' separator") + } + if want := "org.wails.my_app"; got != want { + t.Errorf("applicationID() = %q, want the derived id %q", got, want) + } +} + +// The derived id is never routed through validateApplicationID at runtime, so +// make sure sanitizeAppName cannot produce one GTK would refuse. +func TestDerivedApplicationIDIsAlwaysValid(t *testing.T) { + names := []string{ + "My App", + "koofr-stage", + "", + "1Password", + "...", + "app.with.dots", + "ünïcodé", + "__leading__and__trailing__", + "9", + "-", + strings.Repeat("long", 200), + } + + for _, name := range names { + t.Run(name, func(t *testing.T) { + id, err := applicationID(Options{Name: name}) + if err != nil { + t.Fatalf("applicationID() returned an unexpected error: %v", err) + } + if err := validateApplicationID(id); err != nil { + t.Errorf("derived id %q is not a valid GTK application id: %v", id, err) + } + }) + } +} + +// On Wayland the surface app_id comes from g_get_prgname(), so setting only +// ApplicationID has to be enough to have windows match their .desktop file. +func TestProgramName(t *testing.T) { + tests := []struct { + name string + options Options + want string + }{ + { + name: "left alone when neither option is set", + options: Options{Name: "My App"}, + want: "", + }, + { + name: "inherits the application id", + options: Options{ + Name: "My App", + Linux: LinuxOptions{ApplicationID: "com.example.MyApp"}, + }, + want: "com.example.MyApp", + }, + { + name: "an explicit program name wins", + options: Options{ + Name: "My App", + Linux: LinuxOptions{ + ApplicationID: "com.example.MyApp", + ProgramName: "myapp", + }, + }, + want: "myapp", + }, + { + name: "kept without an application id", + options: Options{ + Name: "My App", + Linux: LinuxOptions{ProgramName: "myapp"}, + }, + want: "myapp", + }, + { + // The id GTK was given, not the one it would have rejected. + name: "inherits the fallback when the id is invalid", + options: Options{ + Name: "My App", + Linux: LinuxOptions{ApplicationID: "yeehaw"}, + }, + want: "org.wails.my_app", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + appID, _ := applicationID(tt.options) + if got := programName(tt.options, appID); got != tt.want { + t.Errorf("programName() = %q, want %q", got, tt.want) + } + }) + } +} + +// Mirrors the contract of g_application_id_is_valid(). +// See: https://docs.gtk.org/gio/type_func.Application.id_is_valid.html +func TestValidateApplicationID(t *testing.T) { + tests := []struct { + name string + id string + valid bool + }{ + {name: "reverse dns", id: "com.example.MyApp", valid: true}, + {name: "two elements", id: "com.example", valid: true}, + {name: "underscores", id: "com.example.my_app", valid: true}, + {name: "hyphens are discouraged but legal", id: "org.wails.koofr-stage", valid: true}, + {name: "digits inside an element", id: "com.example.App2", valid: true}, + {name: "255 characters", id: "com." + strings.Repeat("a", 251), valid: true}, + + {name: "empty", id: "", valid: false}, + {name: "single element", id: "yeehaw", valid: false}, + {name: "leading dot", id: ".com.example", valid: false}, + {name: "trailing dot", id: "com.example.", valid: false}, + {name: "consecutive dots", id: "com..example", valid: false}, + {name: "element starting with a digit", id: "com.example.2ndApp", valid: false}, + {name: "first element starting with a digit", id: "2com.example", valid: false}, + {name: "slash", id: "com.example/MyApp", valid: false}, + {name: "space", id: "com.example.My App", valid: false}, + {name: "colon prefixed unique name", id: ":1.42", valid: false}, + {name: "non ascii", id: "com.example.Mü", valid: false}, + {name: "256 characters", id: "com." + strings.Repeat("a", 252), valid: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateApplicationID(tt.id) + if tt.valid && err != nil { + t.Errorf("validateApplicationID(%q) = %v, want no error", tt.id, err) + } + if !tt.valid && err == nil { + t.Errorf("validateApplicationID(%q) = nil, want an error", tt.id) + } + }) + } +} diff --git a/v3/pkg/application/application_linux_gtk3.go b/v3/pkg/application/application_linux_gtk3.go index e61fd53261f..02ca5dceb24 100644 --- a/v3/pkg/application/application_linux_gtk3.go +++ b/v3/pkg/application/application_linux_gtk3.go @@ -16,7 +16,6 @@ import "C" import ( "fmt" "os" - "regexp" "slices" "strings" "sync" @@ -27,29 +26,6 @@ import ( "github.com/wailsapp/wails/v3/pkg/events" ) -// sanitizeAppName sanitizes the application name to be a valid GTK/D-Bus application ID. -// Valid IDs contain only alphanumeric characters, hyphens, and underscores. -// They must not start with a digit. -var invalidAppNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`) -var leadingDigits = regexp.MustCompile(`^[0-9]+`) - -func sanitizeAppName(name string) string { - // Replace invalid characters with underscores - name = invalidAppNameChars.ReplaceAllString(name, "_") - // Prefix with underscore if starts with digit - name = leadingDigits.ReplaceAllString(name, "_$0") - // Remove consecutive underscores - for strings.Contains(name, "__") { - name = strings.ReplaceAll(name, "__", "_") - } - // Trim leading/trailing underscores - name = strings.Trim(name, "_") - if name == "" { - name = "wailsapp" - } - return strings.ToLower(name) -} - func init() { // FIXME: This should be handled appropriately in the individual files most likely. // Set GDK_BACKEND=x11 if currently unset and XDG_SESSION_TYPE is unset, unspecified or x11 to prevent warnings @@ -227,15 +203,19 @@ func (a *linuxApp) getAccentColor() string { } func newPlatformApp(parent *App) *linuxApp { - name := sanitizeAppName(parent.options.Name) + appID, err := applicationID(parent.options) + if err != nil { + parent.error("invalid Linux.ApplicationID: %w; falling back to %q", err, appID) + } + app := &linuxApp{ parent: parent, - application: appNew(name), + application: appNew(appID), windowMap: map[windowPointer]uint{}, } - if parent.options.Linux.ProgramName != "" { - setProgramName(parent.options.Linux.ProgramName) + if name := programName(parent.options, appID); name != "" { + setProgramName(name) } return app diff --git a/v3/pkg/application/application_options.go b/v3/pkg/application/application_options.go index 560436a36d8..e1cd5810867 100644 --- a/v3/pkg/application/application_options.go +++ b/v3/pkg/application/application_options.go @@ -327,8 +327,32 @@ type LinuxOptions struct { //When a .desktop file is created this value helps with window grouping and desktop icons when the .desktop file's Name //property differs form the executable's filename. // + //Defaults to ApplicationID when that is set, because GTK takes the Wayland + //surface app_id from the program name: leaving this empty there would have + //windows fall back to the executable's name and stop matching the .desktop + //file. Applications that set neither option keep the executable's name. + // //[see the docs]: https://docs.gtk.org/glib/func.set_prgname.html ProgramName string + + // ApplicationID overrides the GTK application id, which otherwise defaults + // to "org.wails." followed by a sanitised Name. + // + // The id has to satisfy g_application_id_is_valid(): two or more non-empty + // elements separated by a '.', each holding only the ASCII characters A-Z, + // a-z, 0-9, '_' and '-', none of them starting with a digit, and at most + // 255 characters in total, e.g. "com.example.MyApp". An id that does not is + // reported through the error handler and replaced with the derived default, + // because GTK only asserts on it and would abort the process instead. + // + // Sandboxed builds have to set this. A flatpak may only own bus names + // prefixed with its app id, and WebKit asks the portal to own + // ".Sandboxed.WebProcess-" for the accessibility bus. + // With the default id that request is refused and the web process aborts, + // taking the application down from inside g_application_run. + // + // See: https://docs.gtk.org/gio/type_func.Application.id_is_valid.html + ApplicationID string } /********* iOS Options *********/ diff --git a/v3/pkg/application/application_options_test.go b/v3/pkg/application/application_options_test.go index 77ddbe522e9..47c48189d06 100644 --- a/v3/pkg/application/application_options_test.go +++ b/v3/pkg/application/application_options_test.go @@ -203,6 +203,9 @@ func TestLinuxOptions_Defaults(t *testing.T) { if opts.ProgramName != "" { t.Error("ProgramName should default to empty string") } + if opts.ApplicationID != "" { + t.Error("ApplicationID should default to empty string") + } } func TestIOSOptions_Defaults(t *testing.T) { diff --git a/v3/pkg/application/linux_cgo.go b/v3/pkg/application/linux_cgo.go index e4751c88262..111443c4de4 100644 --- a/v3/pkg/application/linux_cgo.go +++ b/v3/pkg/application/linux_cgo.go @@ -143,10 +143,9 @@ func appName() string { return C.GoString(name) } -func appNew(name string) pointer { +func appNew(appId string) pointer { C.install_signal_handlers() - appId := fmt.Sprintf("org.wails.%s", name) nameC := C.CString(appId) defer C.free(unsafe.Pointer(nameC)) return pointer(C.gtk_application_new(nameC, C.APPLICATION_DEFAULT_FLAGS)) diff --git a/v3/pkg/application/linux_cgo_gtk3.go b/v3/pkg/application/linux_cgo_gtk3.go index f71f6fe1a72..3a23303370f 100644 --- a/v3/pkg/application/linux_cgo_gtk3.go +++ b/v3/pkg/application/linux_cgo_gtk3.go @@ -619,9 +619,8 @@ func appName() string { return C.GoString(name) } -func appNew(name string) pointer { - // Name is already sanitized by sanitizeAppName() in application_linux.go - appId := fmt.Sprintf("org.wails.%s", name) +func appNew(appId string) pointer { + // Already resolved by applicationID() in application_linux_appid.go. nameC := C.CString(appId) defer C.free(unsafe.Pointer(nameC)) return pointer(C.gtk_application_new(nameC, C.APPLICATION_DEFAULT_FLAGS))