From e91cd32de0b805662a5f4cc78d960512f36321a1 Mon Sep 17 00:00:00 2001 From: EBIBO Date: Thu, 21 Dec 2023 21:16:11 +0800 Subject: [PATCH] Implement global shortcuts (#393) * Implement global shortcuts * No need to remove callback from map when unregistering a shortcut The relevant event will no longer be triggered after the shortcut is unregistered. If the same key is registered again, the original callback will be replaced with new one. Therefore, we don't need to find the registered key from the map. Besides, we don't need to parse the accelerator, too. An error will be returned if the method failed. * Add test case and doc 1. Add test case 2. Add doc to README.md 3. Handle `ctx.Err` in functions * Updated * Updated * Updated * Updated * Updated * Updated --- README.md | 21 +++++++ astilectron.go | 46 +++++++++------ event.go | 73 ++++++++++++----------- global_shortcuts.go | 123 +++++++++++++++++++++++++++++++++++++++ global_shortcuts_test.go | 42 +++++++++++++ 5 files changed, 253 insertions(+), 52 deletions(-) create mode 100644 global_shortcuts.go create mode 100644 global_shortcuts_test.go diff --git a/README.md b/README.md index e38219a..30ee3f2 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,27 @@ var m = d.NewMenu([]*astilectron.MenuItemOptions{ m.Create() ``` +## Global Shortcuts + +Registering a global shortcut. + +```go +// Register a new global shortcut +isRegistered, _ := a.GlobalShortcuts().Register("CmdOrCtrl+x", func() { + fmt.Println("CmdOrCtrl+x is pressed") +}) +fmt.Println("CmdOrCtrl+x is registered:", isRegistered) // true + +// Check if a global shortcut is registered +isRegistered, _ = a.GlobalShortcuts().IsRegistered("Shift+Y") // false + +// Unregister a global shortcut +a.GlobalShortcuts().Unregister("CmdOrCtrl+x") + +// Unregister all global shortcuts +a.GlobalShortcuts().UnregisterAll() +``` + ## Dialogs Add the following line at the top of your javascript file : diff --git a/astilectron.go b/astilectron.go index bd733d3..8f376d9 100644 --- a/astilectron.go +++ b/astilectron.go @@ -2,18 +2,17 @@ package astilectron import ( "fmt" + "github.com/asticode/go-astikit" "net" "os/exec" "runtime" "time" - - "github.com/asticode/go-astikit" ) // Versions const ( DefaultAcceptTCPTimeout = 30 * time.Second - DefaultVersionAstilectron = "0.57.0" + DefaultVersionAstilectron = "0.58.0" DefaultVersionElectron = "11.4.3" ) @@ -41,22 +40,23 @@ const ( // Astilectron represents an object capable of interacting with Astilectron type Astilectron struct { - dispatcher *dispatcher - displayPool *displayPool - dock *Dock - executer Executer - identifier *identifier - l astikit.SeverityLogger - listener net.Listener - options Options - paths *Paths - provisioner Provisioner - reader *reader - stderrWriter *astikit.WriterAdapter - stdoutWriter *astikit.WriterAdapter - supported *Supported - worker *astikit.Worker - writer *writer + dispatcher *dispatcher + displayPool *displayPool + dock *Dock + executer Executer + globalShortcuts *GlobalShortcuts + identifier *identifier + l astikit.SeverityLogger + listener net.Listener + options Options + paths *Paths + provisioner Provisioner + reader *reader + stderrWriter *astikit.WriterAdapter + stdoutWriter *astikit.WriterAdapter + supported *Supported + worker *astikit.Worker + writer *writer } // Options represents Astilectron options @@ -156,6 +156,11 @@ func (a *Astilectron) SetExecuter(e Executer) *Astilectron { return a } +// GlobalShortcuts gets the global shortcuts +func (a *Astilectron) GlobalShortcuts() *GlobalShortcuts { + return a.globalShortcuts +} + // On implements the Listenable interface func (a *Astilectron) On(eventName string, l Listener) { a.dispatcher.addListener(targetIDApp, eventName, l) @@ -322,6 +327,9 @@ func (a *Astilectron) executeCmd(cmd *exec.Cmd) (err error) { // Create dock a.dock = newDock(a.worker.Context(), a.dispatcher, a.identifier, a.writer) + // Create global shortcuts + a.globalShortcuts = newGlobalShortcuts(a.worker.Context(), a.dispatcher, a.identifier, a.writer) + // Update supported features a.supported = e.Supported return diff --git a/event.go b/event.go index c27904c..5062ef5 100644 --- a/event.go +++ b/event.go @@ -20,39 +20,40 @@ type Event struct { // This is a list of all possible payloads. // A choice was made not to use interfaces since it's a pain in the ass asserting each an every payload afterwards // We use pointers so that omitempty works - AuthInfo *EventAuthInfo `json:"authInfo,omitempty"` - Badge *string `json:"badge,omitempty"` - BounceType string `json:"bounceType,omitempty"` - Bounds *RectangleOptions `json:"bounds,omitempty"` - CallbackID string `json:"callbackId,omitempty"` - Code string `json:"code,omitempty"` - Displays *EventDisplays `json:"displays,omitempty"` - Enable *bool `json:"enable,omitempty"` - FilePath string `json:"filePath,omitempty"` - ID *int `json:"id,omitempty"` - Image string `json:"image,omitempty"` - Index *int `json:"index,omitempty"` - Menu *EventMenu `json:"menu,omitempty"` - MenuItem *EventMenuItem `json:"menuItem,omitempty"` - MenuItemOptions *MenuItemOptions `json:"menuItemOptions,omitempty"` - MenuItemPosition *int `json:"menuItemPosition,omitempty"` - MenuPopupOptions *MenuPopupOptions `json:"menuPopupOptions,omitempty"` - Message *EventMessage `json:"message,omitempty"` - NotificationOptions *NotificationOptions `json:"notificationOptions,omitempty"` - Password string `json:"password,omitempty"` - Path string `json:"path,omitempty"` - Reply string `json:"reply,omitempty"` - Request *EventRequest `json:"request,omitempty"` - SecondInstance *EventSecondInstance `json:"secondInstance,omitempty"` - SessionID string `json:"sessionId,omitempty"` - Supported *Supported `json:"supported,omitempty"` - TrayOptions *TrayOptions `json:"trayOptions,omitempty"` - URL string `json:"url,omitempty"` - URLNew string `json:"newUrl,omitempty"` - URLOld string `json:"oldUrl,omitempty"` - Username string `json:"username,omitempty"` - WindowID string `json:"windowId,omitempty"` - WindowOptions *WindowOptions `json:"windowOptions,omitempty"` + AuthInfo *EventAuthInfo `json:"authInfo,omitempty"` + Badge *string `json:"badge,omitempty"` + BounceType string `json:"bounceType,omitempty"` + Bounds *RectangleOptions `json:"bounds,omitempty"` + CallbackID string `json:"callbackId,omitempty"` + Code string `json:"code,omitempty"` + Displays *EventDisplays `json:"displays,omitempty"` + Enable *bool `json:"enable,omitempty"` + FilePath string `json:"filePath,omitempty"` + GlobalShortcuts *EventGlobalShortcuts `json:"globalShortcuts,omitempty"` + ID *int `json:"id,omitempty"` + Image string `json:"image,omitempty"` + Index *int `json:"index,omitempty"` + Menu *EventMenu `json:"menu,omitempty"` + MenuItem *EventMenuItem `json:"menuItem,omitempty"` + MenuItemOptions *MenuItemOptions `json:"menuItemOptions,omitempty"` + MenuItemPosition *int `json:"menuItemPosition,omitempty"` + MenuPopupOptions *MenuPopupOptions `json:"menuPopupOptions,omitempty"` + Message *EventMessage `json:"message,omitempty"` + NotificationOptions *NotificationOptions `json:"notificationOptions,omitempty"` + Password string `json:"password,omitempty"` + Path string `json:"path,omitempty"` + Reply string `json:"reply,omitempty"` + Request *EventRequest `json:"request,omitempty"` + SecondInstance *EventSecondInstance `json:"secondInstance,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Supported *Supported `json:"supported,omitempty"` + TrayOptions *TrayOptions `json:"trayOptions,omitempty"` + URL string `json:"url,omitempty"` + URLNew string `json:"newUrl,omitempty"` + URLOld string `json:"oldUrl,omitempty"` + Username string `json:"username,omitempty"` + WindowID string `json:"windowId,omitempty"` + WindowOptions *WindowOptions `json:"windowOptions,omitempty"` } // EventAuthInfo represents an event auth info @@ -70,6 +71,12 @@ type EventDisplays struct { Primary *DisplayOptions `json:"primary,omitempty"` } +// EventGlobalShortcuts represents event global shortcuts +type EventGlobalShortcuts struct { + Accelerator string `json:"accelerator,omitempty"` + IsRegistered bool `json:"isRegistered,omitempty"` +} + // EventMessage represents an event message type EventMessage struct { i interface{} diff --git a/global_shortcuts.go b/global_shortcuts.go new file mode 100644 index 0000000..1239f50 --- /dev/null +++ b/global_shortcuts.go @@ -0,0 +1,123 @@ +package astilectron + +import ( + "context" + "sync" +) + +const ( + EventNameGlobalShortcutsCmdRegister = "global.shortcuts.cmd.register" + EventNameGlobalShortcutsCmdIsRegistered = "global.shortcuts.cmd.is.registered" + EventNameGlobalShortcutsCmdUnregister = "global.shortcuts.cmd.unregister" + EventNameGlobalShortcutsCmdUnregisterAll = "global.shortcuts.cmd.unregister.all" + EventNameGlobalShortcutsEventRegistered = "global.shortcuts.event.registered" + EventNameGlobalShortcutsEventIsRegistered = "global.shortcuts.event.is.registered" + EventNameGlobalShortcutsEventUnregistered = "global.shortcuts.event.unregistered" + EventNameGlobalShortcutsEventUnregisteredAll = "global.shortcuts.event.unregistered.all" + EventNameGlobalShortcutEventTriggered = "global.shortcuts.event.triggered" +) + +type globalShortcutsCallback func() + +// GlobalShortcuts represents global shortcuts +type GlobalShortcuts struct { + *object + m *sync.Mutex + callbacks map[string]globalShortcutsCallback +} + +func newGlobalShortcuts(ctx context.Context, d *dispatcher, i *identifier, w *writer) (gs *GlobalShortcuts) { + gs = &GlobalShortcuts{ + object: newObject(ctx, d, i, w, i.new()), + m: new(sync.Mutex), + callbacks: make(map[string]globalShortcutsCallback), + } + gs.On(EventNameGlobalShortcutEventTriggered, func(e Event) (deleteListener bool) { // Register the listener for the triggered event + gs.m.Lock() + callback, ok := gs.callbacks[e.GlobalShortcuts.Accelerator] + gs.m.Unlock() + if ok { + (callback)() + } + return + }) + return +} + +// Register registers a global shortcut +func (gs *GlobalShortcuts) Register(accelerator string, callback globalShortcutsCallback) (isRegistered bool, err error) { + if err = gs.ctx.Err(); err != nil { + return + } + + // Send an event to astilectron to register the global shortcut + result, err := synchronousEvent(gs.ctx, gs, gs.w, Event{Name: EventNameGlobalShortcutsCmdRegister, TargetID: gs.id, GlobalShortcuts: &EventGlobalShortcuts{Accelerator: accelerator}}, EventNameGlobalShortcutsEventRegistered) + if err != nil { + return + } + + // If registered successfully, add the callback to the map + if result.GlobalShortcuts != nil { + if result.GlobalShortcuts.IsRegistered { + gs.m.Lock() + gs.callbacks[accelerator] = callback + gs.m.Unlock() + } + isRegistered = result.GlobalShortcuts.IsRegistered + } + return +} + +// IsRegistered checks whether a global shortcut is registered +func (gs *GlobalShortcuts) IsRegistered(accelerator string) (isRegistered bool, err error) { + if err = gs.ctx.Err(); err != nil { + return + } + + // Send an event to astilectron to check if global shortcut is registered + result, err := synchronousEvent(gs.ctx, gs, gs.w, Event{Name: EventNameGlobalShortcutsCmdIsRegistered, TargetID: gs.id, GlobalShortcuts: &EventGlobalShortcuts{Accelerator: accelerator}}, EventNameGlobalShortcutsEventIsRegistered) + if err != nil { + return + } + + if result.GlobalShortcuts != nil { + isRegistered = result.GlobalShortcuts.IsRegistered + } + return +} + +// Unregister unregisters a global shortcut +func (gs *GlobalShortcuts) Unregister(accelerator string) (err error) { + if err = gs.ctx.Err(); err != nil { + return + } + + // Send an event to astilectron to unregister the global shortcut + _, err = synchronousEvent(gs.ctx, gs, gs.w, Event{Name: EventNameGlobalShortcutsCmdUnregister, TargetID: gs.id, GlobalShortcuts: &EventGlobalShortcuts{Accelerator: accelerator}}, EventNameGlobalShortcutsEventUnregistered) + if err != nil { + return + } + gs.m.Lock() + delete(gs.callbacks, accelerator) + gs.m.Unlock() + return +} + +// UnregisterAll unregisters all global shortcuts +func (gs *GlobalShortcuts) UnregisterAll() (err error) { + if err = gs.ctx.Err(); err != nil { + return + } + + // Send an event to astilectron to unregister all global shortcuts + _, err = synchronousEvent(gs.ctx, gs, gs.w, Event{Name: EventNameGlobalShortcutsCmdUnregisterAll, TargetID: gs.id}, EventNameGlobalShortcutsEventUnregisteredAll) + if err != nil { + return + } + + gs.m.Lock() + gs.callbacks = make(map[string]globalShortcutsCallback) // Clear the map + gs.m.Unlock() + + return +} diff --git a/global_shortcuts_test.go b/global_shortcuts_test.go new file mode 100644 index 0000000..12a4e50 --- /dev/null +++ b/global_shortcuts_test.go @@ -0,0 +1,42 @@ +package astilectron + +import ( + "context" + "fmt" + "testing" +) + +func TestGlobalShortcut_Actions(t *testing.T) { + var d = newDispatcher() + var i = newIdentifier() + var wrt = &mockedWriter{} + var w = newWriter(wrt, &logger{}) + + var gs = newGlobalShortcuts(context.Background(), d, i, w) + + // Register + testObjectAction(t, func() error { + _, e := gs.Register("Ctrl+X", func() {}) + return e + }, gs.object, wrt, fmt.Sprintf(`{"name":"%s","targetID":"%s","globalShortcuts":{"accelerator":"Ctrl+X"}}%s`, EventNameGlobalShortcutsCmdRegister, gs.id, "\n"), + EventNameGlobalShortcutsEventRegistered, &Event{GlobalShortcuts: &EventGlobalShortcuts{Accelerator: "Ctrl+X", IsRegistered: true}}) + + // IsRegistered + testObjectAction(t, func() error { + _, e := gs.IsRegistered("Ctrl+Y") + return e + }, gs.object, wrt, fmt.Sprintf(`{"name":"%s","targetID":"%s","globalShortcuts":{"accelerator":"Ctrl+Y"}}%s`, EventNameGlobalShortcutsCmdIsRegistered, gs.id, "\n"), + EventNameGlobalShortcutsEventIsRegistered, &Event{GlobalShortcuts: &EventGlobalShortcuts{Accelerator: "Ctrl+Y", IsRegistered: false}}) + + // Unregister + testObjectAction(t, func() error { + return gs.Unregister("Ctrl+Z") + }, gs.object, wrt, fmt.Sprintf(`{"name":"%s","targetID":"%s","globalShortcuts":{"accelerator":"Ctrl+Z"}}%s`, EventNameGlobalShortcutsCmdUnregister, gs.id, "\n"), + EventNameGlobalShortcutsEventUnregistered, nil) + + // UnregisterAll + testObjectAction(t, func() error { + return gs.UnregisterAll() + }, gs.object, wrt, fmt.Sprintf(`{"name":"%s","targetID":"%s"}%s`, EventNameGlobalShortcutsCmdUnregisterAll, gs.id, "\n"), + EventNameGlobalShortcutsEventUnregisteredAll, nil) +}