Skip to content
This repository has been archived by the owner on Feb 16, 2024. It is now read-only.

Implement global shortcuts #393

Merged
merged 10 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,27 @@ var m = d.NewMenu([]*astilectron.MenuItemOptions{
m.Create()
```

## Global Shortcuts
asticode marked this conversation as resolved.
Show resolved Hide resolved

Registering a global shortcut.

```go
// Register a new global shortcut
isRegistered, _ := astilectron.GlobalShortcutRegister("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, _ = astilectron.GlobalShortcutIsRegistered("Shift+Y") // false

// Unregister a global shortcut
astilectron.GlobalShortcutUnregister("CmdOrCtrl+x")

// Unregister all global shortcuts
astilectron.GlobalShortcutUnregisterAll()
```

## Dialogs

Add the following line at the top of your javascript file :
Expand Down
45 changes: 27 additions & 18 deletions astilectron.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ package astilectron

import (
"fmt"
"github.com/asticode/go-astikit"
"net"
"os/exec"
"runtime"
"time"

"github.com/asticode/go-astikit"
)

// Versions
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update DefaultVersionAstilectron to v0.58.0 below?

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -187,6 +192,7 @@ func (a *Astilectron) Start() (err error) {
} else {
synchronousFunc(a.worker.Context(), a, nil, "app.event.ready")
}

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could your remove this empty line ?

return nil
}

Expand Down Expand Up @@ -322,6 +328,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
Expand Down
73 changes: 40 additions & 33 deletions event.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{}
Expand Down
135 changes: 135 additions & 0 deletions global_shortcuts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package astilectron

import (
"context"
"sync"
)

const (
EventNameGlobalShortcutsCmdRegister = "global.shortcuts.cmd.register"
EventNameGlobalShortcutsCmdIsRegistered = "global.shortcuts.cmd.is.register"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add the missing ed at the end of global.shortcuts.cmd.is.register?

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 a global shortcut
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you replace the comment with // GlobalShortcuts represents global shortcuts?

type GlobalShortcuts struct {
*object
m *sync.Mutex
callbacks map[string]*globalShortcutsCallback // Store all registered Global Shortcuts
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you remove the comment here?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also could you not store pointers to the callback here?

Unless I'm missing something 🤔

}

func newGlobalShortcuts(ctx context.Context, d *dispatcher, i *identifier, w *writer) (gs *GlobalShortcuts) {

asticode marked this conversation as resolved.
Show resolved Hide resolved
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.execCallback(e.GlobalShortcuts.Accelerator)
return
})
return
}

// Register Register global shortcuts
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update the comment to Register registers a global shortcut and remove the empty line under the func declaration?

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
var event = Event{Name: EventNameGlobalShortcutsCmdRegister, TargetID: gs.id, GlobalShortcuts: &EventGlobalShortcuts{Accelerator: accelerator}}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you not create a variable here and add the content of the event directly in synchronousEvent?

Also could you remove the empty line below?

result, err := synchronousEvent(gs.ctx, gs, gs.w, event, EventNameGlobalShortcutsEventRegistered)

if err != nil {
return
}

asticode marked this conversation as resolved.
Show resolved Hide resolved
// If registered successfully, add the callback to the map
if result.GlobalShortcuts.IsRegistered {
gs.m.Lock()
gs.callbacks[accelerator] = &callback
gs.m.Unlock()
}

isRegistered = result.GlobalShortcuts.IsRegistered
return
}

// IsRegistered Check if a global shortcut is registered
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update the comment to IsRegistered checks whether a global shortcut is registered

Also could you make the same modifications I proposed for the previous function?

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
var event = Event{Name: EventNameGlobalShortcutsCmdIsRegistered, TargetID: gs.id, GlobalShortcuts: &EventGlobalShortcuts{Accelerator: accelerator}}
result, err := synchronousEvent(gs.ctx, gs, gs.w, event, EventNameGlobalShortcutsEventIsRegistered)

if err != nil {
return
}

isRegistered = result.GlobalShortcuts.IsRegistered
return
}

// Unregister Unregister a global shortcut
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update the comment to Unregister unregisters a global shortcut

Also could you make the same modifications I proposed for the previous function?

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
var event = Event{Name: EventNameGlobalShortcutsCmdUnregister, TargetID: gs.id, GlobalShortcuts: &EventGlobalShortcuts{Accelerator: accelerator}}
_, err = synchronousEvent(gs.ctx, gs, gs.w, event, EventNameGlobalShortcutsEventUnregistered)

if err != nil {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be instead:

if err != nil {
  return
}

gs.m.Lock()
delete(callbacks, accelerator)
gs.m.Unlock()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah.. yes, my fault

return
}

// No need to find the callback from the map and delete it
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think cleaning the map is still a good idea

Copy link
Contributor Author

@benebsiny benebsiny Dec 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason why I didn't clean the map is that there are extra costs to determine the corresponded shortcut to be removed from the map. For example, a user registered an event with Ctrl+Shift+S then unregistered with S+Shift+Ctrl, expected that shortcut should be unregistred. Therefore Ctrl+Shift+S should be removed from the map.
A more complex case: registered with CtrlOrCmd+X then unregistered with Cmd+X, expected that shortcut is unregistered. Therefore CtrlOrCmd+X should be removed from the map.
It's too complex to determine the equivalent shortcuts, the best way is to let electron decide whether the shortcuts should be registered or unregistered.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm last time I used shortcuts in JS, Ctrl+Shift+S was different than S+Shift+Ctrl 🤔 Therefore wanting to register a shortcut Ctrl+Shift+S and unregister it with S+Shift+Ctrl should not work in Electron 🤔

Nevertheless, I think it's not too much to ask the developer to be consistent in the accelerator he/she uses when registering/unregistering a shortcut. Also I'd rather keep the map the closest to reality.

Therefore, I'd rather see the map cleaned 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm last time I used shortcuts in JS, Ctrl+Shift+S was different than S+Shift+Ctrl 🤔 Therefore wanting to register a shortcut Ctrl+Shift+S and unregister it with S+Shift+Ctrl should not work in Electron 🤔

Howerver, the combination of ctrl+shift+S was unregistered successfully.

Therefore, I'd rather see the map cleaned 👍

Sure thing

// because that event will no longer be triggerred
// If the same global shortcut is registered again, the original callback will be replaced with the new one

return
}

// UnregisterAll Unregister all global shortcuts
func (gs *GlobalShortcuts) UnregisterAll() (err error) {
asticode marked this conversation as resolved.
Show resolved Hide resolved

if err = gs.ctx.Err(); err != nil {
return
}

// Send an event to astilectron to unregister all global shortcuts
var event = Event{Name: EventNameGlobalShortcutsCmdUnregisterAll, TargetID: gs.id}
_, err = synchronousEvent(gs.ctx, gs, gs.w, event, EventNameGlobalShortcutsEventUnregisteredAll)

if err != nil {
return
}

gs.m.Lock()
gs.callbacks = make(map[string]*globalShortcutsCallback) // Clear the map
gs.m.Unlock()

return
}

// execCallback Execute the GlobalShortcuts event triggered from astilectron
func (gs *GlobalShortcuts) execCallback(accelerator string) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's a need for this function since it's called only once and it's not that big 🤔

gs.m.Lock()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you lock the mutex only to retrieve the callback but not when executing it?

Something like this:

gs.m.Lock()
c, ok := gs.callbacks[accelerator]
gs.m.Unlock()
if ok {
  c()
}

if callback, ok := gs.callbacks[accelerator]; ok {
(*callback)()
}
gs.m.Unlock()
}
42 changes: 42 additions & 0 deletions global_shortcuts_test.go
Original file line number Diff line number Diff line change
@@ -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)
}