Skip to content

Commit

Permalink
dexc-desktop: Desktop notifications (#2599)
Browse files Browse the repository at this point in the history
* In webview, send desktop notification via Beeep
---------
Signed-off-by: Philemon Ukane <[email protected]>
  • Loading branch information
peterzen authored Jan 17, 2024
1 parent 920cec1 commit 122c1e7
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 59 deletions.
4 changes: 4 additions & 0 deletions client/cmd/dexc-desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ func bindJSFunctions(w webview.WebView) {
log.Errorf("unable to run URL handler: %s", err.Error())
}
})

w.Bind("sendOSNotification", func(title, body string) {
sendDesktopNotification(title, body)
})
}

func runWebview(url string) {
Expand Down
99 changes: 99 additions & 0 deletions client/cmd/dexc-desktop/app_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import (
"runtime/debug"
"runtime/pprof"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
Expand Down Expand Up @@ -122,6 +123,10 @@ func init() {
// "main" thread.
runtime.LockOSThread()

// Set the user controller. This object coordinates interactions the app’s
// native code and the webpage’s scripts and other content. See:
// https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/1395668-usercontentcontroller?language=objc.
webviewConfig.Set("userContentController:", objc.Get("WKUserContentController").Alloc().Init())
// Set "developerExtrasEnabled" to true to allow viewing the developer
// console.
webviewConfig.Preferences().SetValueForKey(mdCore.True, mdCore.String("developerExtrasEnabled"))
Expand Down Expand Up @@ -337,6 +342,9 @@ func mainCore() error {
}
})

// Bind JS callback function handler.
bindJSFunctionHandler()

app := cocoa.NSApp()
// Set the "ActivationPolicy" to "NSApplicationActivationPolicyRegular" in
// order to run dexc-desktop as a regular MacOS app (i.e as a non-cli
Expand Down Expand Up @@ -662,6 +670,79 @@ func windowWidthAndHeight() (width, height int) {
return limitedWindowWidthAndHeight(int(math.Round(frame.Size.Width)), int(math.Round(frame.Size.Height)))
}

// bindJSFunctionHandler exports a function handler callable in the frontend.
// The exported function will appear under the given name as a global JavaScript
// function window.webkit.messageHandlers.dexcHandler.postMessage([fnName,
// args...]).
// Expected arguments is an array of:
// 1. jsFunctionName as first argument
// 2. jsFunction arguments
func bindJSFunctionHandler() {
const fnName = "dexcHandler"

// Create and register a new objc class for the function handler.
fnClass := objc.NewClass(fnName, "NSObject")
objc.RegisterClass(fnClass)

// JS function handler must implement the WKScriptMessageHandler protocol.
// See:
// https://developer.apple.com/documentation/webkit/wkscriptmessagehandler?language=objc
fnClass.AddMethod("userContentController:didReceiveScriptMessage:", handleJSFunctionsCallback)

// The name of this function in the browser window is
// window.webkit.messageHandlers.<name>.postMessage(<messageBody>), where
// <name> corresponds to the value of this parameter. See:
// https://developer.apple.com/documentation/webkit/wkusercontentcontroller/1537172-addscriptmessagehandler?language=objc
webviewConfig.Get("userContentController").Send("addScriptMessageHandler:name:", objc.Get(fnName).Alloc().Init(), mdCore.String(fnName))
}

// handleJSFunctionsCallback handles function calls from a javascript
// environment.
func handleJSFunctionsCallback(f_ objc.Object /* functionHandler */, ct objc.Object /* WKUserContentController */, msg objc.Object, wv objc.Object /* webview */) {
// Arguments must be provided as an array(NSSingleObjectArrayI or NSArrayI).
msgBody := msg.Get("body")
msgClass := msgBody.Class().String()
if !strings.Contains(msgClass, "Array") {
log.Errorf("Received unexpected argument type %s (content: %s)", msgClass, msgBody.String())
return // do nothing
}

// Parse all argument to an array of strings. Individual function callers
// can handle expected arguments parsed as string. For example, an object
// parsed as a string will be returned as an objc stringified object { name
// = "myName"; }.
args := parseJSCallbackArgsString(msgBody)
if len(args) == 0 {
log.Errorf("Received unexpected argument type %s (content: %s)", msgClass, msgBody.String())
return // do nothing
}

// minArg is the minimum number of args expected which is the function name.
const minArg = 1
fnName := args[0]
nArgs := len(args)
switch {
case fnName == "openURL" && nArgs > minArg:
openURL(args[1])
case fnName == "sendOSNotification" && nArgs > minArg:
sendDesktopNotificationJSCallback(args[1:])
default:
log.Errorf("Received unexpected JS function type %s (message content: %s)", fnName, msgBody.String())
}
}

// sendDesktopNotificationJSCallback sends a desktop notification as request
// from a webpage script. Expected message content: [title, body].
func sendDesktopNotificationJSCallback(msg []string) {
const expectedArgs = 2
const defaultTitle = "DCRDEX Notification"
if len(msg) == 1 {
sendDesktopNotification(defaultTitle, msg[0])
} else if len(msg) >= expectedArgs {
sendDesktopNotification(msg[0], msg[1])
}
}

// openURL opens the provided path using macOS's native APIs. This will ensure
// the "path" is opened with the appropriate app (e.g a valid HTTP URL will be
// opened in the user's default browser)
Expand All @@ -670,6 +751,24 @@ func openURL(path string) {
cocoa.NSWorkspace_sharedWorkspace().Send("openURL:", mdCore.NSURL_Init(path))
}

func parseJSCallbackArgsString(msg objc.Object) []string {
args := mdCore.NSArray_fromRef(msg)
count := args.Count()
if count == 0 {
return nil
}

var argsAsStr []string
for i := 0; i < int(count); i++ {
ob := args.ObjectAtIndex(uint64(i))
if ob.Class().String() == "NSNull" /* this is the string representation of the null type in objc. */ {
continue // ignore
}
argsAsStr = append(argsAsStr, ob.String())
}
return argsAsStr
}

// createDexcDesktopStateFile writes the id of the current process to the file
// located at filePath. If the file already exists, the process id in the file
// is checked to see if the process is still running. Returns true and a nil
Expand Down
8 changes: 4 additions & 4 deletions client/webserver/site/src/js/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ export default class Application {
this.attachCommon(this.header)
this.attach({})
this.updateMenuItemsDisplay()
// initialize browser notifications
ntfn.fetchBrowserNtfnSettings()
// initialize desktop notifications
ntfn.fetchDesktopNtfnSettings()
// Load recent notifications from Window.localStorage.
const notes = State.fetchLocal(State.notificationsLK)
this.setNotes(notes || [])
Expand Down Expand Up @@ -749,8 +749,8 @@ export default class Application {
if (note.severity === ntfn.POKE) this.prependPokeElement(note)
else this.prependNoteElement(note)

// show browser notification
ntfn.browserNotify(note)
// show desktop notification
ntfn.desktopNotify(note)
}

/*
Expand Down
125 changes: 85 additions & 40 deletions client/webserver/site/src/js/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,79 +35,124 @@ const NoteTypeMatch = 'match'
const NoteTypeBondPost = 'bondpost'
const NoteTypeConnEvent = 'conn'

type BrowserNtfnSettingLabel = {
type DesktopNtfnSettingLabel = {
[x: string]: string
}

type BrowserNtfnSetting = {
export type DesktopNtfnSetting = {
[x: string]: boolean
}

function browserNotificationsSettingsKey (): string {
return `browser_notifications-${window.location.host}`
function desktopNtfnSettingsKey (): string {
return `desktop_notifications-${window.location.host}`
}

export const browserNtfnLabels: BrowserNtfnSettingLabel = {
export const desktopNtfnLabels: DesktopNtfnSettingLabel = {
[NoteTypeOrder]: intl.ID_BROWSER_NTFN_ORDERS,
[NoteTypeMatch]: intl.ID_BROWSER_NTFN_MATCHES,
[NoteTypeBondPost]: intl.ID_BROWSER_NTFN_BONDS,
[NoteTypeConnEvent]: intl.ID_BROWSER_NTFN_CONNECTIONS
}

export const defaultBrowserNtfnSettings: BrowserNtfnSetting = {
export const defaultDesktopNtfnSettings: DesktopNtfnSetting = {
[NoteTypeOrder]: true,
[NoteTypeMatch]: true,
[NoteTypeBondPost]: true,
[NoteTypeConnEvent]: true
}

let browserNtfnSettings: BrowserNtfnSetting
let desktopNtfnSettings: DesktopNtfnSetting

export function ntfnPermissionGranted () {
return window.Notification.permission === 'granted'
}
// BrowserNotifier is a wrapper around the browser's notification API.
class BrowserNotifier {
static ntfnPermissionGranted (): boolean {
return window.Notification.permission === 'granted'
}

static ntfnPermissionDenied (): boolean {
return window.Notification.permission === 'denied'
}

export function ntfnPermissionDenied () {
return window.Notification.permission === 'denied'
static async requestNtfnPermission (): Promise<void> {
if (!('Notification' in window)) {
return
}
if (BrowserNotifier.ntfnPermissionGranted()) {
BrowserNotifier.sendDesktopNotification(intl.prep(intl.ID_BROWSER_NTFN_ENABLED))
} else if (!BrowserNotifier.ntfnPermissionDenied()) {
await Notification.requestPermission()
BrowserNotifier.sendDesktopNotification(intl.prep(intl.ID_BROWSER_NTFN_ENABLED))
}
}

static async sendDesktopNotification (title: string, body?: string) {
if (!BrowserNotifier.ntfnPermissionGranted()) return
const ntfn = new window.Notification(title, {
body: body,
icon: '/img/softened-icon.png'
})
return ntfn
}
}

export async function requestNtfnPermission () {
if (!('Notification' in window)) {
return
// OSDesktopNotifier manages OS desktop notifications via the same interface
// as BrowserNotifier, but sends notifications using an underlying Go
// notification library exposed to the webview.
class OSDesktopNotifier {
static ntfnPermissionGranted (): boolean {
return true
}
if (Notification.permission === 'granted') {
showBrowserNtfn(intl.prep(intl.ID_BROWSER_NTFN_ENABLED))
} else if (Notification.permission !== 'denied') {
await Notification.requestPermission()
showBrowserNtfn(intl.prep(intl.ID_BROWSER_NTFN_ENABLED))

static ntfnPermissionDenied (): boolean {
return false
}

static async requestNtfnPermission (): Promise<void> {
await OSDesktopNotifier.sendDesktopNotification(intl.prep(intl.ID_BROWSER_NTFN_ENABLED))
return Promise.resolve()
}

static async sendDesktopNotification (title: string, body?: string): Promise<void> {
// webview/linux or webview/windows
if (isDesktopWebview()) await window.sendOSNotification(title, body)
// webkit/darwin
// See: client/cmd/dexc-desktop/app_darwin.go#L673-#L697
else if (isDesktopWebkit()) await window.webkit.messageHandlers.dexcHandler.postMessage(['sendOSNotification', title, body])
else console.error('sendDesktopNotification: unknown environment')
}
}

export function showBrowserNtfn (title: string, body?: string) {
if (window.Notification.permission !== 'granted') return
const ntfn = new window.Notification(title, {
body: body,
icon: '/img/softened-icon.png'
})
return ntfn
// isDesktopWebview checks if we are running in webview
function isDesktopWebview (): boolean {
return window.isWebview !== undefined
}

export function browserNotify (note: CoreNote) {
if (!browserNtfnSettings[note.type]) return
showBrowserNtfn(note.subject, note.details)
// isDesktopDarwin returns true if we are running in a webview on darwin
// It tests for the existence of the dexcHandler webkit message handler.
function isDesktopWebkit (): boolean {
return window.webkit?.messageHandlers?.dexcHandler !== undefined
}

// determine whether we're running in a webview or in browser, and export
// the appropriate notifier accordingly.
export const Notifier = isDesktopWebview() || isDesktopWebkit() ? OSDesktopNotifier : BrowserNotifier

export async function desktopNotify (note: CoreNote) {
if (!desktopNtfnSettings.browserNtfnEnabled || !desktopNtfnSettings[note.type]) return
await Notifier.sendDesktopNotification(note.subject, note.details)
}

export async function fetchBrowserNtfnSettings (): Promise<BrowserNtfnSetting> {
if (browserNtfnSettings !== undefined) {
return browserNtfnSettings
export function fetchDesktopNtfnSettings (): DesktopNtfnSetting {
if (desktopNtfnSettings !== undefined) {
return desktopNtfnSettings
}
const k = browserNotificationsSettingsKey()
browserNtfnSettings = (await State.fetchLocal(k) ?? {}) as BrowserNtfnSetting
return browserNtfnSettings
const k = desktopNtfnSettingsKey()
desktopNtfnSettings = (State.fetchLocal(k) ?? {}) as DesktopNtfnSetting
return desktopNtfnSettings
}

export async function updateNtfnSetting (noteType: string, enabled: boolean) {
await fetchBrowserNtfnSettings()
browserNtfnSettings[noteType] = enabled
State.storeLocal(browserNotificationsSettingsKey(), browserNtfnSettings)
export function updateNtfnSetting (noteType: string, enabled: boolean) {
fetchDesktopNtfnSettings()
desktopNtfnSettings[noteType] = enabled
State.storeLocal(desktopNtfnSettingsKey(), desktopNtfnSettings)
}
2 changes: 2 additions & 0 deletions client/webserver/site/src/js/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ declare global {
testFormatRateFullPrecision: () => void
user: () => User
isWebview?: () => boolean
webkit: any | undefined
openUrl: (url: string) => void
sendOSNotification (title: string, body?: string): void
}
}

Expand Down
Loading

0 comments on commit 122c1e7

Please sign in to comment.