Skip to content
Open
Changes from all 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
93 changes: 84 additions & 9 deletions notify_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,26 @@

package beeep

/*
#include <libproc.h>
*/
import "C"

import (
"fmt"
"image/png"
"os"
"os/exec"
"time"
"unsafe"

"github.com/jackmordaunt/icns/v3"
)

// Notify sends desktop notification.
// The icon can be string with a path to png file or png []byte data. Stock icon names can also be used where supported.
//
// On macOS, this will first try `terminal-notifier` and will fall back to AppleScript with `osascript`.
// On macOS, this will first try `alerter` and will fall back to AppleScript with `osascript`.
func Notify(title, message string, icon any) error {
return notify1(title, message, icon, false)
}
Expand All @@ -30,46 +37,69 @@ func notify1(title, message string, icon any, urgent bool) error {
}

cmd1 := func() error {
cmd, err := exec.LookPath("terminal-notifier")
cmd, err := exec.LookPath("alerter")
if err != nil {
return err
}

var tmpFiles []string
cleanup := func() {
for _, f := range tmpFiles {
os.Remove(f)
}
}

var img string

if isBytes {
tmp1, err := bytesToFilename(icon.([]byte))
if err != nil {
return err
}
defer os.Remove(tmp1)
tmpFiles = append(tmpFiles, tmp1)

tmp2, err := pngToIcns(tmp1)
if err != nil {
cleanup()
return err
}
defer os.Remove(tmp2)
tmpFiles = append(tmpFiles, tmp2)

img = tmp2
} else {
tmp, err := pngToIcns(pathAbs(icon.(string)))
if err != nil {
return err
}
defer os.Remove(tmp)

tmpFiles = append(tmpFiles, tmp)
img = tmp
}

var args []string
if urgent {
args = []string{"-title", title, "-message", message, "-group", AppName, "-appIcon", img, "-sound", "default"}
args = []string{"--title", title, "--message", message, "--group", AppName, "--app-icon", img, "--sound", "default"}
} else {
args = []string{"-title", title, "-message", message, "-group", AppName, "-appIcon", img}
args = []string{"--title", title, "--message", message, "--group", AppName, "--app-icon", img}
}

c := exec.Command(cmd, args...)

return c.Run()
if err := c.Start(); err != nil {
cleanup()
return err
}

// Block until alerter has finished setup and entered its run loop,
// ensuring the icon temp file is not removed before alerter reads it.
waitUntilIdle(c.Process.Pid, 5*time.Second)

// Schedule cleanup for when alerter eventually exits.
go func() {
c.Wait()
cleanup()
}()

return nil
}

cmd2 := func() error {
Expand All @@ -84,6 +114,7 @@ func notify1(title, message string, icon any, urgent bool) error {
} else {
script = fmt.Sprintf("display notification %q with title %q", message, title)
}

cmd := exec.Command(osa, "-e", script)

return cmd.Run()
Expand Down Expand Up @@ -129,3 +160,47 @@ func pngToIcns(icon string) (string, error) {

return out, nil
}

func pidTaskInfo(pid int) (uint64, error) {
var info C.struct_proc_taskinfo

ret := C.proc_pidinfo(C.int(pid), C.PROC_PIDTASKINFO, 0, unsafe.Pointer(&info), C.int(unsafe.Sizeof(info)))
if ret <= 0 {
return 0, fmt.Errorf("proc_pidinfo failed")
}

return uint64(info.pti_total_user) + uint64(info.pti_total_system), nil
}

func waitUntilIdle(pid int, timeout time.Duration) {
deadline := time.Now().Add(timeout)
var prev uint64
seenActivity := false
stable := 0

for time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)

cur, err := pidTaskInfo(pid)
if err != nil {
return
}

if cur > 0 {
seenActivity = true
}

if seenActivity {
if cur == prev {
stable++
if stable >= 3 {
return
}
} else {
stable = 0
}
}

prev = cur
}
}
Loading