From bc9f313aebb89362d70095a1cf98e1f2853cd243 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sun, 23 Aug 2020 21:26:27 +0200 Subject: [PATCH] Improved the external modal support --- jenkins/create_build.sh | 4 +- modules/core/render-backend/ExternalModal.ts | 111 +++++++++++++++++ modules/core/render-backend/index.ts | 4 + modules/renderer-manifest/index.ts | 4 +- modules/renderer/ExternalModalHandler.ts | 70 +++-------- modules/renderer/UnloadHandler.ts | 22 ++-- modules/shared/ipc/ExternalModal.ts | 12 ++ modules/shared/proxy/Client.ts | 119 +++++++++++++++++++ modules/shared/proxy/Definitions.ts | 35 ++++++ modules/shared/proxy/Server.ts | 83 +++++++++++++ modules/shared/proxy/Test.ts | 30 +++++ package.json | 2 +- 12 files changed, 425 insertions(+), 71 deletions(-) create mode 100644 modules/core/render-backend/ExternalModal.ts create mode 100644 modules/shared/ipc/ExternalModal.ts create mode 100644 modules/shared/proxy/Client.ts create mode 100644 modules/shared/proxy/Definitions.ts create mode 100644 modules/shared/proxy/Server.ts create mode 100644 modules/shared/proxy/Test.ts diff --git a/jenkins/create_build.sh b/jenkins/create_build.sh index 08ef2e3..d96f108 100755 --- a/jenkins/create_build.sh +++ b/jenkins/create_build.sh @@ -122,5 +122,5 @@ function deploy_client() { #install_npm #compile_scripts #compile_native -#package_client -deploy_client +package_client +#deploy_client diff --git a/modules/core/render-backend/ExternalModal.ts b/modules/core/render-backend/ExternalModal.ts new file mode 100644 index 0000000..84d9549 --- /dev/null +++ b/modules/core/render-backend/ExternalModal.ts @@ -0,0 +1,111 @@ +import {ObjectProxyServer} from "../../shared/proxy/Server"; +import {ExternalModal, kIPCChannelExternalModal} from "../../shared/ipc/ExternalModal"; +import {ProxiedClass} from "../../shared/proxy/Definitions"; +import {BrowserWindow, dialog} from "electron"; +import {loadWindowBounds, startTrackWindowBounds} from "../../shared/window"; +import {Arguments, process_args} from "../../shared/process-arguments"; +import {open_preview} from "../url-preview"; +import * as path from "path"; + +class ProxyImplementation extends ProxiedClass implements ExternalModal { + private windowInstance: BrowserWindow; + + public constructor(props) { + super(props); + } + + async focus(): Promise { + this.windowInstance?.focusOnWebView(); + } + + async minimize(): Promise { + this.windowInstance?.minimize(); + } + + async spawnWindow(modalTarget: string, url: string): Promise { + if(this.windowInstance) { + return; + } + + this.windowInstance = new BrowserWindow({ + /* parent: remote.getCurrentWindow(), */ /* do not link them together */ + autoHideMenuBar: true, + + webPreferences: { + nodeIntegration: true, + }, + icon: path.join(__dirname, "..", "..", "resources", "logo.ico"), + minWidth: 600, + minHeight: 300, + + frame: false, + transparent: true, + + show: true + }); + + loadWindowBounds("modal-" + modalTarget, this.windowInstance).then(() => { + startTrackWindowBounds("modal-" + modalTarget, this.windowInstance); + }); + + this.windowInstance.webContents.on('new-window', (event, url_str, frameName, disposition, options, additionalFeatures) => { + console.error("Open: %O", frameName); + if(frameName.startsWith("__modal_external__")) { + return; + } + + event.preventDefault(); + try { + let url: URL; + try { + url = new URL(url_str); + } catch(error) { + throw "failed to parse URL"; + } + + { + let protocol = url.protocol.endsWith(":") ? url.protocol.substring(0, url.protocol.length - 1) : url.protocol; + if(protocol !== "https" && protocol !== "http") { + throw "invalid protocol (" + protocol + "). HTTP(S) are only supported!"; + } + } + + open_preview(url.toString()); + } catch(error) { + console.error("Failed to open preview window for URL %s: %o", url_str, error); + dialog.showErrorBox("Failed to open preview", "Failed to open preview URL: " + url_str + "\nError: " + error); + } + }); + + if(process_args.has_flag(Arguments.DEV_TOOLS)) + this.windowInstance.webContents.openDevTools(); + + try { + await this.windowInstance.loadURL(url); + } catch (error) { + console.error("Failed to load external modal main page: %o", error); + this.windowInstance.close(); + this.windowInstance = undefined; + return false; + } + + this.windowInstance.on("closed", () => { + this.windowInstance = undefined; + this.events.onClose(); + }); + + return true; + } + + destroy() { + if(!this.windowInstance) { + return; + } + + this.windowInstance.close(); + this.windowInstance = undefined; + } +} + +const server = new ObjectProxyServer(kIPCChannelExternalModal, ProxyImplementation); +server.initialize(); \ No newline at end of file diff --git a/modules/core/render-backend/index.ts b/modules/core/render-backend/index.ts index cca6e8e..279086e 100644 --- a/modules/core/render-backend/index.ts +++ b/modules/core/render-backend/index.ts @@ -8,6 +8,10 @@ import {open as open_changelog} from "../app-updater/changelog"; import * as updater from "../app-updater"; import {execute_connect_urls} from "../instance_handler"; import {process_args} from "../../shared/process-arguments"; +import {open_preview} from "../url-preview"; +import {dialog} from "electron"; + +import "./ExternalModal"; ipcMain.on('basic-action', (event, action, ...args: any[]) => { const window = BrowserWindow.fromWebContents(event.sender); diff --git a/modules/renderer-manifest/index.ts b/modules/renderer-manifest/index.ts index 716bde8..9918f7a 100644 --- a/modules/renderer-manifest/index.ts +++ b/modules/renderer-manifest/index.ts @@ -1,14 +1,14 @@ /* --------------- bootstrap --------------- */ import * as RequireProxy from "../renderer/RequireProxy"; import * as path from "path"; +RequireProxy.initialize(path.join(__dirname, "backend-impl")); + /* --------------- entry point --------------- */ import * as loader from "tc-loader"; import {Stage} from "tc-loader"; import {Arguments, process_args} from "../shared/process-arguments"; import {remote} from "electron"; -RequireProxy.initialize(path.join(__dirname, "backend-impl")); - export function initialize(manifestTarget: string) { console.log("Initializing native client for manifest target %s", manifestTarget); diff --git a/modules/renderer/ExternalModalHandler.ts b/modules/renderer/ExternalModalHandler.ts index 48effc7..1738060 100644 --- a/modules/renderer/ExternalModalHandler.ts +++ b/modules/renderer/ExternalModalHandler.ts @@ -1,50 +1,25 @@ import {AbstractExternalModalController} from "tc-shared/ui/react-elements/external-modal/Controller"; -import * as ipc from "tc-shared/ipc/BrowserIPC"; -import * as log from "tc-shared/log"; -import {LogCategory} from "tc-shared/log"; -import {BrowserWindow, remote} from "electron"; -import {tr} from "tc-shared/i18n/localize"; -import * as path from "path"; -import {Arguments, process_args} from "../shared/process-arguments"; import {Popout2ControllerMessages, PopoutIPCMessage} from "tc-shared/ui/react-elements/external-modal/IPCMessage"; -import {loadWindowBounds, startTrackWindowBounds} from "../shared/window"; +import {ExternalModal, kIPCChannelExternalModal} from "../shared/ipc/ExternalModal"; +import {ObjectProxyClient} from "../shared/proxy/Client"; +import * as ipc from "tc-shared/ipc/BrowserIPC"; +import {ProxiedClass} from "../shared/proxy/Definitions"; + +const modalClient = new ObjectProxyClient(kIPCChannelExternalModal); +modalClient.initialize(); export class ExternalModalController extends AbstractExternalModalController { - private window: BrowserWindow; + private handle: ProxiedClass & ExternalModal; constructor(a, b, c) { super(a, b, c); } protected async spawnWindow(): Promise { - if(this.window) { - return true; + if(!this.handle) { + this.handle = await modalClient.createNewInstance(); } - this.window = new remote.BrowserWindow({ - /* parent: remote.getCurrentWindow(), */ /* do not link them together */ - autoHideMenuBar: true, - - webPreferences: { - nodeIntegration: true, - }, - icon: path.join(__dirname, "..", "..", "resources", "logo.ico"), - minWidth: 600, - minHeight: 300, - - frame: false, - transparent: true, - - show: true - }); - - loadWindowBounds("modal-" + this.modalType, this.window).then(() => { - startTrackWindowBounds("modal-" + this.modalType, this.window); - }); - - if(process_args.has_flag(Arguments.DEV_TOOLS)) - this.window.webContents.openDevTools(); - const parameters = { "loader-target": "manifest", "chunk": "modal-external", @@ -57,32 +32,17 @@ export class ExternalModalController extends AbstractExternalModalController { const baseUrl = location.origin + location.pathname + "?"; const url = baseUrl + Object.keys(parameters).map(e => e + "=" + encodeURIComponent(parameters[e])).join("&"); - try { - await this.window.loadURL(url); - } catch (error) { - log.warn(LogCategory.GENERAL, tr("Failed to load external modal main page: %o"), error); - this.window.close(); - this.window = undefined; - return false; - } - this.window.on("closed", () => { - this.window = undefined; - this.handleWindowClosed(); - }); - - return true; + return await this.handle.spawnWindow(this.modalType, url); } protected destroyWindow(): void { - if(this.window) { - this.window.close(); - this.window = undefined; - } + this.handle?.destroy(); + this.handle = undefined; } protected focusWindow(): void { - this.window?.focus(); + this.handle?.focus().then(() => {}); } protected handleTypedIPCMessage(type: T, payload: PopoutIPCMessage[T]) { @@ -97,7 +57,7 @@ export class ExternalModalController extends AbstractExternalModalController { break; case "minimize": - this.window?.minimize(); + this.handle?.minimize().then(() => {}); break; } break; diff --git a/modules/renderer/UnloadHandler.ts b/modules/renderer/UnloadHandler.ts index 7d7a380..07efa98 100644 --- a/modules/renderer/UnloadHandler.ts +++ b/modules/renderer/UnloadHandler.ts @@ -4,7 +4,7 @@ import {tr} from "tc-shared/i18n/localize"; import {Arguments, process_args} from "../shared/process-arguments"; import {remote} from "electron"; -const unloadListener = event => { +window.onbeforeunload = event => { if(settings.static(Settings.KEY_DISABLE_UNLOAD_DIALOG)) return; @@ -15,12 +15,12 @@ const unloadListener = event => { const dp = server_connections.all_connections().map(e => { if(e.serverConnection.connected()) return e.serverConnection.disconnect(tr("client closed")) - .catch(error => { - console.warn(tr("Failed to disconnect from server %s on client close: %o"), - e.serverConnection.remote_address().host + ":" + e.serverConnection.remote_address().port, - error - ); - }); + .catch(error => { + console.warn(tr("Failed to disconnect from server %s on client close: %o"), + e.serverConnection.remote_address().host + ":" + e.serverConnection.remote_address().port, + error + ); + }); return Promise.resolve(); }); @@ -49,12 +49,12 @@ const unloadListener = event => { }).then(result => { if(result.response === 0) { /* prevent quitting because we try to disconnect */ - window.removeEventListener("beforeunload", unloadListener); + window.onbeforeunload = e => e.preventDefault(); do_exit(true); } }); - event.preventDefault(); -} -window.addEventListener("beforeunload", unloadListener); \ No newline at end of file + event.preventDefault(); + event.returnValue = "question"; +} \ No newline at end of file diff --git a/modules/shared/ipc/ExternalModal.ts b/modules/shared/ipc/ExternalModal.ts new file mode 100644 index 0000000..1d2a7c1 --- /dev/null +++ b/modules/shared/ipc/ExternalModal.ts @@ -0,0 +1,12 @@ +export const kIPCChannelExternalModal = "external-modal"; + +export interface ExternalModal { + readonly events: { + onClose: () => void + } + + spawnWindow(modalTarget: string, url: string) : Promise; + + minimize() : Promise; + focus() : Promise; +} \ No newline at end of file diff --git a/modules/shared/proxy/Client.ts b/modules/shared/proxy/Client.ts new file mode 100644 index 0000000..63261fc --- /dev/null +++ b/modules/shared/proxy/Client.ts @@ -0,0 +1,119 @@ +import {ipcRenderer, IpcRendererEvent, remote} from "electron"; +import {tr} from "tc-shared/i18n/localize"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; +import {ProxiedClass, ProxyInterface} from "./Definitions"; + +export class ObjectProxyClient> { + private readonly ipcChannel: string; + private readonly handleIPCMessageBinding; + + private eventInvokers: {[key: string]: { fireEvent: (type: string, ...args: any) => void }} = {}; + + constructor(ipcChannel: string) { + this.ipcChannel = ipcChannel; + this.handleIPCMessageBinding = this.handleIPCMessage.bind(this); + } + + initialize() { + ipcRenderer.on(this.ipcChannel, this.handleIPCMessageBinding); + } + + destroy() { + ipcRenderer.off(this.ipcChannel, this.handleIPCMessageBinding); + /* TODO: Destroy all client instances? */ + } + + async createNewInstance() : Promise> { + let object = { + objectId: undefined as string + }; + + const result = await ipcRenderer.invoke(this.ipcChannel, "create"); + if(result.status !== "success") { + if(result.status === "error") { + throw result.message || tr("failed to create a new instance"); + } else { + throw tr("failed to create a new object instance ({})", result.status); + } + } + object.objectId = result.instanceId; + + const ipcChannel = this.ipcChannel; + + const events = this.generateEvents(object.objectId); + return new Proxy(object, { + get(target, key: PropertyKey) { + if(key === "ownerWindowId") { + return remote.getCurrentWindow().id; + } else if(key === "instanceId") { + return object.objectId; + } else if(key === "destroy") { + return () => { + ipcRenderer.invoke(ipcChannel, "destroy", target.objectId); + events.destroy(); + }; + } else if(key === "events") { + return events; + } else if(key === "then" || key === "catch") { + /* typescript for some reason has an issue if then and catch return anything */ + return undefined; + } + + return (...args: any) => ipcRenderer.invoke(ipcChannel, "invoke", target.objectId, key, ...args); + }, + + set(): boolean { + throw "class is a ready only interface"; + } + }) as any; + } + + private generateEvents(objectId: string) : { destroy() } { + const eventInvokers = this.eventInvokers; + const registeredEvents = {}; + + eventInvokers[objectId] = { + fireEvent(event: string, ...args: any) { + if(typeof registeredEvents[event] === "undefined") + return; + + try { + registeredEvents[event](...args); + } catch (error) { + logError(LogCategory.IPC, tr("Failed to invoke event %s on %s: %o"), event, objectId, error); + } + } + }; + + return new Proxy({ }, { + set(target, key: PropertyKey, value: any): boolean { + registeredEvents[key] = value; + return true; + }, + + get(target, key: PropertyKey): any { + if(key === "destroy") { + return () => delete eventInvokers[objectId]; + } else if(typeof registeredEvents[key] === "function") { + return () => { throw tr("events can only be invoked via IPC") }; + } else { + return undefined; + } + } + }) as any; + } + + private handleIPCMessage(event: IpcRendererEvent, ...args: any[]) { + const actionType = args[0]; + + if(actionType === "notify-event") { + const invoker = this.eventInvokers[args[1]]; + if(typeof invoker !== "object") { + logWarn(LogCategory.IPC, tr("Received event %s for unknown object instance on channel %s"), args[2], args[1]); + return; + } + + invoker.fireEvent(args[2], ...args.slice(3)); + } + } +} \ No newline at end of file diff --git a/modules/shared/proxy/Definitions.ts b/modules/shared/proxy/Definitions.ts new file mode 100644 index 0000000..bf12e27 --- /dev/null +++ b/modules/shared/proxy/Definitions.ts @@ -0,0 +1,35 @@ +export type ProxiedEvents = { + [Q in keyof EventObject]: EventObject[Q] extends (...args: any) => void ? (...args: Parameters) => void : never +} + +export type FunctionalInterface = { + [P in keyof ObjectType]: ObjectType[P] extends (...args: any) => Promise ? (...args: any) => Promise : + P extends "events" ? ObjectType[P] extends ProxiedEvents ? ProxiedEvents : never : never +}; + +export type ProxiedClassProperties = { instanceId: string, ownerWindowId: number, events: any }; + +export type ProxyInterface = FunctionalInterface; +export type ProxyClass = { new(props: ProxiedClassProperties): ProxyInterface & ProxiedClass }; + +export abstract class ProxiedClass }> { + public readonly ownerWindowId: number; + public readonly instanceId: string; + + public readonly events: ProxiedEvents; + + public constructor(props: ProxiedClassProperties) { + this.ownerWindowId = props.ownerWindowId; + this.instanceId = props.instanceId; + this.events = props.events; + } + + public destroy() {} +} + +export function generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} \ No newline at end of file diff --git a/modules/shared/proxy/Server.ts b/modules/shared/proxy/Server.ts new file mode 100644 index 0000000..2c4089c --- /dev/null +++ b/modules/shared/proxy/Server.ts @@ -0,0 +1,83 @@ +import {BrowserWindow, ipcMain, IpcMainEvent} from "electron"; +import {generateUUID, ProxiedClass, ProxyClass, ProxyInterface} from "./Definitions"; + +export class ObjectProxyServer> { + private readonly ipcChannel: string; + private readonly klass: ProxyClass; + private readonly instances: { [key: string]: ProxyInterface & ProxiedClass } = {}; + + private readonly handleIPCMessageBinding; + + constructor(ipcChannel: string, klass: ProxyClass) { + this.klass = klass; + this.ipcChannel = ipcChannel; + + this.handleIPCMessageBinding = this.handleIPCMessage.bind(this); + } + + initialize() { + ipcMain.handle(this.ipcChannel, this.handleIPCMessageBinding); + } + + destroy() { + ipcMain.removeHandler(this.ipcChannel); + } + + private async handleIPCMessage(event: IpcMainEvent, ...args: any[]) { + const actionType = args[0]; + + if(actionType === "create") { + let instance: ProxiedClass & ProxyInterface; + try { + const instanceId = generateUUID(); + instance = new this.klass({ + ownerWindowId: event.sender.id, + instanceId: instanceId, + events: this.generateEventProxy(instanceId, event.sender.id) + }); + this.instances[instance.instanceId] = instance; + } catch (error) { + event.returnValue = { "status": "error", message: "create-error" }; + return; + } + + return { "status": "success", instanceId: instance.instanceId }; + } else { + const instance = this.instances[args[1]]; + + if(!instance) { + throw "instance-unknown"; + } + + if(actionType === "destroy") { + delete this.instances[args[1]]; + instance.destroy(); + } else if(actionType === "invoke") { + if(typeof instance[args[2]] !== "function") { + throw "function-unknown"; + } + + return instance[args[2]](...args.slice(3)); + } else { + console.warn("Received an invalid action: %s", actionType); + } + } + } + + private generateEventProxy(instanceId: string, owningWindowId: number) : {} { + const ipcChannel = this.ipcChannel; + return new Proxy({ }, { + get(target: { }, event: PropertyKey, receiver: any): any { + return (...args: any) => { + const window = BrowserWindow.fromId(owningWindowId); + if(!window) return; + + window.webContents.send(ipcChannel, "notify-event", instanceId, event, ...args); + } + }, + set(): boolean { + throw "the events are read only for the implementation"; + } + }) + } +} \ No newline at end of file diff --git a/modules/shared/proxy/Test.ts b/modules/shared/proxy/Test.ts new file mode 100644 index 0000000..d54b4d7 --- /dev/null +++ b/modules/shared/proxy/Test.ts @@ -0,0 +1,30 @@ +import {ProxiedClass} from "./Definitions"; +import {ObjectProxyClient} from "./Client"; +import {ObjectProxyServer} from "./Server"; + +interface TextModal { + readonly events: { + onHide: () => void; + } + + sayHi() : Promise; +} + +class TextModalImpl extends ProxiedClass implements TextModal { + constructor(props) { + super(props); + } + + async sayHi(): Promise { + this.events.onHide(); + } +} + +async function main() { + let server = new ObjectProxyServer("", TextModalImpl); + let client = new ObjectProxyClient(""); + + const instance = await client.createNewInstance(); + await instance.sayHi(); + instance.events.onHide = () => {}; +} \ No newline at end of file diff --git a/package.json b/package.json index badaacf..c3aed67 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "test": "echo \"Error: no test specified\" && exit 1", "start-d1": "electron . --disable-hardware-acceleration --debug -t --gdb -s -u=http://clientapi.teaspeak.dev/ --updater-ui-loader_type=0", "start-n": "electron . -t --disable-hardware-acceleration --no-single-instance -u=https://clientapi.teaspeak.de/ -d", - "start-nd": "electron . -t --disable-hardware-acceleration --no-single-instance -u=http://clientapi.teaspeak.dev/ -d", + "start-nd": "electron . -t --disable-hardware-acceleration --no-single-instance -u=http://clientapi.teaspeak.dev/ -d --updater-ui-loader_type=0", "start-01": "electron . --updater-channel=test -u=http://dev.clientapi.teaspeak.de/ -d --updater-ui-loader_type=0 --updater-local-version=1.0.1", "start-devel-download": "electron . --disable-hardware-acceleration --gdb --debug --updater-ui-loader_type=2 --updater-ui-ignore-version -t -u http://localhost:8081/", "start-s": "electron . --disable-hardware-acceleration --gdb --debug --updater-ui-loader_type=3 --updater-ui-ignore-version -t -u http://localhost:8081/",