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:
Patrick Pacher
2024-03-20 11:10:32 +01:00
parent 4b77945517
commit ac23ce32a1
392 changed files with 1879 additions and 4 deletions

34
cmds/notifier/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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)
}

View 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)
}
}

View 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
View 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))
}
}

View 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
View 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
View 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
View 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)
}
}()
}

View 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
}

View 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
}