From e4b08f0ae20987e002d353db64d28fa7118e789a Mon Sep 17 00:00:00 2001 From: Peter Banik Date: Wed, 8 Nov 2023 16:12:26 -0500 Subject: [PATCH 1/3] In webview, send desktop notification via Beeep --- client/cmd/dexc-desktop/app.go | 4 + client/webserver/site/src/js/app.ts | 8 +- client/webserver/site/src/js/notifications.ts | 116 ++++++++++++------ client/webserver/site/src/js/settings.ts | 4 +- 4 files changed, 86 insertions(+), 46 deletions(-) diff --git a/client/cmd/dexc-desktop/app.go b/client/cmd/dexc-desktop/app.go index 2cdc7bbeb1..d86d80bea1 100644 --- a/client/cmd/dexc-desktop/app.go +++ b/client/cmd/dexc-desktop/app.go @@ -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) { diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index dd229d4301..0afdbe4640 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -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 || []) @@ -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) } /* diff --git a/client/webserver/site/src/js/notifications.ts b/client/webserver/site/src/js/notifications.ts index 94dfd51b80..f58f809f53 100644 --- a/client/webserver/site/src/js/notifications.ts +++ b/client/webserver/site/src/js/notifications.ts @@ -35,79 +35,115 @@ const NoteTypeMatch = 'match' const NoteTypeBondPost = 'bondpost' const NoteTypeConnEvent = 'conn' -type BrowserNtfnSettingLabel = { +type DesktopNtfnSettingLabel = { [x: string]: string } -type BrowserNtfnSetting = { +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' + } -export function ntfnPermissionDenied () { - return window.Notification.permission === 'denied' -} + static ntfnPermissionDenied (): boolean { + return window.Notification.permission === 'denied' + } -export async function requestNtfnPermission () { - if (!('Notification' in window)) { - return + static async requestNtfnPermission (): Promise { + 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)) + } } - 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 sendDesktopNotification (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 } } -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 +// 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 + } + + static ntfnPermissionDenied (): boolean { + return false + } + + static async requestNtfnPermission (): Promise { + OSDesktopNotifier.sendDesktopNotification(intl.prep(intl.ID_BROWSER_NTFN_ENABLED)) + return Promise.resolve() + } + + static sendDesktopNotification (title: string, body?: string): void { + // this calls a function exported via webview.Bind() + const w = (window as any) + w.sendOSNotification(title, body) + } } -export function browserNotify (note: CoreNote) { - if (!browserNtfnSettings[note.type]) return - showBrowserNtfn(note.subject, note.details) +// determine whether we're running in a webview or in browser, and export +// the appropriate notifier accordingly. +export const Notifier = window.isWebview ? OSDesktopNotifier : BrowserNotifier + +export const ntfnPermissionGranted = Notifier.ntfnPermissionGranted +export const ntfnPermissionDenied = Notifier.ntfnPermissionDenied +export const requestNtfnPermission = Notifier.requestNtfnPermission +export const sendDesktopNotification = Notifier.sendDesktopNotification + +export function desktopNotify (note: CoreNote) { + if (!desktopNtfnSettings[note.type]) return + Notifier.sendDesktopNotification(note.subject, note.details) } -export async function fetchBrowserNtfnSettings (): Promise { - if (browserNtfnSettings !== undefined) { - return browserNtfnSettings +export async function fetchDesktopNtfnSettings (): Promise { + if (desktopNtfnSettings !== undefined) { + return desktopNtfnSettings } - const k = browserNotificationsSettingsKey() - browserNtfnSettings = (await State.fetchLocal(k) ?? {}) as BrowserNtfnSetting - return browserNtfnSettings + const k = desktopNtfnSettingsKey() + desktopNtfnSettings = (await State.fetchLocal(k) ?? {}) as DesktopNtfnSetting + return desktopNtfnSettings } export async function updateNtfnSetting (noteType: string, enabled: boolean) { - await fetchBrowserNtfnSettings() - browserNtfnSettings[noteType] = enabled - State.storeLocal(browserNotificationsSettingsKey(), browserNtfnSettings) + await fetchDesktopNtfnSettings() + desktopNtfnSettings[noteType] = enabled + State.storeLocal(desktopNtfnSettingsKey(), desktopNtfnSettings) } diff --git a/client/webserver/site/src/js/settings.ts b/client/webserver/site/src/js/settings.ts index 2334e03d5b..454aa9ca97 100644 --- a/client/webserver/site/src/js/settings.ts +++ b/client/webserver/site/src/js/settings.ts @@ -185,7 +185,7 @@ export default class SettingsPage extends BasePage { async getBrowserNtfnSettings (form: HTMLElement) { const loaded = app().loading(form) - const permissions = await ntfn.fetchBrowserNtfnSettings() + const permissions = await ntfn.fetchDesktopNtfnSettings() loaded() return permissions } @@ -193,7 +193,7 @@ export default class SettingsPage extends BasePage { async renderDesktopNtfnSettings () { const page = this.page const ntfnSettings = await this.getBrowserNtfnSettings(page.browserNotificationsForm) - const labels = ntfn.browserNtfnLabels + const labels = ntfn.desktopNtfnLabels const tmpl = page.browserNtfnCheckboxTemplate tmpl.removeAttribute('id') const container = page.browserNtfnCheckboxContainer From 074c103904af8b35a2e5803667911fa4234bc3eb Mon Sep 17 00:00:00 2001 From: Peter Banik Date: Tue, 14 Nov 2023 08:14:15 -0500 Subject: [PATCH 2/3] Verify desktop notification permission in webview --- client/webserver/site/src/js/notifications.ts | 11 ++++++----- client/webserver/site/src/js/settings.ts | 14 ++++++-------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/client/webserver/site/src/js/notifications.ts b/client/webserver/site/src/js/notifications.ts index f58f809f53..1ee4895e4b 100644 --- a/client/webserver/site/src/js/notifications.ts +++ b/client/webserver/site/src/js/notifications.ts @@ -39,7 +39,7 @@ type DesktopNtfnSettingLabel = { [x: string]: string } -type DesktopNtfnSetting = { +export type DesktopNtfnSetting = { [x: string]: boolean } @@ -86,7 +86,7 @@ class BrowserNotifier { } static sendDesktopNotification (title: string, body?: string) { - if (window.Notification.permission !== 'granted') return + if (!BrowserNotifier.ntfnPermissionGranted() || !desktopNtfnSettings.browserNtfnEnabled) return const ntfn = new window.Notification(title, { body: body, icon: '/img/softened-icon.png' @@ -113,6 +113,7 @@ class OSDesktopNotifier { } static sendDesktopNotification (title: string, body?: string): void { + if (!desktopNtfnSettings.browserNtfnEnabled) return // this calls a function exported via webview.Bind() const w = (window as any) w.sendOSNotification(title, body) @@ -133,17 +134,17 @@ export function desktopNotify (note: CoreNote) { Notifier.sendDesktopNotification(note.subject, note.details) } -export async function fetchDesktopNtfnSettings (): Promise { +export function fetchDesktopNtfnSettings (): DesktopNtfnSetting { if (desktopNtfnSettings !== undefined) { return desktopNtfnSettings } const k = desktopNtfnSettingsKey() - desktopNtfnSettings = (await State.fetchLocal(k) ?? {}) as DesktopNtfnSetting + desktopNtfnSettings = (State.fetchLocal(k) ?? {}) as DesktopNtfnSetting return desktopNtfnSettings } export async function updateNtfnSetting (noteType: string, enabled: boolean) { - await fetchDesktopNtfnSettings() + fetchDesktopNtfnSettings() desktopNtfnSettings[noteType] = enabled State.storeLocal(desktopNtfnSettingsKey(), desktopNtfnSettings) } diff --git a/client/webserver/site/src/js/settings.ts b/client/webserver/site/src/js/settings.ts index 454aa9ca97..57778edc1c 100644 --- a/client/webserver/site/src/js/settings.ts +++ b/client/webserver/site/src/js/settings.ts @@ -175,24 +175,22 @@ export default class SettingsPage extends BasePage { this.renderDesktopNtfnSettings() } - async updateNtfnSetting (e: Event) { + updateNtfnSetting (e: Event) { const checkbox = e.target as HTMLInputElement const noteType = checkbox.getAttribute('name') if (noteType === null) return const enabled = checkbox.checked - await ntfn.updateNtfnSetting(noteType, enabled) + ntfn.updateNtfnSetting(noteType, enabled) } - async getBrowserNtfnSettings (form: HTMLElement) { - const loaded = app().loading(form) - const permissions = await ntfn.fetchDesktopNtfnSettings() - loaded() + getBrowserNtfnSettings (): ntfn.DesktopNtfnSetting { + const permissions = ntfn.fetchDesktopNtfnSettings() return permissions } async renderDesktopNtfnSettings () { const page = this.page - const ntfnSettings = await this.getBrowserNtfnSettings(page.browserNotificationsForm) + const ntfnSettings = this.getBrowserNtfnSettings() const labels = ntfn.desktopNtfnLabels const tmpl = page.browserNtfnCheckboxTemplate tmpl.removeAttribute('id') @@ -219,7 +217,7 @@ export default class SettingsPage extends BasePage { await ntfn.requestNtfnPermission() checkbox.checked = !ntfn.ntfnPermissionDenied() } - await this.updateNtfnSetting(e) + this.updateNtfnSetting(e) checkbox.dispatchEvent(new Event('change')) }) From 6ee877f7ff4c8108e059573ce696b53fe83da7d6 Mon Sep 17 00:00:00 2001 From: Philemon Ukane Date: Wed, 27 Dec 2023 22:17:36 +0100 Subject: [PATCH 3/3] Implement OSDesktopNotifier for MacOS Signed-off-by: Philemon Ukane --- client/cmd/dexc-desktop/app_darwin.go | 99 +++++++++++++++++++ client/webserver/site/src/js/notifications.ts | 14 ++- client/webserver/site/src/js/registry.ts | 1 + 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/client/cmd/dexc-desktop/app_darwin.go b/client/cmd/dexc-desktop/app_darwin.go index 4c478db27f..32db8136dd 100644 --- a/client/cmd/dexc-desktop/app_darwin.go +++ b/client/cmd/dexc-desktop/app_darwin.go @@ -66,6 +66,7 @@ import ( "runtime/debug" "runtime/pprof" "strconv" + "strings" "sync" "sync/atomic" "syscall" @@ -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")) @@ -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 @@ -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..postMessage(), where + // 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) @@ -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 diff --git a/client/webserver/site/src/js/notifications.ts b/client/webserver/site/src/js/notifications.ts index 1ee4895e4b..8694002214 100644 --- a/client/webserver/site/src/js/notifications.ts +++ b/client/webserver/site/src/js/notifications.ts @@ -112,17 +112,21 @@ class OSDesktopNotifier { return Promise.resolve() } - static sendDesktopNotification (title: string, body?: string): void { + static async sendDesktopNotification (title: string, body?: string): Promise { if (!desktopNtfnSettings.browserNtfnEnabled) return - // this calls a function exported via webview.Bind() - const w = (window as any) - w.sendOSNotification(title, body) + if (window.webkit) await window.webkit.messageHandlers.dexcHandler.postMessage(['sendOSNotification', title, body]) // See: client/cmd/dexc-desktop/app_darwin.go#L673-#L697. + else await (window as any).sendOSNotification(title, body) // this calls a function exported via webview.Bind() } } +// isWebview checks if we are running in webview or webkit (MacOS). +function isWebview (): boolean { + return window.isWebview !== undefined || window.webkit !== undefined // MacOS +} + // determine whether we're running in a webview or in browser, and export // the appropriate notifier accordingly. -export const Notifier = window.isWebview ? OSDesktopNotifier : BrowserNotifier +export const Notifier = isWebview() ? OSDesktopNotifier : BrowserNotifier export const ntfnPermissionGranted = Notifier.ntfnPermissionGranted export const ntfnPermissionDenied = Notifier.ntfnPermissionDenied diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 573970c89e..b8c595c332 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -1,5 +1,6 @@ declare global { interface Window { + webkit: any | undefined log: (...args: any) => void enableLogger: (loggerID: string, enable: boolean) => void recordLogger: (loggerID: string, enable: boolean) => void