Skip to content

Commit

Permalink
multi: implement OSDesktopNotifier for MacOS and some refactoring
Browse files Browse the repository at this point in the history
Signed-off-by: Philemon Ukane <[email protected]>
  • Loading branch information
ukane-philemon committed Dec 27, 2023
1 parent 074c103 commit 3bf646a
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 30 deletions.
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
25 changes: 11 additions & 14 deletions client/webserver/site/src/js/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,8 @@ class BrowserNotifier {
}

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))
}
if (!('Notification' in window) || BrowserNotifier.ntfnPermissionDenied()) return
await Notification.requestPermission()
}

static sendDesktopNotification (title: string, body?: string) {
Expand Down Expand Up @@ -112,17 +105,21 @@ class OSDesktopNotifier {
return Promise.resolve()
}

static sendDesktopNotification (title: string, body?: string): void {
static async sendDesktopNotification (title: string, body?: string): Promise<void> {
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
Expand Down
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
29 changes: 13 additions & 16 deletions client/webserver/site/src/js/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,29 +208,26 @@ export default class SettingsPage extends BasePage {
Doc.bind(checkbox, 'click', this.updateNtfnSetting)
})

const enabledCheckbox = page.browserNtfnEnabled
const updateCheckBox = (checkbox: HTMLInputElement, checked: boolean, notifyEnabled: boolean, e?: Event) => {
checkbox.checked = checked
if (e) this.updateNtfnSetting(e)
Doc.setVis(checked, page.browserNtfnCheckboxContainer)
Doc.setVis(ntfn.ntfnPermissionDenied(), page.browserNtfnBlockedMsg)
if (checked && notifyEnabled) ntfn.sendDesktopNotification(intl.prep(intl.ID_BROWSER_NTFN_ENABLED))
}

Doc.bind(enabledCheckbox, 'click', async (e: Event) => {
if (ntfn.ntfnPermissionDenied()) return
const enabledCheckbox = page.browserNtfnEnabled
Doc.bind(enabledCheckbox, 'change', async (e: Event) => {
const checkbox = e.target as HTMLInputElement
if (checkbox.checked) {
if (ntfn.ntfnPermissionDenied() || !checkbox.checked) updateCheckBox(checkbox, false, false, e)
else if (checkbox.checked) {
await ntfn.requestNtfnPermission()
checkbox.checked = !ntfn.ntfnPermissionDenied()
updateCheckBox(checkbox, ntfn.ntfnPermissionGranted(), true, 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()
Doc.setVis(checkbox.checked, page.browserNtfnCheckboxContainer)
Doc.setVis(permDenied, page.browserNtfnBlockedMsg)
checkbox.disabled = permDenied
})

enabledCheckbox.checked = (ntfn.ntfnPermissionGranted() && ntfnSettings.browserNtfnEnabled)
enabledCheckbox.dispatchEvent(new Event('change'))
updateCheckBox(enabledCheckbox as HTMLInputElement, enabledCheckbox.checked, false)
}

/*
Expand Down

0 comments on commit 3bf646a

Please sign in to comment.