diff --git a/docs/src/content/docs/changelog.mdx b/docs/src/content/docs/changelog.mdx index b40a4aecde7..957b3a8536d 100644 --- a/docs/src/content/docs/changelog.mdx +++ b/docs/src/content/docs/changelog.mdx @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `wails3 generate webview2bootstrapper` command by [@leaanthony](https://github.com/leaanthony) - Added `init()` method in runtime to allow manual initialisation of the runtime by [@leaanthony](https://github.com/leaanthony) - Added `WindowDidMoveDebounceMS` option to Window's WindowOptions by [@leaanthony](https://github.com/leaanthony) +- Added Single Instance feature by [@leaanthony](https://github.com/leaanthony). Based on the [v2 PR](https://github.com/wailsapp/wails/pull/2951) by @APshenkin. ### Fixed diff --git a/docs/src/content/docs/guides/cli.mdx b/docs/src/content/docs/guides/cli.mdx index c0b82f4c25f..bc7b2845c95 100644 --- a/docs/src/content/docs/guides/cli.mdx +++ b/docs/src/content/docs/guides/cli.mdx @@ -5,8 +5,6 @@ sidebar: order: 1 --- -import { Tabs, TabItem } from "@astrojs/starlight/components"; - The Wails CLI provides a comprehensive set of commands to help you develop, build, and maintain your Wails applications. ## Core Commands @@ -133,21 +131,21 @@ wails3 generate bindings [flags] [patterns...] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-f` | Additional Go build flags | | -| `-d` | Output directory | `frontend/bindings` | -| `-models` | Models filename | `models` | -| `-internal` | Internal filename | `internal` | -| `-index` | Index filename | `index` | -| `-ts` | Generate TypeScript | `false` | -| `-i` | Use TS interfaces | `false` | -| `-b` | Use bundled runtime | `false` | -| `-names` | Use names instead of IDs | `false` | -| `-noindex` | Skip index files | `false` | -| `-dry` | Dry run | `false` | -| `-silent` | Silent mode | `false` | -| `-v` | Debug output | `false` | +| Flag | Description | Default | +|-------------|---------------------------|---------------------| +| `-f` | Additional Go build flags | | +| `-d` | Output directory | `frontend/bindings` | +| `-models` | Models filename | `models` | +| `-internal` | Internal filename | `internal` | +| `-index` | Index filename | `index` | +| `-ts` | Generate TypeScript | `false` | +| `-i` | Use TS interfaces | `false` | +| `-b` | Use bundled runtime | `false` | +| `-names` | Use names instead of IDs | `false` | +| `-noindex` | Skip index files | `false` | +| `-dry` | Dry run | `false` | +| `-silent` | Silent mode | `false` | +| `-v` | Debug output | `false` | ### `generate build-assets` Generates build assets for your application. @@ -157,18 +155,18 @@ wails3 generate build-assets [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-name` | Project name | | -| `-dir` | Output directory | `build` | -| `-silent` | Suppress output | `false` | -| `-company` | Company name | | -| `-productname` | Product name | | -| `-description` | Product description | | -| `-version` | Product version | | -| `-identifier` | Product identifier | `com.wails.[name]` | -| `-copyright` | Copyright notice | | -| `-comments` | File comments | | +| Flag | Description | Default | +|----------------|---------------------|--------------------| +| `-name` | Project name | | +| `-dir` | Output directory | `build` | +| `-silent` | Suppress output | `false` | +| `-company` | Company name | | +| `-productname` | Product name | | +| `-description` | Product description | | +| `-version` | Product version | | +| `-identifier` | Product identifier | `com.wails.[name]` | +| `-copyright` | Copyright notice | | +| `-comments` | File comments | | ### `generate icons` Generates application icons. @@ -178,13 +176,13 @@ wails3 generate icons [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-input` | Input PNG file | Required | -| `-windows` | Windows output filename | | -| `-mac` | macOS output filename | | -| `-sizes` | Icon sizes (comma-separated) | `256,128,64,48,32,16` | -| `-example` | Generate example icon | `false` | +| Flag | Description | Default | +|------------|------------------------------|-----------------------| +| `-input` | Input PNG file | Required | +| `-windows` | Windows output filename | | +| `-mac` | macOS output filename | | +| `-sizes` | Icon sizes (comma-separated) | `256,128,64,48,32,16` | +| `-example` | Generate example icon | `false` | ### `generate syso` Generates Windows .syso file. @@ -194,13 +192,13 @@ wails3 generate syso [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-manifest` | Path to manifest file | Required | -| `-icon` | Path to icon file | Required | -| `-info` | Path to version info file | | -| `-arch` | Target architecture | Current GOARCH | -| `-out` | Output filename | `rsrc_windows_[arch].syso` | +| Flag | Description | Default | +|-------------|---------------------------|----------------------------| +| `-manifest` | Path to manifest file | Required | +| `-icon` | Path to icon file | Required | +| `-info` | Path to version info file | | +| `-arch` | Target architecture | Current GOARCH | +| `-out` | Output filename | `rsrc_windows_[arch].syso` | ### `generate .desktop` Generates a Linux .desktop file. @@ -210,20 +208,20 @@ wails3 generate .desktop [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-name` | Application name | Required | -| `-exec` | Executable path | Required | -| `-icon` | Icon path | | -| `-categories` | Application categories | `Utility` | -| `-comment` | Application comment | | -| `-terminal` | Run in terminal | `false` | -| `-keywords` | Search keywords | | -| `-version` | Application version | | -| `-genericname` | Generic name | | -| `-startupnotify` | Show startup notification | `false` | -| `-mimetype` | Supported MIME types | | -| `-output` | Output filename | `[name].desktop` | +| Flag | Description | Default | +|------------------|---------------------------|------------------| +| `-name` | Application name | Required | +| `-exec` | Executable path | Required | +| `-icon` | Icon path | | +| `-categories` | Application categories | `Utility` | +| `-comment` | Application comment | | +| `-terminal` | Run in terminal | `false` | +| `-keywords` | Search keywords | | +| `-version` | Application version | | +| `-genericname` | Generic name | | +| `-startupnotify` | Show startup notification | `false` | +| `-mimetype` | Supported MIME types | | +| `-output` | Output filename | `[name].desktop` | ### `generate runtime` Generates the pre-built version of the runtime. @@ -247,13 +245,13 @@ wails3 generate appimage [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-binary` | Path to binary | Required | -| `-icon` | Path to icon file | Required | -| `-desktop` | Path to .desktop file | Required | -| `-builddir` | Build directory | Temp directory | -| `-output` | Output directory | `.` | +| Flag | Description | Default | +|-------------|-----------------------|----------------| +| `-binary` | Path to binary | Required | +| `-icon` | Path to icon file | Required | +| `-desktop` | Path to .desktop file | Required | +| `-builddir` | Build directory | Temp directory | +| `-output` | Output directory | `.` | Base command: `wails3 service` @@ -269,18 +267,18 @@ wails3 service init [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-n` | Service name | `example_service` | +| Flag | Description | Default | +|------|---------------------|-------------------| +| `-n` | Service name | `example_service` | | `-d` | Service description | `Example service` | -| `-p` | Package name | | -| `-o` | Output directory | `.` | -| `-q` | Suppress output | `false` | -| `-a` | Author name | | -| `-v` | Version | | -| `-w` | Website URL | | -| `-r` | Repository URL | | -| `-l` | License | | +| `-p` | Package name | | +| `-o` | Output directory | `.` | +| `-q` | Suppress output | `false` | +| `-a` | Author name | | +| `-v` | Version | | +| `-w` | Website URL | | +| `-r` | Repository URL | | +| `-l` | License | | Base command: `wails3 tool` @@ -296,9 +294,9 @@ wails3 tool checkport [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-port` | Port to check | `9245` | +| Flag | Description | Default | +|---------|---------------|-------------| +| `-port` | Port to check | `9245` | | `-host` | Host to check | `localhost` | ### `tool watcher` @@ -309,11 +307,11 @@ wails3 tool watcher [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-config` | Config file path | `./build/config.yml` | -| `-ignore` | Patterns to ignore | | -| `-include` | Patterns to include | | +| Flag | Description | Default | +|------------|---------------------|----------------------| +| `-config` | Config file path | `./build/config.yml` | +| `-ignore` | Patterns to ignore | | +| `-include` | Patterns to include | | ### `tool cp` Copies files. @@ -337,20 +335,20 @@ wails3 tool package [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-format` | Package format (deb, rpm, archlinux) | `deb` | -| `-name` | Executable name | Required | -| `-config` | Config file path | Required | -| `-out` | Output directory | `.` | +| Flag | Description | Default | +|-----------|--------------------------------------|----------| +| `-format` | Package format (deb, rpm, archlinux) | `deb` | +| `-name` | Executable name | Required | +| `-config` | Config file path | Required | +| `-out` | Output directory | `.` | #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-format` | Package format (deb, rpm, archlinux) | `deb` | -| `-name` | Executable name | `myapp` | -| `-config` | Config file path | | -| `-out` | Output directory | `.` | +| Flag | Description | Default | +|-----------|--------------------------------------|---------| +| `-format` | Package format (deb, rpm, archlinux) | `deb` | +| `-name` | Executable name | `myapp` | +| `-config` | Config file path | | +| `-out` | Output directory | `.` | Base command: `wails3 update` @@ -366,18 +364,18 @@ wails3 update build-assets [flags] ``` #### Flags -| Flag | Description | Default | -|------|-------------|---------| -| `-config` | Config file path | | -| `-dir` | Output directory | `build` | -| `-silent` | Suppress output | `false` | -| `-company` | Company name | | -| `-productname` | Product name | | -| `-description` | Product description | | -| `-version` | Product version | | -| `-identifier` | Product identifier | | -| `-copyright` | Copyright notice | | -| `-comments` | File comments | | +| Flag | Description | Default | +|----------------|---------------------|---------| +| `-config` | Config file path | | +| `-dir` | Output directory | `build` | +| `-silent` | Suppress output | `false` | +| `-company` | Company name | | +| `-productname` | Product name | | +| `-description` | Product description | | +| `-version` | Product version | | +| `-identifier` | Product identifier | | +| `-copyright` | Copyright notice | | +| `-comments` | File comments | | Base command: `wails3` diff --git a/docs/src/content/docs/guides/single-instance.mdx b/docs/src/content/docs/guides/single-instance.mdx new file mode 100644 index 00000000000..25b7fac47ce --- /dev/null +++ b/docs/src/content/docs/guides/single-instance.mdx @@ -0,0 +1,119 @@ +--- +title: Single Instance +description: Limiting your app to a single running instance +sidebar: + order: 40 +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components"; + +Single instance locking is a mechanism that prevents 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. + + +## Usage + +To enable single instance functionality in your app, provide a `SingleInstanceOptions` struct when creating your application: + +```go +app := application.New(application.Options{ + // ... other options ... + SingleInstance: &application.SingleInstanceOptions{ + UniqueID: "com.myapp.unique-id", + OnSecondInstanceLaunch: func(data application.SecondInstanceData) { + log.Printf("Second instance launched with args: %v", data.Args) + log.Printf("Working directory: %s", data.WorkingDir) + log.Printf("Additional data: %v", data.AdditionalData) + }, + // Optional: Pass additional data to second instance + AdditionalData: map[string]string{ + "launchtime": time.Now().String(), + }, + }, +}) +``` + +The `SingleInstanceOptions` struct has the following fields: + +- `UniqueID`: A unique identifier for your application. This should be a unique string, typically in reverse domain notation (e.g., "com.company.appname"). +- `EncryptionKey`: Optional 32-byte array for encrypting data passed between instances using AES-256-GCM. If provided as a non-zero array, all communication between instances will be encrypted. +- `OnSecondInstanceLaunch`: A callback function that is called when a second instance of your app is launched. The callback receives a `SecondInstanceData` struct containing: + - `Args`: The command line arguments passed to the second instance + - `WorkingDir`: The working directory of the second instance + - `AdditionalData`: Any additional data passed from the second instance (if provided) +- `AdditionalData`: Optional map of string key-value pairs that will be passed to the first instance when subsequent instances are launched + +:::danger[Warning] +The Single Instance feature implements an optional encryption protocol using AES-256-GCM. Without encryption enabled, +data passed between instances is not secure. When using the single instance feature without encryption, +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. +::: + +### Secure Communication + +To enable secure communication between instances, provide a 32-byte encryption key. This key must be the same for all instances of your application: + +```go +// Define your encryption key (must be exactly 32 bytes) +var encryptionKey = [32]byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, +} + +// Use the key in SingleInstanceOptions +SingleInstance: &application.SingleInstanceOptions{ + UniqueID: "com.myapp.unique-id", + // Enable encryption for instance communication + EncryptionKey: encryptionKey, + // ... other options ... +} +``` + +:::tip[Security Best Practices] +- Use a unique key for your application +- Store the key securely if loading it from configuration +- Do not use the example key shown above - create your own! +::: + +### Window Management + +When handling second instance launches, you'll often want to bring your application window to the front. You can do this using the window's `Focus()` method. If your window is minimized, you may need to restore it first: + +```go + + var mainWindow *application.WebviewWindow + + SingleInstance: &application.SingleInstanceOptions{ + // Other options... + OnSecondInstanceLaunch: func(data application.SecondInstanceData) { + // Focus the window if needed + if mainWindow != nil { + mainWindow.Restore() + mainWindow.Focus() + } + }, + } +``` + +## How it works + + + + + Single instance lock 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) + + + + + Single instance lock 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) + + + + + Single instance lock 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/) + + + diff --git a/v3/examples/single-instance/README.md b/v3/examples/single-instance/README.md new file mode 100644 index 00000000000..5982cb90935 --- /dev/null +++ b/v3/examples/single-instance/README.md @@ -0,0 +1,62 @@ +# Single Instance Example + +This example demonstrates the single instance functionality in Wails v3. It shows how to: + +1. Ensure only one instance of your application can run at a time +2. Notify the first instance when a second instance is launched +3. Pass data between instances +4. Handle command line arguments and working directory information from second instances + +## Running the Example + +1. Build and run the application: + ```bash + go build + ./single-instance + ``` + +2. Try launching a second instance of the application. You'll notice: + - The second instance will exit immediately + - The first instance will receive and display: + - Command line arguments from the second instance + - Working directory of the second instance + - Additional data passed from the second instance + +3. Check the application logs to see the information received from second instances. + +## Features Demonstrated + +- Setting up single instance lock with a unique identifier +- Handling second instance launches through callbacks +- Passing custom data between instances +- Displaying instance information in a web UI +- Cross-platform support (Windows, macOS, Linux) + +## Code Overview + +The example consists of: + +- `main.go`: The main application code demonstrating single instance setup +- A simple web UI showing current instance information +- Callback handling for second instance launches + +## Implementation Details + +The application uses the Wails v3 single instance feature: + +```go +app := application.New(&application.Options{ + SingleInstance: &application.SingleInstanceOptions{ + UniqueID: "com.wails.example.single-instance", + OnSecondInstance: func(data application.SecondInstanceData) { + // Handle second instance launch + }, + AdditionalData: map[string]string{ + }, + }, +}) +``` + +The implementation uses platform-specific mechanisms: +- Windows: Named mutex and window messages +- Unix (Linux/macOS): File locking with flock and signals diff --git a/v3/examples/single-instance/assets/index.html b/v3/examples/single-instance/assets/index.html new file mode 100644 index 00000000000..330b062ecd4 --- /dev/null +++ b/v3/examples/single-instance/assets/index.html @@ -0,0 +1,141 @@ + + + + + + Single Instance Demo + + + + +
+

Single Instance Demo

+ +
+

Current Instance Information

+
Loading...
+
+ +
+

Instructions

+

Try launching another instance of this application. The first instance will:

+ +

Check the application logs to see the information received from second instances.

+
+
+ + + + \ No newline at end of file diff --git a/v3/examples/single-instance/main.go b/v3/examples/single-instance/main.go new file mode 100644 index 00000000000..e0658ae8f45 --- /dev/null +++ b/v3/examples/single-instance/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "embed" + "log" + "log/slog" + "os" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +//go:embed assets/index.html +var assets embed.FS + +type App struct{} + +func (a *App) GetCurrentInstanceInfo() map[string]interface{} { + return map[string]interface{}{ + "args": os.Args, + "workingDir": getCurrentWorkingDir(), + } +} + +var encryptionKey = [32]byte{ + 0x1e, 0x1f, 0x1c, 0x1d, 0x1a, 0x1b, 0x18, 0x19, + 0x16, 0x17, 0x14, 0x15, 0x12, 0x13, 0x10, 0x11, + 0x0e, 0x0f, 0x0c, 0x0d, 0x0a, 0x0b, 0x08, 0x09, + 0x06, 0x07, 0x04, 0x05, 0x02, 0x03, 0x00, 0x01, +} + +func main() { + + var window *application.WebviewWindow + app := application.New(application.Options{ + Name: "Single Instance Example", + LogLevel: slog.LevelDebug, + Description: "An example of single instance functionality in Wails v3", + Services: []application.Service{ + application.NewService(&App{}), + }, + SingleInstance: &application.SingleInstanceOptions{ + UniqueID: "com.wails.example.single-instance", + EncryptionKey: encryptionKey, + OnSecondInstanceLaunch: func(data application.SecondInstanceData) { + if window != nil { + window.EmitEvent("secondInstanceLaunched", data) + window.Restore() + window.Focus() + } + log.Printf("Second instance launched with args: %v\n", data.Args) + log.Printf("Working directory: %s\n", data.WorkingDir) + if data.AdditionalData != nil { + log.Printf("Additional data: %v\n", data.AdditionalData) + } + }, + AdditionalData: map[string]string{ + "launchtime": time.Now().Local().String(), + }, + }, + Assets: application.AssetOptions{ + Handler: application.BundledAssetFileServer(assets), + }, + }) + + window = app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + Title: "Single Instance Demo", + Width: 800, + Height: 700, + URL: "/", + }) + + app.Run() +} + +func getCurrentWorkingDir() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + return dir +} diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/package.json b/v3/internal/runtime/desktop/@wailsio/runtime/package.json index 4c909126bd2..28325672e57 100644 --- a/v3/internal/runtime/desktop/@wailsio/runtime/package.json +++ b/v3/internal/runtime/desktop/@wailsio/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@wailsio/runtime", "type": "module", - "version": "3.0.0-alpha.35", + "version": "3.0.0-alpha.36", "description": "Wails Runtime", "types": "types/index.d.ts", "exports": { diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/src/event_types.js b/v3/internal/runtime/desktop/@wailsio/runtime/src/event_types.js index eea9aed8b7a..a663c60ed08 100644 --- a/v3/internal/runtime/desktop/@wailsio/runtime/src/event_types.js +++ b/v3/internal/runtime/desktop/@wailsio/runtime/src/event_types.js @@ -174,6 +174,8 @@ export const EventTypes = { WindowFileDraggingEntered: "mac:WindowFileDraggingEntered", WindowFileDraggingPerformed: "mac:WindowFileDraggingPerformed", WindowFileDraggingExited: "mac:WindowFileDraggingExited", + WindowShow: "mac:WindowShow", + WindowHide: "mac:WindowHide", }, Linux: { SystemThemeChanged: "linux:SystemThemeChanged", diff --git a/v3/internal/runtime/desktop/@wailsio/runtime/types/event_types.d.ts b/v3/internal/runtime/desktop/@wailsio/runtime/types/event_types.d.ts index 9c90df3d4ea..fbae10e1048 100644 --- a/v3/internal/runtime/desktop/@wailsio/runtime/types/event_types.d.ts +++ b/v3/internal/runtime/desktop/@wailsio/runtime/types/event_types.d.ts @@ -174,6 +174,8 @@ export declare const EventTypes: { WindowFileDraggingEntered: string, WindowFileDraggingPerformed: string, WindowFileDraggingExited: string, + WindowShow: string, + WindowHide: string, }, Linux: { SystemThemeChanged: string, diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index a68c589f7e2..7b5298fe7e8 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -4,6 +4,7 @@ import ( "context" "embed" "encoding/json" + "errors" "fmt" "io" "log" @@ -174,6 +175,23 @@ func New(appOptions Options) *App { result.OnShutdown(appOptions.OnShutdown) } + // Initialize single instance manager if enabled + if appOptions.SingleInstance != nil { + manager, err := newSingleInstanceManager(result, appOptions.SingleInstance) + if err != nil { + if errors.Is(err, alreadyRunningError) && manager != nil { + err = manager.notifyFirstInstance() + if err != nil { + globalApplication.error("Failed to notify first instance: " + err.Error()) + } + os.Exit(appOptions.SingleInstance.ExitCode) + } + result.handleFatalError(fmt.Errorf("failed to initialize single instance manager: %w", err)) + } else { + result.singleInstanceManager = manager + } + } + return result } @@ -357,6 +375,9 @@ type App struct { // Wails ApplicationEvent Listener related wailsEventListenerLock sync.Mutex wailsEventListeners []WailsEventListener + + // singleInstanceManager handles single instance functionality + singleInstanceManager *singleInstanceManager } func (a *App) handleWarning(msg string) { @@ -792,6 +813,10 @@ func (a *App) cleanup() { a.systemTrays = nil a.systemTraysLock.Unlock() }) + // Cleanup single instance manager + if a.singleInstanceManager != nil { + a.singleInstanceManager.cleanup() + } } func (a *App) Quit() { diff --git a/v3/pkg/application/application_darwin.go b/v3/pkg/application/application_darwin.go index ecdfa5f3062..4a88b9a25cf 100644 --- a/v3/pkg/application/application_darwin.go +++ b/v3/pkg/application/application_darwin.go @@ -15,6 +15,7 @@ package application extern void registerListener(unsigned int event); #import +#import static AppDelegate *appDelegate = nil; @@ -157,6 +158,13 @@ static const char* serializationNSDictionary(void *dict) { return nil; } + +static void startSingleInstanceListener(const char *uniqueID) { + // Convert to NSString + NSString *uid = [NSString stringWithUTF8String:uniqueID]; + [[NSDistributedNotificationCenter defaultCenter] addObserver:appDelegate + selector:@selector(handleSecondInstanceNotification:) name:uid object:nil]; +} */ import "C" import ( @@ -221,6 +229,11 @@ func (m *macosApp) setApplicationMenu(menu *Menu) { } func (m *macosApp) run() error { + if m.parent.options.SingleInstance != nil { + cUniqueID := C.CString(m.parent.options.SingleInstance.UniqueID) + defer C.free(unsafe.Pointer(cUniqueID)) + C.startSingleInstanceListener(cUniqueID) + } // Add a hook to the ApplicationDidFinishLaunching event m.parent.OnApplicationEvent(events.Mac.ApplicationDidFinishLaunching, func(*ApplicationEvent) { C.setApplicationShouldTerminateAfterLastWindowClosed(C.bool(m.parent.options.Mac.ApplicationShouldTerminateAfterLastWindowClosed)) diff --git a/v3/pkg/application/application_darwin_delegate.m b/v3/pkg/application/application_darwin_delegate.m index 82a91df2f29..0aeaaa4210c 100644 --- a/v3/pkg/application/application_darwin_delegate.m +++ b/v3/pkg/application/application_darwin_delegate.m @@ -4,6 +4,7 @@ extern bool hasListeners(unsigned int); extern bool shouldQuitApplication(); extern void cleanup(); +extern void handleSecondInstanceData(char * message); @implementation AppDelegate - (void)dealloc { @@ -47,6 +48,15 @@ - (BOOL)applicationShouldHandleReopen:(NSNotification *)notification return TRUE; } +- (void)handleSecondInstanceNotification:(NSNotification *)note; +{ + if (note.userInfo[@"message"] != nil) { + NSString *message = note.userInfo[@"message"]; + const char* utf8Message = message.UTF8String; + handleSecondInstanceData((char*)utf8Message); + } +} + // GENERATED EVENTS START - (void)applicationDidBecomeActive:(NSNotification *)notification { if( hasListeners(EventApplicationDidBecomeActive) ) { diff --git a/v3/pkg/application/application_options.go b/v3/pkg/application/application_options.go index 171b7e8a543..25328c9c6f5 100644 --- a/v3/pkg/application/application_options.go +++ b/v3/pkg/application/application_options.go @@ -117,6 +117,9 @@ type Options struct { // The '.' is required FileAssociations []string + // SingleInstance options for single instance functionality + SingleInstance *SingleInstanceOptions + // This blank field ensures types from other packages // are never convertible to Options. // This property, in turn, improves the accuracy of the binding generator. diff --git a/v3/pkg/application/dialogs.go b/v3/pkg/application/dialogs.go index 5fc908bc5fe..1e52225ce52 100644 --- a/v3/pkg/application/dialogs.go +++ b/v3/pkg/application/dialogs.go @@ -1,7 +1,6 @@ package application import ( - "fmt" "strings" "sync" ) @@ -294,9 +293,7 @@ func (d *OpenFileDialogStruct) PromptForMultipleSelection() ([]string, error) { selections, err := InvokeSyncWithResultAndError(d.impl.show) var result []string - fmt.Println("Waiting for results:") for filename := range selections { - fmt.Println(filename) result = append(result, filename) } diff --git a/v3/pkg/application/single_instance.go b/v3/pkg/application/single_instance.go new file mode 100644 index 00000000000..43270ede16e --- /dev/null +++ b/v3/pkg/application/single_instance.go @@ -0,0 +1,214 @@ +package application + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" +) + +var alreadyRunningError = errors.New("application is already running") +var secondInstanceBuffer = make(chan string, 1) +var once sync.Once + +// SecondInstanceData contains information about the second instance launch +type SecondInstanceData struct { + Args []string `json:"args"` + WorkingDir string `json:"workingDir"` + AdditionalData map[string]string `json:"additionalData,omitempty"` +} + +// SingleInstanceOptions defines options for single instance functionality +type SingleInstanceOptions struct { + // UniqueID is used to identify the application instance + // This should be unique per application, e.g. "com.myapp.myapplication" + UniqueID string + + // OnSecondInstanceLaunch is called when a second instance of the application is launched + // The callback receives data about the second instance launch + OnSecondInstanceLaunch func(data SecondInstanceData) + + // AdditionalData allows passing custom data from second instance to first + AdditionalData map[string]string + + // ExitCode is the exit code to use when the second instance exits + ExitCode int + + // EncryptionKey is a 32-byte key used for encrypting instance communication + // If not provided (zero array), data will be sent unencrypted + EncryptionKey [32]byte +} + +// platformLock is the interface that platform-specific lock implementations must implement +type platformLock interface { + // acquire attempts to acquire the lock + acquire(uniqueID string) error + // release releases the lock and cleans up resources + release() + // notify sends data to the first instance + notify(data string) error +} + +// singleInstanceManager handles the single instance functionality +type singleInstanceManager struct { + options *SingleInstanceOptions + lock platformLock + app *App +} + +func newSingleInstanceManager(app *App, options *SingleInstanceOptions) (*singleInstanceManager, error) { + if options == nil { + return nil, nil + } + + manager := &singleInstanceManager{ + options: options, + app: app, + } + + // Launch second instance data listener + once.Do(func() { + go func() { + for encryptedData := range secondInstanceBuffer { + var secondInstanceData SecondInstanceData + var jsonData []byte + var err error + + // Check if encryption key is non-zero + var zeroKey [32]byte + if options.EncryptionKey != zeroKey { + // Try to decrypt the data + jsonData, err = decrypt(options.EncryptionKey, encryptedData) + if err != nil { + continue // Skip invalid data + } + } else { + jsonData = []byte(encryptedData) + } + + if err := json.Unmarshal(jsonData, &secondInstanceData); err == nil && manager.options.OnSecondInstanceLaunch != nil { + manager.options.OnSecondInstanceLaunch(secondInstanceData) + } + } + }() + }) + + // Create platform-specific lock + lock, err := newPlatformLock(manager) + if err != nil { + return nil, err + } + + manager.lock = lock + + // Try to acquire the lock + err = lock.acquire(options.UniqueID) + if err != nil { + return manager, err + } + + return manager, nil +} + +func (m *singleInstanceManager) cleanup() { + if m == nil || m.lock == nil { + return + } + m.lock.release() +} + +// encrypt encrypts data using AES-256-GCM +func encrypt(key [32]byte, plaintext []byte) (string, error) { + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + + nonce := make([]byte, 12) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil) + encrypted := append(nonce, ciphertext...) + return base64.StdEncoding.EncodeToString(encrypted), nil +} + +// decrypt decrypts data using AES-256-GCM +func decrypt(key [32]byte, encrypted string) ([]byte, error) { + data, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + return nil, err + } + + if len(data) < 12 { + return nil, errors.New("invalid encrypted data") + } + + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + + aesgcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := data[:12] + ciphertext := data[12:] + + return aesgcm.Open(nil, nonce, ciphertext, nil) +} + +// notifyFirstInstance sends data to the first instance of the application +func (m *singleInstanceManager) notifyFirstInstance() error { + data := SecondInstanceData{ + Args: os.Args, + WorkingDir: getCurrentWorkingDir(), + AdditionalData: m.options.AdditionalData, + } + + serialized, err := json.Marshal(data) + if err != nil { + return err + } + + // Check if encryption key is non-zero + var zeroKey [32]byte + if m.options.EncryptionKey != zeroKey { + encrypted, err := encrypt(m.options.EncryptionKey, serialized) + if err != nil { + return err + } + return m.lock.notify(encrypted) + } + + return m.lock.notify(string(serialized)) +} + +func getCurrentWorkingDir() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + return dir +} + +// getLockPath returns the path to the lock file for Unix systems +func getLockPath(uniqueID string) string { + // Use system temp directory + tmpDir := os.TempDir() + lockFileName := uniqueID + ".lock" + return filepath.Join(tmpDir, lockFileName) +} diff --git a/v3/pkg/application/single_instance_darwin.go b/v3/pkg/application/single_instance_darwin.go new file mode 100644 index 00000000000..4101294d81a --- /dev/null +++ b/v3/pkg/application/single_instance_darwin.go @@ -0,0 +1,96 @@ +//go:build darwin + +package application + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa + +#include +#import +#import + +static void SendDataToFirstInstance(char *singleInstanceUniqueId, char* message) { + [[NSDistributedNotificationCenter defaultCenter] + postNotificationName:[NSString stringWithUTF8String:singleInstanceUniqueId] + object:nil + userInfo:@{@"message": [NSString stringWithUTF8String:message]} + deliverImmediately:YES]; +} + +*/ +import "C" +import ( + "os" + "syscall" + "unsafe" +) + +type darwinLock struct { + file *os.File + uniqueID string + manager *singleInstanceManager +} + +func newPlatformLock(manager *singleInstanceManager) (platformLock, error) { + return &darwinLock{ + manager: manager, + }, nil +} + +func (l *darwinLock) acquire(uniqueID string) error { + l.uniqueID = uniqueID + lockFilePath := os.TempDir() + lockFileName := uniqueID + ".lock" + var err error + l.file, err = createLockFile(lockFilePath + "/" + lockFileName) + if err != nil { + return alreadyRunningError + } + return nil +} + +func (l *darwinLock) release() { + if l.file != nil { + syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN) + l.file.Close() + os.Remove(l.file.Name()) + l.file = nil + } +} + +func (l *darwinLock) notify(data string) error { + singleInstanceUniqueId := C.CString(l.uniqueID) + defer C.free(unsafe.Pointer(singleInstanceUniqueId)) + cData := C.CString(data) + defer C.free(unsafe.Pointer(cData)) + + C.SendDataToFirstInstance(singleInstanceUniqueId, cData) + + os.Exit(l.manager.options.ExitCode) + return nil +} + +// 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 +} + +//export handleSecondInstanceData +func handleSecondInstanceData(secondInstanceMessage *C.char) { + message := C.GoString(secondInstanceMessage) + secondInstanceBuffer <- message +} diff --git a/v3/pkg/application/single_instance_linux.go b/v3/pkg/application/single_instance_linux.go new file mode 100644 index 00000000000..c2a306eeb68 --- /dev/null +++ b/v3/pkg/application/single_instance_linux.go @@ -0,0 +1,100 @@ +//go:build linux + +package application + +import ( + "fmt" + "github.com/godbus/dbus/v5" + "os" + "strings" + "sync" + "syscall" +) + +type dbusHandler func(string) + +var setup sync.Once + +func (f dbusHandler) SendMessage(message string) *dbus.Error { + f(message) + return nil +} + +type linuxLock struct { + file *os.File + uniqueID string + dbusPath string + dbusName string + manager *singleInstanceManager +} + +func newPlatformLock(manager *singleInstanceManager) (platformLock, error) { + return &linuxLock{ + manager: manager, + }, nil +} + +func (l *linuxLock) acquire(uniqueID string) error { + if uniqueID == "" { + return fmt.Errorf("UniqueID is required for single instance lock") + } + + id := "wails_app_" + strings.ReplaceAll(strings.ReplaceAll(uniqueID, "-", "_"), ".", "_") + + l.dbusName = "org." + id + ".SingleInstance" + l.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 err + } + + setup.Do(func() { + f := dbusHandler(func(message string) { + secondInstanceBuffer <- message + }) + + err := conn.Export(f, dbus.ObjectPath(l.dbusPath), l.dbusName) + if err != nil { + globalApplication.error(err.Error()) + } + }) + + reply, err := conn.RequestName(l.dbusName, dbus.NameFlagDoNotQueue) + if err != nil { + return err + } + + // if name already taken, try to send args to existing instance, if no success just launch new instance + if reply == dbus.RequestNameReplyExists { + return alreadyRunningError + } + return nil +} + +func (l *linuxLock) release() { + if l.file != nil { + syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN) + l.file.Close() + os.Remove(l.file.Name()) + l.file = nil + } +} + +func (l *linuxLock) notify(data string) error { + 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 err + } + + err = conn.Object(l.dbusName, dbus.ObjectPath(l.dbusPath)).Call(l.dbusName+".SendMessage", 0, data).Store() + if err != nil { + return err + } + os.Exit(l.manager.options.ExitCode) + return nil +} diff --git a/v3/pkg/application/single_instance_windows.go b/v3/pkg/application/single_instance_windows.go new file mode 100644 index 00000000000..9f92b4eb7a7 --- /dev/null +++ b/v3/pkg/application/single_instance_windows.go @@ -0,0 +1,129 @@ +//go:build windows + +package application + +import ( + "errors" + "fmt" + "github.com/wailsapp/wails/v3/pkg/w32" + "golang.org/x/sys/windows" + "syscall" + "unsafe" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") +) + +type windowsLock struct { + handle syscall.Handle + uniqueID string + msgString string + hwnd w32.HWND + manager *singleInstanceManager + className string + windowName string +} + +func newPlatformLock(manager *singleInstanceManager) (platformLock, error) { + return &windowsLock{ + manager: manager, + }, nil +} + +func (l *windowsLock) acquire(uniqueID string) error { + if uniqueID == "" { + return fmt.Errorf("UniqueID is required for single instance lock") + } + + l.uniqueID = uniqueID + id := "wails-app-" + uniqueID + l.className = id + "-sic" + l.windowName = id + "-siw" + mutexName := id + "-sim" + + _, err := windows.CreateMutex(nil, false, windows.StringToUTF16Ptr(mutexName)) + if err != nil { + // Find the window + return alreadyRunningError + } else { + l.hwnd = createEventTargetWindow(l.className, l.windowName) + } + + return nil +} + +func (l *windowsLock) release() { + if l.handle != 0 { + syscall.CloseHandle(l.handle) + l.handle = 0 + } + if l.hwnd != 0 { + w32.DestroyWindow(l.hwnd) + l.hwnd = 0 + } +} + +func (l *windowsLock) notify(data string) error { + + // app is already running + hwnd := w32.FindWindowW(windows.StringToUTF16Ptr(l.className), windows.StringToUTF16Ptr(l.windowName)) + + if hwnd == 0 { + return errors.New("unable to notify other instance") + } + + w32.SendMessageToWindow(hwnd, data) + + return nil +} + +func createEventTargetWindow(className string, windowName string) w32.HWND { + 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 = w32.MustStringToUTF16Ptr(className) + class.IconSm = 0 + + w32.RegisterClassEx(&class) + + // Create hidden message-only window + hwnd := w32.CreateWindowEx( + 0, + w32.MustStringToUTF16Ptr(className), + w32.MustStringToUTF16Ptr(windowName), + 0, + 0, + 0, + 0, + 0, + w32.HWND_MESSAGE, + 0, + w32.GetModuleHandle(""), + nil, + ) + + return hwnd +} + +func wndProc(hwnd w32.HWND, msg uint32, wparam w32.WPARAM, lparam w32.LPARAM) w32.LRESULT { + if msg == w32.WM_COPYDATA { + ldata := (*w32.COPYDATASTRUCT)(unsafe.Pointer(lparam)) + + if ldata.DwData == w32.WMCOPYDATA_SINGLE_INSTANCE_DATA { + serialized := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(ldata.LpData))) + secondInstanceBuffer <- serialized + } + return w32.LRESULT(0) + } + + return w32.DefWindowProc(hwnd, msg, wparam, lparam) +} diff --git a/v3/pkg/application/webview_window_darwin.m b/v3/pkg/application/webview_window_darwin.m index 25684066046..a7b9f5010a9 100644 --- a/v3/pkg/application/webview_window_darwin.m +++ b/v3/pkg/application/webview_window_darwin.m @@ -195,38 +195,43 @@ - (void) dealloc { } - (void)windowDidZoom:(NSNotification *)notification { NSWindow *window = notification.object; + WebviewWindowDelegate* delegate = (WebviewWindowDelegate*)[window delegate]; if ([window isZoomed]) { if (hasListeners(EventWindowMaximise)) { - processWindowEvent(self.windowId, EventWindowMaximise); + processWindowEvent(delegate.windowId, EventWindowMaximise); } } else { if (hasListeners(EventWindowUnMaximise)) { - processWindowEvent(self.windowId, EventWindowUnMaximise); + processWindowEvent(delegate.windowId, EventWindowUnMaximise); } } } - (void)performZoomIn:(id)sender { [super zoom:sender]; if (hasListeners(EventWindowZoomIn)) { - processWindowEvent(self.windowId, EventWindowZoomIn); + WebviewWindowDelegate* delegate = (WebviewWindowDelegate*)[sender delegate]; + processWindowEvent(delegate.windowId, EventWindowZoomIn); } } - (void)performZoomOut:(id)sender { [super zoom:sender]; if (hasListeners(EventWindowZoomOut)) { - processWindowEvent(self.windowId, EventWindowZoomOut); + WebviewWindowDelegate* delegate = (WebviewWindowDelegate*)[sender delegate]; + processWindowEvent(delegate.windowId, EventWindowZoomOut); } } - (void)performZoomReset:(id)sender { [self setFrame:[self frameRectForContentRect:[[self screen] visibleFrame]] display:YES]; if (hasListeners(EventWindowZoomReset)) { - processWindowEvent(self.windowId, EventWindowZoomReset); + WebviewWindowDelegate* delegate = (WebviewWindowDelegate*)[sender delegate]; + processWindowEvent(delegate.windowId, EventWindowZoomReset); } } @end @implementation WebviewWindowDelegate - (BOOL)windowShouldClose:(NSWindow *)sender { - processWindowEvent(self.windowId, EventWindowShouldClose); + WebviewWindowDelegate* delegate = (WebviewWindowDelegate*)[sender delegate]; + processWindowEvent(delegate.windowId, EventWindowShouldClose); return false; } - (void) dealloc { @@ -730,6 +735,18 @@ - (void)windowFileDraggingExited:(NSNotification *)notification { } } +- (void)windowShow:(NSNotification *)notification { + if( hasListeners(EventWindowShow) ) { + processWindowEvent(self.windowId, EventWindowShow); + } +} + +- (void)windowHide:(NSNotification *)notification { + if( hasListeners(EventWindowHide) ) { + processWindowEvent(self.windowId, EventWindowHide); + } +} + - (void)webView:(WKWebView *)webview didStartProvisionalNavigation:(WKNavigation *)navigation { if( hasListeners(EventWebViewDidStartProvisionalNavigation) ) { processWindowEvent(self.windowId, EventWebViewDidStartProvisionalNavigation); diff --git a/v3/pkg/events/events.go b/v3/pkg/events/events.go index dc34cb3f6bc..56529ae9d2e 100644 --- a/v3/pkg/events/events.go +++ b/v3/pkg/events/events.go @@ -34,30 +34,30 @@ type commonEvents struct { func newCommonEvents() commonEvents { return commonEvents{ - ApplicationStarted: 1203, - WindowMaximise: 1204, - WindowUnMaximise: 1205, - WindowFullscreen: 1206, - WindowUnFullscreen: 1207, - WindowRestore: 1208, - WindowMinimise: 1209, - WindowUnMinimise: 1210, - WindowClosing: 1211, - WindowZoom: 1212, - WindowZoomIn: 1213, - WindowZoomOut: 1214, - WindowZoomReset: 1215, - WindowFocus: 1216, - WindowLostFocus: 1217, - WindowShow: 1218, - WindowHide: 1219, - WindowDPIChanged: 1220, - WindowFilesDropped: 1221, - WindowRuntimeReady: 1222, - ThemeChanged: 1223, - WindowDidMove: 1224, - WindowDidResize: 1225, - ApplicationOpenedWithFile: 1226, + ApplicationStarted: 1205, + WindowMaximise: 1206, + WindowUnMaximise: 1207, + WindowFullscreen: 1208, + WindowUnFullscreen: 1209, + WindowRestore: 1210, + WindowMinimise: 1211, + WindowUnMinimise: 1212, + WindowClosing: 1213, + WindowZoom: 1214, + WindowZoomIn: 1215, + WindowZoomOut: 1216, + WindowZoomReset: 1217, + WindowFocus: 1218, + WindowLostFocus: 1219, + WindowShow: 1220, + WindowHide: 1221, + WindowDPIChanged: 1222, + WindowFilesDropped: 1223, + WindowRuntimeReady: 1224, + ThemeChanged: 1225, + WindowDidMove: 1226, + WindowDidResize: 1227, + ApplicationOpenedWithFile: 1228, } } @@ -218,6 +218,8 @@ type macEvents struct { WindowFileDraggingEntered WindowEventType WindowFileDraggingPerformed WindowEventType WindowFileDraggingExited WindowEventType + WindowShow WindowEventType + WindowHide WindowEventType } func newMacEvents() macEvents { @@ -350,6 +352,8 @@ func newMacEvents() macEvents { WindowFileDraggingEntered: 1157, WindowFileDraggingPerformed: 1158, WindowFileDraggingExited: 1159, + WindowShow: 1160, + WindowHide: 1161, } } @@ -403,49 +407,49 @@ type windowsEvents struct { func newWindowsEvents() windowsEvents { return windowsEvents{ - SystemThemeChanged: 1160, - APMPowerStatusChange: 1161, - APMSuspend: 1162, - APMResumeAutomatic: 1163, - APMResumeSuspend: 1164, - APMPowerSettingChange: 1165, - ApplicationStarted: 1166, - WebViewNavigationCompleted: 1167, - WindowInactive: 1168, - WindowActive: 1169, - WindowClickActive: 1170, - WindowMaximise: 1171, - WindowUnMaximise: 1172, - WindowFullscreen: 1173, - WindowUnFullscreen: 1174, - WindowRestore: 1175, - WindowMinimise: 1176, - WindowUnMinimise: 1177, - WindowClosing: 1178, - WindowSetFocus: 1179, - WindowKillFocus: 1180, - WindowDragDrop: 1181, - WindowDragEnter: 1182, - WindowDragLeave: 1183, - WindowDragOver: 1184, - WindowDidMove: 1185, - WindowDidResize: 1186, - WindowShow: 1187, - WindowHide: 1188, - WindowStartMove: 1189, - WindowEndMove: 1190, - WindowStartResize: 1191, - WindowEndResize: 1192, - WindowKeyDown: 1193, - WindowKeyUp: 1194, - WindowZOrderChanged: 1195, - WindowPaint: 1196, - WindowBackgroundErase: 1197, - WindowNonClientHit: 1198, - WindowNonClientMouseDown: 1199, - WindowNonClientMouseUp: 1200, - WindowNonClientMouseMove: 1201, - WindowNonClientMouseLeave: 1202, + SystemThemeChanged: 1162, + APMPowerStatusChange: 1163, + APMSuspend: 1164, + APMResumeAutomatic: 1165, + APMResumeSuspend: 1166, + APMPowerSettingChange: 1167, + ApplicationStarted: 1168, + WebViewNavigationCompleted: 1169, + WindowInactive: 1170, + WindowActive: 1171, + WindowClickActive: 1172, + WindowMaximise: 1173, + WindowUnMaximise: 1174, + WindowFullscreen: 1175, + WindowUnFullscreen: 1176, + WindowRestore: 1177, + WindowMinimise: 1178, + WindowUnMinimise: 1179, + WindowClosing: 1180, + WindowSetFocus: 1181, + WindowKillFocus: 1182, + WindowDragDrop: 1183, + WindowDragEnter: 1184, + WindowDragLeave: 1185, + WindowDragOver: 1186, + WindowDidMove: 1187, + WindowDidResize: 1188, + WindowShow: 1189, + WindowHide: 1190, + WindowStartMove: 1191, + WindowEndMove: 1192, + WindowStartResize: 1193, + WindowEndResize: 1194, + WindowKeyDown: 1195, + WindowKeyUp: 1196, + WindowZOrderChanged: 1197, + WindowPaint: 1198, + WindowBackgroundErase: 1199, + WindowNonClientHit: 1200, + WindowNonClientMouseDown: 1201, + WindowNonClientMouseUp: 1202, + WindowNonClientMouseMove: 1203, + WindowNonClientMouseLeave: 1204, } } @@ -590,71 +594,73 @@ var eventToJS = map[uint]string{ 1157: "mac:WindowFileDraggingEntered", 1158: "mac:WindowFileDraggingPerformed", 1159: "mac:WindowFileDraggingExited", - 1160: "windows:SystemThemeChanged", - 1161: "windows:APMPowerStatusChange", - 1162: "windows:APMSuspend", - 1163: "windows:APMResumeAutomatic", - 1164: "windows:APMResumeSuspend", - 1165: "windows:APMPowerSettingChange", - 1166: "windows:ApplicationStarted", - 1167: "windows:WebViewNavigationCompleted", - 1168: "windows:WindowInactive", - 1169: "windows:WindowActive", - 1170: "windows:WindowClickActive", - 1171: "windows:WindowMaximise", - 1172: "windows:WindowUnMaximise", - 1173: "windows:WindowFullscreen", - 1174: "windows:WindowUnFullscreen", - 1175: "windows:WindowRestore", - 1176: "windows:WindowMinimise", - 1177: "windows:WindowUnMinimise", - 1178: "windows:WindowClosing", - 1179: "windows:WindowSetFocus", - 1180: "windows:WindowKillFocus", - 1181: "windows:WindowDragDrop", - 1182: "windows:WindowDragEnter", - 1183: "windows:WindowDragLeave", - 1184: "windows:WindowDragOver", - 1185: "windows:WindowDidMove", - 1186: "windows:WindowDidResize", - 1187: "windows:WindowShow", - 1188: "windows:WindowHide", - 1189: "windows:WindowStartMove", - 1190: "windows:WindowEndMove", - 1191: "windows:WindowStartResize", - 1192: "windows:WindowEndResize", - 1193: "windows:WindowKeyDown", - 1194: "windows:WindowKeyUp", - 1195: "windows:WindowZOrderChanged", - 1196: "windows:WindowPaint", - 1197: "windows:WindowBackgroundErase", - 1198: "windows:WindowNonClientHit", - 1199: "windows:WindowNonClientMouseDown", - 1200: "windows:WindowNonClientMouseUp", - 1201: "windows:WindowNonClientMouseMove", - 1202: "windows:WindowNonClientMouseLeave", - 1203: "common:ApplicationStarted", - 1204: "common:WindowMaximise", - 1205: "common:WindowUnMaximise", - 1206: "common:WindowFullscreen", - 1207: "common:WindowUnFullscreen", - 1208: "common:WindowRestore", - 1209: "common:WindowMinimise", - 1210: "common:WindowUnMinimise", - 1211: "common:WindowClosing", - 1212: "common:WindowZoom", - 1213: "common:WindowZoomIn", - 1214: "common:WindowZoomOut", - 1215: "common:WindowZoomReset", - 1216: "common:WindowFocus", - 1217: "common:WindowLostFocus", - 1218: "common:WindowShow", - 1219: "common:WindowHide", - 1220: "common:WindowDPIChanged", - 1221: "common:WindowFilesDropped", - 1222: "common:WindowRuntimeReady", - 1223: "common:ThemeChanged", - 1224: "common:WindowDidMove", - 1225: "common:WindowDidResize", - 1226: "common:ApplicationOpenedWithFile", + 1160: "mac:WindowShow", + 1161: "mac:WindowHide", + 1162: "windows:SystemThemeChanged", + 1163: "windows:APMPowerStatusChange", + 1164: "windows:APMSuspend", + 1165: "windows:APMResumeAutomatic", + 1166: "windows:APMResumeSuspend", + 1167: "windows:APMPowerSettingChange", + 1168: "windows:ApplicationStarted", + 1169: "windows:WebViewNavigationCompleted", + 1170: "windows:WindowInactive", + 1171: "windows:WindowActive", + 1172: "windows:WindowClickActive", + 1173: "windows:WindowMaximise", + 1174: "windows:WindowUnMaximise", + 1175: "windows:WindowFullscreen", + 1176: "windows:WindowUnFullscreen", + 1177: "windows:WindowRestore", + 1178: "windows:WindowMinimise", + 1179: "windows:WindowUnMinimise", + 1180: "windows:WindowClosing", + 1181: "windows:WindowSetFocus", + 1182: "windows:WindowKillFocus", + 1183: "windows:WindowDragDrop", + 1184: "windows:WindowDragEnter", + 1185: "windows:WindowDragLeave", + 1186: "windows:WindowDragOver", + 1187: "windows:WindowDidMove", + 1188: "windows:WindowDidResize", + 1189: "windows:WindowShow", + 1190: "windows:WindowHide", + 1191: "windows:WindowStartMove", + 1192: "windows:WindowEndMove", + 1193: "windows:WindowStartResize", + 1194: "windows:WindowEndResize", + 1195: "windows:WindowKeyDown", + 1196: "windows:WindowKeyUp", + 1197: "windows:WindowZOrderChanged", + 1198: "windows:WindowPaint", + 1199: "windows:WindowBackgroundErase", + 1200: "windows:WindowNonClientHit", + 1201: "windows:WindowNonClientMouseDown", + 1202: "windows:WindowNonClientMouseUp", + 1203: "windows:WindowNonClientMouseMove", + 1204: "windows:WindowNonClientMouseLeave", + 1205: "common:ApplicationStarted", + 1206: "common:WindowMaximise", + 1207: "common:WindowUnMaximise", + 1208: "common:WindowFullscreen", + 1209: "common:WindowUnFullscreen", + 1210: "common:WindowRestore", + 1211: "common:WindowMinimise", + 1212: "common:WindowUnMinimise", + 1213: "common:WindowClosing", + 1214: "common:WindowZoom", + 1215: "common:WindowZoomIn", + 1216: "common:WindowZoomOut", + 1217: "common:WindowZoomReset", + 1218: "common:WindowFocus", + 1219: "common:WindowLostFocus", + 1220: "common:WindowShow", + 1221: "common:WindowHide", + 1222: "common:WindowDPIChanged", + 1223: "common:WindowFilesDropped", + 1224: "common:WindowRuntimeReady", + 1225: "common:ThemeChanged", + 1226: "common:WindowDidMove", + 1227: "common:WindowDidResize", + 1228: "common:ApplicationOpenedWithFile", } diff --git a/v3/pkg/events/events.txt b/v3/pkg/events/events.txt index 02193852e9f..5703c13d0c3 100644 --- a/v3/pkg/events/events.txt +++ b/v3/pkg/events/events.txt @@ -134,6 +134,8 @@ mac:WebViewDidCommitNavigation mac:WindowFileDraggingEntered mac:WindowFileDraggingPerformed mac:WindowFileDraggingExited +mac:WindowShow +mac:WindowHide windows:SystemThemeChanged windows:APMPowerStatusChange windows:APMSuspend diff --git a/v3/pkg/events/events_darwin.h b/v3/pkg/events/events_darwin.h index 4e551ea0e61..6a6cefc4c7a 100644 --- a/v3/pkg/events/events_darwin.h +++ b/v3/pkg/events/events_darwin.h @@ -134,8 +134,10 @@ extern void processWindowEvent(unsigned int, unsigned int); #define EventWindowFileDraggingEntered 1157 #define EventWindowFileDraggingPerformed 1158 #define EventWindowFileDraggingExited 1159 +#define EventWindowShow 1160 +#define EventWindowHide 1161 -#define MAX_EVENTS 1160 +#define MAX_EVENTS 1162 #endif \ No newline at end of file diff --git a/v3/pkg/w32/window.go b/v3/pkg/w32/window.go index eb75e3ef100..542971db7d9 100644 --- a/v3/pkg/w32/window.go +++ b/v3/pkg/w32/window.go @@ -25,8 +25,20 @@ var ( user32 = syscall.NewLazyDLL("user32.dll") getSystemMenu = user32.NewProc("GetSystemMenu") enableMenuItem = user32.NewProc("EnableMenuItem") + findWindow = user32.NewProc("FindWindowW") + sendMessage = user32.NewProc("SendMessageW") ) +const ( + WMCOPYDATA_SINGLE_INSTANCE_DATA = 1542 +) + +type COPYDATASTRUCT struct { + DwData uintptr + CbData uint32 + LpData uintptr +} + var Fatal func(error) const ( @@ -305,3 +317,31 @@ func EnableCloseButton(hwnd HWND) error { return nil } + +func FindWindowW(className, windowName *uint16) HWND { + ret, _, _ := findWindow.Call( + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(windowName)), + ) + return HWND(ret) +} + +func SendMessageToWindow(hwnd HWND, msg string) { + // Convert data to UTF16 string + dataUTF16 := MustStringToUTF16(msg) + + // Prepare COPYDATASTRUCT + cds := COPYDATASTRUCT{ + DwData: WMCOPYDATA_SINGLE_INSTANCE_DATA, + CbData: uint32((len(dataUTF16) * 2) + 1), // +1 for null terminator + LpData: uintptr(unsafe.Pointer(&dataUTF16[0])), + } + + // Send message to first instance + _, _, _ = procSendMessage.Call( + hwnd, + WM_COPYDATA, + 0, + uintptr(unsafe.Pointer(&cds)), + ) +}