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

multi: Implement OSDesktopNotifier for MacOS and some refactoring #2631

Closed
wants to merge 3 commits into from
Closed
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
Copy link
Member

Choose a reason for hiding this comment

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

This code still manages the browser ntfn's - as well as the desktop toasts. Renaming these functions to suggest they handle desktop notifications only isn't warranted.

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
119 changes: 80 additions & 39 deletions client/webserver/site/src/js/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,79 +35,120 @@ 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 sendDesktopNotification (title: string, body?: string) {
if (!BrowserNotifier.ntfnPermissionGranted() || !desktopNtfnSettings.browserNtfnEnabled) 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 {
ukane-philemon marked this conversation as resolved.
Show resolved Hide resolved
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> {
OSDesktopNotifier.sendDesktopNotification(intl.prep(intl.ID_BROWSER_NTFN_ENABLED))
return Promise.resolve()
}

static async sendDesktopNotification (title: string, body?: string): Promise<void> {
if (!desktopNtfnSettings.browserNtfnEnabled) return
if (window.webkit) await window.webkit.messageHandlers.dexcHandler.postMessage(['sendOSNotification', title, body]) // See: client/cmd/dexc-desktop/app_darwin.go#L673-#L697.
Copy link
Member

Choose a reason for hiding this comment

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

window.webkit is defined on Linux too, apparently, so this breaks Linux desktop notifications.

Copy link
Member

Choose a reason for hiding this comment

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

It works correctly because we test for webview first.
https://github.com/peterzen/dcrdex/blob/c509a3ffdb7a4ae626f395ef0f7ead3f9239f851/client/webserver/site/src/js/notifications.ts#L117-L120

But it did seem a bit brittle indeed, refactored the check to not rely on window.webkit in #2599. c509a3f

else await (window as any).sendOSNotification(title, body) // this calls a function exported via webview.Bind()
Copy link
Member

Choose a reason for hiding this comment

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

Instead of

window as any

add the method to the global Window definition.

sendOSNotification: (title: string, body?: string) => void

Copy link
Member

Choose a reason for hiding this comment

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

This PR was superseded by #2599, these changes were rolled into it.

}
}

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
// isWebview checks if we are running in webview or webkit (MacOS).
function isWebview (): boolean {
return window.isWebview !== undefined || window.webkit !== undefined // MacOS
}

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 = 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<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)
fetchDesktopNtfnSettings()
desktopNtfnSettings[noteType] = enabled
State.storeLocal(desktopNtfnSettingsKey(), desktopNtfnSettings)
}
1 change: 1 addition & 0 deletions client/webserver/site/src/js/registry.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 7 additions & 9 deletions client/webserver/site/src/js/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,25 +175,23 @@ export default class SettingsPage extends BasePage {
this.renderDesktopNtfnSettings()
}

async updateNtfnSetting (e: Event) {
updateNtfnSetting (e: Event) {
Copy link
Member

Choose a reason for hiding this comment

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

No need for the async here - this is fixed in the parent PR.

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.fetchBrowserNtfnSettings()
loaded()
getBrowserNtfnSettings (): ntfn.DesktopNtfnSetting {
const permissions = ntfn.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 = ntfn.desktopNtfnLabels
const tmpl = page.browserNtfnCheckboxTemplate
tmpl.removeAttribute('id')
const container = page.browserNtfnCheckboxContainer
Expand All @@ -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'))
})

Expand Down
Loading