From f0f7741fa2c4d3ad0867696ca924156621827977 Mon Sep 17 00:00:00 2001 From: Brian Ingenito Date: Mon, 5 Nov 2018 09:02:05 -0500 Subject: [PATCH] Add ability for windows to save custom state as part of the persisted window layout (#197) Add ability for windows to save custom state as part of the persisted window layout and be provided that state back on load of the layout. If the underlying native js window has getState/setState defined they will be invoked. --- examples/web/assets/js/app.js | 10 ++++ src/Default/default.ts | 55 +++++++++++++++---- src/Electron/electron.ts | 19 ++++++- src/OpenFin/openfin.ts | 81 +++++++++++++++++----------- src/container.ts | 7 ++- src/window.ts | 11 ++++ tests/unit/Default/default.spec.ts | 44 +++++++++++++++ tests/unit/Electron/electron.spec.ts | 45 +++++++++++++++- tests/unit/OpenFin/openfin.spec.ts | 44 ++++++++++++++- tests/unit/container.spec.ts | 10 ++-- tests/unit/window.spec.ts | 12 +++++ 11 files changed, 286 insertions(+), 52 deletions(-) diff --git a/examples/web/assets/js/app.js b/examples/web/assets/js/app.js index 7ec586d8..d596b503 100644 --- a/examples/web/assets/js/app.js +++ b/examples/web/assets/js/app.js @@ -203,3 +203,13 @@ loadlayoutButton.onclick = function () { savelayoutButton.onclick = function () { container.saveLayout("Layout"); }; + +/** Invoked by window layout saving to maintain state specific to this window */ +function getState() { + return { value: "Foo" }; +} + +/** Invoked by window layout loading to restore state specific to this window */ +function setState(state) { + this.container.log("info", state); +} diff --git a/src/Default/default.ts b/src/Default/default.ts index 6d20217d..fd3d2c06 100644 --- a/src/Default/default.ts +++ b/src/Default/default.ts @@ -107,6 +107,22 @@ export class DefaultContainerWindow extends ContainerWindow { }); } + public getState(): Promise { + return new Promise(resolve => { + (this.nativeWindow && (this.nativeWindow).getState) ? resolve((this.nativeWindow).getState()) : resolve(undefined); + }); + } + + public setState(state: any): Promise { + return new Promise(resolve => { + if (this.nativeWindow && (this.nativeWindow).setState) { + (this.nativeWindow).setState(state); + } + + resolve(); + }); + } + protected attachListener(eventName: string, listener: (...args: any[]) => void): void { this.innerWindow.addEventListener(windowEventMap[eventName] || eventName, listener); } @@ -329,7 +345,7 @@ export class DefaultContainer extends WebContainerBase { const windows: ContainerWindow[] = []; const trackedWindows = this.globalWindow[DefaultContainer.windowsPropertyKey]; for (const key in trackedWindows) { - windows.push(trackedWindows[key]); + windows.push(this.wrapWindow(trackedWindows[key])); } resolve(windows); }); @@ -360,16 +376,33 @@ export class DefaultContainer extends WebContainerBase { const layout = new PersistedWindowLayout(); return new Promise((resolve, reject) => { - const windows = this.globalWindow[DefaultContainer.windowsPropertyKey]; - for (const key in windows) { - const win = windows[key]; - if (this.globalWindow !== win) { - layout.windows.push({ name: win.name, url: win.location.toString(), bounds: { x: win.screenX, y: win.screenY, width: win.outerWidth, height: win.outerHeight }, options: win[Container.windowOptionsPropertyKey] }); - } - } - - this.saveLayoutToStorage(name, layout); - resolve(layout); + const promises: Promise[] = []; + + this.getAllWindows().then(windows => { + windows.forEach(window => { + promises.push(new Promise(async (innerResolve) => { + const nativeWin = window.nativeWindow; + if (this.globalWindow !== nativeWin) { + layout.windows.push( + { + name: window.name, + url: nativeWin.location.toString(), + id: window.id, + bounds: { x: nativeWin.screenX, y: nativeWin.screenY, width: nativeWin.outerWidth, height: nativeWin.outerHeight }, + options: nativeWin[Container.windowOptionsPropertyKey], + state: await window.getState() + } + ); + } + innerResolve(); + })); + }); + + Promise.all(promises).then(() => { + this.saveLayoutToStorage(name, layout); + resolve(layout); + }).catch(reason => reject(reason)); + }); }); } } diff --git a/src/Electron/electron.ts b/src/Electron/electron.ts index 3b965fd9..7d86420f 100644 --- a/src/Electron/electron.ts +++ b/src/Electron/electron.ts @@ -188,6 +188,22 @@ export class ElectronContainerWindow extends ContainerWindow { }); } + public getState(): Promise { + if (this.innerWindow && this.innerWindow.webContents) { + return this.innerWindow.webContents.executeJavaScript("window.getState ? window.getState() : undefined"); + } else { + return Promise.resolve(undefined); + } + } + + public setState(state: any): Promise { + if (this.innerWindow && this.innerWindow.webContents) { + return this.innerWindow.webContents.executeJavaScript(`if (window.setState) { window.setState(JSON.parse(\`${JSON.stringify(state)}\`)); }`); + } else { + return Promise.resolve(); + } + } + protected attachListener(eventName: string, listener: (...args: any[]) => void): void { if (eventName === "beforeunload") { const win = this.window || window; @@ -482,13 +498,14 @@ export class ElectronContainer extends WebContainerBase { this.getAllWindows().then(windows => { windows.forEach(window => { promises.push(new Promise((innerResolve, innerReject) => { - window.getGroup().then(group => { + window.getGroup().then(async group => { layout.windows.push( { id: window.id, name: window.name, url: window.innerWindow.webContents.getURL(), main: (mainWindow === window.innerWindow), + state: await window.getState(), options: window.innerWindow[Container.windowOptionsPropertyKey], bounds: window.innerWindow.getBounds(), group: group.map(win => win.id) diff --git a/src/OpenFin/openfin.ts b/src/OpenFin/openfin.ts index 80c5d6d6..145e2062 100644 --- a/src/OpenFin/openfin.ts +++ b/src/OpenFin/openfin.ts @@ -163,6 +163,22 @@ export class OpenFinContainerWindow extends ContainerWindow { }); } + public getState(): Promise { + return new Promise(resolve => { + (this.nativeWindow && (this.nativeWindow).getState) ? resolve((this.nativeWindow).getState()) : resolve(undefined); + }); + } + + public setState(state: any): Promise { + return new Promise(resolve => { + if (this.nativeWindow && (this.nativeWindow).setState) { + (this.nativeWindow).setState(state); + } + + resolve(); + }); + } + protected attachListener(eventName: string, listener: (...args: any[]) => void): void { if (eventName === "beforeunload") { this.innerWindow.addEventListener("close-requested", (e) => { @@ -612,41 +628,42 @@ export class OpenFinContainer extends WebContainerBase { public saveLayout(name: string): Promise { const layout = new PersistedWindowLayout(); - return new Promise((resolve, reject) => { - this.desktop.Application.getCurrent().getChildWindows(windows => { - const promises: Promise[] = []; - const mainWindow = this.desktop.Application.getCurrent().getWindow(); - - windows.concat(mainWindow) - .filter(window => window.name !== "queueCounter" && !window.name.startsWith(OpenFinContainer.notificationGuid)) - .forEach(window => { - promises.push(new Promise((innerResolve, innerReject) => { - window.getBounds(bounds => { - window.getOptions(options => { - delete (options).show; // show is an undocumented option that interferes with the createWindow mapping of show -> autoShow - window.getGroup(group => { - layout.windows.push( - { - name: window.name, - id: window.name, - url: window.getNativeWindow() ? window.getNativeWindow().location.toString() : options.url, - main: (mainWindow.name === window.name), - options: options, - bounds: { x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height }, - group: group.map(win => win.name) - }); - innerResolve(); - }, innerReject); + return new Promise(async (resolve, reject) => { + const windows = await this.getAllWindows(); + const mainWindow = this.getMainWindow(); + const promises: Promise[] = []; + + windows.filter(window => window.name !== "queueCounter" && !window.name.startsWith(OpenFinContainer.notificationGuid)) + .forEach(djsWindow => { + promises.push(new Promise(async (innerResolve, innerReject) => { + const state = await djsWindow.getState(); + const window = djsWindow.innerWindow; + window.getBounds(bounds => { + window.getOptions(options => { + delete (options).show; // show is an undocumented option that interferes with the createWindow mapping of show -> autoShow + window.getGroup(group => { + layout.windows.push( + { + name: window.name, + id: window.name, + url: window.getNativeWindow() ? window.getNativeWindow().location.toString() : options.url, + main: (mainWindow && (mainWindow.name === window.name)), + options: options, + state: state, + bounds: { x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height }, + group: group.map(win => win.name) + }); + innerResolve(); }, innerReject); }, innerReject); - })); - }); + }, innerReject); + })); + }); - Promise.all(promises).then(() => { - this.saveLayoutToStorage(name, layout); - resolve(layout); - }).catch(reason => reject(reason)); - }); + Promise.all(promises).then(() => { + this.saveLayoutToStorage(name, layout); + resolve(layout); + }).catch(reason => reject(reason)); }); } } diff --git a/src/container.ts b/src/container.ts index a3b00baa..d1504e1d 100644 --- a/src/container.ts +++ b/src/container.ts @@ -193,8 +193,12 @@ export abstract class ContainerBase extends Container { // de-dupe window grouping windows.forEach(window => { - let found = false; + const matchingWindow = layout.windows.find(win => win.name === window.name); + if (matchingWindow && matchingWindow.state && window.setState) { + window.setState(matchingWindow.state).catch(e => this.log("error", "Error invoking setState: " + e)); + } + let found = false; groupMap.forEach((targets, win) => { if (!found && targets.indexOf(window.id) >= 0) { found = true; @@ -202,7 +206,6 @@ export abstract class ContainerBase extends Container { }); if (!found) { - const matchingWindow = layout.windows.find(win => win.name === window.name); const group = matchingWindow ? matchingWindow.group : undefined; if (group && group.length > 0) { groupMap.set(window, group.filter(id => id !== window.id)); diff --git a/src/window.ts b/src/window.ts index 5f2afa9d..7706b779 100644 --- a/src/window.ts +++ b/src/window.ts @@ -144,6 +144,16 @@ export abstract class ContainerWindow extends EventEmitter { public abstract getOptions(): Promise; + /** Retrieves custom window state from underlying native window by invoking 'window.getState()' if defined. */ + public getState(): Promise { + return Promise.resolve(undefined); + } + + /** Provide custom window state to underlying native window by invoking 'window.setState()' if defined */ + public setState(state: any): Promise { + return Promise.resolve(); + } + /** Gets the underlying native JavaScript window object. As some containers * do not support native access, check for undefined. */ @@ -265,6 +275,7 @@ export class PersistedWindow { public id: string; public bounds: any; public options?: any; + public state?: any; public url?: string; public main?: boolean; public group?: string[]; diff --git a/tests/unit/Default/default.spec.ts b/tests/unit/Default/default.spec.ts index b6dd9440..13d3e349 100644 --- a/tests/unit/Default/default.spec.ts +++ b/tests/unit/Default/default.spec.ts @@ -3,6 +3,7 @@ import { DefaultContainerWindow, DefaultContainer, DefaultMessageBus } from "../ class MockWindow { public listener: any; + public name: string = "Name"; public focus(): void { }; public show(): void { }; public close(): Promise { return Promise.resolve(); }; @@ -12,6 +13,8 @@ class MockWindow { public postMessage(message: string, origin: string): void { }; public moveTo(x: number, y: number): void { }; public resizeTo(width: number, height: number): void { } + public getState(): Promise { return Promise.resolve(undefined); } + public setState(): Promise { return Promise.resolve(); } public screenX: any = 0; public screenY: any = 1; public outerWidth: any = 2; @@ -68,6 +71,46 @@ describe("DefaultContainerWindow", () => { expect(win.isShowing()).toBeDefined(); }); + describe("getState", () => { + it("getState undefined", (done) => { + let mockWindow = new MockWindow(); + delete mockWindow.getState; + let win = new DefaultContainerWindow(mockWindow); + + win.getState().then(state => { + expect(state).toBeUndefined(); + }).then(done); + }); + + it("getState defined", (done) => { + const mockState = { value: "Foo" }; + spyOn(win.innerWindow, "getState").and.returnValue(Promise.resolve(mockState)); + win.getState().then(state => { + expect(win.innerWindow.getState).toHaveBeenCalled(); + expect(state).toEqual(mockState); + }).then(done); + }); + }); + + describe("setState", () => { + it("setState undefined", (done) => { + let mockWindow = new MockWindow(); + delete mockWindow.setState; + let win = new DefaultContainerWindow(mockWindow); + + win.setState({}).then(done); + }); + + it("setState defined", (done) => { + const mockState = { value: "Foo" }; + spyOn(win.innerWindow, "setState").and.returnValue(Promise.resolve()); + + win.setState(mockState).then(() => { + expect(win.innerWindow.setState).toHaveBeenCalledWith(mockState); + }).then(done); + }); + }); + it("getSnapshot rejects", (done) => { let success: boolean = false; @@ -333,6 +376,7 @@ describe("DefaultContainer", () => { container.getAllWindows().then(wins => { expect(wins).not.toBeNull(); expect(wins.length).toEqual(2); + wins.forEach(win => expect(win instanceof DefaultContainerWindow).toBeTruthy("Window is not of type DefaultContainerWindow")); done(); }); }); diff --git a/tests/unit/Electron/electron.spec.ts b/tests/unit/Electron/electron.spec.ts index 39ad6013..0f345449 100644 --- a/tests/unit/Electron/electron.spec.ts +++ b/tests/unit/Electron/electron.spec.ts @@ -31,7 +31,6 @@ class MockWindow extends MockEventEmitter { public id: number; public group: string; private bounds: any = { x: 0, y: 1, width: 2, height: 3 } - public innerWindow: any = {}; constructor(name?: string) { super(); @@ -55,7 +54,8 @@ class MockWindow extends MockEventEmitter { public webContents: any = { send(channel: string, ...args: any[]) { }, - getURL() { return "url"; } + getURL() { return "url"; }, + executeJavaScript() {} } public getBounds(): any { return this.bounds; } @@ -192,6 +192,47 @@ describe("ElectronContainerWindow", () => { }).then(done); }); + describe("getState", () => { + it("getState undefined", (done) => { + let mockWindow = new MockWindow(); + mockWindow.webContents = null; + let win = new ElectronContainerWindow(mockWindow, container); + + win.getState().then(state => { + expect(state).toBeUndefined(); + }).then(done); + }); + + it("getState defined", (done) => { + const mockState = { value: "Foo" }; + spyOn(innerWin.webContents, "executeJavaScript").and.returnValue(Promise.resolve(mockState)); + + win.getState().then(state => { + expect(innerWin.webContents.executeJavaScript).toHaveBeenCalled(); + expect(state).toEqual(mockState); + }).then(done); + }); + }); + + describe("setState", () => { + it("setState undefined", (done) => { + let mockWindow = new MockWindow(); + mockWindow.webContents = null; + let win = new ElectronContainerWindow(mockWindow, container); + + win.setState({}).then(done); + }); + + it("setState defined", (done) => { + const mockState = { value: "Foo" }; + spyOn(innerWin.webContents, "executeJavaScript").and.returnValue(Promise.resolve()); + + win.setState(mockState).then(() => { + expect(innerWin.webContents.executeJavaScript).toHaveBeenCalled(); + }).then(done); + }); + }); + it("getSnapshot", (done) => { spyOn(innerWin, "capturePage").and.callThrough(); let success: boolean = false; diff --git a/tests/unit/OpenFin/openfin.spec.ts b/tests/unit/OpenFin/openfin.spec.ts index eeec6c8a..d3d41622 100644 --- a/tests/unit/OpenFin/openfin.spec.ts +++ b/tests/unit/OpenFin/openfin.spec.ts @@ -57,7 +57,7 @@ class MockInterApplicationBus { class MockWindow { static singleton: MockWindow = new MockWindow("Singleton"); - public nativeWindow: Window = jasmine.createSpyObj("window", ["location"]); + public nativeWindow: Window = jasmine.createSpyObj("window", ["location", "getState", "setState"]); constructor(name?: string) { this.name = name; @@ -259,6 +259,48 @@ describe("OpenFinContainerWindow", () => { }).then(done); }); + + describe("getState", () => { + it("getState undefined", (done) => { + let mockWindow = new MockWindow(); + delete (mockWindow.nativeWindow).getState; + let win = new OpenFinContainerWindow(innerWin); + + win.getState().then(state => { + expect(state).toBeUndefined(); + }).then(done); + }); + + it("getState defined", (done) => { + const mockState = { value: "Foo" }; + innerWin.nativeWindow.getState.and.returnValue(mockState); + + win.getState().then(state => { + expect(innerWin.nativeWindow.getState).toHaveBeenCalled(); + expect(state).toEqual(mockState); + }).then(done); + }); + }); + + describe("setState", () => { + it("setState undefined", (done) => { + let mockWindow = new MockWindow(); + delete (mockWindow.nativeWindow).setState; + let win = new OpenFinContainerWindow(innerWin); + + win.setState({}).then(done); + }); + + it("setState defined", (done) => { + const mockState = { value: "Foo" }; + innerWin.nativeWindow.setState.and.returnValue(Promise.resolve()); + + win.setState(mockState).then(() => { + expect(innerWin.nativeWindow.setState).toHaveBeenCalledWith(mockState); + }).then(done); + }); + }); + describe("getSnapshot", () => { it("getSnapshot invokes underlying getSnapshot", (done) => { spyOn(innerWin, "getSnapshot").and.callThrough(); diff --git a/tests/unit/container.spec.ts b/tests/unit/container.spec.ts index df706bbb..1b77e0df 100644 --- a/tests/unit/container.spec.ts +++ b/tests/unit/container.spec.ts @@ -28,8 +28,10 @@ export class MockMessageBus implements MessageBus { // tslint:disable-line export class TestContainer extends ContainerBase { getMainWindow(): ContainerWindow { - const win = jasmine.createSpyObj("ContainerWindow", ["setBounds"]); + const win = jasmine.createSpyObj("ContainerWindow", ["setBounds", "getState", "setState"]); Object.defineProperty(win, "name", { value: "1" }); + win.getState.and.returnValue(Promise.resolve({})); + win.setState.and.returnValue(Promise.resolve()); return win; } @@ -40,9 +42,11 @@ export class TestContainer extends ContainerBase { } createWindow(url: string, options?: any): Promise { - const win = jasmine.createSpyObj("ContainerWindow", ["id"]); + const win = jasmine.createSpyObj("ContainerWindow", ["id", "getState", "setState"]); Object.defineProperty(win, "name", { value: options.name || "1" }); Object.defineProperty(win, "id", { value: options.name || "1" }); + win.getState.and.returnValue(Promise.resolve({})); + win.setState.and.returnValue(Promise.resolve()); return Promise.resolve(win); } @@ -54,7 +58,7 @@ export class TestContainer extends ContainerBase { this.storage = { getItem(key: string): string { const layout: PersistedWindowLayout = new PersistedWindowLayout(); - layout.windows.push({ name: "1", id: "1", url: "url", bounds: {}, group: ["1", "2", "3"]}); + layout.windows.push({ name: "1", id: "1", url: "url", bounds: {}, state: { "value": "foo" }, group: ["1", "2", "3"]}); layout.windows.push({ name: "2", id: "2", main: true, url: "url", bounds: {}, group: ["1", "2", "3"]}); layout.windows.push({ name: "3", id: "3", url: "url", bounds: {}, group: ["1", "2", "3"]}); layout.name = "Test"; diff --git a/tests/unit/window.spec.ts b/tests/unit/window.spec.ts index aea6a0f5..0c8ce405 100644 --- a/tests/unit/window.spec.ts +++ b/tests/unit/window.spec.ts @@ -16,6 +16,18 @@ describe ("ContainerWindow", () => { it("nativeWindow returns undefined", () => { expect(new MockWindow(undefined).nativeWindow).toBeUndefined(); }); + + it("getState returns undefined", (done) => { + new MockWindow(undefined).getState().then(state => { + expect(state).toBeUndefined(); + }).then(done); + }); + + it("setState returns", (done) => { + new MockWindow(undefined).setState({}).then(() => { + expect(true); + }).then(done); + }); }); describe ("static events", () => {