Migrate notifier from portmaster-ui to cmds/notifier, remove some duplicated code, move assets to assets/data and add a small go package in assets to allow embedding icons
This commit is contained in:
34
cmds/notifier/.gitignore
vendored
Normal file
34
cmds/notifier/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Compiled binaries
|
||||
notifier
|
||||
notifier.exe
|
||||
|
||||
# Go vendor
|
||||
vendor
|
||||
|
||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Folders
|
||||
_obj
|
||||
_test
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
*.[568vq]
|
||||
[568vq].out
|
||||
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
5
cmds/notifier/README.md
Normal file
5
cmds/notifier/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
### Development Dependencies
|
||||
|
||||
sudo apt install libgtk-3-dev libayatana-appindicator3-dev libwebkitgtk-3.0-dev libgl1-mesa-dev libglu1-mesa-dev libnotify-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
|
||||
sudo pacman -S libappindicator-gtk3
|
||||
65
cmds/notifier/http_api.go
Normal file
65
cmds/notifier/http_api.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/safing/portbase/log"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseURL = "http://127.0.0.1:817/api/v1/"
|
||||
apiShutdownEndpoint = "core/shutdown"
|
||||
)
|
||||
|
||||
var (
|
||||
httpApiClient *http.Client
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Make cookie jar.
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
log.Warningf("http-api: failed to create cookie jar: %s", err)
|
||||
jar = nil
|
||||
}
|
||||
|
||||
// Create client.
|
||||
httpApiClient = &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 3 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func httpApiAction(endpoint string) (response string, err error) {
|
||||
// Make action request.
|
||||
resp, err := httpApiClient.Post(apiBaseURL+endpoint, "", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
// Read the response body.
|
||||
defer resp.Body.Close()
|
||||
respData, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read data: %w", err)
|
||||
}
|
||||
response = strings.TrimSpace(string(respData))
|
||||
|
||||
// Check if the request was successful on the server.
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return response, fmt.Errorf("server failed with %s: %s", resp.Status, response)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// TriggerShutdown triggers a shutdown via the APi.
|
||||
func TriggerShutdown() error {
|
||||
_, err := httpApiAction(apiShutdownEndpoint)
|
||||
return err
|
||||
}
|
||||
25
cmds/notifier/icons.go
Normal file
25
cmds/notifier/icons.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
icons "github.com/safing/portmaster/assets"
|
||||
)
|
||||
|
||||
var (
|
||||
appIconEnsureOnce sync.Once
|
||||
appIconPath string
|
||||
)
|
||||
|
||||
func ensureAppIcon() (location string, err error) {
|
||||
appIconEnsureOnce.Do(func() {
|
||||
if appIconPath == "" {
|
||||
appIconPath = filepath.Join(dataDir, "exec", "portmaster.png")
|
||||
}
|
||||
err = os.WriteFile(appIconPath, icons.PNG, 0o0644)
|
||||
})
|
||||
|
||||
return appIconPath, err
|
||||
}
|
||||
286
cmds/notifier/main.go
Normal file
286
cmds/notifier/main.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/tevino/abool"
|
||||
|
||||
"github.com/safing/portbase/api/client"
|
||||
"github.com/safing/portbase/dataroot"
|
||||
"github.com/safing/portbase/info"
|
||||
"github.com/safing/portbase/log"
|
||||
"github.com/safing/portbase/modules"
|
||||
"github.com/safing/portbase/updater"
|
||||
"github.com/safing/portbase/utils"
|
||||
"github.com/safing/portmaster/service/updates/helper"
|
||||
)
|
||||
|
||||
var (
|
||||
dataDir string
|
||||
printStackOnExit bool
|
||||
showVersion bool
|
||||
|
||||
apiClient = client.NewClient("127.0.0.1:817")
|
||||
connected = abool.New()
|
||||
shuttingDown = abool.New()
|
||||
restarting = abool.New()
|
||||
|
||||
mainCtx, cancelMainCtx = context.WithCancel(context.Background())
|
||||
mainWg = &sync.WaitGroup{}
|
||||
|
||||
dataRoot *utils.DirStructure
|
||||
// Create registry.
|
||||
registry = &updater.ResourceRegistry{
|
||||
Name: "updates",
|
||||
UpdateURLs: []string{
|
||||
"https://updates.safing.io",
|
||||
},
|
||||
DevMode: false,
|
||||
Online: false, // disable download of resources (this is job for the core).
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&dataDir, "data", "", "set data directory")
|
||||
flag.BoolVar(&printStackOnExit, "print-stack-on-exit", false, "prints the stack before of shutting down")
|
||||
flag.BoolVar(&showVersion, "version", false, "show version and exit")
|
||||
|
||||
runtime.GOMAXPROCS(2)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// parse flags
|
||||
flag.Parse()
|
||||
|
||||
// set meta info
|
||||
info.Set("Portmaster Notifier", "0.3.6", "GPLv3", false)
|
||||
|
||||
// check if meta info is ok
|
||||
err := info.CheckVersion()
|
||||
if err != nil {
|
||||
fmt.Println("compile error: please compile using the provided build script")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// print help
|
||||
if modules.HelpFlag {
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if showVersion {
|
||||
fmt.Println(info.FullVersion())
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// auto detect
|
||||
if dataDir == "" {
|
||||
dataDir = detectDataDir()
|
||||
}
|
||||
|
||||
// check data dir
|
||||
if dataDir == "" {
|
||||
fmt.Fprintln(os.Stderr, "please set the data directory using --data=/path/to/data/dir")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// switch to safe exec dir
|
||||
err = os.Chdir(filepath.Join(dataDir, "exec"))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to switch to safe exec dir: %s\n", err)
|
||||
}
|
||||
|
||||
// start log writer
|
||||
err = log.Start()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to start logging: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// load registry
|
||||
err = configureRegistry(true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to load registry: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// connect to API
|
||||
go apiClient.StayConnected()
|
||||
go apiStatusMonitor()
|
||||
|
||||
// start subsystems
|
||||
go tray()
|
||||
go subsystemsClient()
|
||||
go spnStatusClient()
|
||||
go notifClient()
|
||||
go startShutdownEventListener()
|
||||
|
||||
// Shutdown
|
||||
// catch interrupt for clean shutdown
|
||||
signalCh := make(chan os.Signal, 1)
|
||||
signal.Notify(
|
||||
signalCh,
|
||||
os.Interrupt,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGQUIT,
|
||||
)
|
||||
|
||||
// wait for shutdown
|
||||
select {
|
||||
case <-signalCh:
|
||||
fmt.Println(" <INTERRUPT>")
|
||||
log.Warning("program was interrupted, shutting down")
|
||||
case <-mainCtx.Done():
|
||||
log.Warning("program is shutting down")
|
||||
}
|
||||
|
||||
if printStackOnExit {
|
||||
fmt.Println("=== PRINTING STACK ===")
|
||||
_ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 2)
|
||||
fmt.Println("=== END STACK ===")
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(10 * time.Second)
|
||||
fmt.Println("===== TAKING TOO LONG FOR SHUTDOWN - PRINTING STACK TRACES =====")
|
||||
_ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 2)
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
// clear all notifications
|
||||
clearNotifications()
|
||||
|
||||
// shutdown
|
||||
cancelMainCtx()
|
||||
mainWg.Wait()
|
||||
|
||||
apiClient.Shutdown()
|
||||
exitTray()
|
||||
log.Shutdown()
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func apiStatusMonitor() {
|
||||
for {
|
||||
// Wait for connection.
|
||||
<-apiClient.Online()
|
||||
connected.Set()
|
||||
triggerTrayUpdate()
|
||||
|
||||
// Wait for lost connection.
|
||||
<-apiClient.Offline()
|
||||
connected.UnSet()
|
||||
triggerTrayUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
func detectDataDir() string {
|
||||
// get path of executable
|
||||
binPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// get directory
|
||||
binDir := filepath.Dir(binPath)
|
||||
// check if we in the updates directory
|
||||
identifierDir := filepath.Join("updates", runtime.GOOS+"_"+runtime.GOARCH, "notifier")
|
||||
// check if there is a match and return data dir
|
||||
if strings.HasSuffix(binDir, identifierDir) {
|
||||
return filepath.Clean(strings.TrimSuffix(binDir, identifierDir))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func configureRegistry(mustLoadIndex bool) error {
|
||||
// If dataDir is not set, check the environment variable.
|
||||
if dataDir == "" {
|
||||
dataDir = os.Getenv("PORTMASTER_DATA")
|
||||
}
|
||||
|
||||
// If it's still empty, try to auto-detect it.
|
||||
if dataDir == "" {
|
||||
dataDir = detectInstallationDir()
|
||||
}
|
||||
|
||||
// Finally, if it's still empty, the user must provide it.
|
||||
if dataDir == "" {
|
||||
return errors.New("please set the data directory using --data=/path/to/data/dir")
|
||||
}
|
||||
|
||||
// Remove left over quotes.
|
||||
dataDir = strings.Trim(dataDir, `\"`)
|
||||
// Initialize data root.
|
||||
err := dataroot.Initialize(dataDir, 0o0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize data root: %w", err)
|
||||
}
|
||||
dataRoot = dataroot.Root()
|
||||
|
||||
// Initialize registry.
|
||||
err = registry.Initialize(dataRoot.ChildDir("updates", 0o0755))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return updateRegistryIndex(mustLoadIndex)
|
||||
}
|
||||
|
||||
func detectInstallationDir() string {
|
||||
exePath, err := filepath.Abs(os.Args[0])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
parent := filepath.Dir(exePath) // parent should be "...\updates\windows_amd64\notifier"
|
||||
stableJSONFile := filepath.Join(parent, "..", "..", "stable.json") // "...\updates\stable.json"
|
||||
stat, err := os.Stat(stableJSONFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if stat.IsDir() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parent
|
||||
}
|
||||
|
||||
func updateRegistryIndex(mustLoadIndex bool) error {
|
||||
// Set indexes based on the release channel.
|
||||
warning := helper.SetIndexes(registry, "", false, false, false)
|
||||
if warning != nil {
|
||||
log.Warningf("%q", warning)
|
||||
}
|
||||
|
||||
// Load indexes from disk or network, if needed and desired.
|
||||
err := registry.LoadIndexes(context.Background())
|
||||
if err != nil {
|
||||
log.Warningf("error loading indexes %q", warning)
|
||||
if mustLoadIndex {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Load versions from disk to know which others we have and which are available.
|
||||
err = registry.ScanStorage("")
|
||||
if err != nil {
|
||||
log.Warningf("error during storage scan: %q\n", err)
|
||||
}
|
||||
|
||||
registry.SelectVersions()
|
||||
return nil
|
||||
}
|
||||
36
cmds/notifier/notification.go
Normal file
36
cmds/notifier/notification.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
pbnotify "github.com/safing/portbase/notifications"
|
||||
)
|
||||
|
||||
// Notification represents a notification that is to be delivered to the user.
|
||||
type Notification struct {
|
||||
pbnotify.Notification
|
||||
|
||||
// systemID holds the ID returned by the dbus interface on Linux or by WinToast library on Windows.
|
||||
systemID NotificationID
|
||||
}
|
||||
|
||||
// IsSupported returns whether the action is supported on this system.
|
||||
func IsSupportedAction(a pbnotify.Action) bool {
|
||||
switch a.Type {
|
||||
case pbnotify.ActionTypeNone:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SelectAction sends an action back to the portmaster.
|
||||
func (n *Notification) SelectAction(action string) {
|
||||
new := &pbnotify.Notification{
|
||||
EventID: n.EventID,
|
||||
SelectedActionID: action,
|
||||
}
|
||||
|
||||
// FIXME: check response
|
||||
apiClient.Update(fmt.Sprintf("%s%s", dbNotifBasePath, new.EventID), new, nil)
|
||||
}
|
||||
103
cmds/notifier/notify.go
Normal file
103
cmds/notifier/notify.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/safing/portbase/api/client"
|
||||
"github.com/safing/portbase/formats/dsd"
|
||||
"github.com/safing/portbase/log"
|
||||
|
||||
pbnotify "github.com/safing/portbase/notifications"
|
||||
)
|
||||
|
||||
const (
|
||||
dbNotifBasePath = "notifications:all/"
|
||||
)
|
||||
|
||||
var (
|
||||
notifications = make(map[string]*Notification)
|
||||
notificationsLock sync.Mutex
|
||||
)
|
||||
|
||||
func notifClient() {
|
||||
notifOp := apiClient.Qsub(fmt.Sprintf("query %s where ShowOnSystem is true", dbNotifBasePath), handleNotification)
|
||||
notifOp.EnableResuscitation()
|
||||
|
||||
// start the action listener and block
|
||||
// until it's closed.
|
||||
actionListener()
|
||||
}
|
||||
|
||||
func handleNotification(m *client.Message) {
|
||||
notificationsLock.Lock()
|
||||
defer notificationsLock.Unlock()
|
||||
|
||||
log.Tracef("received %s msg: %s", m.Type, m.Key)
|
||||
|
||||
switch m.Type {
|
||||
case client.MsgError:
|
||||
case client.MsgDone:
|
||||
case client.MsgSuccess:
|
||||
case client.MsgOk, client.MsgUpdate, client.MsgNew:
|
||||
|
||||
n := &Notification{}
|
||||
_, err := dsd.Load(m.RawValue, &n.Notification)
|
||||
if err != nil {
|
||||
log.Warningf("notify: failed to parse new notification: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
// copy existing system values
|
||||
existing, ok := notifications[n.EventID]
|
||||
if ok {
|
||||
existing.Lock()
|
||||
n.systemID = existing.systemID
|
||||
existing.Unlock()
|
||||
}
|
||||
|
||||
// save
|
||||
notifications[n.EventID] = n
|
||||
|
||||
// Handle notification.
|
||||
switch {
|
||||
case existing != nil:
|
||||
// Cancel existing notification if not active, else ignore.
|
||||
if n.State != pbnotify.Active {
|
||||
existing.Cancel()
|
||||
}
|
||||
return
|
||||
case n.State == pbnotify.Active:
|
||||
// Show new notifications that are active.
|
||||
n.Show()
|
||||
default:
|
||||
// Ignore new notifications that are not active.
|
||||
}
|
||||
|
||||
case client.MsgDelete:
|
||||
|
||||
n, ok := notifications[strings.TrimPrefix(m.Key, dbNotifBasePath)]
|
||||
if ok {
|
||||
n.Cancel()
|
||||
delete(notifications, n.EventID)
|
||||
}
|
||||
|
||||
case client.MsgWarning:
|
||||
case client.MsgOffline:
|
||||
}
|
||||
}
|
||||
|
||||
func clearNotifications() {
|
||||
notificationsLock.Lock()
|
||||
defer notificationsLock.Unlock()
|
||||
|
||||
for _, n := range notifications {
|
||||
n.Cancel()
|
||||
}
|
||||
|
||||
// Wait for goroutines that cancel notifications.
|
||||
// TODO: Revamp to use a waitgroup.
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
154
cmds/notifier/notify_linux.go
Normal file
154
cmds/notifier/notify_linux.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
notify "github.com/dhaavi/go-notify"
|
||||
"github.com/safing/portbase/log"
|
||||
)
|
||||
|
||||
type NotificationID uint32
|
||||
|
||||
var (
|
||||
capabilities notify.Capabilities
|
||||
notifsByID sync.Map
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
capabilities, err = notify.GetCapabilities()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get notification system capabilities: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleActions(ctx context.Context, actions chan notify.Signal) {
|
||||
mainWg.Add(1)
|
||||
defer mainWg.Done()
|
||||
|
||||
listenForNotifications:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case sig := <-actions:
|
||||
if sig.Name != "org.freedesktop.Notifications.ActionInvoked" {
|
||||
// we don't care for anything else (dismissed, closed)
|
||||
continue listenForNotifications
|
||||
}
|
||||
|
||||
// get notification by system ID
|
||||
n, ok := notifsByID.LoadAndDelete(NotificationID(sig.ID))
|
||||
|
||||
if !ok {
|
||||
continue listenForNotifications
|
||||
}
|
||||
|
||||
notification := n.(*Notification)
|
||||
|
||||
log.Tracef("notify: received signal: %+v", sig)
|
||||
if sig.ActionKey != "" {
|
||||
// send action
|
||||
if ok {
|
||||
notification.Lock()
|
||||
notification.SelectAction(sig.ActionKey)
|
||||
notification.Unlock()
|
||||
}
|
||||
} else {
|
||||
log.Tracef("notify: notification clicked: %+v", sig)
|
||||
// Global action invoked, start the app
|
||||
launchApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func actionListener() {
|
||||
actions := make(chan notify.Signal, 100)
|
||||
|
||||
go handleActions(mainCtx, actions)
|
||||
|
||||
err := notify.SignalNotify(mainCtx, actions)
|
||||
if err != nil && err != context.Canceled {
|
||||
log.Errorf("notify: signal listener failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Show shows the notification.
|
||||
func (n *Notification) Show() {
|
||||
sysN := notify.NewNotification("Portmaster", n.Message)
|
||||
// see https://developer.gnome.org/notification-spec/
|
||||
|
||||
// The optional name of the application sending the notification.
|
||||
// Can be blank.
|
||||
sysN.AppName = "Portmaster"
|
||||
|
||||
// The optional notification ID that this notification replaces.
|
||||
sysN.ReplacesID = uint32(n.systemID)
|
||||
|
||||
// The optional program icon of the calling application.
|
||||
// sysN.AppIcon string
|
||||
|
||||
// The summary text briefly describing the notification.
|
||||
// Summary string (arg 1)
|
||||
|
||||
// The optional detailed body text.
|
||||
// Body string (arg 2)
|
||||
|
||||
// The actions send a request message back to the notification client
|
||||
// when invoked.
|
||||
// sysN.Actions []string
|
||||
if capabilities.Actions {
|
||||
sysN.Actions = make([]string, 0, len(n.AvailableActions)*2)
|
||||
for _, action := range n.AvailableActions {
|
||||
if IsSupportedAction(*action) {
|
||||
sysN.Actions = append(sysN.Actions, action.ID)
|
||||
sysN.Actions = append(sysN.Actions, action.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set Portmaster icon.
|
||||
iconLocation, err := ensureAppIcon()
|
||||
if err != nil {
|
||||
log.Warningf("notify: failed to write icon: %s", err)
|
||||
}
|
||||
sysN.AppIcon = iconLocation
|
||||
|
||||
// TODO: Use hints to display icon of affected app.
|
||||
// Hints are a way to provide extra data to a notification server.
|
||||
// sysN.Hints = make(map[string]interface{})
|
||||
|
||||
// The timeout time in milliseconds since the display of the
|
||||
// notification at which the notification should automatically close.
|
||||
// sysN.Timeout int32
|
||||
|
||||
newID, err := sysN.Show()
|
||||
if err != nil {
|
||||
log.Warningf("notify: failed to show notification %s", n.EventID)
|
||||
return
|
||||
}
|
||||
|
||||
notifsByID.Store(NotificationID(newID), n)
|
||||
|
||||
n.Lock()
|
||||
defer n.Unlock()
|
||||
n.systemID = NotificationID(newID)
|
||||
}
|
||||
|
||||
// Cancel cancels the notification.
|
||||
func (n *Notification) Cancel() {
|
||||
n.Lock()
|
||||
defer n.Unlock()
|
||||
|
||||
// TODO: could a ID of 0 be valid?
|
||||
if n.systemID != 0 {
|
||||
err := notify.CloseNotification(uint32(n.systemID))
|
||||
if err != nil {
|
||||
log.Warningf("notify: failed to close notification %s/%d", n.EventID, n.systemID)
|
||||
}
|
||||
notifsByID.Delete(n.systemID)
|
||||
}
|
||||
}
|
||||
184
cmds/notifier/notify_windows.go
Normal file
184
cmds/notifier/notify_windows.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/safing/portbase/log"
|
||||
"github.com/safing/portmaster/cmds/notifier/wintoast"
|
||||
"github.com/safing/portmaster/service/updates/helper"
|
||||
)
|
||||
|
||||
type NotificationID int64
|
||||
|
||||
const (
|
||||
appName = "Portmaster"
|
||||
appUserModelID = "io.safing.portmaster.2"
|
||||
originalShortcutPath = "C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\Portmaster\\Portmaster.lnk"
|
||||
)
|
||||
|
||||
const (
|
||||
SoundDefault = 0
|
||||
SoundSilent = 1
|
||||
SoundLoop = 2
|
||||
)
|
||||
|
||||
const (
|
||||
SoundPathDefault = 0
|
||||
// see notification_glue.h if you need more types
|
||||
)
|
||||
|
||||
var (
|
||||
initOnce sync.Once
|
||||
lib *wintoast.WinToast
|
||||
notificationsByIDs sync.Map
|
||||
)
|
||||
|
||||
func getLib() *wintoast.WinToast {
|
||||
initOnce.Do(func() {
|
||||
dllPath, err := getDllPath()
|
||||
if err != nil {
|
||||
log.Errorf("notify: failed to get dll path: %s", err)
|
||||
return
|
||||
}
|
||||
// Load dll and all the functions
|
||||
newLib, err := wintoast.New(dllPath)
|
||||
if err != nil {
|
||||
log.Errorf("notify: failed to load library: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize. This will create or update application shortcut. C:\Users\<user>\AppData\Roaming\Microsoft\Windows\Start Menu\Programs
|
||||
// and it will be of the originalShortcutPath with no CLSID and different AUMI
|
||||
err = newLib.Initialize(appName, appUserModelID, originalShortcutPath)
|
||||
if err != nil {
|
||||
log.Errorf("notify: failed to load library: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
// library was initialized successfully
|
||||
lib = newLib
|
||||
|
||||
// Set callbacks
|
||||
|
||||
err = lib.SetCallbacks(notificationActivatedCallback, notificationDismissedCallback, notificationDismissedCallback)
|
||||
if err != nil {
|
||||
log.Warningf("notify: failed to set callbacks: %s", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return lib
|
||||
}
|
||||
|
||||
// Show shows the notification.
|
||||
func (n *Notification) Show() {
|
||||
// Lock notification
|
||||
n.Lock()
|
||||
defer n.Unlock()
|
||||
|
||||
// Create new notification object
|
||||
builder, err := getLib().NewNotification(n.Title, n.Message)
|
||||
if err != nil {
|
||||
log.Errorf("notify: failed to create notification: %s", err)
|
||||
return
|
||||
}
|
||||
// Make sure memory is freed when done
|
||||
defer builder.Delete()
|
||||
|
||||
// if needed set notification icon
|
||||
// _ = builder.SetImage(iconLocation)
|
||||
|
||||
// Leaving the default value for the sound
|
||||
// _ = builder.SetSound(SoundDefault, SoundPathDefault)
|
||||
|
||||
// Set all the required actions.
|
||||
for _, action := range n.AvailableActions {
|
||||
err = builder.AddButton(action.Text)
|
||||
if err != nil {
|
||||
log.Warningf("notify: failed to add button: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification.
|
||||
id, err := builder.Show()
|
||||
if err != nil {
|
||||
log.Errorf("notify: failed to show notification: %s", err)
|
||||
return
|
||||
}
|
||||
n.systemID = NotificationID(id)
|
||||
|
||||
// Link system id to the notification object
|
||||
notificationsByIDs.Store(NotificationID(id), n)
|
||||
|
||||
log.Debugf("notify: showing notification %q: %d", n.Title, n.systemID)
|
||||
}
|
||||
|
||||
// Cancel cancels the notification.
|
||||
func (n *Notification) Cancel() {
|
||||
// Lock notification
|
||||
n.Lock()
|
||||
defer n.Unlock()
|
||||
|
||||
// No need to check for errors. If it fails it is probably already dismissed
|
||||
_ = getLib().HideNotification(int64(n.systemID))
|
||||
|
||||
notificationsByIDs.Delete(n.systemID)
|
||||
log.Debugf("notify: notification canceled %q: %d", n.Title, n.systemID)
|
||||
}
|
||||
|
||||
func notificationActivatedCallback(id int64, actionIndex int32) {
|
||||
if actionIndex == -1 {
|
||||
// The user clicked on the notification (not a button), open the portmaster and delete
|
||||
launchApp()
|
||||
notificationsByIDs.Delete(NotificationID(id))
|
||||
log.Debugf("notify: notification clicked %d", id)
|
||||
return
|
||||
}
|
||||
|
||||
// The user click one of the buttons
|
||||
|
||||
// Get notified object
|
||||
n, ok := notificationsByIDs.LoadAndDelete(NotificationID(id))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
notification := n.(*Notification)
|
||||
|
||||
notification.Lock()
|
||||
defer notification.Unlock()
|
||||
|
||||
// Set selected action
|
||||
actionID := notification.AvailableActions[actionIndex].ID
|
||||
notification.SelectAction(actionID)
|
||||
|
||||
log.Debugf("notify: notification button cliecked %d button id: %d", id, actionIndex)
|
||||
}
|
||||
|
||||
func notificationDismissedCallback(id int64, reason int32) {
|
||||
// Failure or user dismissed the notification
|
||||
if reason == 0 {
|
||||
notificationsByIDs.Delete(NotificationID(id))
|
||||
log.Debugf("notify: notification dissmissed %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
func getDllPath() (string, error) {
|
||||
if dataDir == "" {
|
||||
return "", fmt.Errorf("dataDir is empty")
|
||||
}
|
||||
|
||||
// Aks the registry for the dll path
|
||||
identifier := helper.PlatformIdentifier("notifier/portmaster-wintoast.dll")
|
||||
file, err := registry.GetFile(identifier)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return file.Path(), nil
|
||||
}
|
||||
|
||||
func actionListener() {
|
||||
// initialize the library
|
||||
_ = getLib()
|
||||
}
|
||||
50
cmds/notifier/shutdown.go
Normal file
50
cmds/notifier/shutdown.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/safing/portbase/api/client"
|
||||
"github.com/safing/portbase/log"
|
||||
)
|
||||
|
||||
func startShutdownEventListener() {
|
||||
shutdownNotifOp := apiClient.Sub("query runtime:modules/core/event/shutdown", handleShutdownEvent)
|
||||
shutdownNotifOp.EnableResuscitation()
|
||||
|
||||
restartNotifOp := apiClient.Sub("query runtime:modules/core/event/restart", handleRestartEvent)
|
||||
restartNotifOp.EnableResuscitation()
|
||||
}
|
||||
|
||||
func handleShutdownEvent(m *client.Message) {
|
||||
switch m.Type {
|
||||
case client.MsgOk, client.MsgUpdate, client.MsgNew:
|
||||
shuttingDown.Set()
|
||||
triggerTrayUpdate()
|
||||
|
||||
log.Warningf("shutdown: received shutdown event, shutting down now")
|
||||
|
||||
// wait for the API client connection to die
|
||||
<-apiClient.Offline()
|
||||
shuttingDown.UnSet()
|
||||
|
||||
cancelMainCtx()
|
||||
|
||||
case client.MsgWarning, client.MsgError:
|
||||
log.Errorf("shutdown: event subscription error: %s", string(m.RawValue))
|
||||
}
|
||||
}
|
||||
|
||||
func handleRestartEvent(m *client.Message) {
|
||||
switch m.Type {
|
||||
case client.MsgOk, client.MsgUpdate, client.MsgNew:
|
||||
restarting.Set()
|
||||
triggerTrayUpdate()
|
||||
|
||||
log.Warningf("restart: received restart event")
|
||||
|
||||
// wait for the API client connection to die
|
||||
<-apiClient.Offline()
|
||||
restarting.UnSet()
|
||||
triggerTrayUpdate()
|
||||
case client.MsgWarning, client.MsgError:
|
||||
log.Errorf("shutdown: event subscription error: %s", string(m.RawValue))
|
||||
}
|
||||
}
|
||||
15
cmds/notifier/snoretoast-guid.patch
Normal file
15
cmds/notifier/snoretoast-guid.patch
Normal file
@@ -0,0 +1,15 @@
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index 498226a..446ba5e 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -2,7 +2,9 @@ cmake_minimum_required(VERSION 3.4)
|
||||
|
||||
project(snoretoast VERSION 0.6.0)
|
||||
# Always change the guid when the version is changed SNORETOAST_CALLBACK_GUID
|
||||
-set(SNORETOAST_CALLBACK_GUID eb1fdd5b-8f70-4b5a-b230-998a2dc19303)
|
||||
+#We keep it fixed!
|
||||
+set(SNORETOAST_CALLBACK_GUID 7F00FB48-65D5-4BA8-A35B-F194DA7E1A51)
|
||||
+
|
||||
|
||||
set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake/)
|
||||
|
||||
103
cmds/notifier/spn.go
Normal file
103
cmds/notifier/spn.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/safing/portbase/api/client"
|
||||
"github.com/safing/portbase/formats/dsd"
|
||||
"github.com/safing/portbase/log"
|
||||
"github.com/tevino/abool"
|
||||
)
|
||||
|
||||
const (
|
||||
spnModuleKey = "config:spn/enable"
|
||||
spnStatusKey = "runtime:spn/status"
|
||||
)
|
||||
|
||||
var (
|
||||
spnEnabled = abool.New()
|
||||
|
||||
spnStatusCache *SPNStatus
|
||||
spnStatusCacheLock sync.Mutex
|
||||
)
|
||||
|
||||
// SPNStatus holds SPN status information.
|
||||
type SPNStatus struct {
|
||||
Status string
|
||||
HomeHubID string
|
||||
HomeHubName string
|
||||
ConnectedIP string
|
||||
ConnectedTransport string
|
||||
ConnectedSince *time.Time
|
||||
}
|
||||
|
||||
// GetSPNStatus returns the SPN status.
|
||||
func GetSPNStatus() *SPNStatus {
|
||||
spnStatusCacheLock.Lock()
|
||||
defer spnStatusCacheLock.Unlock()
|
||||
|
||||
return spnStatusCache
|
||||
}
|
||||
|
||||
func updateSPNStatus(s *SPNStatus) {
|
||||
spnStatusCacheLock.Lock()
|
||||
defer spnStatusCacheLock.Unlock()
|
||||
|
||||
spnStatusCache = s
|
||||
}
|
||||
|
||||
func spnStatusClient() {
|
||||
moduleQueryOp := apiClient.Qsub("query "+spnModuleKey, handleSPNModuleUpdate)
|
||||
moduleQueryOp.EnableResuscitation()
|
||||
|
||||
statusQueryOp := apiClient.Qsub("query "+spnStatusKey, handleSPNStatusUpdate)
|
||||
statusQueryOp.EnableResuscitation()
|
||||
}
|
||||
|
||||
func handleSPNModuleUpdate(m *client.Message) {
|
||||
switch m.Type {
|
||||
case client.MsgOk, client.MsgUpdate, client.MsgNew:
|
||||
var cfg struct {
|
||||
Value bool `json:"Value"`
|
||||
}
|
||||
_, err := dsd.Load(m.RawValue, &cfg)
|
||||
if err != nil {
|
||||
log.Warningf("config: failed to parse config: %s", err)
|
||||
return
|
||||
}
|
||||
log.Infof("config: received update to SPN module: enabled=%v", cfg.Value)
|
||||
|
||||
spnEnabled.SetTo(cfg.Value)
|
||||
triggerTrayUpdate()
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func handleSPNStatusUpdate(m *client.Message) {
|
||||
switch m.Type {
|
||||
case client.MsgOk, client.MsgUpdate, client.MsgNew:
|
||||
newStatus := &SPNStatus{}
|
||||
_, err := dsd.Load(m.RawValue, newStatus)
|
||||
if err != nil {
|
||||
log.Warningf("config: failed to parse config: %s", err)
|
||||
return
|
||||
}
|
||||
log.Infof("config: received update to SPN status: %+v", newStatus)
|
||||
|
||||
updateSPNStatus(newStatus)
|
||||
triggerTrayUpdate()
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func ToggleSPN() {
|
||||
var cfg struct {
|
||||
Value bool `json:"Value"`
|
||||
}
|
||||
cfg.Value = !spnEnabled.IsSet()
|
||||
|
||||
apiClient.Update(spnModuleKey, &cfg, nil)
|
||||
}
|
||||
122
cmds/notifier/subsystems.go
Normal file
122
cmds/notifier/subsystems.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/safing/portbase/api/client"
|
||||
"github.com/safing/portbase/formats/dsd"
|
||||
"github.com/safing/portbase/log"
|
||||
)
|
||||
|
||||
const (
|
||||
subsystemsKeySpace = "runtime:subsystems/"
|
||||
|
||||
// Module Failure Status Values
|
||||
// FailureNone = 0 // unused
|
||||
// FailureHint = 1 // unused
|
||||
FailureWarning = 2
|
||||
FailureError = 3
|
||||
)
|
||||
|
||||
var (
|
||||
subsystems = make(map[string]*Subsystem)
|
||||
subsystemsLock sync.Mutex
|
||||
)
|
||||
|
||||
// Subsystem describes a subset of modules that represent a part of a
|
||||
// service or program to the user. Subsystems can be (de-)activated causing
|
||||
// all related modules to be brought down or up.
|
||||
type Subsystem struct { //nolint:maligned // not worth the effort
|
||||
// ID is a unique identifier for the subsystem.
|
||||
ID string
|
||||
|
||||
// Name holds a human readable name of the subsystem.
|
||||
Name string
|
||||
|
||||
// Description may holds an optional description of
|
||||
// the subsystem's purpose.
|
||||
Description string
|
||||
|
||||
// Modules contains all modules that are related to the subsystem.
|
||||
// Note that this slice also contains a reference to the subsystem
|
||||
// module itself.
|
||||
Modules []*ModuleStatus
|
||||
|
||||
// FailureStatus is the worst failure status that is currently
|
||||
// set in one of the subsystem's dependencies.
|
||||
FailureStatus uint8
|
||||
}
|
||||
|
||||
// ModuleStatus describes the status of a module.
|
||||
type ModuleStatus struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Status uint8
|
||||
FailureStatus uint8
|
||||
FailureID string
|
||||
FailureMsg string
|
||||
}
|
||||
|
||||
// GetFailure returns the worst of all subsystem failures.
|
||||
func GetFailure() (failureStatus uint8, failureMsg string) {
|
||||
subsystemsLock.Lock()
|
||||
defer subsystemsLock.Unlock()
|
||||
|
||||
for _, subsystem := range subsystems {
|
||||
for _, module := range subsystem.Modules {
|
||||
if failureStatus < module.FailureStatus {
|
||||
failureStatus = module.FailureStatus
|
||||
failureMsg = module.FailureMsg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func updateSubsystem(s *Subsystem) {
|
||||
subsystemsLock.Lock()
|
||||
defer subsystemsLock.Unlock()
|
||||
|
||||
subsystems[s.ID] = s
|
||||
}
|
||||
|
||||
func clearSubsystems() {
|
||||
subsystemsLock.Lock()
|
||||
defer subsystemsLock.Unlock()
|
||||
|
||||
for key := range subsystems {
|
||||
delete(subsystems, key)
|
||||
}
|
||||
}
|
||||
|
||||
func subsystemsClient() {
|
||||
subsystemsOp := apiClient.Qsub(fmt.Sprintf("query %s", subsystemsKeySpace), handleSubsystem)
|
||||
subsystemsOp.EnableResuscitation()
|
||||
}
|
||||
|
||||
func handleSubsystem(m *client.Message) {
|
||||
switch m.Type {
|
||||
case client.MsgError:
|
||||
case client.MsgDone:
|
||||
case client.MsgSuccess:
|
||||
case client.MsgOk, client.MsgUpdate, client.MsgNew:
|
||||
|
||||
newSubsystem := &Subsystem{}
|
||||
_, err := dsd.Load(m.RawValue, newSubsystem)
|
||||
if err != nil {
|
||||
log.Warningf("subsystems: failed to parse new subsystem: %s", err)
|
||||
return
|
||||
}
|
||||
updateSubsystem(newSubsystem)
|
||||
triggerTrayUpdate()
|
||||
|
||||
case client.MsgDelete:
|
||||
case client.MsgWarning:
|
||||
case client.MsgOffline:
|
||||
|
||||
clearSubsystems()
|
||||
|
||||
}
|
||||
}
|
||||
218
cmds/notifier/tray.go
Normal file
218
cmds/notifier/tray.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
|
||||
"github.com/safing/portbase/log"
|
||||
icons "github.com/safing/portmaster/assets"
|
||||
)
|
||||
|
||||
const (
|
||||
shortenStatusMsgTo = 40
|
||||
)
|
||||
|
||||
var (
|
||||
trayLock sync.Mutex
|
||||
|
||||
scaleColoredIconsTo int
|
||||
|
||||
activeIconID int = -1
|
||||
activeStatusMsg = ""
|
||||
activeSPNStatus = ""
|
||||
activeSPNSwitch = ""
|
||||
|
||||
menuItemStatusMsg *systray.MenuItem
|
||||
menuItemSPNStatus *systray.MenuItem
|
||||
menuItemSPNSwitch *systray.MenuItem
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.IntVar(&scaleColoredIconsTo, "scale-icons", 32, "scale colored icons to given size in pixels")
|
||||
|
||||
// lock until ready
|
||||
trayLock.Lock()
|
||||
}
|
||||
|
||||
func tray() {
|
||||
if scaleColoredIconsTo > 0 {
|
||||
icons.ScaleColoredIconsTo(scaleColoredIconsTo)
|
||||
}
|
||||
|
||||
systray.Run(onReady, onExit)
|
||||
}
|
||||
|
||||
func exitTray() {
|
||||
systray.Quit()
|
||||
}
|
||||
|
||||
func onReady() {
|
||||
// unlock when ready
|
||||
defer trayLock.Unlock()
|
||||
|
||||
// icon
|
||||
systray.SetIcon(icons.ColoredIcons[icons.RedID])
|
||||
if runtime.GOOS == "windows" {
|
||||
// systray.SetTitle("Portmaster Notifier") // Don't set title, as it may be displayed in full in the menu/tray bar. (Ubuntu)
|
||||
systray.SetTooltip("Portmaster Notifier")
|
||||
}
|
||||
|
||||
// menu: open app
|
||||
if dataDir != "" {
|
||||
menuItemOpenApp := systray.AddMenuItem("Open App", "")
|
||||
go clickListener(menuItemOpenApp, launchApp)
|
||||
systray.AddSeparator()
|
||||
}
|
||||
|
||||
// menu: status
|
||||
|
||||
menuItemStatusMsg = systray.AddMenuItem("Loading...", "")
|
||||
menuItemStatusMsg.Disable()
|
||||
systray.AddSeparator()
|
||||
|
||||
// menu: SPN
|
||||
|
||||
menuItemSPNStatus = systray.AddMenuItem("Loading...", "")
|
||||
menuItemSPNStatus.Disable()
|
||||
menuItemSPNSwitch = systray.AddMenuItem("Loading...", "")
|
||||
go clickListener(menuItemSPNSwitch, func() {
|
||||
ToggleSPN()
|
||||
})
|
||||
systray.AddSeparator()
|
||||
|
||||
// menu: quit
|
||||
systray.AddSeparator()
|
||||
closeTray := systray.AddMenuItem("Close Tray Notifier", "")
|
||||
go clickListener(closeTray, func() {
|
||||
cancelMainCtx()
|
||||
})
|
||||
shutdownPortmaster := systray.AddMenuItem("Shut Down Portmaster", "")
|
||||
go clickListener(shutdownPortmaster, func() {
|
||||
_ = TriggerShutdown()
|
||||
time.Sleep(1 * time.Second)
|
||||
cancelMainCtx()
|
||||
})
|
||||
}
|
||||
|
||||
func onExit() {
|
||||
|
||||
}
|
||||
|
||||
func triggerTrayUpdate() {
|
||||
// TODO: Deduplicate triggers.
|
||||
go updateTray()
|
||||
}
|
||||
|
||||
// updateTray update the state of the tray depending on the currently available information.
|
||||
func updateTray() {
|
||||
// Get current information.
|
||||
spnStatus := GetSPNStatus()
|
||||
failureID, failureMsg := GetFailure()
|
||||
|
||||
trayLock.Lock()
|
||||
defer trayLock.Unlock()
|
||||
|
||||
// Select icon and status message to show.
|
||||
newIconID := icons.GreenID
|
||||
newStatusMsg := "Secure"
|
||||
switch {
|
||||
case shuttingDown.IsSet():
|
||||
newIconID = icons.RedID
|
||||
newStatusMsg = "Shutting Down Portmaster"
|
||||
|
||||
case restarting.IsSet():
|
||||
newIconID = icons.YellowID
|
||||
newStatusMsg = "Restarting Portmaster"
|
||||
|
||||
case !connected.IsSet():
|
||||
newIconID = icons.RedID
|
||||
newStatusMsg = "Waiting for Portmaster Core Service"
|
||||
|
||||
case failureID == FailureError:
|
||||
newIconID = icons.RedID
|
||||
newStatusMsg = failureMsg
|
||||
|
||||
case failureID == FailureWarning:
|
||||
newIconID = icons.YellowID
|
||||
newStatusMsg = failureMsg
|
||||
|
||||
case spnEnabled.IsSet():
|
||||
newIconID = icons.BlueID
|
||||
}
|
||||
|
||||
// Set icon if changed.
|
||||
if newIconID != activeIconID {
|
||||
activeIconID = newIconID
|
||||
systray.SetIcon(icons.ColoredIcons[activeIconID])
|
||||
}
|
||||
|
||||
// Set message if changed.
|
||||
if newStatusMsg != activeStatusMsg {
|
||||
activeStatusMsg = newStatusMsg
|
||||
|
||||
// Shorten message if too long.
|
||||
shortenedMsg := activeStatusMsg
|
||||
if len(shortenedMsg) > shortenStatusMsgTo && strings.Contains(shortenedMsg, ". ") {
|
||||
shortenedMsg = strings.SplitN(shortenedMsg, ". ", 2)[0]
|
||||
}
|
||||
if len(shortenedMsg) > shortenStatusMsgTo {
|
||||
shortenedMsg = shortenedMsg[:shortenStatusMsgTo] + "..."
|
||||
}
|
||||
|
||||
menuItemStatusMsg.SetTitle("Status: " + shortenedMsg)
|
||||
}
|
||||
|
||||
// Set SPN status if changed.
|
||||
if spnStatus != nil && activeSPNStatus != spnStatus.Status {
|
||||
activeSPNStatus = spnStatus.Status
|
||||
menuItemSPNStatus.SetTitle("SPN: " + strings.Title(activeSPNStatus))
|
||||
}
|
||||
|
||||
// Set SPN switch if changed.
|
||||
newSPNSwitch := "Enable SPN"
|
||||
if spnEnabled.IsSet() {
|
||||
newSPNSwitch = "Disable SPN"
|
||||
}
|
||||
if activeSPNSwitch != newSPNSwitch {
|
||||
activeSPNSwitch = newSPNSwitch
|
||||
menuItemSPNSwitch.SetTitle(activeSPNSwitch)
|
||||
}
|
||||
}
|
||||
|
||||
func clickListener(item *systray.MenuItem, fn func()) {
|
||||
for range item.ClickedCh {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
func launchApp() {
|
||||
// build path to app
|
||||
pmStartPath := filepath.Join(dataDir, "portmaster-start")
|
||||
if runtime.GOOS == "windows" {
|
||||
pmStartPath += ".exe"
|
||||
}
|
||||
|
||||
// start app
|
||||
cmd := exec.Command(pmStartPath, "app", "--data", dataDir)
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
log.Warningf("failed to start app: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Use cmd.Wait() instead of cmd.Process.Release() to properly release its resources.
|
||||
// See https://github.com/golang/go/issues/36534
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
if err != nil {
|
||||
log.Warningf("failed to wait/release app process: %s", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
90
cmds/notifier/wintoast/notification_builder.go
Normal file
90
cmds/notifier/wintoast/notification_builder.go
Normal file
@@ -0,0 +1,90 @@
|
||||
//go:build windows
|
||||
|
||||
package wintoast
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type NotificationBuilder struct {
|
||||
templatePointer uintptr
|
||||
lib *WinToast
|
||||
}
|
||||
|
||||
func newNotification(lib *WinToast, title string, message string) (*NotificationBuilder, error) {
|
||||
lib.Lock()
|
||||
defer lib.Unlock()
|
||||
|
||||
titleUTF, _ := windows.UTF16PtrFromString(title)
|
||||
messageUTF, _ := windows.UTF16PtrFromString(message)
|
||||
titleP := unsafe.Pointer(titleUTF)
|
||||
messageP := unsafe.Pointer(messageUTF)
|
||||
|
||||
ptr, _, err := lib.createNotification.Call(uintptr(titleP), uintptr(messageP))
|
||||
if ptr == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &NotificationBuilder{ptr, lib}, nil
|
||||
}
|
||||
|
||||
func (n *NotificationBuilder) Delete() {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
n.lib.Lock()
|
||||
defer n.lib.Unlock()
|
||||
|
||||
_, _, _ = n.lib.deleteNotification.Call(n.templatePointer)
|
||||
}
|
||||
|
||||
func (n *NotificationBuilder) AddButton(text string) error {
|
||||
n.lib.Lock()
|
||||
defer n.lib.Unlock()
|
||||
textUTF, _ := windows.UTF16PtrFromString(text)
|
||||
textP := unsafe.Pointer(textUTF)
|
||||
|
||||
rc, _, err := n.lib.addButton.Call(n.templatePointer, uintptr(textP))
|
||||
if rc != 1 {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NotificationBuilder) SetImage(iconPath string) error {
|
||||
n.lib.Lock()
|
||||
defer n.lib.Unlock()
|
||||
pathUTF, _ := windows.UTF16PtrFromString(iconPath)
|
||||
pathP := unsafe.Pointer(pathUTF)
|
||||
|
||||
rc, _, err := n.lib.setImage.Call(n.templatePointer, uintptr(pathP))
|
||||
if rc != 1 {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NotificationBuilder) SetSound(option int, path int) error {
|
||||
n.lib.Lock()
|
||||
defer n.lib.Unlock()
|
||||
|
||||
rc, _, err := n.lib.setSound.Call(n.templatePointer, uintptr(option), uintptr(path))
|
||||
if rc != 1 {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NotificationBuilder) Show() (int64, error) {
|
||||
n.lib.Lock()
|
||||
defer n.lib.Unlock()
|
||||
|
||||
id, _, err := n.lib.showNotification.Call(n.templatePointer)
|
||||
if int64(id) == -1 {
|
||||
return -1, err
|
||||
}
|
||||
return int64(id), nil
|
||||
}
|
||||
217
cmds/notifier/wintoast/wintoast.go
Normal file
217
cmds/notifier/wintoast/wintoast.go
Normal file
@@ -0,0 +1,217 @@
|
||||
//go:build windows
|
||||
|
||||
package wintoast
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"github.com/tevino/abool"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// WinNotify holds the DLL handle.
|
||||
type WinToast struct {
|
||||
sync.RWMutex
|
||||
|
||||
dll *windows.DLL
|
||||
|
||||
initialized *abool.AtomicBool
|
||||
|
||||
initialize *windows.Proc
|
||||
isInitialized *windows.Proc
|
||||
createNotification *windows.Proc
|
||||
deleteNotification *windows.Proc
|
||||
addButton *windows.Proc
|
||||
setImage *windows.Proc
|
||||
setSound *windows.Proc
|
||||
showNotification *windows.Proc
|
||||
hideNotification *windows.Proc
|
||||
setActivatedCallback *windows.Proc
|
||||
setDismissedCallback *windows.Proc
|
||||
setFailedCallback *windows.Proc
|
||||
}
|
||||
|
||||
func New(dllPath string) (*WinToast, error) {
|
||||
if dllPath == "" {
|
||||
return nil, fmt.Errorf("winnotifiy: path to dll not specified")
|
||||
}
|
||||
|
||||
libraryObject := &WinToast{}
|
||||
libraryObject.initialized = abool.New()
|
||||
|
||||
// load dll
|
||||
var err error
|
||||
libraryObject.dll, err = windows.LoadDLL(dllPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: failed to load notifier dll %w", err)
|
||||
}
|
||||
|
||||
// load functions
|
||||
libraryObject.initialize, err = libraryObject.dll.FindProc("PortmasterToastInitialize")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastInitialize not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.isInitialized, err = libraryObject.dll.FindProc("PortmasterToastIsInitialized")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastIsInitialized not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.createNotification, err = libraryObject.dll.FindProc("PortmasterToastCreateNotification")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastCreateNotification not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.deleteNotification, err = libraryObject.dll.FindProc("PortmasterToastDeleteNotification")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastDeleteNotification not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.addButton, err = libraryObject.dll.FindProc("PortmasterToastAddButton")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastAddButton not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.setImage, err = libraryObject.dll.FindProc("PortmasterToastSetImage")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastSetImage not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.setSound, err = libraryObject.dll.FindProc("PortmasterToastSetSound")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastSetSound not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.showNotification, err = libraryObject.dll.FindProc("PortmasterToastShow")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastShow not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.setActivatedCallback, err = libraryObject.dll.FindProc("PortmasterToastActivatedCallback")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterActivatedCallback not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.setDismissedCallback, err = libraryObject.dll.FindProc("PortmasterToastDismissedCallback")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastDismissedCallback not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.setFailedCallback, err = libraryObject.dll.FindProc("PortmasterToastFailedCallback")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastFailedCallback not found %w", err)
|
||||
}
|
||||
|
||||
libraryObject.hideNotification, err = libraryObject.dll.FindProc("PortmasterToastHide")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("winnotifiy: PortmasterToastHide not found %w", err)
|
||||
}
|
||||
|
||||
return libraryObject, nil
|
||||
}
|
||||
|
||||
func (lib *WinToast) Initialize(appName, aumi, originalShortcutPath string) error {
|
||||
if lib == nil {
|
||||
return fmt.Errorf("wintoast: lib object was nil")
|
||||
}
|
||||
|
||||
lib.Lock()
|
||||
defer lib.Unlock()
|
||||
|
||||
// Initialize all necessary string for the notification meta data
|
||||
appNameUTF, _ := windows.UTF16PtrFromString(appName)
|
||||
aumiUTF, _ := windows.UTF16PtrFromString(aumi)
|
||||
linkUTF, _ := windows.UTF16PtrFromString(originalShortcutPath)
|
||||
|
||||
// They are needed as unsafe pointers
|
||||
appNameP := unsafe.Pointer(appNameUTF)
|
||||
aumiP := unsafe.Pointer(aumiUTF)
|
||||
linkP := unsafe.Pointer(linkUTF)
|
||||
|
||||
// Initialize notifications
|
||||
rc, _, err := lib.initialize.Call(uintptr(appNameP), uintptr(aumiP), uintptr(linkP))
|
||||
if rc != 0 {
|
||||
return fmt.Errorf("wintoast: failed to initialize library rc = %d, %w", rc, err)
|
||||
}
|
||||
|
||||
// Check if if the initialization was successfully
|
||||
rc, _, _ = lib.isInitialized.Call()
|
||||
if rc == 1 {
|
||||
lib.initialized.Set()
|
||||
} else {
|
||||
return fmt.Errorf("wintoast: initialized flag was not set: rc = %d", rc)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lib *WinToast) SetCallbacks(activated func(id int64, actionIndex int32), dismissed func(id int64, reason int32), failed func(id int64, reason int32)) error {
|
||||
if lib == nil {
|
||||
return fmt.Errorf("wintoast: lib object was nil")
|
||||
}
|
||||
|
||||
if lib.initialized.IsNotSet() {
|
||||
return fmt.Errorf("winnotifiy: library not initialized")
|
||||
}
|
||||
|
||||
// Initialize notification activated callback
|
||||
callback := windows.NewCallback(func(id int64, actionIndex int32) uint64 {
|
||||
activated(id, actionIndex)
|
||||
return 0
|
||||
})
|
||||
rc, _, err := lib.setActivatedCallback.Call(callback)
|
||||
if rc != 1 {
|
||||
return fmt.Errorf("winnotifiy: failed to initialize activated callback %w", err)
|
||||
}
|
||||
|
||||
// Initialize notification dismissed callback
|
||||
callback = windows.NewCallback(func(id int64, actionIndex int32) uint64 {
|
||||
dismissed(id, actionIndex)
|
||||
return 0
|
||||
})
|
||||
rc, _, err = lib.setDismissedCallback.Call(callback)
|
||||
if rc != 1 {
|
||||
return fmt.Errorf("winnotifiy: failed to initialize dismissed callback %w", err)
|
||||
}
|
||||
|
||||
// Initialize notification failed callback
|
||||
callback = windows.NewCallback(func(id int64, actionIndex int32) uint64 {
|
||||
failed(id, actionIndex)
|
||||
return 0
|
||||
})
|
||||
rc, _, err = lib.setFailedCallback.Call(callback)
|
||||
if rc != 1 {
|
||||
return fmt.Errorf("winnotifiy: failed to initialize failed callback %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewNotification starts a creation of new notification. NotificationBuilder.Delete should allays be called when done using the object or there will be memory leeks
|
||||
func (lib *WinToast) NewNotification(title string, content string) (*NotificationBuilder, error) {
|
||||
if lib == nil {
|
||||
return nil, fmt.Errorf("wintoast: lib object was nil")
|
||||
}
|
||||
return newNotification(lib, title, content)
|
||||
}
|
||||
|
||||
// HideNotification hides notification
|
||||
func (lib *WinToast) HideNotification(id int64) error {
|
||||
if lib == nil {
|
||||
return fmt.Errorf("wintoast: lib object was nil")
|
||||
}
|
||||
|
||||
lib.Lock()
|
||||
defer lib.Unlock()
|
||||
|
||||
rc, _, _ := lib.hideNotification.Call(uintptr(id))
|
||||
|
||||
if rc != 1 {
|
||||
return fmt.Errorf("wintoast: failed to hide notification %d", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user