Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dexc-desktop: Desktop notifications #2599

Merged
merged 6 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -203,8 +203,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 @@ -714,8 +714,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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be necessary, right?

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
Loading