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/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/app.ts b/client/webserver/site/src/js/app.ts index 88b01d3ec1..8e7e0764ba 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -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 || []) @@ -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) } /* diff --git a/client/webserver/site/src/js/notifications.ts b/client/webserver/site/src/js/notifications.ts index 94dfd51b80..8b0970e781 100644 --- a/client/webserver/site/src/js/notifications.ts +++ b/client/webserver/site/src/js/notifications.ts @@ -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 { + 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 { + await OSDesktopNotifier.sendDesktopNotification(intl.prep(intl.ID_BROWSER_NTFN_ENABLED)) + return Promise.resolve() + } + + static async sendDesktopNotification (title: string, body?: string): Promise { + // 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 { - 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) } diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 9d9d531f58..805eac3d3d 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -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 } } diff --git a/client/webserver/site/src/js/settings.ts b/client/webserver/site/src/js/settings.ts index 2334e03d5b..695633ba3e 100644 --- a/client/webserver/site/src/js/settings.ts +++ b/client/webserver/site/src/js/settings.ts @@ -4,7 +4,13 @@ import State from './state' import { postJSON } from './http' import * as forms from './forms' import * as intl from './locales' -import * as ntfn from './notifications' +import { + updateNtfnSetting, + DesktopNtfnSetting, + fetchDesktopNtfnSettings, + desktopNtfnLabels, + Notifier +} from './notifications' import { app, Exchange, @@ -175,25 +181,23 @@ 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) + updateNtfnSetting(noteType, enabled) } - async getBrowserNtfnSettings (form: HTMLElement) { - const loaded = app().loading(form) - const permissions = await ntfn.fetchBrowserNtfnSettings() - loaded() + getBrowserNtfnSettings (): DesktopNtfnSetting { + const permissions = fetchDesktopNtfnSettings() return permissions } async renderDesktopNtfnSettings () { const page = this.page - const ntfnSettings = await this.getBrowserNtfnSettings(page.browserNotificationsForm) - const labels = ntfn.browserNtfnLabels + const ntfnSettings = this.getBrowserNtfnSettings() + const labels = desktopNtfnLabels const tmpl = page.browserNtfnCheckboxTemplate tmpl.removeAttribute('id') const container = page.browserNtfnCheckboxContainer @@ -213,25 +217,25 @@ export default class SettingsPage extends BasePage { const enabledCheckbox = page.browserNtfnEnabled Doc.bind(enabledCheckbox, 'click', async (e: Event) => { - if (ntfn.ntfnPermissionDenied()) return + if (Notifier.ntfnPermissionDenied()) return const checkbox = e.target as HTMLInputElement if (checkbox.checked) { - await ntfn.requestNtfnPermission() - checkbox.checked = !ntfn.ntfnPermissionDenied() + await Notifier.requestNtfnPermission() + checkbox.checked = !Notifier.ntfnPermissionDenied() } - await this.updateNtfnSetting(e) + this.updateNtfnSetting(e) checkbox.dispatchEvent(new Event('change')) }) Doc.bind(enabledCheckbox, 'change', (e: Event) => { const checkbox = e.target as HTMLInputElement - const permDenied = ntfn.ntfnPermissionDenied() + const permDenied = Notifier.ntfnPermissionDenied() Doc.setVis(checkbox.checked, page.browserNtfnCheckboxContainer) Doc.setVis(permDenied, page.browserNtfnBlockedMsg) checkbox.disabled = permDenied }) - enabledCheckbox.checked = (ntfn.ntfnPermissionGranted() && ntfnSettings.browserNtfnEnabled) + enabledCheckbox.checked = (Notifier.ntfnPermissionGranted() && ntfnSettings.browserNtfnEnabled) enabledCheckbox.dispatchEvent(new Event('change')) }