From c24bd5e3e889a3bfe9f6fb431d9e12430e0b41b0 Mon Sep 17 00:00:00 2001 From: Andrey Pshenkin Date: Mon, 23 Oct 2023 11:31:56 +0100 Subject: [PATCH] Implement Single instance lock feature with passing arguments to initial instance (#2951) * implement MacOS openFile/openFiles events * wip: windows file association * fix macro import * add file icon copy * try copy icon * keep only required part of scripts * update config schema * fix json * set fileAssociation for mac via config * proper iconName handling * add fileAssociation icon generator * fix file association icons bundle * don't break compatibility * remove mimeType as not supported linux for now * add documentation * adjust config schema * restore formatting * try implement single instance lock with params passing * fix focusing * fix focusing * formatting * use channel buffer for second instance events * handle errors * add comment * remove unused option in file association * wip: linux single instance lock * wip: linux single instance * some experiments with making window active * try to use unminimise * remove unused * try present for window * try present for window * fix build * cleanup * cleanup * implement single instance lock on mac os * implement proper show for windows * proper unmimimise * get rid of openFiles mac os. change configuration structure * remove unused channel * remove unused function * add documentation for single instance lock * add PR link * changes after review * update docs * changes after review --------- Co-authored-by: Lea Anthony --- v2/go.mod | 1 + v2/go.sum | 2 + .../frontend/desktop/darwin/AppDelegate.h | 6 + .../frontend/desktop/darwin/AppDelegate.m | 22 +++ .../frontend/desktop/darwin/Application.h | 2 +- .../frontend/desktop/darwin/Application.m | 39 +++--- .../frontend/desktop/darwin/WailsContext.h | 3 + .../frontend/desktop/darwin/frontend.go | 15 +++ .../desktop/darwin/single_instance.go | 75 +++++++++++ v2/internal/frontend/desktop/darwin/window.go | 11 +- .../frontend/desktop/linux/frontend.go | 17 +++ .../frontend/desktop/linux/single_instance.go | 69 ++++++++++ .../frontend/desktop/windows/frontend.go | 17 +++ .../desktop/windows/single_instance.go | 126 ++++++++++++++++++ .../desktop/windows/winc/controlbase.go | 18 ++- .../frontend/desktop/windows/winc/form.go | 9 ++ .../desktop/windows/winc/w32/user32.go | 9 ++ v2/pkg/options/options.go | 28 ++++ website/docs/guides/file-association.mdx | 50 ++++++- website/docs/guides/single-instance-lock.mdx | 80 +++++++++++ website/src/pages/changelog.mdx | 1 + 21 files changed, 578 insertions(+), 22 deletions(-) create mode 100644 v2/internal/frontend/desktop/darwin/single_instance.go create mode 100644 v2/internal/frontend/desktop/linux/single_instance.go create mode 100644 v2/internal/frontend/desktop/windows/single_instance.go create mode 100644 website/docs/guides/single-instance-lock.mdx diff --git a/v2/go.mod b/v2/go.mod index 891928653..b707a5d55 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -12,6 +12,7 @@ require ( github.com/fsnotify/fsnotify v1.4.9 github.com/go-git/go-git/v5 v5.3.0 github.com/go-ole/go-ole v1.2.6 + github.com/godbus/dbus/v5 v5.1.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.3.0 github.com/jackmordaunt/icns v1.0.0 diff --git a/v2/go.sum b/v2/go.sum index c3faec7c0..dd11b8555 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -71,6 +71,8 @@ github.com/go-git/go-git/v5 v5.3.0/go.mod h1:xdX4bWJ48aOrdhnl2XqHYstHbbp6+LFS4r4 github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +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/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= diff --git a/v2/internal/frontend/desktop/darwin/AppDelegate.h b/v2/internal/frontend/desktop/darwin/AppDelegate.h index 7b533ad5f..4fa32233e 100644 --- a/v2/internal/frontend/desktop/darwin/AppDelegate.h +++ b/v2/internal/frontend/desktop/darwin/AppDelegate.h @@ -15,6 +15,8 @@ @property bool alwaysOnTop; @property bool startHidden; +@property (retain) NSString* singleInstanceUniqueId; +@property bool singleInstanceLockEnabled; @property bool startFullscreen; @property (retain) WailsWindow* mainWindow; @@ -22,4 +24,8 @@ extern void HandleOpenFile(char *); +extern void HandleSecondInstanceData(char * message); + +void SendDataToFirstInstance(char * singleInstanceUniqueId, char * text); + #endif /* AppDelegate_h */ diff --git a/v2/internal/frontend/desktop/darwin/AppDelegate.m b/v2/internal/frontend/desktop/darwin/AppDelegate.m index 6a9f5a4c3..55bc2b4a8 100644 --- a/v2/internal/frontend/desktop/darwin/AppDelegate.m +++ b/v2/internal/frontend/desktop/darwin/AppDelegate.m @@ -40,6 +40,28 @@ [self.mainWindow setCollectionBehavior:behaviour]; [self.mainWindow toggleFullScreen:nil]; } + + if ( self.singleInstanceLockEnabled ) { + [[NSDistributedNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleSecondInstanceNotification:) name:self.singleInstanceUniqueId object:nil]; + } +} + +void SendDataToFirstInstance(char * singleInstanceUniqueId, char * message) { + [[NSDistributedNotificationCenter defaultCenter] + postNotificationName:[NSString stringWithUTF8String:singleInstanceUniqueId] + object:nil + userInfo:@{@"message": [NSString stringWithUTF8String:message]} + deliverImmediately:YES]; +} + +- (void)handleSecondInstanceNotification:(NSNotification *)note; +{ + if (note.userInfo[@"message"] != nil) { + NSString *message = note.userInfo[@"message"]; + const char* utf8Message = message.UTF8String; + HandleSecondInstanceData((char*)utf8Message); + } } - (void)dealloc { diff --git a/v2/internal/frontend/desktop/darwin/Application.h b/v2/internal/frontend/desktop/darwin/Application.h index 458016cc8..eb679f3b5 100644 --- a/v2/internal/frontend/desktop/darwin/Application.h +++ b/v2/internal/frontend/desktop/darwin/Application.h @@ -17,7 +17,7 @@ #define WindowStartsMinimised 2 #define WindowStartsFullscreen 3 -WailsContext* Create(const char* title, int width, int height, int frameless, int resizable, int fullscreen, int fullSizeContent, int hideTitleBar, int titlebarAppearsTransparent, int hideTitle, int useToolbar, int hideToolbarSeparator, int webviewIsTransparent, int alwaysOnTop, int hideWindowOnClose, const char *appearance, int windowIsTranslucent, int devtoolsEnabled, int defaultContextMenuEnabled, int windowStartState, int startsHidden, int minWidth, int minHeight, int maxWidth, int maxHeight, bool fraudulentWebsiteWarningEnabled, struct Preferences preferences); +WailsContext* Create(const char* title, int width, int height, int frameless, int resizable, int fullscreen, int fullSizeContent, int hideTitleBar, int titlebarAppearsTransparent, int hideTitle, int useToolbar, int hideToolbarSeparator, int webviewIsTransparent, int alwaysOnTop, int hideWindowOnClose, const char *appearance, int windowIsTranslucent, int devtoolsEnabled, int defaultContextMenuEnabled, int windowStartState, int startsHidden, int minWidth, int minHeight, int maxWidth, int maxHeight, bool fraudulentWebsiteWarningEnabled, struct Preferences preferences, int singleInstanceEnabled, const char* singleInstanceUniqueId); void Run(void*, const char* url); void SetTitle(void* ctx, const char *title); diff --git a/v2/internal/frontend/desktop/darwin/Application.m b/v2/internal/frontend/desktop/darwin/Application.m index e0c05212d..aa35b2a6b 100644 --- a/v2/internal/frontend/desktop/darwin/Application.m +++ b/v2/internal/frontend/desktop/darwin/Application.m @@ -14,15 +14,15 @@ #import "WailsMenu.h" #import "WailsMenuItem.h" -WailsContext* Create(const char* title, int width, int height, int frameless, int resizable, int fullscreen, int fullSizeContent, int hideTitleBar, int titlebarAppearsTransparent, int hideTitle, int useToolbar, int hideToolbarSeparator, int webviewIsTransparent, int alwaysOnTop, int hideWindowOnClose, const char *appearance, int windowIsTranslucent, int devtoolsEnabled, int defaultContextMenuEnabled, int windowStartState, int startsHidden, int minWidth, int minHeight, int maxWidth, int maxHeight, bool fraudulentWebsiteWarningEnabled, struct Preferences preferences) { - +WailsContext* Create(const char* title, int width, int height, int frameless, int resizable, int fullscreen, int fullSizeContent, int hideTitleBar, int titlebarAppearsTransparent, int hideTitle, int useToolbar, int hideToolbarSeparator, int webviewIsTransparent, int alwaysOnTop, int hideWindowOnClose, const char *appearance, int windowIsTranslucent, int devtoolsEnabled, int defaultContextMenuEnabled, int windowStartState, int startsHidden, int minWidth, int minHeight, int maxWidth, int maxHeight, bool fraudulentWebsiteWarningEnabled, struct Preferences preferences, int singleInstanceLockEnabled, const char* singleInstanceUniqueId) { + [NSApplication sharedApplication]; WailsContext *result = [WailsContext new]; result.devtoolsEnabled = devtoolsEnabled; result.defaultContextMenuEnabled = defaultContextMenuEnabled; - + if ( windowStartState == WindowStartsFullscreen ) { fullscreen = 1; } @@ -30,7 +30,7 @@ WailsContext* Create(const char* title, int width, int height, int frameless, in [result CreateWindow:width :height :frameless :resizable :fullscreen :fullSizeContent :hideTitleBar :titlebarAppearsTransparent :hideTitle :useToolbar :hideToolbarSeparator :webviewIsTransparent :hideWindowOnClose :safeInit(appearance) :windowIsTranslucent :minWidth :minHeight :maxWidth :maxHeight :fraudulentWebsiteWarningEnabled :preferences]; [result SetTitle:safeInit(title)]; [result Center]; - + switch( windowStartState ) { case WindowStartsMaximised: [result.mainWindow zoom:nil]; @@ -43,14 +43,19 @@ WailsContext* Create(const char* title, int width, int height, int frameless, in if ( startsHidden == 1 ) { result.startHidden = true; } - + if ( fullscreen == 1 ) { result.startFullscreen = true; } - + + if ( singleInstanceLockEnabled == 1 ) { + result.singleInstanceLockEnabled = true; + result.singleInstanceUniqueId = safeInit(singleInstanceUniqueId); + } + result.alwaysOnTop = alwaysOnTop; result.hideOnClose = hideWindowOnClose; - + return result; } @@ -181,7 +186,7 @@ const char* GetPosition(void *inctx) { NSString *result = [NSString stringWithFormat:@"%d,%d",x,y]; return [result UTF8String]; } - + const bool IsFullScreen(void *inctx) { WailsContext *ctx = (__bridge WailsContext*) inctx; return [ctx IsFullScreen]; @@ -249,7 +254,7 @@ NSString* safeInit(const char* input) { void MessageDialog(void *inctx, const char* dialogType, const char* title, const char* message, const char* button1, const char* button2, const char* button3, const char* button4, const char* defaultButton, const char* cancelButton, void* iconData, int iconDataLength) { WailsContext *ctx = (__bridge WailsContext*) inctx; - + NSString *_dialogType = safeInit(dialogType); NSString *_title = safeInit(title); NSString *_message = safeInit(message); @@ -259,33 +264,33 @@ void MessageDialog(void *inctx, const char* dialogType, const char* title, const NSString *_button4 = safeInit(button4); NSString *_defaultButton = safeInit(defaultButton); NSString *_cancelButton = safeInit(cancelButton); - + ON_MAIN_THREAD( [ctx MessageDialog:_dialogType :_title :_message :_button1 :_button2 :_button3 :_button4 :_defaultButton :_cancelButton :iconData :iconDataLength]; ) } void OpenFileDialog(void *inctx, const char* title, const char* defaultFilename, const char* defaultDirectory, int allowDirectories, int allowFiles, int canCreateDirectories, int treatPackagesAsDirectories, int resolveAliases, int showHiddenFiles, int allowMultipleSelection, const char* filters) { - + WailsContext *ctx = (__bridge WailsContext*) inctx; NSString *_title = safeInit(title); NSString *_defaultFilename = safeInit(defaultFilename); NSString *_defaultDirectory = safeInit(defaultDirectory); NSString *_filters = safeInit(filters); - + ON_MAIN_THREAD( [ctx OpenFileDialog:_title :_defaultFilename :_defaultDirectory :allowDirectories :allowFiles :canCreateDirectories :treatPackagesAsDirectories :resolveAliases :showHiddenFiles :allowMultipleSelection :_filters]; ) } void SaveFileDialog(void *inctx, const char* title, const char* defaultFilename, const char* defaultDirectory, int canCreateDirectories, int treatPackagesAsDirectories, int showHiddenFiles, const char* filters) { - + WailsContext *ctx = (__bridge WailsContext*) inctx; NSString *_title = safeInit(title); NSString *_defaultFilename = safeInit(defaultFilename); NSString *_defaultDirectory = safeInit(defaultDirectory); NSString *_filters = safeInit(filters); - + ON_MAIN_THREAD( [ctx SaveFileDialog:_title :_defaultFilename :_defaultDirectory :canCreateDirectories :treatPackagesAsDirectories :showHiddenFiles :_filters]; ) @@ -367,6 +372,8 @@ void Run(void *inctx, const char* url) { delegate.mainWindow = ctx.mainWindow; delegate.alwaysOnTop = ctx.alwaysOnTop; delegate.startHidden = ctx.startHidden; + delegate.singleInstanceLockEnabled = ctx.singleInstanceLockEnabled; + delegate.singleInstanceUniqueId = ctx.singleInstanceUniqueId; delegate.startFullscreen = ctx.startFullscreen; NSString *_url = safeInit(url); @@ -395,7 +402,7 @@ void WindowPrint(void *inctx) { WailsContext *ctx = (__bridge WailsContext*) inctx; WKWebView* webView = ctx.webview; - // I think this should be exposed as a config + // I think this should be exposed as a config // It directly affects the printed output/PDF NSPrintInfo *pInfo = [NSPrintInfo sharedPrintInfo]; pInfo.horizontalPagination = NSPrintingPaginationModeAutomatic; @@ -417,4 +424,4 @@ void WindowPrint(void *inctx) { [po runOperationModalForWindow:ctx.mainWindow delegate:ctx.mainWindow.delegate didRunSelector:nil contextInfo:nil]; ) } -} \ No newline at end of file +} diff --git a/v2/internal/frontend/desktop/darwin/WailsContext.h b/v2/internal/frontend/desktop/darwin/WailsContext.h index 99b6ce2de..ab5e5c2a2 100644 --- a/v2/internal/frontend/desktop/darwin/WailsContext.h +++ b/v2/internal/frontend/desktop/darwin/WailsContext.h @@ -40,6 +40,9 @@ @property bool startHidden; @property bool startFullscreen; +@property bool singleInstanceLockEnabled; +@property (retain) NSString* singleInstanceUniqueId; + @property (retain) NSEvent* mouseEvent; @property bool alwaysOnTop; diff --git a/v2/internal/frontend/desktop/darwin/frontend.go b/v2/internal/frontend/desktop/darwin/frontend.go index 66e5dc0cf..7a4905eeb 100644 --- a/v2/internal/frontend/desktop/darwin/frontend.go +++ b/v2/internal/frontend/desktop/darwin/frontend.go @@ -39,6 +39,7 @@ var messageBuffer = make(chan string, 100) var requestBuffer = make(chan webview.Request, 100) var callbackBuffer = make(chan uint, 10) var openFilepathBuffer = make(chan string, 100) +var secondInstanceBuffer = make(chan options.SecondInstanceData, 1) type Frontend struct { @@ -109,6 +110,7 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger. go result.startMessageProcessor() go result.startCallbackProcessor() go result.startFileOpenProcessor() + go result.startSecondInstanceProcessor() return result } @@ -119,6 +121,15 @@ func (f *Frontend) startFileOpenProcessor() { } } +func (f *Frontend) startSecondInstanceProcessor() { + for secondInstanceData := range secondInstanceBuffer { + if f.frontendOptions.SingleInstanceLock != nil && + f.frontendOptions.SingleInstanceLock.OnSecondInstanceLaunch != nil { + f.frontendOptions.SingleInstanceLock.OnSecondInstanceLaunch(secondInstanceData) + } + } +} + func (f *Frontend) startMessageProcessor() { for message := range messageBuffer { f.processMessage(message) @@ -162,6 +173,10 @@ func (f *Frontend) WindowSetDarkTheme() { func (f *Frontend) Run(ctx context.Context) error { f.ctx = ctx + if f.frontendOptions.SingleInstanceLock != nil { + SetupSingleInstance(f.frontendOptions.SingleInstanceLock.UniqueId) + } + var _debug = ctx.Value("debug") var _devtoolsEnabled = ctx.Value("devtoolsEnabled") diff --git a/v2/internal/frontend/desktop/darwin/single_instance.go b/v2/internal/frontend/desktop/darwin/single_instance.go new file mode 100644 index 000000000..6f61ed173 --- /dev/null +++ b/v2/internal/frontend/desktop/darwin/single_instance.go @@ -0,0 +1,75 @@ +//go:build darwin +// +build darwin + +package darwin + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa +#import "AppDelegate.h" + +*/ +import "C" +import ( + "encoding/json" + "github.com/wailsapp/wails/v2/pkg/options" + "os" + "syscall" +) + +func SetupSingleInstance(uniqueID string) { + lockFilePath := os.TempDir() + lockFileName := uniqueID + ".lock" + _, err := createLockFile(lockFilePath + "/" + lockFileName) + + // if lockFile exist – send notification to second instance + if err != nil { + c := NewCalloc() + defer c.Free() + singleInstanceUniqueId := c.String(uniqueID) + + data, err := options.NewSecondInstanceData() + if err != nil { + return + } + + serialized, err := json.Marshal(data) + if err != nil { + return + } + + C.SendDataToFirstInstance(singleInstanceUniqueId, c.String(string(serialized))) + + os.Exit(0) + } +} + +//export HandleSecondInstanceData +func HandleSecondInstanceData(secondInstanceMessage *C.char) { + message := C.GoString(secondInstanceMessage) + + var secondInstanceData options.SecondInstanceData + + err := json.Unmarshal([]byte(message), &secondInstanceData) + if err == nil { + secondInstanceBuffer <- secondInstanceData + } +} + +// CreateLockFile tries to create a file with given name and acquire an +// exclusive lock on it. If the file already exists AND is still locked, it will +// fail. +func createLockFile(filename string) (*os.File, error) { + file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return nil, err + } + + err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + file.Close() + return nil, err + } + + return file, nil +} diff --git a/v2/internal/frontend/desktop/darwin/window.go b/v2/internal/frontend/desktop/darwin/window.go index c0f627419..740848df1 100644 --- a/v2/internal/frontend/desktop/darwin/window.go +++ b/v2/internal/frontend/desktop/darwin/window.go @@ -58,6 +58,7 @@ func NewWindow(frontendOptions *options.App, debug bool, devtools bool) *Window startsHidden := bool2Cint(frontendOptions.StartHidden) devtoolsEnabled := bool2Cint(devtools) defaultContextMenuEnabled := bool2Cint(debug || frontendOptions.EnableDefaultContextMenu) + singleInstanceEnabled := bool2Cint(frontendOptions.SingleInstanceLock != nil) var fullSizeContent, hideTitleBar, hideTitle, useToolbar, webviewIsTransparent C.int var titlebarAppearsTransparent, hideToolbarSeparator, windowIsTranslucent C.int @@ -74,6 +75,12 @@ func NewWindow(frontendOptions *options.App, debug bool, devtools bool) *Window title = c.String(frontendOptions.Title) + singleInstanceUniqueIdStr := "" + if frontendOptions.SingleInstanceLock != nil { + singleInstanceUniqueIdStr = frontendOptions.SingleInstanceLock.UniqueId + } + singleInstanceUniqueId := c.String(singleInstanceUniqueIdStr) + enableFraudulentWebsiteWarnings := C.bool(frontendOptions.EnableFraudulentWebsiteDetection) if frontendOptions.Mac != nil { @@ -109,7 +116,9 @@ func NewWindow(frontendOptions *options.App, debug bool, devtools bool) *Window var context *C.WailsContext = C.Create(title, width, height, frameless, resizable, fullscreen, fullSizeContent, hideTitleBar, titlebarAppearsTransparent, hideTitle, useToolbar, hideToolbarSeparator, webviewIsTransparent, alwaysOnTop, hideWindowOnClose, appearance, windowIsTranslucent, devtoolsEnabled, defaultContextMenuEnabled, - windowStartState, startsHidden, minWidth, minHeight, maxWidth, maxHeight, enableFraudulentWebsiteWarnings, preferences) + windowStartState, startsHidden, minWidth, minHeight, maxWidth, maxHeight, enableFraudulentWebsiteWarnings, + preferences, singleInstanceEnabled, singleInstanceUniqueId, + ) // Create menu result := &Window{ diff --git a/v2/internal/frontend/desktop/linux/frontend.go b/v2/internal/frontend/desktop/linux/frontend.go index 70f8468b6..2410457b7 100644 --- a/v2/internal/frontend/desktop/linux/frontend.go +++ b/v2/internal/frontend/desktop/linux/frontend.go @@ -102,6 +102,8 @@ var initOnce = sync.Once{} const startURL = "wails://wails/" +var secondInstanceBuffer = make(chan options.SecondInstanceData, 1) + type Frontend struct { // Context @@ -201,6 +203,8 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger. C.free(unsafe.Pointer(prgname)) } + go result.startSecondInstanceProcessor() + return result } @@ -235,6 +239,10 @@ func (f *Frontend) Run(ctx context.Context) error { } }() + if f.frontendOptions.SingleInstanceLock != nil { + SetupSingleInstance(f.frontendOptions.SingleInstanceLock.UniqueId) + } + f.mainWindow.Run(f.startURL.String()) return nil @@ -505,3 +513,12 @@ func (f *Frontend) startRequestProcessor() { func processURLRequest(request unsafe.Pointer) { requestBuffer <- webview.NewRequest(request) } + +func (f *Frontend) startSecondInstanceProcessor() { + for secondInstanceData := range secondInstanceBuffer { + if f.frontendOptions.SingleInstanceLock != nil && + f.frontendOptions.SingleInstanceLock.OnSecondInstanceLaunch != nil { + f.frontendOptions.SingleInstanceLock.OnSecondInstanceLaunch(secondInstanceData) + } + } +} diff --git a/v2/internal/frontend/desktop/linux/single_instance.go b/v2/internal/frontend/desktop/linux/single_instance.go new file mode 100644 index 000000000..9d6426e14 --- /dev/null +++ b/v2/internal/frontend/desktop/linux/single_instance.go @@ -0,0 +1,69 @@ +//go:build linux +// +build linux + +package linux + +import ( + "encoding/json" + "github.com/godbus/dbus/v5" + "github.com/wailsapp/wails/v2/pkg/options" + "os" + "strings" +) + +type dbusHandler func(string) + +func (f dbusHandler) SendMessage(message string) *dbus.Error { + f(message) + return nil +} + +func SetupSingleInstance(uniqueID string) { + id := "wails_app_" + strings.ReplaceAll(strings.ReplaceAll(uniqueID, "-", "_"), ".", "_") + + dbusName := "org." + id + ".SingleInstance" + dbusPath := "/org/" + id + "/SingleInstance" + + conn, err := dbus.ConnectSessionBus() + // if we will reach any error during establishing connection or sending message we will just continue. + // It should not be the case that such thing will happen actually, but just in case. + if err != nil { + return + } + + f := dbusHandler(func(message string) { + var secondInstanceData options.SecondInstanceData + + err := json.Unmarshal([]byte(message), &secondInstanceData) + if err == nil { + secondInstanceBuffer <- secondInstanceData + } + }) + + err = conn.Export(f, dbus.ObjectPath(dbusPath), dbusName) + if err != nil { + return + } + + reply, err := conn.RequestName(dbusName, dbus.NameFlagDoNotQueue) + if err != nil { + return + } + + // if name already taken, try to send args to existing instance, if no success just launch new instance + if reply == dbus.RequestNameReplyExists { + data := options.SecondInstanceData{ + Args: os.Args[1:], + } + serialized, err := json.Marshal(data) + if err != nil { + return + } + + err = conn.Object(dbusName, dbus.ObjectPath(dbusPath)).Call(dbusName+".SendMessage", 0, string(serialized)).Store() + if err != nil { + return + } + os.Exit(1) + } +} diff --git a/v2/internal/frontend/desktop/windows/frontend.go b/v2/internal/frontend/desktop/windows/frontend.go index c388d7384..7671ad742 100644 --- a/v2/internal/frontend/desktop/windows/frontend.go +++ b/v2/internal/frontend/desktop/windows/frontend.go @@ -35,6 +35,8 @@ import ( const startURL = "http://wails.localhost/" +var secondInstanceBuffer = make(chan options.SecondInstanceData, 1) + type Screen = frontend.Screen type Frontend struct { @@ -113,6 +115,8 @@ func NewFrontend(ctx context.Context, appoptions *options.App, myLogger *logger. } result.assets = assets + go result.startSecondInstanceProcessor() + return result } @@ -137,6 +141,10 @@ func (f *Frontend) Run(ctx context.Context) error { f.chromium = edge.NewChromium() + if f.frontendOptions.SingleInstanceLock != nil { + SetupSingleInstance(f.frontendOptions.SingleInstanceLock.UniqueId) + } + mainWindow := NewWindow(nil, f.frontendOptions, f.versionInfo, f.chromium) f.mainWindow = mainWindow @@ -826,3 +834,12 @@ func (f *Frontend) ShowWindow() { func (f *Frontend) onFocus(arg *winc.Event) { f.chromium.Focus() } + +func (f *Frontend) startSecondInstanceProcessor() { + for secondInstanceData := range secondInstanceBuffer { + if f.frontendOptions.SingleInstanceLock != nil && + f.frontendOptions.SingleInstanceLock.OnSecondInstanceLaunch != nil { + f.frontendOptions.SingleInstanceLock.OnSecondInstanceLaunch(secondInstanceData) + } + } +} diff --git a/v2/internal/frontend/desktop/windows/single_instance.go b/v2/internal/frontend/desktop/windows/single_instance.go new file mode 100644 index 000000000..222d9fe1a --- /dev/null +++ b/v2/internal/frontend/desktop/windows/single_instance.go @@ -0,0 +1,126 @@ +//go:build windows + +package windows + +import ( + "encoding/json" + "github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32" + "github.com/wailsapp/wails/v2/pkg/options" + "golang.org/x/sys/windows" + "os" + "syscall" + "unsafe" +) + +type COPYDATASTRUCT struct { + dwData uintptr + cbData uint32 + lpData uintptr +} + +// WMCOPYDATA_SINGLE_INSTANCE_DATA we define our own type for WM_COPYDATA message +const WMCOPYDATA_SINGLE_INSTANCE_DATA = 1542 + +func SendMessage(hwnd w32.HWND, data string) { + arrUtf16, _ := syscall.UTF16FromString(data) + + pCopyData := new(COPYDATASTRUCT) + pCopyData.dwData = WMCOPYDATA_SINGLE_INSTANCE_DATA + pCopyData.cbData = uint32(len(arrUtf16)*2 + 1) + pCopyData.lpData = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr(data))) + + w32.SendMessage(hwnd, w32.WM_COPYDATA, 0, uintptr(unsafe.Pointer(pCopyData))) +} + +// SetupSingleInstance single instance Windows app +func SetupSingleInstance(uniqueId string) { + id := "wails-app-" + uniqueId + + className := id + "-sic" + windowName := id + "-siw" + mutexName := id + "sim" + + _, err := windows.CreateMutex(nil, false, windows.StringToUTF16Ptr(mutexName)) + + if err != nil { + if err == windows.ERROR_ALREADY_EXISTS { + // app is already running + hwnd := w32.FindWindowW(windows.StringToUTF16Ptr(className), windows.StringToUTF16Ptr(windowName)) + + if hwnd != 0 { + data := options.SecondInstanceData{ + Args: os.Args[1:], + } + serialized, _ := json.Marshal(data) + + SendMessage(hwnd, string(serialized)) + // exit second instance of app after sending message + os.Exit(0) + } + // if we got any other unknown error we will just start new application instance + } + } else { + createEventTargetWindow(className, windowName) + } +} + +func createEventTargetWindow(className string, windowName string) w32.HWND { + // callback handler in the event target window + wndProc := func( + hwnd w32.HWND, msg uint32, wparam w32.WPARAM, lparam w32.LPARAM, + ) w32.LRESULT { + if msg == w32.WM_COPYDATA { + ldata := (*COPYDATASTRUCT)(unsafe.Pointer(lparam)) + + if ldata.dwData == WMCOPYDATA_SINGLE_INSTANCE_DATA { + serialized := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(ldata.lpData))) + + var secondInstanceData options.SecondInstanceData + + err := json.Unmarshal([]byte(serialized), &secondInstanceData) + + if err == nil { + secondInstanceBuffer <- secondInstanceData + } + } + + return w32.LRESULT(0) + } + + return w32.DefWindowProc(hwnd, msg, wparam, lparam) + } + + var class w32.WNDCLASSEX + class.Size = uint32(unsafe.Sizeof(class)) + class.Style = 0 + class.WndProc = syscall.NewCallback(wndProc) + class.ClsExtra = 0 + class.WndExtra = 0 + class.Instance = w32.GetModuleHandle("") + class.Icon = 0 + class.Cursor = 0 + class.Background = 0 + class.MenuName = nil + class.ClassName = windows.StringToUTF16Ptr(className) + class.IconSm = 0 + + w32.RegisterClassEx(&class) + + // create event window that will not be visible for user + hwnd := w32.CreateWindowEx( + 0, + windows.StringToUTF16Ptr(className), + windows.StringToUTF16Ptr(windowName), + 0, + 0, + 0, + 0, + 0, + w32.HWND_MESSAGE, + 0, + w32.GetModuleHandle(""), + nil, + ) + + return hwnd +} diff --git a/v2/internal/frontend/desktop/windows/winc/controlbase.go b/v2/internal/frontend/desktop/windows/winc/controlbase.go index b745cb1b0..086609aed 100644 --- a/v2/internal/frontend/desktop/windows/winc/controlbase.go +++ b/v2/internal/frontend/desktop/windows/winc/controlbase.go @@ -334,7 +334,23 @@ func (cba *ControlBase) ClientHeight() int { } func (cba *ControlBase) Show() { - w32.ShowWindow(cba.hwnd, w32.SW_SHOWDEFAULT) + // WindowPos is used with HWND_TOPMOST to guarantee bring our app on top + // force set our main window on top + w32.SetWindowPos( + cba.hwnd, + w32.HWND_TOPMOST, + 0, 0, 0, 0, + w32.SWP_SHOWWINDOW|w32.SWP_NOSIZE|w32.SWP_NOMOVE, + ) + // remove topmost to allow normal windows manipulations + w32.SetWindowPos( + cba.hwnd, + w32.HWND_NOTOPMOST, + 0, 0, 0, 0, + w32.SWP_SHOWWINDOW|w32.SWP_NOSIZE|w32.SWP_NOMOVE, + ) + // put main window on tops foreground + w32.SetForegroundWindow(cba.hwnd) } func (cba *ControlBase) Hide() { diff --git a/v2/internal/frontend/desktop/windows/winc/form.go b/v2/internal/frontend/desktop/windows/winc/form.go index 9b9cadb2c..8a42d63f3 100644 --- a/v2/internal/frontend/desktop/windows/winc/form.go +++ b/v2/internal/frontend/desktop/windows/winc/form.go @@ -136,6 +136,15 @@ func (fm *Form) Minimise() { } func (fm *Form) Restore() { + // SC_RESTORE param for WM_SYSCOMMAND to restore app if it is minimized + const SC_RESTORE = 0xF120 + // restore the minimized window, if it is + w32.SendMessage( + fm.hwnd, + w32.WM_SYSCOMMAND, + SC_RESTORE, + 0, + ) w32.ShowWindow(fm.hwnd, w32.SW_RESTORE) } diff --git a/v2/internal/frontend/desktop/windows/winc/w32/user32.go b/v2/internal/frontend/desktop/windows/winc/w32/user32.go index ec5e4a596..8ca72ce4b 100644 --- a/v2/internal/frontend/desktop/windows/winc/w32/user32.go +++ b/v2/internal/frontend/desktop/windows/winc/w32/user32.go @@ -24,6 +24,7 @@ var ( procShowWindowAsync = moduser32.NewProc("ShowWindowAsync") procUpdateWindow = moduser32.NewProc("UpdateWindow") procCreateWindowEx = moduser32.NewProc("CreateWindowExW") + procFindWindowW = moduser32.NewProc("FindWindowW") procAdjustWindowRect = moduser32.NewProc("AdjustWindowRect") procAdjustWindowRectEx = moduser32.NewProc("AdjustWindowRectEx") procDestroyWindow = moduser32.NewProc("DestroyWindow") @@ -263,6 +264,14 @@ func CreateWindowEx(exStyle uint, className, windowName *uint16, return HWND(ret) } +func FindWindowW(className, windowName *uint16) HWND { + ret, _, _ := procFindWindowW.Call( + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(windowName))) + + return HWND(ret) +} + func AdjustWindowRectEx(rect *RECT, style uint, menu bool, exStyle uint) bool { ret, _, _ := procAdjustWindowRectEx.Call( uintptr(unsafe.Pointer(rect)), diff --git a/v2/pkg/options/options.go b/v2/pkg/options/options.go index b64befe12..1ecad7fb9 100644 --- a/v2/pkg/options/options.go +++ b/v2/pkg/options/options.go @@ -5,6 +5,8 @@ import ( "html" "io/fs" "net/http" + "os" + "path/filepath" "runtime" "github.com/wailsapp/wails/v2/pkg/options/assetserver" @@ -82,6 +84,8 @@ type App struct { // services of Apple and Microsoft. EnableFraudulentWebsiteDetection bool + SingleInstanceLock *SingleInstanceLock + Windows *windows.Options Mac *mac.Options Linux *linux.Options @@ -165,6 +169,30 @@ func MergeDefaults(appoptions *App) { processDragOptions(appoptions) } +type SingleInstanceLock struct { + // uniqueId that will be used for setting up messaging between instances + UniqueId string + OnSecondInstanceLaunch func(secondInstanceData SecondInstanceData) +} + +type SecondInstanceData struct { + Args []string + WorkingDirectory string +} + +func NewSecondInstanceData() (*SecondInstanceData, error) { + ex, err := os.Executable() + if err != nil { + return nil, err + } + workingDirectory := filepath.Dir(ex) + + return &SecondInstanceData{ + Args: os.Args[1:], + WorkingDirectory: workingDirectory, + }, nil +} + func processMenus(appoptions *App) { switch runtime.GOOS { case "darwin": diff --git a/website/docs/guides/file-association.mdx b/website/docs/guides/file-association.mdx index ff3ca6e6b..71bbff37e 100644 --- a/website/docs/guides/file-association.mdx +++ b/website/docs/guides/file-association.mdx @@ -85,6 +85,30 @@ func main() { } ``` +You also can enable single instance lock for your app. In this case, when you open file with your app, new instance of app is not launched +and arguments are passed to already running instance. Check single instance lock guide for details. Example: +```go title="main.go" +func main() { + // Create application with options + err := wails.Run(&options.App{ + Title: "wails-open-file", + Width: 1024, + Height: 768, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + SingleInstanceLock: &options.SingleInstanceLock{ + UniqueId: "e3984e08-28dc-4e3d-b70a-45e961589cdc", + OnSecondInstanceLaunch: app.onSecondInstanceLaunch, + }, + Bind: []interface{}{ + app, + }, + }) +} +``` + ### Linux Currently, Wails doesn't support bundling for Linux. So, you need to create file associations manually. For example if you distribute your app as a .deb package, you can create file associations by adding required files in you bundle. @@ -179,6 +203,26 @@ func main() { } ``` -## Limitations: -On Windows and Linux when associated file is opened, new instance of your app is launched. -Currently, Wails doesn't support opening files in already running app. There is plugin for single instance support for v3 in development. +You also can enable single instance lock for your app. In this case, when you open file with your app, new instance of app is not launched +and arguments are passed to already running instance. Check single instance lock guide for details. Example: +```go title="main.go" +func main() { + // Create application with options + err := wails.Run(&options.App{ + Title: "wails-open-file", + Width: 1024, + Height: 768, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + SingleInstanceLock: &options.SingleInstanceLock{ + UniqueId: "e3984e08-28dc-4e3d-b70a-45e961589cdc", + OnSecondInstanceLaunch: app.onSecondInstanceLaunch, + }, + Bind: []interface{}{ + app, + }, + }) +} +``` diff --git a/website/docs/guides/single-instance-lock.mdx b/website/docs/guides/single-instance-lock.mdx new file mode 100644 index 000000000..644e1e0d8 --- /dev/null +++ b/website/docs/guides/single-instance-lock.mdx @@ -0,0 +1,80 @@ +# Single Instance Lock + +Single instance lock is a mechanism that allows you to prevent multiple instances of your app from running at the same time. +It is useful for apps that are designed to open files from the command line or from the OS file explorer. + +## Important + +Single Instance Lock does not implement a secure communications protocol between instances. When using single instance lock, +your app should treat any data passed to it from second instance callback as untrusted. +You should verify that args that you receive are valid and don't contain any malicious data. + +## How it works + +Windows: Single instance lock is implemented using a named mutex. The mutex name is generated from the unique id that you provide. Data is passed to the first instance via a shared window using [SendMessage](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-sendmessage) +macOS: Single instance lock is implemented using a named mutex. The mutex name is generated from the unique id that you provide. Data is passed to the first instance via [NSDistributedNotificationCenter](https://developer.apple.com/documentation/foundation/nsdistributednotificationcenter) +Linux: Single instance lock is implemented using [dbus](https://www.freedesktop.org/wiki/Software/dbus/). The dbus name is generated from the unique id that you provide. Data is passed to the first instance via [dbus](https://www.freedesktop.org/wiki/Software/dbus/) + +## Usage +When creating your app, you can enable single instance lock by passing a `SingleInstanceLock` struct to the `App` struct. +Use the `UniqueId` field to specify a unique id for your app. +This id is used to generate the mutex name on Windows and macOS and the dbus name on Linux. Use a UUID to ensure that the id is unique. +The `OnSecondInstanceLaunch` field is used to specify a callback that is called when a second instance of your app is launched. +The callback receives a `SecondInstanceData` struct that contains the command line arguments passed to the second instance and the working directory of the second instance. + +Note that OnSecondInstanceLaunch don't trigger windows focus. +You need to call `runtime.WindowUnminimise` and `runtime.Show` to bring your app to the front. +Note that on linux systems window managers may prevent your app from being brought to the front to avoid stealing focus. + +```go title="main.go" +var wailsContext *context.Context + +// NewApp creates a new App application struct +func NewApp() *App { + return &App{} +} + +// startup is called when the app starts. The context is saved +// so we can call the runtime methods +func (a *App) startup(ctx context.Context) { + wailsContext = &ctx +} + +func (a *App) onSecondInstanceLaunch(secondInstanceData options.SecondInstanceData) { + secondInstanceArgs = secondInstanceData.Args + + println("user opened second instance", strings.Join(secondInstanceData.Args, ",")) + println("user opened second from", secondInstanceData.WorkingDirectory) + runtime.WindowUnminimise(*wailsContext) + runtime.Show(*wailsContext) + go runtime.EventsEmit(*wailsContext, "launchArgs", secondInstanceArgs) +} + +func main() { + // Create an instance of the app structure + app := NewApp() + + // Create application with options + err := wails.Run(&options.App{ + Title: "wails-open-file", + Width: 1024, + Height: 768, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + OnStartup: app.startup, + SingleInstanceLock: &options.SingleInstanceLock{ + UniqueId: "e3984e08-28dc-4e3d-b70a-45e961589cdc", + OnSecondInstanceLaunch: app.onSecondInstanceLaunch, + }, + Bind: []interface{}{ + app, + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} +``` diff --git a/website/src/pages/changelog.mdx b/website/src/pages/changelog.mdx index 10f80d737..96c1145e9 100644 --- a/website/src/pages/changelog.mdx +++ b/website/src/pages/changelog.mdx @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added Single Instance Lock support with passing arguments to first instance. Added by @APshenkin in [PR](https://github.com/wailsapp/wails/pull/2951) - Added support for enabling/disabling swipe gestures for Windows WebView2. Added by @leaanthony in [PR](https://github.com/wailsapp/wails/pull/2878) - When building with `-devtools` flag, CMD/CTRL+SHIFT+F12 can be used to open the devtools. Added by @leaanthony in [PR](https://github.com/wailsapp/wails/pull/2915) – Added file association support for macOS and Windows. Added by @APshenkin in [PR](https://github.com/wailsapp/wails/pull/2918)