From aae803489190f381842b8a9f521831e4b1398f70 Mon Sep 17 00:00:00 2001 From: Andraz Vrhovec Date: Thu, 13 Aug 2026 10:46:10 +0000 Subject: [PATCH] fix(v3/linux): claim the single instance name under the app's own id linuxLock.acquire mangled UniqueID into an org.wails_app_*.SingleInstance name unrelated to the application id. A flatpak may only own names prefixed with its app id, so a sandboxed app could not claim it without being granted unfiltered --socket=session-bus, which is access to the entire session bus in order to obtain one well-known name. The mangling existed because one string served as bus name, interface name and object path, and D-Bus spells those differently: bus names may contain hyphens, interface names may not, and object paths separate elements with / and allow neither. Derive the three separately so the bus name can keep UniqueID verbatim, as its documented convention (com.myapp.myapplication) already suggests. Also report a refused name instead of swallowing it. RequestName errors were returned bare and unexpected replies were treated as success, so a sandbox refusal could reach the caller as alreadyRunningError -- notifying a first instance that does not exist and exiting silently at startup. --- v3/pkg/application/single_instance_linux.go | 78 +++++++++++++--- .../application/single_instance_linux_test.go | 92 +++++++++++++++++++ 2 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 v3/pkg/application/single_instance_linux_test.go diff --git a/v3/pkg/application/single_instance_linux.go b/v3/pkg/application/single_instance_linux.go index 1d2a55c1891..975b67b2a3d 100644 --- a/v3/pkg/application/single_instance_linux.go +++ b/v3/pkg/application/single_instance_linux.go @@ -4,6 +4,7 @@ package application import ( "errors" + "fmt" "os" "strings" "sync" @@ -22,11 +23,48 @@ func (f dbusHandler) SendMessage(message string) *dbus.Error { } type linuxLock struct { - file *os.File - uniqueID string - dbusPath string - dbusName string - manager *singleInstanceManager + file *os.File + uniqueID string + dbusPath string + dbusName string + dbusInterface string + manager *singleInstanceManager +} + +// singleInstanceNames derives the three D-Bus identifiers the lock needs from +// UniqueID. They cannot all be the same string, because D-Bus spells them +// differently: bus names may contain hyphens, interface names may not, and +// object paths separate elements with "/" and allow neither hyphens nor dots. +// +// The bus name keeps UniqueID verbatim. That matters for sandboxed builds: a +// flatpak may only own names prefixed with its own app id, so an app that +// follows the documented convention for UniqueID ("unique per application, e.g. +// com.myapp.myapplication") claims a name it is allowed to own and needs no +// extra portal permission. +func singleInstanceNames(uniqueID string) (busName, interfaceName, objectPath string, err error) { + for _, element := range strings.Split(uniqueID, ".") { + if element == "" { + return "", "", "", fmt.Errorf("UniqueID %q has an empty element; it must be a dot-separated name such as com.myapp.myapplication", uniqueID) + } + if element[0] >= '0' && element[0] <= '9' { + return "", "", "", fmt.Errorf("UniqueID %q has an element starting with a digit (%q), which D-Bus does not allow", uniqueID, element) + } + for _, r := range element { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' { + continue + } + return "", "", "", fmt.Errorf("UniqueID %q contains %q, which D-Bus does not allow in a name", uniqueID, r) + } + } + + // Hyphens are legal in a bus name but not in an interface name or an object + // path, so those two are built from a hyphen-free form. + unhyphenated := strings.ReplaceAll(uniqueID, "-", "_") + + busName = uniqueID + ".SingleInstance" + interfaceName = unhyphenated + ".SingleInstance" + objectPath = "/" + strings.ReplaceAll(unhyphenated, ".", "/") + "/SingleInstance" + return busName, interfaceName, objectPath, nil } func newPlatformLock(manager *singleInstanceManager) (platformLock, error) { @@ -40,10 +78,15 @@ func (l *linuxLock) acquire(uniqueID string) error { return errors.New("UniqueID is required for single instance lock") } - id := "wails_app_" + strings.ReplaceAll(strings.ReplaceAll(uniqueID, "-", "_"), ".", "_") + busName, interfaceName, objectPath, err := singleInstanceNames(uniqueID) + if err != nil { + return err + } - l.dbusName = "org." + id + ".SingleInstance" - l.dbusPath = "/org/" + id + "/SingleInstance" + l.uniqueID = uniqueID + l.dbusName = busName + l.dbusInterface = interfaceName + l.dbusPath = objectPath conn, err := dbus.ConnectSessionBus() // if we will reach any error during establishing connection or sending message we will just continue. @@ -57,7 +100,7 @@ func (l *linuxLock) acquire(uniqueID string) error { secondInstanceBuffer <- message }) - err = conn.Export(f, dbus.ObjectPath(l.dbusPath), l.dbusName) + err = conn.Export(f, dbus.ObjectPath(l.dbusPath), l.dbusInterface) }) if err != nil { return err @@ -65,14 +108,21 @@ func (l *linuxLock) acquire(uniqueID string) error { reply, err := conn.RequestName(l.dbusName, dbus.NameFlagDoNotQueue) if err != nil { - return err + // A sandbox that refuses the name fails here. Say so, rather than + // letting the caller read it as a second instance and exit silently. + return fmt.Errorf("could not claim the single instance name %q: %w", l.dbusName, err) } - // if name already taken, try to send args to existing instance, if no success just launch new instance - if reply == dbus.RequestNameReplyExists { + switch reply { + case dbus.RequestNameReplyPrimaryOwner, dbus.RequestNameReplyAlreadyOwner: + return nil + case dbus.RequestNameReplyExists: + // Someone else holds the name, so this is a second instance. The caller + // hands off to the first one and exits. return alreadyRunningError + default: + return fmt.Errorf("unexpected reply %d when claiming the single instance name %q", reply, l.dbusName) } - return nil } func (l *linuxLock) release() { @@ -92,7 +142,7 @@ func (l *linuxLock) notify(data string) error { return err } - err = conn.Object(l.dbusName, dbus.ObjectPath(l.dbusPath)).Call(l.dbusName+".SendMessage", 0, data).Store() + err = conn.Object(l.dbusName, dbus.ObjectPath(l.dbusPath)).Call(l.dbusInterface+".SendMessage", 0, data).Store() if err != nil { return err } diff --git a/v3/pkg/application/single_instance_linux_test.go b/v3/pkg/application/single_instance_linux_test.go new file mode 100644 index 00000000000..1abba009fdc --- /dev/null +++ b/v3/pkg/application/single_instance_linux_test.go @@ -0,0 +1,92 @@ +//go:build linux && !android && !server + +package application + +import "testing" + +func TestSingleInstanceNames(t *testing.T) { + tests := []struct { + name string + uniqueID string + busName string + iface string + path string + }{ + { + name: "reverse dns id is kept verbatim in the bus name", + uniqueID: "com.myapp.myapplication", + busName: "com.myapp.myapplication.SingleInstance", + iface: "com.myapp.myapplication.SingleInstance", + path: "/com/myapp/myapplication/SingleInstance", + }, + { + // Hyphens are legal in a bus name but not in an interface name or + // an object path, so only the bus name keeps them. + name: "hyphens survive in the bus name only", + uniqueID: "net.my-company.my-app", + busName: "net.my-company.my-app.SingleInstance", + iface: "net.my_company.my_app.SingleInstance", + path: "/net/my_company/my_app/SingleInstance", + }, + { + name: "single element id still yields two name elements", + uniqueID: "myapp", + busName: "myapp.SingleInstance", + iface: "myapp.SingleInstance", + path: "/myapp/SingleInstance", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + busName, interfaceName, path, err := singleInstanceNames(tt.uniqueID) + if err != nil { + t.Fatalf("singleInstanceNames(%q) returned error: %v", tt.uniqueID, err) + } + if busName != tt.busName { + t.Errorf("bus name = %q, want %q", busName, tt.busName) + } + if interfaceName != tt.iface { + t.Errorf("interface name = %q, want %q", interfaceName, tt.iface) + } + if path != tt.path { + t.Errorf("object path = %q, want %q", path, tt.path) + } + }) + } +} + +// The bus name has to stay under the app id, which is what lets a sandboxed +// build claim it without being granted the whole session bus. +func TestSingleInstanceNamesArePrefixedByUniqueID(t *testing.T) { + const uniqueID = "net.koofr.stage" + + busName, _, _, err := singleInstanceNames(uniqueID) + if err != nil { + t.Fatalf("singleInstanceNames(%q) returned error: %v", uniqueID, err) + } + if got, want := busName[:len(uniqueID)], uniqueID; got != want { + t.Errorf("bus name %q is not prefixed by the UniqueID %q", busName, want) + } +} + +func TestSingleInstanceNamesRejectsInvalidIDs(t *testing.T) { + tests := []struct { + name string + uniqueID string + }{ + {"empty element", "com..myapp"}, + {"trailing dot", "com.myapp."}, + {"element starting with a digit", "com.1myapp"}, + {"character D-Bus does not allow", "com.my app"}, + {"slash", "com/myapp"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, _, _, err := singleInstanceNames(tt.uniqueID); err == nil { + t.Errorf("singleInstanceNames(%q) succeeded, want an error", tt.uniqueID) + } + }) + } +}