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/notifications.ts b/client/webserver/site/src/js/notifications.ts index 1ee4895e4b..71aabb86f8 100644 --- a/client/webserver/site/src/js/notifications.ts +++ b/client/webserver/site/src/js/notifications.ts @@ -74,15 +74,8 @@ class BrowserNotifier { } 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)) - } + if (!('Notification' in window) || BrowserNotifier.ntfnPermissionDenied()) return + await Notification.requestPermission() } static sendDesktopNotification (title: string, body?: string) { @@ -112,17 +105,21 @@ class OSDesktopNotifier { return Promise.resolve() } - static sendDesktopNotification (title: string, body?: string): void { + static async sendDesktopNotification (title: string, body?: string): Promise { 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 diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 04280e40ab..0de7b0cef4 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -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 @@ -648,8 +649,8 @@ export interface OrderFilter { } export interface OrderPlacement { - lots : number - gapFactor : number + lots: number + gapFactor: number } export interface BasicMarketMakingCfg { @@ -768,7 +769,7 @@ export interface Stances { treasuryKeys: TKeyPolicyResult[] } -export interface TicketStats{ +export interface TicketStats { totalRewards: number ticketCount: number votes: number @@ -814,48 +815,48 @@ export interface Application { showPopups: boolean commitHash: string authed(): boolean - start (): Promise - reconnected (): void - fetchUser (): Promise - loadPage (page: string, data?: any, skipPush?: boolean): Promise - 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 + reconnected(): void + fetchUser(): Promise + loadPage(page: string, data?: any, skipPush?: boolean): Promise + 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 - checkResponse (resp: APIResponse): boolean - signOut (): Promise - registerNoteFeeder (receivers: Record void>): void - getMarketMakingStatus (): Promise - stopMarketMaking (): Promise - getMarketMakingConfig (): Promise - updateMarketMakingConfig (cfg: BotConfig): Promise - removeMarketMakingConfig (cfg: BotConfig): Promise - 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 + checkResponse(resp: APIResponse): boolean + signOut(): Promise + registerNoteFeeder(receivers: Record void>): void + getMarketMakingStatus(): Promise + stopMarketMaking(): Promise + getMarketMakingConfig(): Promise + updateMarketMakingConfig(cfg: BotConfig): Promise + removeMarketMakingConfig(cfg: BotConfig): Promise + setMarketMakingEnabled(host: string, baseAsset: number, quoteAsset: number, enabled: boolean): void } // TODO: Define an interface for Application? diff --git a/client/webserver/site/src/js/settings.ts b/client/webserver/site/src/js/settings.ts index 57778edc1c..fb5a541a2c 100644 --- a/client/webserver/site/src/js/settings.ts +++ b/client/webserver/site/src/js/settings.ts @@ -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) } /*