diff --git a/src/behaviours/extensions/navigation-using-application-menu.test.ts b/src/behaviours/extensions/navigation-using-application-menu.test.ts index 5d05ec31c2..a8b9cc55b1 100644 --- a/src/behaviours/extensions/navigation-using-application-menu.test.ts +++ b/src/behaviours/extensions/navigation-using-application-menu.test.ts @@ -37,8 +37,8 @@ describe("extensions - navigation using application menu", () => { }); describe("when navigating to extensions using application menu", () => { - beforeEach(async () => { - await applicationBuilder.applicationMenu.click("root.extensions"); + beforeEach(() => { + applicationBuilder.applicationMenu.click("root.extensions"); }); it("focuses the window", () => { diff --git a/src/behaviours/preferences/navigation-using-application-menu.test.ts b/src/behaviours/preferences/navigation-using-application-menu.test.ts index e12aa85a44..c78e546442 100644 --- a/src/behaviours/preferences/navigation-using-application-menu.test.ts +++ b/src/behaviours/preferences/navigation-using-application-menu.test.ts @@ -28,8 +28,8 @@ describe("preferences - navigation using application menu", () => { }); describe("when navigating to preferences using application menu", () => { - beforeEach(async () => { - await applicationBuilder.applicationMenu.click("root.preferences"); + beforeEach(() => { + applicationBuilder.applicationMenu.click("root.preferences"); }); it("renders", () => { diff --git a/src/behaviours/quitting-and-restarting-the-app/opening-application-window-using-tray.test.ts b/src/behaviours/quitting-and-restarting-the-app/opening-application-window-using-tray.test.ts new file mode 100644 index 0000000000..7b7765b988 --- /dev/null +++ b/src/behaviours/quitting-and-restarting-the-app/opening-application-window-using-tray.test.ts @@ -0,0 +1,238 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { lensWindowInjectionToken } from "../../main/start-main-application/lens-window/application-window/lens-window-injection-token"; +import applicationWindowInjectable from "../../main/start-main-application/lens-window/application-window/application-window.injectable"; +import createElectronWindowForInjectable from "../../main/start-main-application/lens-window/application-window/create-electron-window.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { ElectronWindow, LensWindowConfiguration } from "../../main/start-main-application/lens-window/application-window/create-lens-window.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; +import { flushPromises } from "../../common/test-utils/flush-promises"; +import lensResourcesDirInjectable from "../../common/vars/lens-resources-dir.injectable"; + +describe("opening application window using tray", () => { + describe("given application has started", () => { + let applicationBuilder: ApplicationBuilder; + let createElectronWindowMock: jest.Mock; + let expectWindowsToBeOpen: (windowIds: string[]) => void; + let callForSplashWindowHtmlMock: AsyncFnMock<() => void>; + let callForApplicationWindowHtmlMock: AsyncFnMock<() => void>; + + beforeEach(async () => { + applicationBuilder = getApplicationBuilder().beforeApplicationStart( + ({ mainDi }) => { + mainDi.override(lensResourcesDirInjectable, () => "some-lens-resources-directory"); + + createElectronWindowMock = jest.fn((configuration: LensWindowConfiguration) => + ({ + splash: { + send: () => {}, + close: () => {}, + show: () => {}, + loadFile: callForSplashWindowHtmlMock, + loadUrl: () => { throw new Error("Should never come here"); }, + }, + + "only-application-window": { + send: () => {}, + close: () => {}, + show: () => {}, + loadFile: () => { throw new Error("Should never come here"); }, + loadUrl: callForApplicationWindowHtmlMock, + }, + }[configuration.id] as ElectronWindow)); + + mainDi.override( + createElectronWindowForInjectable, + + () => createElectronWindowMock, + ); + + expectWindowsToBeOpen = expectWindowsToBeOpenFor(mainDi); + + callForSplashWindowHtmlMock = asyncFn(); + callForApplicationWindowHtmlMock = asyncFn(); + }, + ); + + const renderPromise = applicationBuilder.render(); + + await flushPromises(); + + await callForSplashWindowHtmlMock.resolve(); + await callForApplicationWindowHtmlMock.resolve(); + + await renderPromise; + }); + + it("only an application window is open", () => { + expectWindowsToBeOpen(["only-application-window"]); + }); + + describe("when an attempt to reopen the already started application is made using tray", () => { + beforeEach(() => { + applicationBuilder.tray.click("open-app"); + }); + + it("still shows only the application window", () => { + expectWindowsToBeOpen(["only-application-window"]); + }); + }); + + describe("when the application window is closed", () => { + beforeEach(() => { + const applicationWindow = applicationBuilder.dis.mainDi.inject( + applicationWindowInjectable, + ); + + applicationWindow.close(); + }); + + it("no windows are open", () => { + expectWindowsToBeOpen([]); + }); + + describe("when an application window is reopened using tray", () => { + beforeEach(() => { + callForSplashWindowHtmlMock.mockClear(); + callForApplicationWindowHtmlMock.mockClear(); + + applicationBuilder.tray.click("open-app"); + }); + + it("still no windows are open", () => { + expectWindowsToBeOpen([]); + }); + + it("starts loading static HTML of splash window", () => { + expect(callForSplashWindowHtmlMock).toHaveBeenCalledWith("/some-absolute-root-directory/some-lens-resources-directory/static/splash.html"); + }); + + describe("when loading of splash window HTML resolves", () => { + beforeEach(async () => { + await callForSplashWindowHtmlMock.resolve(); + }); + + it("shows just the splash window", () => { + expectWindowsToBeOpen(["splash"]); + }); + + it("starts loading of content for the application window", () => { + expect(callForApplicationWindowHtmlMock).toHaveBeenCalledWith("http://localhost:42"); + }); + + describe("given static HTML of application window has not resolved yet, when opening from tray again", () => { + beforeEach(() => { + callForApplicationWindowHtmlMock.mockClear(); + callForSplashWindowHtmlMock.mockClear(); + + applicationBuilder.tray.click("open-app"); + }); + + it("does not load contents of splash window again", () => { + expect(callForSplashWindowHtmlMock).not.toHaveBeenCalled(); + }); + + it("does not load contents of application window again", () => { + expect(callForApplicationWindowHtmlMock).not.toHaveBeenCalled(); + }); + + it("shows just the blank application window to permit developer tool access", () => { + expectWindowsToBeOpen(["only-application-window"]); + }); + }); + + describe("when static HTML of application window resolves", () => { + beforeEach(async () => { + await callForApplicationWindowHtmlMock.resolve(); + }); + + it("shows just the application window", () => { + expectWindowsToBeOpen(["only-application-window"]); + }); + + describe("when reopening the application using tray", () => { + beforeEach(() => { + callForSplashWindowHtmlMock.mockClear(); + callForApplicationWindowHtmlMock.mockClear(); + + applicationBuilder.tray.click("open-app"); + }); + + it("still shows just the application window", () => { + expectWindowsToBeOpen(["only-application-window"]); + }); + + it("does not load HTML for splash window again", () => { + expect(callForSplashWindowHtmlMock).not.toHaveBeenCalled(); + }); + + it("does not load HTML for application window again", () => { + expect(callForApplicationWindowHtmlMock).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe("given opening of splash window has not finished yet, but another attempt to open the application is made", () => { + beforeEach(() => { + createElectronWindowMock.mockClear(); + + applicationBuilder.tray.click("open-app"); + }); + + it("does not open any new windows", () => { + expect(createElectronWindowMock).not.toHaveBeenCalled(); + }); + }); + + describe("when opening of splash window resolves", () => { + beforeEach(async () => { + await callForSplashWindowHtmlMock.resolve(); + }); + + it("still only splash window is open", () => { + expectWindowsToBeOpen(["splash"]); + }); + + it("when opening of application window finishes, only an application window is open", async () => { + await callForApplicationWindowHtmlMock.resolve(); + + expectWindowsToBeOpen(["only-application-window"]); + }); + + describe("given opening of application window has not finished yet, but another attempt to open the application is made", () => { + beforeEach(() => { + createElectronWindowMock.mockClear(); + + applicationBuilder.tray.click("open-app"); + }); + + it("does not open any new windows", () => { + expect(createElectronWindowMock).not.toHaveBeenCalled(); + }); + + it("when opening finishes, only an application window is open", async () => { + await callForApplicationWindowHtmlMock.resolve(); + + expectWindowsToBeOpen(["only-application-window"]); + }); + }); + }); + }); + }); + }); +}); + +const expectWindowsToBeOpenFor = (di: DiContainer) => (windowIds: string[]) => { + const windows = di.injectMany(lensWindowInjectionToken); + + expect( + windows.filter((window) => window.isVisible).map((window) => window.id), + ).toEqual(windowIds); +}; diff --git a/src/behaviours/quitting-and-restarting-the-app/quitting-the-app-using-application-menu.test.ts b/src/behaviours/quitting-and-restarting-the-app/quitting-the-app-using-application-menu.test.ts new file mode 100644 index 0000000000..5f96bb06fb --- /dev/null +++ b/src/behaviours/quitting-and-restarting-the-app/quitting-the-app-using-application-menu.test.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import type { ClusterManager } from "../../main/cluster-manager"; +import { lensWindowInjectionToken } from "../../main/start-main-application/lens-window/application-window/lens-window-injection-token"; +import exitAppInjectable from "../../main/electron-app/features/exit-app.injectable"; +import clusterManagerInjectable from "../../main/cluster-manager.injectable"; +import stopServicesAndExitAppInjectable from "../../main/stop-services-and-exit-app.injectable"; + +describe("quitting the app using application menu", () => { + describe("given application has started", () => { + let applicationBuilder: ApplicationBuilder; + let clusterManagerStub: ClusterManager; + let exitAppMock: jest.Mock; + + beforeEach(async () => { + jest.useFakeTimers(); + + applicationBuilder = getApplicationBuilder().beforeApplicationStart( + ({ mainDi }) => { + mainDi.unoverride(stopServicesAndExitAppInjectable); + + clusterManagerStub = { stop: jest.fn() } as unknown as ClusterManager; + mainDi.override(clusterManagerInjectable, () => clusterManagerStub); + + exitAppMock = jest.fn(); + mainDi.override(exitAppInjectable, () => exitAppMock); + }, + ); + + await applicationBuilder.render(); + }); + + it("only an application window is open", () => { + const windows = applicationBuilder.dis.mainDi.injectMany( + lensWindowInjectionToken, + ); + + expect( + windows.map((window) => ({ id: window.id, visible: window.isVisible })), + ).toEqual([ + { id: "only-application-window", visible: true }, + { id: "splash", visible: false }, + ]); + }); + + describe("when application is quit", () => { + beforeEach(() => { + applicationBuilder.applicationMenu.click("root.quit"); + }); + + it("closes all windows", () => { + const windows = applicationBuilder.dis.mainDi.injectMany( + lensWindowInjectionToken, + ); + + expect( + windows.map((window) => ({ id: window.id, visible: window.isVisible })), + ).toEqual([ + { id: "only-application-window", visible: false }, + { id: "splash", visible: false }, + ]); + }); + + it("disconnects all clusters", () => { + expect(clusterManagerStub.stop).toHaveBeenCalled(); + }); + + it("after insufficient time passes, does not terminate application yet", () => { + jest.advanceTimersByTime(999); + + expect(exitAppMock).not.toHaveBeenCalled(); + }); + + describe("after sufficient time passes", () => { + beforeEach(() => { + jest.advanceTimersByTime(1000); + }); + + it("terminates application", () => { + expect(exitAppMock).toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/behaviours/welcome/navigation-using-application-menu.test.ts b/src/behaviours/welcome/navigation-using-application-menu.test.ts index a9f09c783c..805e99efb4 100644 --- a/src/behaviours/welcome/navigation-using-application-menu.test.ts +++ b/src/behaviours/welcome/navigation-using-application-menu.test.ts @@ -28,8 +28,8 @@ describe("welcome - navigation using application menu", () => { }); describe("when navigating to welcome using application menu", () => { - beforeEach(async () => { - await applicationBuilder.applicationMenu.click("help.welcome"); + beforeEach(() => { + applicationBuilder.applicationMenu.click("help.welcome"); }); it("renders", () => { diff --git a/src/common/utils/channel/channel.test.ts b/src/common/utils/channel/channel.test.ts index f2748104d7..84c1366f35 100644 --- a/src/common/utils/channel/channel.test.ts +++ b/src/common/utils/channel/channel.test.ts @@ -70,13 +70,13 @@ describe("channel", () => { closeAllWindows(); }); - describe("given window is shown", () => { + describe("given window is started", () => { let someWindowFake: LensWindow; beforeEach(async () => { someWindowFake = createTestWindow(mainDi, "some-window"); - await someWindowFake.show(); + await someWindowFake.start(); }); it("when sending message, triggers listener in window", () => { @@ -94,12 +94,12 @@ describe("channel", () => { }); }); - it("given multiple shown windows, when sending message, triggers listeners in all windows", async () => { + it("given multiple started windows, when sending message, triggers listeners in all windows", async () => { const someWindowFake = createTestWindow(mainDi, "some-window"); const someOtherWindowFake = createTestWindow(mainDi, "some-other-window"); - await someWindowFake.show(); - await someOtherWindowFake.show(); + await someWindowFake.start(); + await someOtherWindowFake.start(); messageToChannel(testMessageChannel, "some-message"); diff --git a/src/main/electron-app/runnables/dock-visibility/hide-dock-for-last-closed-window.injectable.ts b/src/main/electron-app/runnables/dock-visibility/hide-dock-for-last-closed-window.injectable.ts index de4b2b357d..315b2a5689 100644 --- a/src/main/electron-app/runnables/dock-visibility/hide-dock-for-last-closed-window.injectable.ts +++ b/src/main/electron-app/runnables/dock-visibility/hide-dock-for-last-closed-window.injectable.ts @@ -5,23 +5,19 @@ import { getInjectable } from "@ogre-tools/injectable"; import { beforeQuitOfFrontEndInjectionToken } from "../../../start-main-application/runnable-tokens/before-quit-of-front-end-injection-token"; import electronAppInjectable from "../../electron-app.injectable"; -import { lensWindowInjectionToken } from "../../../start-main-application/lens-window/application-window/lens-window-injection-token"; -import { pipeline } from "@ogre-tools/fp"; -import { filter, isEmpty } from "lodash/fp"; +import { isEmpty } from "lodash/fp"; +import getVisibleWindowsInjectable from "../../../start-main-application/lens-window/get-visible-windows.injectable"; const hideDockForLastClosedWindowInjectable = getInjectable({ id: "hide-dock-when-there-are-no-windows", instantiate: (di) => { const app = di.inject(electronAppInjectable); - const getLensWindows = () => di.injectMany(lensWindowInjectionToken); + const getVisibleWindows = di.inject(getVisibleWindowsInjectable); return { run: () => { - const visibleWindows = pipeline( - getLensWindows(), - filter(window => !!window.visible), - ); + const visibleWindows = getVisibleWindows(); if (isEmpty(visibleWindows)) { app.dock?.hide(); diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index 43f61c22cc..e7f0968237 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -64,7 +64,7 @@ import { observable } from "mobx"; import waitForElectronToBeReadyInjectable from "./electron-app/features/wait-for-electron-to-be-ready.injectable"; import setupListenerForCurrentClusterFrameInjectable from "./start-main-application/lens-window/current-cluster-frame/setup-listener-for-current-cluster-frame.injectable"; import ipcMainInjectable from "./utils/channel/ipc-main/ipc-main.injectable"; -import createElectronWindowForInjectable from "./start-main-application/lens-window/application-window/create-electron-window-for.injectable"; +import createElectronWindowForInjectable from "./start-main-application/lens-window/application-window/create-electron-window.injectable"; import setupRunnablesAfterWindowIsOpenedInjectable from "./electron-app/runnables/setup-runnables-after-window-is-opened.injectable"; import sendToChannelInElectronBrowserWindowInjectable from "./start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable"; import broadcastMessageInjectable from "../common/ipc/broadcast-message.injectable"; @@ -97,6 +97,7 @@ import installHelmChartInjectable from "./helm/helm-service/install-helm-chart.i import listHelmReleasesInjectable from "./helm/helm-service/list-helm-releases.injectable"; import rollbackHelmReleaseInjectable from "./helm/helm-service/rollback-helm-release.injectable"; import updateHelmReleaseInjectable from "./helm/helm-service/update-helm-release.injectable"; +import waitUntilBundledExtensionsAreLoadedInjectable from "./start-main-application/lens-window/application-window/wait-until-bundled-extensions-are-loaded.injectable"; export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {}) { const { @@ -120,6 +121,7 @@ export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {}) di.preventSideEffects(); if (doGeneralOverrides) { + di.override(waitUntilBundledExtensionsAreLoadedInjectable, () => async () => {}); di.override(getRandomIdInjectable, () => () => "some-irrelevant-random-id"); di.override(hotbarStoreInjectable, () => ({ load: () => {} })); di.override(userStoreInjectable, () => ({ startMainReactions: () => {}, extensionRegistryUrl: { customUrl: "some-custom-url" }}) as UserStore); @@ -242,7 +244,7 @@ const overrideElectronFeatures = (di: DiContainer) => { di.override(getCommandLineSwitchInjectable, () => () => "irrelevant"); di.override(requestSingleInstanceLockInjectable, () => () => true); di.override(disableHardwareAccelerationInjectable, () => () => {}); - di.override(shouldStartHiddenInjectable, () => true); + di.override(shouldStartHiddenInjectable, () => false); di.override(showMessagePopupInjectable, () => () => {}); di.override(waitForElectronToBeReadyInjectable, () => () => Promise.resolve()); di.override(ipcMainInjectable, () => ({})); @@ -256,7 +258,7 @@ const overrideElectronFeatures = (di: DiContainer) => { throw new Error("Tried to check for platform updates without explicit override."); }); - di.override(createElectronWindowForInjectable, () => () => async () => ({ + di.override(createElectronWindowForInjectable, () => () => ({ show: () => {}, close: () => {}, @@ -266,6 +268,9 @@ const overrideElectronFeatures = (di: DiContainer) => { sendFake(null, arg); }, + + loadFile: async () => {}, + loadUrl: async () => {}, })); di.override( diff --git a/src/main/start-main-application/lens-window/application-window/application-window.injectable.ts b/src/main/start-main-application/lens-window/application-window/application-window.injectable.ts index c63191c71c..45d7631415 100644 --- a/src/main/start-main-application/lens-window/application-window/application-window.injectable.ts +++ b/src/main/start-main-application/lens-window/application-window/application-window.injectable.ts @@ -9,9 +9,7 @@ import lensProxyPortInjectable from "../../../lens-proxy/lens-proxy-port.injecta import isMacInjectable from "../../../../common/vars/is-mac.injectable"; import appNameInjectable from "../../../app-paths/app-name/app-name.injectable"; import appEventBusInjectable from "../../../../common/app-event-bus/app-event-bus.injectable"; -import { delay } from "../../../../common/utils"; -import { bundledExtensionsLoaded } from "../../../../common/ipc/extension-handling"; -import ipcMainInjectable from "../../../utils/channel/ipc-main/ipc-main.injectable"; +import waitUntilBundledExtensionsAreLoadedInjectable from "./wait-until-bundled-extensions-are-loaded.injectable"; const applicationWindowInjectable = getInjectable({ id: "application-window", @@ -21,7 +19,7 @@ const applicationWindowInjectable = getInjectable({ const isMac = di.inject(isMacInjectable); const applicationName = di.inject(appNameInjectable); const appEventBus = di.inject(appEventBusInjectable); - const ipcMain = di.inject(ipcMainInjectable); + const waitUntilBundledExtensionsAreLoaded = di.inject(waitUntilBundledExtensionsAreLoadedInjectable); const lensProxyPort = di.inject(lensProxyPortInjectable); return createLensWindow({ @@ -45,14 +43,7 @@ const applicationWindowInjectable = getInjectable({ onDomReady: () => { appEventBus.emit({ name: "app", action: "dom-ready" }); }, - beforeOpen: async () => { - const viewHasLoaded = new Promise((resolve) => { - ipcMain.once(bundledExtensionsLoaded, () => resolve()); - }); - - await viewHasLoaded; - await delay(50); // wait just a bit longer to let the first round of rendering happen - }, + beforeOpen: waitUntilBundledExtensionsAreLoaded, }); }, diff --git a/src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts b/src/main/start-main-application/lens-window/application-window/create-electron-window.injectable.ts similarity index 83% rename from src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts rename to src/main/start-main-application/lens-window/application-window/create-electron-window.injectable.ts index 5279bff30f..00fcdb6231 100644 --- a/src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts +++ b/src/main/start-main-application/lens-window/application-window/create-electron-window.injectable.ts @@ -8,7 +8,7 @@ import applicationWindowStateInjectable from "./application-window-state.injecta import { BrowserWindow } from "electron"; import { openBrowser } from "../../../../common/utils"; import sendToChannelInElectronBrowserWindowInjectable from "./send-to-channel-in-electron-browser-window.injectable"; -import type { LensWindow } from "./create-lens-window.injectable"; +import type { ElectronWindow } from "./create-lens-window.injectable"; import type { RequireExactlyOne } from "type-fest"; export type ElectronWindowTitleBarStyle = "hiddenInset" | "hidden" | "default" | "customButtonsOnHover"; @@ -38,21 +38,16 @@ export interface ElectronWindowConfiguration { onDomReady?: () => void; } -export type CreateElectronWindow = () => Promise; -export type CreateElectronWindowFor = (config: ElectronWindowConfiguration) => CreateElectronWindow; +export type CreateElectronWindow = (config: ElectronWindowConfiguration) => ElectronWindow; -function isFileSource(src: ContentSource): src is FileSource { - return typeof (src as FileSource).file === "string"; -} +const createElectronWindowInjectable = getInjectable({ + id: "create-electron-window", -const createElectronWindowFor = getInjectable({ - id: "create-electron-window-for", - - instantiate: (di): CreateElectronWindowFor => { + instantiate: (di): CreateElectronWindow => { const logger = di.inject(loggerInjectable); const sendToChannelInLensWindow = di.inject(sendToChannelInElectronBrowserWindowInjectable); - return (configuration) => async () => { + return (configuration) => { const applicationWindowState = di.inject( applicationWindowStateInjectable, { @@ -172,19 +167,23 @@ const createElectronWindowFor = getInjectable({ return { action: "deny" }; }); - const contentSource = configuration.getContentSource(); - - if (isFileSource(contentSource)) { - logger.info(`[CREATE-ELECTRON-WINDOW]: Loading content for window "${configuration.id}" from file: ${contentSource.file}...`); - await browserWindow.loadFile(contentSource.file); - } else { - logger.info(`[CREATE-ELECTRON-WINDOW]: Loading content for window "${configuration.id}" from url: ${contentSource.url}...`); - await browserWindow.loadURL(contentSource.url); - } - - await configuration.beforeOpen?.(); - return { + loadFile: async (filePath) => { + logger.info( + `[CREATE-ELECTRON-WINDOW]: Loading content for window "${configuration.id}" from file: ${filePath}...`, + ); + + await browserWindow.loadFile(filePath); + }, + + loadUrl: async (url) => { + logger.info( + `[CREATE-ELECTRON-WINDOW]: Loading content for window "${configuration.id}" from url: ${url}...`, + ); + + await browserWindow.loadURL(url); + }, + show: () => browserWindow.show(), close: () => browserWindow.close(), send: (args) => sendToChannelInLensWindow(browserWindow, args), @@ -195,4 +194,4 @@ const createElectronWindowFor = getInjectable({ causesSideEffects: true, }); -export default createElectronWindowFor; +export default createElectronWindowInjectable; diff --git a/src/main/start-main-application/lens-window/application-window/create-lens-window.injectable.ts b/src/main/start-main-application/lens-window/application-window/create-lens-window.injectable.ts index a44b0ebd4c..fe43f2e41e 100644 --- a/src/main/start-main-application/lens-window/application-window/create-lens-window.injectable.ts +++ b/src/main/start-main-application/lens-window/application-window/create-lens-window.injectable.ts @@ -3,14 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import type { SendToViewArgs } from "./lens-window-injection-token"; -import type { ContentSource, ElectronWindowTitleBarStyle } from "./create-electron-window-for.injectable"; -import createElectronWindowForInjectable from "./create-electron-window-for.injectable"; +import type { LensWindow, SendToViewArgs } from "./lens-window-injection-token"; +import type { ContentSource, ElectronWindowTitleBarStyle } from "./create-electron-window.injectable"; +import createElectronWindowForInjectable from "./create-electron-window.injectable"; +import assert from "assert"; -export interface LensWindow { +export interface ElectronWindow { show: () => void; close: () => void; send: (args: SendToViewArgs) => void; + loadFile: (filePath: string) => Promise; + loadUrl: (url: string) => Promise; } export interface LensWindowConfiguration { @@ -33,31 +36,69 @@ const createLensWindowInjectable = getInjectable({ id: "create-lens-window", instantiate: (di) => { - const createElectronWindowFor = di.inject(createElectronWindowForInjectable); + const createElectronWindow = di.inject(createElectronWindowForInjectable); - return (configuration: LensWindowConfiguration) => { - let browserWindow: LensWindow | undefined; + return (configuration: LensWindowConfiguration): LensWindow => { + let browserWindow: ElectronWindow | undefined; - const createElectronWindow = createElectronWindowFor({ - ...configuration, - onClose: () => browserWindow = undefined, - }); + let windowIsShown = false; + let windowIsStarting = false; + + const showWindow = () => { + assert(browserWindow); + + browserWindow.show(); + windowIsShown = true; + }; return { - get visible() { - return !!browserWindow; + id: configuration.id, + + get isVisible() { + return windowIsShown; }, - show: async () => { + + get isStarting() { + return windowIsStarting; + }, + + start: async () => { if (!browserWindow) { - browserWindow = await createElectronWindow(); + windowIsStarting = true; + + browserWindow = createElectronWindow({ + ...configuration, + onClose: () => { + browserWindow = undefined; + windowIsShown = false; + }, + }); + + const { file: filePathForContent, url: urlForContent } = + configuration.getContentSource(); + + if (filePathForContent) { + await browserWindow.loadFile(filePathForContent); + } else if (urlForContent) { + await browserWindow.loadUrl(urlForContent); + } + + await configuration.beforeOpen?.(); } - browserWindow.show(); + showWindow(); + + windowIsStarting = false; }, + + show: showWindow, + close: () => { browserWindow?.close(); browserWindow = undefined; + windowIsShown = false; }, + send: (args: SendToViewArgs) => { if (!browserWindow) { throw new Error(`Tried to send message to window "${configuration.id}" but the window was closed`); diff --git a/src/main/start-main-application/lens-window/application-window/lens-window-injection-token.ts b/src/main/start-main-application/lens-window/application-window/lens-window-injection-token.ts index 3e62b0894b..04c0939a6a 100644 --- a/src/main/start-main-application/lens-window/application-window/lens-window-injection-token.ts +++ b/src/main/start-main-application/lens-window/application-window/lens-window-injection-token.ts @@ -12,10 +12,13 @@ export interface SendToViewArgs { } export interface LensWindow { - show: () => Promise; + id: string; + start: () => Promise; close: () => void; + show: () => void; send: (args: SendToViewArgs) => void; - visible: boolean; + isVisible: boolean; + isStarting: boolean; } export const lensWindowInjectionToken = getInjectionToken({ diff --git a/src/main/start-main-application/lens-window/application-window/wait-until-bundled-extensions-are-loaded.injectable.ts b/src/main/start-main-application/lens-window/application-window/wait-until-bundled-extensions-are-loaded.injectable.ts new file mode 100644 index 0000000000..2c0f30b83b --- /dev/null +++ b/src/main/start-main-application/lens-window/application-window/wait-until-bundled-extensions-are-loaded.injectable.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import { bundledExtensionsLoaded } from "../../../../common/ipc/extension-handling"; +import { delay } from "../../../../common/utils"; +import ipcMainInjectable from "../../../utils/channel/ipc-main/ipc-main.injectable"; + +const waitUntilBundledExtensionsAreLoadedInjectable = getInjectable({ + id: "wait-until-bundled-extensions-are-loaded", + + instantiate: (di) => { + const ipcMain = di.inject(ipcMainInjectable); + + return async () => { + const viewHasLoaded = new Promise((resolve) => { + ipcMain.once(bundledExtensionsLoaded, () => resolve()); + }); + + await viewHasLoaded; + await delay(50); // wait just a bit longer to let the first round of rendering happen + }; + }, + + causesSideEffects: true, +}); + +export default waitUntilBundledExtensionsAreLoadedInjectable; diff --git a/src/main/start-main-application/lens-window/get-visible-windows.injectable.ts b/src/main/start-main-application/lens-window/get-visible-windows.injectable.ts new file mode 100644 index 0000000000..0179bdb377 --- /dev/null +++ b/src/main/start-main-application/lens-window/get-visible-windows.injectable.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { pipeline } from "@ogre-tools/fp"; +import { getInjectable } from "@ogre-tools/injectable"; +import { filter } from "lodash/fp"; +import { lensWindowInjectionToken } from "./application-window/lens-window-injection-token"; + +const getVisibleWindowsInjectable = getInjectable({ + id: "get-visible-windows", + + instantiate: (di) => { + const getAllLensWindows = () => di.injectMany(lensWindowInjectionToken); + + return () => + pipeline( + getAllLensWindows(), + filter((lensWindow) => !!lensWindow.isVisible), + ); + }, +}); + +export default getVisibleWindowsInjectable; diff --git a/src/main/start-main-application/lens-window/show-application-window.injectable.ts b/src/main/start-main-application/lens-window/show-application-window.injectable.ts index b514c41891..6fa6fca62c 100644 --- a/src/main/start-main-application/lens-window/show-application-window.injectable.ts +++ b/src/main/start-main-application/lens-window/show-application-window.injectable.ts @@ -5,25 +5,36 @@ import { getInjectable } from "@ogre-tools/injectable"; import splashWindowInjectable from "./splash-window/splash-window.injectable"; import applicationWindowInjectable from "./application-window/application-window.injectable"; +import { identity, some } from "lodash/fp"; +const someIsTruthy = some(identity); const showApplicationWindowInjectable = getInjectable({ id: "show-application-window", instantiate: (di) => { const applicationWindow = di.inject(applicationWindowInjectable); - - const splashWindow = di.inject( - splashWindowInjectable, - ); + const splashWindow = di.inject(splashWindowInjectable); return async () => { - if (applicationWindow.visible) { + if (applicationWindow.isStarting) { + applicationWindow.show(); + splashWindow.close(); + return; } - await splashWindow.show(); + const windowIsAlreadyBeingShown = someIsTruthy([ + applicationWindow.isVisible, + splashWindow.isStarting, + ]); - await applicationWindow.show(); + if (windowIsAlreadyBeingShown) { + return; + } + + await splashWindow.start(); + + await applicationWindow.start(); splashWindow.close(); }; diff --git a/src/main/start-main-application/start-main-application.injectable.ts b/src/main/start-main-application/start-main-application.injectable.ts index 8d48977089..fa85d9f029 100644 --- a/src/main/start-main-application/start-main-application.injectable.ts +++ b/src/main/start-main-application/start-main-application.injectable.ts @@ -49,7 +49,7 @@ const startMainApplicationInjectable = getInjectable({ await beforeApplicationIsLoading(); if (!shouldStartHidden) { - await splashWindow.show(); + await splashWindow.start(); } await onLoadOfApplication(); @@ -60,7 +60,7 @@ const startMainApplicationInjectable = getInjectable({ if (deepLinkUrl) { await openDeepLink(deepLinkUrl); } else { - await applicationWindow.show(); + await applicationWindow.start(); } splashWindow.close(); diff --git a/src/main/utils/channel/message-to-channel.injectable.ts b/src/main/utils/channel/message-to-channel.injectable.ts index 00e588a16a..cbcdc2badd 100644 --- a/src/main/utils/channel/message-to-channel.injectable.ts +++ b/src/main/utils/channel/message-to-channel.injectable.ts @@ -2,32 +2,24 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { lensWindowInjectionToken } from "../../start-main-application/lens-window/application-window/lens-window-injection-token"; -import { pipeline } from "@ogre-tools/fp"; import { getInjectable } from "@ogre-tools/injectable"; -import { filter } from "lodash/fp"; import { messageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token"; import type { MessageChannel } from "../../../common/utils/channel/message-channel-injection-token"; import { tentativeStringifyJson } from "../../../common/utils/tentative-stringify-json"; +import getVisibleWindowsInjectable from "../../start-main-application/lens-window/get-visible-windows.injectable"; const messageToChannelInjectable = getInjectable({ id: "message-to-channel", instantiate: (di) => { - const getAllLensWindows = () => di.injectMany(lensWindowInjectionToken); + const getVisibleWindows = di.inject(getVisibleWindowsInjectable); // TODO: Figure out way to improve typing in internals // Notice that this should be injected using "messageToChannelInjectionToken" which is typed correctly. return (channel: MessageChannel, message?: unknown) => { const stringifiedMessage = tentativeStringifyJson(message); - - const visibleWindows = pipeline( - getAllLensWindows(), - filter((lensWindow) => !!lensWindow.visible), - ); - - visibleWindows.forEach((lensWindow) => + getVisibleWindows().forEach((lensWindow) => lensWindow.send({ channel: channel.id, data: stringifiedMessage ? [stringifiedMessage] : [] }), ); }; diff --git a/src/main/utils/channel/message-to-channel.test.ts b/src/main/utils/channel/message-to-channel.test.ts index cf2fc46549..67a407de55 100644 --- a/src/main/utils/channel/message-to-channel.test.ts +++ b/src/main/utils/channel/message-to-channel.test.ts @@ -43,9 +43,9 @@ describe("message to channel from main", () => { expect(sendToChannelInBrowserMock).not.toHaveBeenCalled(); }); - describe("given visible window", () => { + describe("given started window", () => { beforeEach(async () => { - await someTestWindow.show(); + await someTestWindow.start(); }); it("when messaging to channel, messages to window", () => { @@ -109,9 +109,9 @@ describe("message to channel from main", () => { }); }); - it("given multiple visible windows, when messaging to channel, messages to window", async () => { - await someTestWindow.show(); - await someOtherTestWindow.show(); + it("given multiple started windows, when messaging to channel, messages to window", async () => { + await someTestWindow.start(); + await someOtherTestWindow.start(); messageToChannel(someChannel, "some-message"); diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx index 3e052d9f93..e9fc09f7fe 100644 --- a/src/renderer/components/test-utils/get-application-builder.tsx +++ b/src/renderer/components/test-utils/get-application-builder.tsx @@ -39,7 +39,6 @@ import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import clusterFrameContextInjectable from "../../cluster-frame-context/cluster-frame-context.injectable"; import startMainApplicationInjectable from "../../../main/start-main-application/start-main-application.injectable"; import startFrameInjectable from "../../start-frame/start-frame.injectable"; -import { flushPromises } from "../../../common/test-utils/flush-promises"; import type { NamespaceStore } from "../+namespaces/store"; import namespaceStoreInjectable from "../+namespaces/store.injectable"; import historyInjectable from "../../navigation/history.injectable"; @@ -56,6 +55,7 @@ import assert from "assert"; import { openMenu } from "react-select-event"; import userEvent from "@testing-library/user-event"; import { StatusBar } from "../status-bar/status-bar"; +import lensProxyPortInjectable from "../../../main/lens-proxy/lens-proxy-port.injectable"; type Callback = (dis: DiContainers) => void | Promise; @@ -75,7 +75,7 @@ export interface ApplicationBuilder { }; applicationMenu: { - click: (path: string) => Promise; + click: (path: string) => void; }; preferences: { @@ -181,13 +181,13 @@ export const getApplicationBuilder = () => { computed(() => []), ); - const iconPaths = mainDi.inject(trayIconPathsInjectable); - let trayMenuItemsStateFake: TrayMenuItem[]; let trayMenuIconPath: string; mainDi.override(electronTrayInjectable, () => ({ start: () => { + const iconPaths = mainDi.inject(trayIconPathsInjectable); + trayMenuIconPath = iconPaths.normal; }, stop: () => {}, @@ -206,7 +206,7 @@ export const getApplicationBuilder = () => { dis, applicationMenu: { - click: async (path: string) => { + click: (path: string) => { const applicationMenuItems = mainDi.inject( applicationMenuItemsInjectable, ); @@ -237,8 +237,6 @@ export const getApplicationBuilder = () => { undefined, {}, ); - - await flushPromises(); }, }, @@ -398,6 +396,8 @@ export const getApplicationBuilder = () => { }, async render() { + mainDi.inject(lensProxyPortInjectable).set(42); + for (const callback of beforeApplicationStartCallbacks) { await callback(dis); } @@ -408,7 +408,7 @@ export const getApplicationBuilder = () => { const applicationWindow = mainDi.inject(applicationWindowInjectable); - await applicationWindow.show(); + await applicationWindow.start(); const startFrame = rendererDi.inject(startFrameInjectable);