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 41bc6e8 commit 32e9eda
Show file tree
Hide file tree
Showing 4 changed files with 168 additions and 74 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
89 changes: 45 additions & 44 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 Expand Up @@ -648,8 +649,8 @@ export interface OrderFilter {
}

export interface OrderPlacement {
lots : number
gapFactor : number
lots: number
gapFactor: number
}

export interface BasicMarketMakingCfg {
Expand Down Expand Up @@ -768,7 +769,7 @@ export interface Stances {
treasuryKeys: TKeyPolicyResult[]
}

export interface TicketStats{
export interface TicketStats {
totalRewards: number
ticketCount: number
votes: number
Expand Down Expand Up @@ -814,48 +815,48 @@ export interface Application {
showPopups: boolean
commitHash: string
authed(): boolean
start (): Promise<void>
reconnected (): void
fetchUser (): Promise<User | void>
loadPage (page: string, data?: any, skipPush?: boolean): Promise<boolean>
attach (data: any): void
bindTooltips (ancestor: HTMLElement): void
bindUrlHandlers (ancestor: HTMLElement): void
attachHeader (): void
showDropdown (icon: HTMLElement, dialog: HTMLElement): void
ackNotes (): void
setNoteTimes (noteList: HTMLElement): void
bindInternalNavigation (ancestor: HTMLElement): void
storeNotes (): void
updateMenuItemsDisplay (): void
attachCommon (node: HTMLElement): void
updateBondConfs (dexAddr: string, coinID: string, confs: number, assetID: number): void
handleBondNote (note: BondNote): void
setNotes (notes: CoreNote[]): void
notify (note: CoreNote): void
log (loggerID: string, ...msg: any): void
prependPokeElement (note: CoreNote): void
prependNoteElement (note: CoreNote, skipSave?: boolean): void
prependListElement (noteList: HTMLElement, note: CoreNote, el: NoteElement): void
loading (el: HTMLElement): () => void
orders (host: string, mktID: string): Order[]
haveActiveOrders (assetID: number): boolean
order (oid: string): Order | null
start(): Promise<void>
reconnected(): void
fetchUser(): Promise<User | void>
loadPage(page: string, data?: any, skipPush?: boolean): Promise<boolean>
attach(data: any): void
bindTooltips(ancestor: HTMLElement): void
bindUrlHandlers(ancestor: HTMLElement): void
attachHeader(): void
showDropdown(icon: HTMLElement, dialog: HTMLElement): void
ackNotes(): void
setNoteTimes(noteList: HTMLElement): void
bindInternalNavigation(ancestor: HTMLElement): void
storeNotes(): void
updateMenuItemsDisplay(): void
attachCommon(node: HTMLElement): void
updateBondConfs(dexAddr: string, coinID: string, confs: number, assetID: number): void
handleBondNote(note: BondNote): void
setNotes(notes: CoreNote[]): void
notify(note: CoreNote): void
log(loggerID: string, ...msg: any): void
prependPokeElement(note: CoreNote): void
prependNoteElement(note: CoreNote, skipSave?: boolean): void
prependListElement(noteList: HTMLElement, note: CoreNote, el: NoteElement): void
loading(el: HTMLElement): () => void
orders(host: string, mktID: string): Order[]
haveActiveOrders(assetID: number): boolean
order(oid: string): Order | null
canAccelerateOrder(order: Order): boolean
unitInfo (assetID: number, xc?: Exchange): UnitInfo
conventionalRate (baseID: number, quoteID: number, encRate: number, xc?: Exchange): number
walletDefinition (assetID: number, walletType: string): WalletDefinition
currentWalletDefinition (assetID: number): WalletDefinition
fetchBalance (assetID: number): Promise<WalletBalance>
checkResponse (resp: APIResponse): boolean
signOut (): Promise<void>
registerNoteFeeder (receivers: Record<string, (n: CoreNote) => void>): void
getMarketMakingStatus (): Promise<MarketMakingStatus>
stopMarketMaking (): Promise<void>
getMarketMakingConfig (): Promise<MarketMakingConfig>
updateMarketMakingConfig (cfg: BotConfig): Promise<void>
removeMarketMakingConfig (cfg: BotConfig): Promise<void>
setMarketMakingEnabled (host: string, baseAsset: number, quoteAsset: number, enabled: boolean): void
unitInfo(assetID: number, xc?: Exchange): UnitInfo
conventionalRate(baseID: number, quoteID: number, encRate: number, xc?: Exchange): number
walletDefinition(assetID: number, walletType: string): WalletDefinition
currentWalletDefinition(assetID: number): WalletDefinition
fetchBalance(assetID: number): Promise<WalletBalance>
checkResponse(resp: APIResponse): boolean
signOut(): Promise<void>
registerNoteFeeder(receivers: Record<string, (n: CoreNote) => void>): void
getMarketMakingStatus(): Promise<MarketMakingStatus>
stopMarketMaking(): Promise<void>
getMarketMakingConfig(): Promise<MarketMakingConfig>
updateMarketMakingConfig(cfg: BotConfig): Promise<void>
removeMarketMakingConfig(cfg: BotConfig): Promise<void>
setMarketMakingEnabled(host: string, baseAsset: number, quoteAsset: number, enabled: boolean): void
}

// TODO: Define an interface for Application?
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 32e9eda

Please sign in to comment.