diff --git a/app/state.go b/app/state.go index b06ea72..7678b21 100644 --- a/app/state.go +++ b/app/state.go @@ -180,7 +180,13 @@ type State struct { AllBadge widget.Clickable // "All" filter badge // Settings view state (UI-THREAD-ONLY) - SettingsList widget.List + SettingsList widget.List + AutoStartEnabled bool // Whether autostart is currently on + AutoStartClick widget.Clickable // Toggle button + AutoStartError string // Error message after toggle attempt + AutoStartSuccess bool // Show success message after toggle + AutoStartNeedsUpdate bool // Deferred toggle flag (like Hotkeys.NeedsUpdate) + AutoStartUpdating bool // Guard: set on UI thread before goroutine launch, cleared by goroutine before Invalidate // Background-to-UI invalidation flag. // Background goroutines set this (via MarkDirty) alongside Window.Invalidate(). diff --git a/data/syntax/braces.go b/data/syntax/braces.go index 999fb81..3fc9b8c 100644 --- a/data/syntax/braces.go +++ b/data/syntax/braces.go @@ -35,11 +35,6 @@ func (s *ScannerState) Advance(ch byte) bool { return !s.inSingleQuote && !s.inDoubleQuote } -// InQuote reports whether the scanner is currently inside a quoted string. -func (s *ScannerState) InQuote() bool { - return s.inSingleQuote || s.inDoubleQuote -} - // IsBalancedBraces checks if braces are balanced in a command, // respecting quotes. func IsBalancedBraces(command string) bool { diff --git a/data/syntax/braces_test.go b/data/syntax/braces_test.go index 5d04db6..45b33a9 100644 --- a/data/syntax/braces_test.go +++ b/data/syntax/braces_test.go @@ -55,26 +55,6 @@ func TestScannerStateAdvance(t *testing.T) { } } -func TestScannerStateInQuote(t *testing.T) { - var s ScannerState - - if s.InQuote() { - t.Error("expected InQuote() = false for initial state") - } - - s.Advance('"') - - if !s.InQuote() { - t.Error("expected InQuote() = true after opening double quote") - } - - s.Advance('"') - - if s.InQuote() { - t.Error("expected InQuote() = false after closing double quote") - } -} - func TestBalancedBraces(t *testing.T) { tests := []struct { input string diff --git a/go.mod b/go.mod index 12b2c7d..22dc673 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/godbus/dbus/v5 v5.2.2 golang.design/x/hotkey v0.4.1 golang.org/x/exp/shiny v0.0.0-20260212183809-81e46e3db34a + golang.org/x/sys v0.41.0 ) require ( @@ -32,6 +33,5 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect golang.org/x/image v0.36.0 // indirect - golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect ) diff --git a/infra/autostart/autostart_darwin.go b/infra/autostart/autostart_darwin.go new file mode 100644 index 0000000..1ba6a63 --- /dev/null +++ b/infra/autostart/autostart_darwin.go @@ -0,0 +1,110 @@ +//go:build darwin + +package autostart + +import ( + "encoding/xml" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + launchAgentLabel = "com.debrief" + plistName = launchAgentLabel + ".plist" + plistDir = "Library/LaunchAgents" + launchAgentsDirPerm = 0o750 + plistFilePerm = 0o600 +) + +// Enable registers the app as a macOS LaunchAgent to start on login. +func Enable() error { + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + plistPath, err := launchAgentPath() + if err != nil { + return err + } + + dir := filepath.Dir(plistPath) + if err := os.MkdirAll(dir, launchAgentsDirPerm); err != nil { + return fmt.Errorf("failed to create LaunchAgents directory: %w", err) + } + + var escaped strings.Builder + if err := xml.EscapeText(&escaped, []byte(exePath)); err != nil { + return fmt.Errorf("failed to escape executable path: %w", err) + } + + content := ` + + + + Label + ` + launchAgentLabel + ` + ProgramArguments + + ` + escaped.String() + ` + + RunAtLoad + + + +` + + if err := os.WriteFile(plistPath, []byte(content), plistFilePerm); err != nil { + return fmt.Errorf("failed to write LaunchAgent plist: %w", err) + } + + return nil +} + +// Disable removes the LaunchAgent plist to stop the app from starting on login. +func Disable() error { + plistPath, err := launchAgentPath() + if err != nil { + return err + } + + if err := os.Remove(plistPath); err != nil { + if os.IsNotExist(err) { + return nil + } + + return fmt.Errorf("failed to remove LaunchAgent plist: %w", err) + } + + return nil +} + +// IsEnabled checks whether the LaunchAgent plist exists. +func IsEnabled() (bool, error) { + plistPath, err := launchAgentPath() + if err != nil { + return false, err + } + + _, err = os.Stat(plistPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + + return false, fmt.Errorf("failed to check LaunchAgent plist: %w", err) + } + + return true, nil +} + +func launchAgentPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + return filepath.Join(home, plistDir, plistName), nil +} diff --git a/infra/autostart/autostart_linux.go b/infra/autostart/autostart_linux.go new file mode 100644 index 0000000..a7bc17d --- /dev/null +++ b/infra/autostart/autostart_linux.go @@ -0,0 +1,99 @@ +//go:build linux + +package autostart + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + desktopFileName = "debrief.desktop" + autostartDir = "autostart" + autostartDirPerm = 0o750 + desktopFilePerm = 0o600 +) + +// Enable creates an XDG autostart .desktop file so the app starts on login. +func Enable() error { + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + desktopPath, err := autostartFilePath() + if err != nil { + return err + } + + dir := filepath.Dir(desktopPath) + if err := os.MkdirAll(dir, autostartDirPerm); err != nil { + return fmt.Errorf("failed to create autostart directory: %w", err) + } + + content := "[Desktop Entry]\n" + + "Type=Application\n" + + "Name=Debrief\n" + + "Exec=" + exePath + "\n" + + "X-GNOME-Autostart-enabled=true\n" + + "StartupNotify=false\n" + + "Terminal=false\n" + + if err := os.WriteFile(desktopPath, []byte(content), desktopFilePerm); err != nil { + return fmt.Errorf("failed to write autostart desktop file: %w", err) + } + + return nil +} + +// Disable removes the XDG autostart .desktop file. +func Disable() error { + desktopPath, err := autostartFilePath() + if err != nil { + return err + } + + if err := os.Remove(desktopPath); err != nil { + if os.IsNotExist(err) { + return nil + } + + return fmt.Errorf("failed to remove autostart desktop file: %w", err) + } + + return nil +} + +// IsEnabled checks whether the XDG autostart .desktop file exists. +func IsEnabled() (bool, error) { + desktopPath, err := autostartFilePath() + if err != nil { + return false, err + } + + _, err = os.Stat(desktopPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + + return false, fmt.Errorf("failed to check autostart desktop file: %w", err) + } + + return true, nil +} + +func autostartFilePath() (string, error) { + configDir := os.Getenv("XDG_CONFIG_HOME") + if configDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + configDir = filepath.Join(home, ".config") + } + + return filepath.Join(configDir, autostartDir, desktopFileName), nil +} diff --git a/infra/autostart/autostart_windows.go b/infra/autostart/autostart_windows.go new file mode 100644 index 0000000..7c3939a --- /dev/null +++ b/infra/autostart/autostart_windows.go @@ -0,0 +1,75 @@ +//go:build windows + +package autostart + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/windows/registry" +) + +const ( + registryPath = `SOFTWARE\Microsoft\Windows\CurrentVersion\Run` + valueName = "Debrief" +) + +// Enable registers the app to start on login via the Windows registry. +func Enable() error { + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + key, _, err := registry.CreateKey(registry.CURRENT_USER, registryPath, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("failed to open registry key: %w", err) + } + defer key.Close() //nolint:errcheck // registry key close errors are non-actionable + + if err := key.SetStringValue(valueName, exePath); err != nil { + return fmt.Errorf("failed to set registry value: %w", err) + } + + return nil +} + +// Disable removes the app from login startup in the Windows registry. +func Disable() error { + key, err := registry.OpenKey(registry.CURRENT_USER, registryPath, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("failed to open registry key: %w", err) + } + defer key.Close() //nolint:errcheck // registry key close errors are non-actionable + + if err := key.DeleteValue(valueName); err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil + } + + return fmt.Errorf("failed to delete registry value: %w", err) + } + + return nil +} + +// IsEnabled checks whether the app is registered for login startup. +func IsEnabled() (bool, error) { + key, err := registry.OpenKey(registry.CURRENT_USER, registryPath, registry.QUERY_VALUE) + if err != nil { + return false, nil + } + defer key.Close() //nolint:errcheck // registry key close errors are non-actionable + + _, _, err = key.GetStringValue(valueName) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return false, nil + } + + return false, fmt.Errorf("failed to read registry value: %w", err) + } + + return true, nil +} diff --git a/infra/config/config.go b/infra/config/config.go index 8cd71a7..d62587d 100644 --- a/infra/config/config.go +++ b/infra/config/config.go @@ -16,6 +16,8 @@ type Config struct { HotkeyPreset int `json:"hotkeyPreset"` // Preset index (0, 1, or 2) + AutoStart bool `json:"autoStart,omitempty"` // Start on computer boot + // Window geometry persisted across restarts (pixels). // Zero values mean "use default". WindowW int `json:"windowW,omitempty"` @@ -27,6 +29,7 @@ func DefaultConfig() *Config { return &Config{ Version: SettingsVersion, HotkeyPreset: 0, + AutoStart: true, } } diff --git a/infra/hotkey/hotkey.go b/infra/hotkey/hotkey.go index 9c8bad1..81150ae 100644 --- a/infra/hotkey/hotkey.go +++ b/infra/hotkey/hotkey.go @@ -84,29 +84,6 @@ func (m *Manager) stopListener() { } } -// Unregister unregisters the hotkey -func (m *Manager) Unregister() error { - m.mu.Lock() - defer m.mu.Unlock() - - if !m.registered { - return nil - } - - log.Println("Unregistering hotkey") - - m.stopListener() - - if err := m.b.Unregister(); err != nil { - log.Printf("Failed to unregister hotkey: %v", err) - return fmt.Errorf("failed to unregister global hotkey: %w", err) - } - - m.registered = false - - return nil -} - // StringToModifier converts string to hk.Modifier func StringToModifier(s string) (hk.Modifier, error) { switch s { diff --git a/infra/platform/platform.go b/infra/platform/platform.go index c78f53b..9f1ea59 100644 --- a/infra/platform/platform.go +++ b/infra/platform/platform.go @@ -41,14 +41,6 @@ func ExpandPath(path string) string { return path } -func FileExists(path string) bool { - if info, err := os.Stat(path); err == nil { - return !info.IsDir() - } - - return false -} - func UserHomeDir() string { if home, err := os.UserHomeDir(); err == nil { return home diff --git a/main.go b/main.go index 2fb7cb8..acff7e3 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "github.com/debrief-dev/debrief/data/model" "github.com/debrief-dev/debrief/data/shell" "github.com/debrief-dev/debrief/font" + "github.com/debrief-dev/debrief/infra/autostart" "github.com/debrief-dev/debrief/infra/config" "github.com/debrief-dev/debrief/infra/hotkey" "github.com/debrief-dev/debrief/infra/platform" @@ -609,7 +610,8 @@ func run(p *runParams) *savedUIState { ShellFilter: nil, // nil = show all shells ShellBadges: make(map[string]*widget.Clickable), // Initialize badge widgets map - SettingsList: widget.List{List: layout.List{Axis: layout.Vertical}}, + SettingsList: widget.List{List: layout.List{Axis: layout.Vertical}}, + AutoStartEnabled: syncAutoStartState(cfg), StoreShutdown: make(chan struct{}), // Unbuffered: signal store/polling goroutine to stop } @@ -828,6 +830,13 @@ func run(p *runParams) *savedUIState { go ui.ProcessHotkeyUpdate(appState) } + if appState.AutoStartNeedsUpdate && !appState.AutoStartUpdating { + appState.AutoStartNeedsUpdate = false + + appState.AutoStartUpdating = true + go ui.ProcessAutostartUpdate(appState) + } + if p.pprofEnabled { var currentStats runtime.MemStats runtime.ReadMemStats(¤tStats) @@ -848,3 +857,29 @@ func run(p *runParams) *savedUIState { } } } + +// syncAutoStartState reconciles the OS autostart state with the config value. +// On first run (config says enabled, OS says disabled), it registers autostart. +// Returns the effective autostart state. +func syncAutoStartState(cfg *config.Config) bool { + enabled, err := autostart.IsEnabled() + if err != nil { + log.Printf("Failed to check autostart state: %v, using config value", err) + + return cfg.AutoStart + } + + if cfg.AutoStart && !enabled { + if err := autostart.Enable(); err != nil { + log.Printf("Failed to enable autostart on first run: %v", err) + + return false + } + + log.Printf("Autostart enabled on first run") + + return true + } + + return enabled +} diff --git a/ui/constants.go b/ui/constants.go index fc8d9a7..fe17a70 100644 --- a/ui/constants.go +++ b/ui/constants.go @@ -97,6 +97,12 @@ const ( HotKeyCardSuccess = "Hotkey updated to " HotKeyCardFailure = "Failed to register hotkey: %v" HotKeyCardFailureDueToCfg = "Warning: Hotkey updated but config save failed: %v" + + AutoStartCardTitle = "Start on Boot" + AutoStartCardDescription = "Automatically start Debrief when you log in to your computer" + AutoStartSuccess = "Autostart updated successfully" + AutoStartFailure = "Failed to update autostart: %v" + AutoStartFailureCfg = "Warning: Autostart updated but config save failed: %v" ) // --- @@ -195,6 +201,8 @@ const ( ColorErrorRed = 255 // ColorErrorGreen is the RGB green channel value for error messages ColorErrorGreen = 100 + // ColorErrorBlue is the RGB blue channel value for error messages + ColorErrorBlue = 100 // ColorSuccessRed is the RGB red channel value for success messages ColorSuccessRed = 100 // ColorSuccessGreen is the RGB green channel value for success messages @@ -236,6 +244,14 @@ const ( TreePrefixFadeSteps = 8 // CircleIndicatorSize is the size for circular radio button indicators CircleIndicatorSize = unit.Dp(16) + // CheckboxSize is the size for checkbox indicators + CheckboxSize = unit.Dp(18) + // CheckboxRadius is the corner radius for checkbox indicators + CheckboxRadius = unit.Dp(3) + // CheckboxMarginDivisor controls X mark inset relative to checkbox size (size / 4 = 25% margin) + CheckboxMarginDivisor = 4 + // CheckboxStrokeDivisor controls X mark stroke width relative to checkbox size (size / 8) + CheckboxStrokeDivisor = 8 // searchEditorMaxHeight is the max height for the search editor in dp searchEditorMaxHeight = 28 // itemsPerPage is the number of items to jump on PageUp/PageDown in command/stat lists diff --git a/ui/tab_settings.go b/ui/tab_settings.go index ec94d93..82987cc 100644 --- a/ui/tab_settings.go +++ b/ui/tab_settings.go @@ -6,15 +6,19 @@ import ( "image/color" "log" + "gioui.org/f32" "gioui.org/layout" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/widget/material" appstate "github.com/debrief-dev/debrief/app" + "github.com/debrief-dev/debrief/infra/autostart" "github.com/debrief-dev/debrief/infra/hotkey" ) // renderSettingsTab renders the settings view +// +//nolint:dupl // top-level layout is structurally similar to card internals but serves a different purpose func renderSettingsTab(gtx C, app *appstate.State, theme *material.Theme) D { return material.List(theme, &app.SettingsList).Layout(gtx, 1, func(gtx C, _ int) D { return layout.Inset{ @@ -26,15 +30,14 @@ func renderSettingsTab(gtx C, app *appstate.State, theme *material.Theme) D { return layout.Flex{ Axis: layout.Vertical, }.Layout(gtx, - // Title + // Hotkey configuration card layout.Rigid(func(gtx C) D { - title := material.H5(theme, "Settings") - return layout.Inset{Bottom: SpacingHuge}.Layout(gtx, title.Layout) + return renderHotkeyCard(gtx, app, theme) }), - // Hotkey configuration card + // Autostart card layout.Rigid(func(gtx C) D { - return renderHotkeyCard(gtx, app, theme) + return renderAutostartCard(gtx, app, theme) }), ) }) @@ -98,6 +101,8 @@ func renderHotkeyPresets(gtx C, app *appstate.State, theme *material.Theme) D { } // renderPresetButton renders a single preset radio button +// +//nolint:dupl // preset and toggle buttons share visual structure but differ in behavior func renderPresetButton(gtx C, app *appstate.State, theme *material.Theme, presetID int) D { preset := app.Hotkeys.Presets[presetID] isSelected := app.Hotkeys.SelectedPresetID == presetID @@ -193,7 +198,7 @@ func renderHotkeyMessages(gtx C, app *appstate.State, theme *material.Theme) D { if app.Hotkeys.Error != "" { return layout.Inset{Bottom: SpacingMedium}.Layout(gtx, func(gtx C) D { label := material.Body2(theme, app.Hotkeys.Error) - label.Color = color.NRGBA{R: ColorErrorRed, G: ColorErrorGreen, B: ColorErrorGreen, A: ColorWhite} // Red + label.Color = color.NRGBA{R: ColorErrorRed, G: ColorErrorGreen, B: ColorErrorBlue, A: ColorWhite} return label.Layout(gtx) }) @@ -213,6 +218,168 @@ func renderHotkeyMessages(gtx C, app *appstate.State, theme *material.Theme) D { return D{} } +// renderAutostartCard renders the autostart toggle card. +// +//nolint:dupl // label+inset patterns are structurally similar but have different content +func renderAutostartCard(gtx C, app *appstate.State, theme *material.Theme) D { + return renderCard(gtx, theme, AutoStartCardTitle, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + // Toggle button + layout.Rigid(func(gtx C) D { + return renderAutostartToggle(gtx, app, theme) + }), + + // Error/Success messages + layout.Rigid(func(gtx C) D { + return renderAutostartMessages(gtx, app, theme) + }), + ) + }) +} + +// renderAutostartToggle renders the enabled/disabled checkbox toggle. +func renderAutostartToggle(gtx C, app *appstate.State, theme *material.Theme) D { + clickable := &app.AutoStartClick + + for clickable.Clicked(gtx) { + app.AutoStartError = "" + app.AutoStartSuccess = false + app.AutoStartNeedsUpdate = true + } + + textColor := color.NRGBA{R: ColorGray220, G: ColorGray220, B: ColorGray220, A: ColorWhite} + + return layout.Inset{Bottom: SpacingLarge}.Layout(gtx, func(gtx C) D { + return clickable.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return renderCheckbox(gtx, app.AutoStartEnabled) + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Left: SpacingLarge}.Layout(gtx, func(gtx C) D { + lbl := material.Body1(theme, AutoStartCardDescription) + lbl.Color = textColor + + return lbl.Layout(gtx) + }) + }), + ) + }) + }) +} + +// renderCheckbox draws a square checkbox indicator with an X mark when checked. +func renderCheckbox(gtx C, checked bool) D { + size := gtx.Dp(CheckboxSize) + rr := gtx.Dp(CheckboxRadius) + + // Draw box background + rect := clip.RRect{ + Rect: image.Rectangle{Max: image.Pt(size, size)}, + NE: rr, NW: rr, SE: rr, SW: rr, + } + + stack := rect.Push(gtx.Ops) + + if checked { + paint.Fill(gtx.Ops, color.NRGBA{R: ColorBlueRed, G: ColorBlueGreen, B: ColorBlueBlue, A: ColorWhite}) + } else { + paint.Fill(gtx.Ops, color.NRGBA{R: ColorDarkGray60, G: ColorDarkGray60, B: ColorDarkGray60, A: ColorWhite}) + } + + stack.Pop() + + if checked { + drawXMark(gtx, size) + } + + return D{Size: image.Pt(size, size)} +} + +// drawXMark draws two diagonal stroked lines forming an X inside a box. +func drawXMark(gtx C, size int) { + margin := float32(size) / CheckboxMarginDivisor + width := float32(size) / CheckboxStrokeDivisor + clr := color.NRGBA{R: ColorWhite, G: ColorWhite, B: ColorWhite, A: ColorWhite} + + // Top-left to bottom-right + drawStrokeLine(gtx, f32.Pt(margin, margin), f32.Pt(float32(size)-margin, float32(size)-margin), width, clr) + // Top-right to bottom-left + drawStrokeLine(gtx, f32.Pt(float32(size)-margin, margin), f32.Pt(margin, float32(size)-margin), width, clr) +} + +// drawStrokeLine draws a single stroked line between two points. +func drawStrokeLine(gtx C, from, to f32.Point, width float32, clr color.NRGBA) { + var path clip.Path + + path.Begin(gtx.Ops) + path.MoveTo(from) + path.LineTo(to) + + stack := clip.Stroke{Path: path.End(), Width: width}.Op().Push(gtx.Ops) + paint.Fill(gtx.Ops, clr) + stack.Pop() +} + +// renderAutostartMessages shows error/success messages for the autostart toggle. +func renderAutostartMessages(gtx C, app *appstate.State, theme *material.Theme) D { + if app.AutoStartError != "" { + return layout.Inset{Bottom: SpacingMedium}.Layout(gtx, func(gtx C) D { + label := material.Body2(theme, app.AutoStartError) + label.Color = color.NRGBA{R: ColorErrorRed, G: ColorErrorGreen, B: ColorErrorBlue, A: ColorWhite} + + return label.Layout(gtx) + }) + } + + if app.AutoStartSuccess { + return layout.Inset{Bottom: SpacingMedium}.Layout(gtx, func(gtx C) D { + label := material.Body2(theme, AutoStartSuccess) + label.Color = color.NRGBA{R: ColorSuccessRed, G: ColorSuccessGreen, B: ColorSuccessBlue, A: ColorWhite} + + return label.Layout(gtx) + }) + } + + return D{} +} + +// ProcessAutostartUpdate performs the actual autostart toggle and config save. +// Must be called after ev.Frame in a goroutine to avoid blocking the UI thread. +func ProcessAutostartUpdate(app *appstate.State) { + newState := !app.AutoStartEnabled + + var err error + if newState { + err = autostart.Enable() + } else { + err = autostart.Disable() + } + + if err != nil { + app.AutoStartError = fmt.Sprintf(AutoStartFailure, err) + log.Printf("Autostart toggle failed: %v", err) + + app.AutoStartUpdating = false + app.Window.Invalidate() + + return + } + + app.AutoStartEnabled = newState + app.Config.AutoStart = newState + + if err := app.Config.SaveConfig(app.ConfigPath); err != nil { + app.AutoStartError = fmt.Sprintf(AutoStartFailureCfg, err) + log.Printf("Config save failed after autostart toggle: %v", err) + } else { + app.AutoStartSuccess = true + } + + app.AutoStartUpdating = false + app.Window.Invalidate() +} + // saveHotkeyPreset schedules a hotkey update for after frame submission. // Actual registration is deferred to ProcessHotkeyUpdate to avoid a // dispatch_sync deadlock on macOS: the hotkey library calls