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 <ukanephilemon@gmail.com>
  • Loading branch information
ukane-philemon committed Dec 12, 2023
1 parent 4f2fa57 commit e04d503
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 40 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
2 changes: 1 addition & 1 deletion client/cmd/dexc-desktop/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.19
replace decred.org/dcrdex => ../../..

require (
decred.org/dcrdex v0.6.1
decred.org/dcrdex v0.6.3
fyne.io/systray v1.10.1-0.20230403195833-7dc3c09283d6
github.com/gen2brain/beeep v0.0.0-20220909211152-5a9ec94374f6
github.com/progrium/macdriver v0.4.0
Expand Down
1 change: 0 additions & 1 deletion client/webserver/locales/en-us.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@ var EnUS = map[string]string{
"simultaneous_servers_msg": "The Decred DEX Client supports simultaneous use of any number of DEX servers.",
"Change App Password": "Change App Password",
"enable_browser_ntfn": "Enable desktop notifications",
"browser_ntfn_blocked": "Browser notifications are currently blocked. Please unblock this site in your browser to receive notifications.",
"enable_browser_ntfn_info": "Desktop notifications appear even when this window is not active. When you have other applications open this can be helpful as you will be notified on DCRDEX events. Customize below the types of notifications you would like to receive.",
"Save Notifications": "Save Notifications",
"Build ID": "Build ID",
Expand Down
2 changes: 1 addition & 1 deletion client/webserver/site/src/html/settings.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
<span class="ico-info" data-tooltip="[[[enable_browser_ntfn_info]]]"></span>
</label>
</div>
<div id="browserNtfnBlockedMsg" class="d-hide">[[[browser_ntfn_blocked]]]</div>
<div id="browserNtfnBlockedMsg" class="d-hide"></div>
<div id="browserNtfnCheckboxContainer" class="d-hide checkbox-container">
<div class="form-check" id="browserNtfnCheckboxTemplate">
<label class="form-check-label" >
Expand Down
17 changes: 10 additions & 7 deletions client/webserver/site/src/js/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export const ID_NO_WALLET_MSG = 'ID_NO_WALLET_MSG'
export const ID_TRADING_TIER_UPDATED = 'TRADING_TIER_UPDATED'
export const ID_INVALID_TIER_VALUE = 'INVALID_TIER_VALUE'
export const ID_INVALID_COMPS_VALUE = 'INVALID_COMPS_VALUE'
export const ID_NOTIFICATION_BLOCKED_BROWSER_MSG = 'NOTIFICATION_BLOCKED_BROWSER_MSG'
export const ID_NOTIFICATION_BLOCKED_DESKTOP_MSG = 'NOTIFICATION_BLOCKED_DESKTOP_MSG'

export const enUS: Locale = {
[ID_NO_PASS_ERROR_MSG]: 'password cannot be empty',
Expand Down Expand Up @@ -204,7 +206,6 @@ export const enUS: Locale = {
[ID_WAITING_FOR_CONFS]: 'Waiting for confirmations...',
[ID_NONE_SELECTED]: 'none selected',
[ID_REGISTRATION_FEE_SUCCESS]: 'Fidelity bond accepted!',
[ID_API_ERROR]: 'API error',
[ID_ADD]: 'Add',
[ID_CREATE]: 'Create',
[ID_WALLET_READY]: 'Ready',
Expand Down Expand Up @@ -316,7 +317,9 @@ export const enUS: Locale = {
[ID_TRADING_TIER_UPDATED]: 'Trading Tier Updated',
[ID_INVALID_TIER_VALUE]: 'Invalid tier value',
[ID_INVALID_COMPS_VALUE]: 'Invalid comps value',
[ID_API_ERROR]: 'api error: {{ msg }}'
[ID_API_ERROR]: 'api error: {{ msg }}',
[ID_NOTIFICATION_BLOCKED_BROWSER_MSG]: 'Browser notifications are currently blocked. Please unblock this site in your browser settings to receive notifications.',
[ID_NOTIFICATION_BLOCKED_DESKTOP_MSG]: 'Notifications are currently blocked for DCRDEX. Please unblock DCRDEX in your system notification settings.'
}

export const ptBR: Locale = {
Expand Down Expand Up @@ -360,7 +363,7 @@ export const ptBR: Locale = {
[ID_WAITING_FOR_CONFS]: 'Esperando confirmações...',
[ID_NONE_SELECTED]: 'nenhuma selecionado',
[ID_REGISTRATION_FEE_SUCCESS]: 'Sucesso no pagamento da taxa de registro!', // TODO: reword from fee => bond
[ID_API_ERROR]: 'Erro de API',
[ID_API_ERROR]: 'erro de API: {{ msg }}',
[ID_ADD]: 'Adicionar',
[ID_CREATE]: 'Criar',
[ID_WALLET_READY]: 'Escolher',
Expand Down Expand Up @@ -406,7 +409,7 @@ export const zhCN: Locale = {
[ID_LOT]: '批处理',
[ID_LOTS]: '批', // alt. 很多
[ID_EPOCH]: '时间',
[ID_API_ERROR]: '接口错误',
[ID_API_ERROR]: '接口错误: {{ msg }}',
[ID_ADD]: '添加',
[ID_CREATE]: '创建',
[ID_AVAILABLE]: '可用',
Expand Down Expand Up @@ -454,7 +457,7 @@ export const plPL: Locale = {
[ID_WAITING_FOR_CONFS]: 'Oczekiwanie na potwierdzenia...',
[ID_NONE_SELECTED]: 'brak zaznaczenia',
[ID_REGISTRATION_FEE_SUCCESS]: 'Płatność rejestracyjna powiodła się!', // TODO: reword from fee => bond
[ID_API_ERROR]: 'błąd API',
[ID_API_ERROR]: 'błąd API: {{ msg }}',
[ID_ADD]: 'Dodaj',
[ID_CREATE]: 'Utwórz',
[ID_WALLET_READY]: 'Gotowy',
Expand Down Expand Up @@ -508,7 +511,7 @@ export const deDE: Locale = {
[ID_WAITING_FOR_CONFS]: 'Warten auf Bestätigungen...',
[ID_NONE_SELECTED]: 'keine ausgewählt',
[ID_REGISTRATION_FEE_SUCCESS]: 'Zahlung der Registrierungsgebühr erfolgreich!', // TODO: reword from fee => bond
[ID_API_ERROR]: 'API Fehler',
[ID_API_ERROR]: 'API Fehler: {{ msg }}',
[ID_ADD]: 'Hinzufügen',
[ID_CREATE]: 'Erstellen',
[ID_WALLET_READY]: 'Bereit',
Expand Down Expand Up @@ -566,7 +569,7 @@ export const ar: Locale = {
[ID_WAITING_FOR_CONFS]: 'في انتظار التأكيدات ...',
[ID_NONE_SELECTED]: 'لم يتم تحديد أي شيء',
[ID_REGISTRATION_FEE_SUCCESS]: 'تم دفع رسوم التسجيل بنجاح!', // TODO: reword from fee => bond
[ID_API_ERROR]: 'خطأ في واجهة برمجة التطبيقات',
[ID_API_ERROR]: 'خطأ في واجهة برمجة التطبيقات :{{ msg }}',
[ID_ADD]: 'إضافة',
[ID_CREATE]: 'إنشاء',
[ID_WALLET_READY]: 'جاهزة',
Expand Down
37 changes: 23 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 All @@ -100,29 +93,38 @@ class BrowserNotifier {
// notification library exposed to the webview.
class OSDesktopNotifier {
static ntfnPermissionGranted (): boolean {
// TODO: This method should call webview function to check notification
// permission status.
return true
}

static ntfnPermissionDenied (): boolean {
// TODO: This method should call webview function to check notification
// permission status.
return false
}

static async requestNtfnPermission (): Promise<void> {
OSDesktopNotifier.sendDesktopNotification(intl.prep(intl.ID_BROWSER_NTFN_ENABLED))
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 (w.webkit) await w.webkit.messageHandlers.dexcHandler.postMessage(['sendOSNotification', title, body]) // See: client/cmd/dexc-desktop/app_darwin.go#L673-#L697.
else await w.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 {
const w = (window as any)
return w.isWebview || w.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 All @@ -148,3 +150,10 @@ export async function updateNtfnSetting (noteType: string, enabled: boolean) {
desktopNtfnSettings[noteType] = enabled
State.storeLocal(desktopNtfnSettingsKey(), desktopNtfnSettings)
}

export function ntfnPermissionDeniedMsg (): string {
if (isWebview()) {
return intl.prep(intl.ID_NOTIFICATION_BLOCKED_DESKTOP_MSG)
}
return intl.prep(intl.ID_NOTIFICATION_BLOCKED_BROWSER_MSG)
}
32 changes: 16 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,29 @@ 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)
const ntfnPermissionDenied = ntfn.ntfnPermissionDenied()
if (ntfnPermissionDenied) page.browserNtfnBlockedMsg.textContent = ntfn.ntfnPermissionDeniedMsg()
Doc.setVis(ntfnPermissionDenied, page.browserNtfnBlockedMsg)
// Send note enabled notification.
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 e04d503

Please sign in to comment.