diff --git a/package.json b/package.json index 36141a0ba3..00bfd584f2 100644 --- a/package.json +++ b/package.json @@ -281,7 +281,7 @@ "ws": "^8.5.0" }, "devDependencies": { - "@async-fn/jest": "1.6.0", + "@async-fn/jest": "1.6.1", "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.60", diff --git a/src/behaviours/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap b/src/behaviours/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap index 3b43a51f66..80b0028469 100644 --- a/src/behaviours/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap +++ b/src/behaviours/__snapshots__/extension-special-characters-in-page-registrations.test.tsx.snap @@ -1,11 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`extension special characters in page registrations renders 1`] = `
`; +exports[`extension special characters in page registrations renders 1`] = ` +
+
+
+`; exports[`extension special characters in page registrations when navigating to route with ID having special characters renders 1`] = `
Some page
+
`; diff --git a/src/behaviours/__snapshots__/navigate-to-extension-page.test.tsx.snap b/src/behaviours/__snapshots__/navigate-to-extension-page.test.tsx.snap index edab04b903..c96763fe6e 100644 --- a/src/behaviours/__snapshots__/navigate-to-extension-page.test.tsx.snap +++ b/src/behaviours/__snapshots__/navigate-to-extension-page.test.tsx.snap @@ -1,12 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`navigate to extension page renders 1`] = `
`; +exports[`navigate to extension page renders 1`] = ` +
+
+
+`; exports[`navigate to extension page when extension navigates to child route renders 1`] = `
Child page
+
`; @@ -31,6 +40,9 @@ exports[`navigate to extension page when extension navigates to route with param Some button
+
`; @@ -55,6 +67,9 @@ exports[`navigate to extension page when extension navigates to route without pa Some button
+
`; @@ -79,5 +94,8 @@ exports[`navigate to extension page when extension navigates to route without pa Some button
+
`; diff --git a/src/behaviours/__snapshots__/navigating-between-routes.test.tsx.snap b/src/behaviours/__snapshots__/navigating-between-routes.test.tsx.snap index 90ff615b2b..10e9eb2d39 100644 --- a/src/behaviours/__snapshots__/navigating-between-routes.test.tsx.snap +++ b/src/behaviours/__snapshots__/navigating-between-routes.test.tsx.snap @@ -8,6 +8,9 @@ exports[`navigating between routes given route with optional path parameters whe "someOtherParameter": "some-other-value" } +
`; @@ -16,5 +19,8 @@ exports[`navigating between routes given route without path parameters when navi
Some component
+
`; diff --git a/src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap b/src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap index d19612eac3..0fd00133aa 100644 --- a/src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap +++ b/src/behaviours/add-cluster/__snapshots__/navigation-using-application-menu.test.tsx.snap @@ -1,6 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`add-cluster - navigation using application menu renders 1`] = `
`; +exports[`add-cluster - navigation using application menu renders 1`] = ` +
+
+
+`; exports[`add-cluster - navigation using application menu when navigating to add cluster using application menu renders 1`] = `
@@ -85,5 +91,8 @@ exports[`add-cluster - navigation using application menu when navigating to add
+
`; diff --git a/src/behaviours/add-cluster/navigation-using-application-menu.test.tsx b/src/behaviours/add-cluster/navigation-using-application-menu.test.tsx index e982b9de1c..bb68918c1a 100644 --- a/src/behaviours/add-cluster/navigation-using-application-menu.test.tsx +++ b/src/behaviours/add-cluster/navigation-using-application-menu.test.tsx @@ -6,16 +6,17 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import isAutoUpdateEnabledInjectable from "../../main/is-auto-update-enabled.injectable"; import React from "react"; // TODO: Make components free of side effects by making them deterministic jest.mock("../../renderer/components/tooltip/tooltip", () => ({ Tooltip: () => null, })); + jest.mock("../../renderer/components/tooltip/withTooltip", () => ({ withTooltip: (Target: any) => ({ tooltip, tooltipOverrideDisabled, ...props }: any) => , })); + jest.mock("../../renderer/components/monaco-editor/monaco-editor", () => ({ MonacoEditor: () => null, })); @@ -25,9 +26,7 @@ describe("add-cluster - navigation using application menu", () => { let rendered: RenderResult; beforeEach(async () => { - applicationBuilder = getApplicationBuilder().beforeApplicationStart(({ mainDi }) => { - mainDi.override(isAutoUpdateEnabledInjectable, () => () => false); - }); + applicationBuilder = getApplicationBuilder(); rendered = await applicationBuilder.render(); }); diff --git a/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap b/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap new file mode 100644 index 0000000000..32e6cb1cb1 --- /dev/null +++ b/src/behaviours/application-update/__snapshots__/installing-update-using-tray.test.ts.snap @@ -0,0 +1,536 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`installing update using tray when started renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray when new update is discovered renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ Download for version some-version started... +
+
+ + + close + + +
+
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray when new update is discovered when download fails renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ Download for version some-version started... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ Download of update failed +
+
+ + + close + + +
+
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray when new update is discovered when download succeeds renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ Download for version some-version started... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+
+ + Update Available + +

+ Version some-version of Lens IDE is available and ready to be installed. Would you like to update now? + +Lens should restart automatically, if it doesn't please restart manually. Installed extensions might require updating. +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`installing update using tray when started when user checks for updates using tray when no new update is discovered renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+ Checking for updates... +
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+ No new updates available +
+
+ + + close + + +
+
+
+
+ +`; diff --git a/src/behaviours/application-update/__snapshots__/installing-update.test.ts.snap b/src/behaviours/application-update/__snapshots__/installing-update.test.ts.snap new file mode 100644 index 0000000000..7025289254 --- /dev/null +++ b/src/behaviours/application-update/__snapshots__/installing-update.test.ts.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`installing update when started renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered when download fails renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered when download succeeds renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered when download succeeds when user answers not to install the update renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when new update is discovered when download succeeds when user answers to install the update renders 1`] = ` + +
+
+
+ +`; + +exports[`installing update when started when user checks for updates when no new update is discovered renders 1`] = ` + +
+
+
+ +`; diff --git a/src/behaviours/application-update/__snapshots__/periodical-checking-of-updates.test.ts.snap b/src/behaviours/application-update/__snapshots__/periodical-checking-of-updates.test.ts.snap new file mode 100644 index 0000000000..84fa35ae04 --- /dev/null +++ b/src/behaviours/application-update/__snapshots__/periodical-checking-of-updates.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`periodical checking of updates given updater is enabled and configuration exists, when started renders 1`] = ` + +
+
+
+ +`; diff --git a/src/behaviours/application-update/__snapshots__/selection-of-update-stability.test.ts.snap b/src/behaviours/application-update/__snapshots__/selection-of-update-stability.test.ts.snap new file mode 100644 index 0000000000..dc96c447b0 --- /dev/null +++ b/src/behaviours/application-update/__snapshots__/selection-of-update-stability.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`selection of update stability when started renders 1`] = ` + +
+
+
+ +`; diff --git a/src/behaviours/application-update/downgrading-version-update.test.ts b/src/behaviours/application-update/downgrading-version-update.test.ts new file mode 100644 index 0000000000..e8e5635fb3 --- /dev/null +++ b/src/behaviours/application-update/downgrading-version-update.test.ts @@ -0,0 +1,87 @@ +/** + * 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 electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { CheckForPlatformUpdates } from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import processCheckingForUpdatesInjectable from "../../main/application-update/check-for-updates/process-checking-for-updates.injectable"; +import selectedUpdateChannelInjectable from "../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; +import appVersionInjectable from "../../common/get-configuration-file-model/app-version/app-version.injectable"; +import { updateChannels } from "../../common/application-update/update-channels"; + +describe("downgrading version update", () => { + let applicationBuilder: ApplicationBuilder; + let checkForPlatformUpdatesMock: AsyncFnMock; + let mainDi: DiContainer; + + beforeEach(() => { + jest.useFakeTimers(); + + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + checkForPlatformUpdatesMock = asyncFn(); + + mainDi.override( + checkForPlatformUpdatesInjectable, + () => checkForPlatformUpdatesMock, + ); + + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + + mainDi = applicationBuilder.dis.mainDi; + }); + + [ + { + updateChannel: updateChannels.latest, + appVersion: "4.0.0-beta", + downgradeIsAllowed: true, + }, + { + updateChannel: updateChannels.beta, + appVersion: "4.0.0-beta", + downgradeIsAllowed: false, + }, + { + updateChannel: updateChannels.beta, + appVersion: "4.0.0-beta.1", + downgradeIsAllowed: false, + }, + { + updateChannel: updateChannels.alpha, + appVersion: "4.0.0-beta", + downgradeIsAllowed: true, + }, + { + updateChannel: updateChannels.alpha, + appVersion: "4.0.0-alpha", + downgradeIsAllowed: false, + }, + ].forEach(({ appVersion, updateChannel, downgradeIsAllowed }) => { + it(`given application version "${appVersion}" and update channel "${updateChannel.id}", when checking for updates, can${downgradeIsAllowed ? "": "not"} downgrade`, async () => { + mainDi.override(appVersionInjectable, () => appVersion); + + await applicationBuilder.render(); + + const selectedUpdateChannel = mainDi.inject(selectedUpdateChannelInjectable); + + selectedUpdateChannel.setValue(updateChannel.id); + + const processCheckingForUpdates = mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(expect.any(Object), { allowDowngrade: downgradeIsAllowed }); + }); + }); +}); diff --git a/src/behaviours/application-update/installing-update-using-tray.test.ts b/src/behaviours/application-update/installing-update-using-tray.test.ts new file mode 100644 index 0000000000..f6570bb8fa --- /dev/null +++ b/src/behaviours/application-update/installing-update-using-tray.test.ts @@ -0,0 +1,235 @@ +/** + * 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 { RenderResult } from "@testing-library/react"; +import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { CheckForPlatformUpdates } from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { DownloadPlatformUpdate } from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import downloadPlatformUpdateInjectable from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import showApplicationWindowInjectable from "../../main/start-main-application/lens-window/show-application-window.injectable"; +import progressOfUpdateDownloadInjectable from "../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; + +describe("installing update using tray", () => { + let applicationBuilder: ApplicationBuilder; + let checkForPlatformUpdatesMock: AsyncFnMock; + let downloadPlatformUpdateMock: AsyncFnMock; + let showApplicationWindowMock: jest.Mock; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + checkForPlatformUpdatesMock = asyncFn(); + downloadPlatformUpdateMock = asyncFn(); + showApplicationWindowMock = jest.fn(); + + mainDi.override(showApplicationWindowInjectable, () => showApplicationWindowMock); + + mainDi.override( + checkForPlatformUpdatesInjectable, + () => checkForPlatformUpdatesMock, + ); + + mainDi.override( + downloadPlatformUpdateInjectable, + () => downloadPlatformUpdateMock, + ); + + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + }); + + describe("when started", () => { + let rendered: RenderResult; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("user cannot install update yet", () => { + expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + }); + + describe("when user checks for updates using tray", () => { + let processCheckingForUpdatesPromise: Promise; + + beforeEach(async () => { + processCheckingForUpdatesPromise = + applicationBuilder.tray.click("check-for-updates"); + }); + + it("does not show application window yet", () => { + expect(showApplicationWindowMock).not.toHaveBeenCalled(); + }); + + it("user cannot check for updates again", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(false); + }); + + it("name of tray item for checking updates indicates that checking is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Checking for updates..."); + }); + + it("user cannot install update yet", () => { + expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when no new update is discovered", () => { + beforeEach(async () => { + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: false, + }); + + await processCheckingForUpdatesPromise; + }); + + it("shows application window", () => { + expect(showApplicationWindowMock).toHaveBeenCalled(); + }); + + it("user cannot install update", () => { + expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + }); + + it("user can check for updates again", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(true); + }); + + it("name of tray item for checking updates no longer indicates that checking is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Check for updates"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + + describe("when new update is discovered", () => { + beforeEach(async () => { + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + await processCheckingForUpdatesPromise; + }); + + it("shows application window", () => { + expect(showApplicationWindowMock).toHaveBeenCalled(); + }); + + it("user cannot check for updates again yet", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(false); + }); + + it("name of tray item for checking updates indicates that downloading is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Downloading update some-version (0%)..."); + }); + + it("when download progresses with decimals, percentage increases as integers", () => { + const progressOfUpdateDownload = applicationBuilder.dis.mainDi.inject( + progressOfUpdateDownloadInjectable, + ); + + progressOfUpdateDownload.set({ percentage: 42.424242 }); + + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Downloading update some-version (42%)..."); + }); + + it("user still cannot install update", () => { + expect(applicationBuilder.tray.get("install-update")).toBeUndefined(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when download fails", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: false }); + }); + + it("user cannot install update", () => { + expect( + applicationBuilder.tray.get("install-update"), + ).toBeUndefined(); + }); + + it("user can check for updates again", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(true); + }); + + it("name of tray item for checking updates no longer indicates that downloading is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Check for updates"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + + describe("when download succeeds", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: true }); + }); + + it("user can install update", () => { + expect( + applicationBuilder.tray.get("install-update")?.label?.get(), + ).toBe("Install update some-version"); + }); + + it("user can check for updates again", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.enabled.get(), + ).toBe(true); + }); + + it("name of tray item for checking updates no longer indicates that downloading is happening", () => { + expect( + applicationBuilder.tray.get("check-for-updates")?.label?.get(), + ).toBe("Check for updates"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + }); + }); + }); +}); diff --git a/src/behaviours/application-update/installing-update.test.ts b/src/behaviours/application-update/installing-update.test.ts new file mode 100644 index 0000000000..3fec5f6d27 --- /dev/null +++ b/src/behaviours/application-update/installing-update.test.ts @@ -0,0 +1,225 @@ +/** + * 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 quitAndInstallUpdateInjectable from "../../main/electron-app/features/quit-and-install-update.injectable"; +import type { RenderResult } from "@testing-library/react"; +import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { CheckForPlatformUpdates } from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { DownloadPlatformUpdate } from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import downloadPlatformUpdateInjectable from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import setUpdateOnQuitInjectable from "../../main/electron-app/features/set-update-on-quit.injectable"; +import type { AskBoolean } from "../../main/ask-boolean/ask-boolean.injectable"; +import askBooleanInjectable from "../../main/ask-boolean/ask-boolean.injectable"; +import showInfoNotificationInjectable from "../../renderer/components/notifications/show-info-notification.injectable"; +import processCheckingForUpdatesInjectable from "../../main/application-update/check-for-updates/process-checking-for-updates.injectable"; + +describe("installing update", () => { + let applicationBuilder: ApplicationBuilder; + let quitAndInstallUpdateMock: jest.Mock; + let checkForPlatformUpdatesMock: AsyncFnMock; + let downloadPlatformUpdateMock: AsyncFnMock; + let setUpdateOnQuitMock: jest.Mock; + let showInfoNotificationMock: jest.Mock; + let askBooleanMock: AsyncFnMock; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi, rendererDi }) => { + quitAndInstallUpdateMock = jest.fn(); + checkForPlatformUpdatesMock = asyncFn(); + downloadPlatformUpdateMock = asyncFn(); + setUpdateOnQuitMock = jest.fn(); + showInfoNotificationMock = jest.fn(() => () => {}); + askBooleanMock = asyncFn(); + + rendererDi.override(showInfoNotificationInjectable, () => showInfoNotificationMock); + + mainDi.override(askBooleanInjectable, () => askBooleanMock); + mainDi.override(setUpdateOnQuitInjectable, () => setUpdateOnQuitMock); + + mainDi.override( + checkForPlatformUpdatesInjectable, + () => checkForPlatformUpdatesMock, + ); + + mainDi.override( + downloadPlatformUpdateInjectable, + () => downloadPlatformUpdateMock, + ); + + mainDi.override( + quitAndInstallUpdateInjectable, + () => quitAndInstallUpdateMock, + ); + + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + }); + + describe("when started", () => { + let rendered: RenderResult; + let processCheckingForUpdates: () => Promise; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + + processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when user checks for updates", () => { + let processCheckingForUpdatesPromise: Promise; + + beforeEach(async () => { + processCheckingForUpdatesPromise = processCheckingForUpdates(); + }); + + it("checks for updates", () => { + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + expect.any(Object), + { allowDowngrade: true }, + ); + }); + + it("notifies the user that checking for updates is happening", () => { + expect(showInfoNotificationMock).toHaveBeenCalledWith("Checking for updates..."); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when no new update is discovered", () => { + beforeEach(async () => { + showInfoNotificationMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: false, + }); + + await processCheckingForUpdatesPromise; + }); + + it("notifies the user", () => { + expect(showInfoNotificationMock).toHaveBeenCalledWith("No new updates available"); + }); + + it("does not start downloading update", () => { + expect(downloadPlatformUpdateMock).not.toHaveBeenCalled(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + + describe("when new update is discovered", () => { + beforeEach(async () => { + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + await processCheckingForUpdatesPromise; + }); + + it("starts downloading the update", () => { + expect(downloadPlatformUpdateMock).toHaveBeenCalled(); + }); + + it("notifies the user that download is happening", () => { + expect(showInfoNotificationMock).toHaveBeenCalledWith("Download for version some-version started..."); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe("when download fails", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: false }); + }); + + it("does not quit and install update yet", () => { + expect(quitAndInstallUpdateMock).not.toHaveBeenCalled(); + }); + + it("notifies the user about failed download", () => { + expect(showInfoNotificationMock).toHaveBeenCalledWith("Download of update failed"); + }); + + it("does not ask user to install update", () => { + expect(askBooleanMock).not.toHaveBeenCalled(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + }); + + describe("when download succeeds", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: true }); + }); + + it("does not quit and install update yet", () => { + expect(quitAndInstallUpdateMock).not.toHaveBeenCalled(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("asks user to install update immediately", () => { + expect(askBooleanMock).toHaveBeenCalledWith({ + title: "Update Available", + question: + "Version some-version of Lens IDE is available and ready to be installed. Would you like to update now?\n\n" + + "Lens should restart automatically, if it doesn't please restart manually. Installed extensions might require updating.", + }); + }); + + describe("when user answers to install the update", () => { + beforeEach(async () => { + await askBooleanMock.resolve(true); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("quits application and installs the update", () => { + expect(quitAndInstallUpdateMock).toHaveBeenCalled(); + }); + }); + + describe("when user answers not to install the update", () => { + beforeEach(async () => { + await askBooleanMock.resolve(false); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not quit application and install the update", () => { + expect(quitAndInstallUpdateMock).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); + }); +}); diff --git a/src/behaviours/application-update/periodical-checking-of-updates.test.ts b/src/behaviours/application-update/periodical-checking-of-updates.test.ts new file mode 100644 index 0000000000..e81c002e34 --- /dev/null +++ b/src/behaviours/application-update/periodical-checking-of-updates.test.ts @@ -0,0 +1,117 @@ +/** + * 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 { RenderResult } from "@testing-library/react"; +import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import processCheckingForUpdatesInjectable from "../../main/application-update/check-for-updates/process-checking-for-updates.injectable"; +import periodicalCheckForUpdatesInjectable from "../../main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable"; + +const ENOUGH_TIME = 1000 * 60 * 60 * 2; + +describe("periodical checking of updates", () => { + let applicationBuilder: ApplicationBuilder; + let processCheckingForUpdatesMock: AsyncFnMock<() => Promise>; + + beforeEach(() => { + jest.useFakeTimers(); + + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.unoverride(periodicalCheckForUpdatesInjectable); + mainDi.permitSideEffects(periodicalCheckForUpdatesInjectable); + + processCheckingForUpdatesMock = asyncFn(); + + mainDi.override( + processCheckingForUpdatesInjectable, + () => processCheckingForUpdatesMock, + ); + }); + }); + + describe("given updater is enabled and configuration exists, when started", () => { + let rendered: RenderResult; + + beforeEach(async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + + rendered = await applicationBuilder.render(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("checks for updates", () => { + expect(processCheckingForUpdatesMock).toHaveBeenCalled(); + }); + + it("when just not enough time passes, does not check for updates again automatically yet", () => { + processCheckingForUpdatesMock.mockClear(); + + jest.advanceTimersByTime(ENOUGH_TIME - 1); + + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + + it("when just enough time passes, checks for updates again automatically", () => { + processCheckingForUpdatesMock.mockClear(); + + jest.advanceTimersByTime(ENOUGH_TIME); + + expect(processCheckingForUpdatesMock).toHaveBeenCalled(); + }); + }); + + describe("given updater is enabled but no configuration exist, when started", () => { + beforeEach(async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => false); + }); + + await applicationBuilder.render(); + }); + + it("does not check for updates", () => { + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + + it("when time passes, never checks for updates", () => { + jest.runOnlyPendingTimers(); + + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + }); + + describe("given updater is not enabled but and configuration exist, when started", () => { + beforeEach(async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(electronUpdaterIsActiveInjectable, () => false); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + + await applicationBuilder.render(); + }); + + it("does not check for updates", () => { + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + + it("when time passes, never checks for updates", () => { + jest.runOnlyPendingTimers(); + + expect(processCheckingForUpdatesMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/behaviours/application-update/selection-of-update-stability.test.ts b/src/behaviours/application-update/selection-of-update-stability.test.ts new file mode 100644 index 0000000000..1792fcd484 --- /dev/null +++ b/src/behaviours/application-update/selection-of-update-stability.test.ts @@ -0,0 +1,331 @@ +/** + * 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 quitAndInstallUpdateInjectable from "../../main/electron-app/features/quit-and-install-update.injectable"; +import type { RenderResult } from "@testing-library/react"; +import electronUpdaterIsActiveInjectable from "../../main/electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "../../main/application-update/publish-is-configured.injectable"; +import type { CheckForPlatformUpdates } from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "../../main/application-update/check-for-platform-updates/check-for-platform-updates.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { UpdateChannel, UpdateChannelId } from "../../common/application-update/update-channels"; +import { updateChannels } from "../../common/application-update/update-channels"; +import type { DownloadPlatformUpdate } from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import downloadPlatformUpdateInjectable from "../../main/application-update/download-platform-update/download-platform-update.injectable"; +import selectedUpdateChannelInjectable from "../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import type { IComputedValue } from "mobx"; +import setUpdateOnQuitInjectable from "../../main/electron-app/features/set-update-on-quit.injectable"; +import type { AskBoolean } from "../../main/ask-boolean/ask-boolean.injectable"; +import askBooleanInjectable from "../../main/ask-boolean/ask-boolean.injectable"; +import showInfoNotificationInjectable from "../../renderer/components/notifications/show-info-notification.injectable"; +import processCheckingForUpdatesInjectable from "../../main/application-update/check-for-updates/process-checking-for-updates.injectable"; +import appVersionInjectable from "../../common/get-configuration-file-model/app-version/app-version.injectable"; + +describe("selection of update stability", () => { + let applicationBuilder: ApplicationBuilder; + let quitAndInstallUpdateMock: jest.Mock; + let checkForPlatformUpdatesMock: AsyncFnMock; + let downloadPlatformUpdateMock: AsyncFnMock; + let setUpdateOnQuitMock: jest.Mock; + let showInfoNotificationMock: jest.Mock; + let askBooleanMock: AsyncFnMock; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi, rendererDi }) => { + quitAndInstallUpdateMock = jest.fn(); + checkForPlatformUpdatesMock = asyncFn(); + downloadPlatformUpdateMock = asyncFn(); + setUpdateOnQuitMock = jest.fn(); + showInfoNotificationMock = jest.fn(() => () => {}); + askBooleanMock = asyncFn(); + + rendererDi.override(showInfoNotificationInjectable, () => showInfoNotificationMock); + + mainDi.override(askBooleanInjectable, () => askBooleanMock); + mainDi.override(setUpdateOnQuitInjectable, () => setUpdateOnQuitMock); + + mainDi.override( + checkForPlatformUpdatesInjectable, + () => checkForPlatformUpdatesMock, + ); + + mainDi.override( + downloadPlatformUpdateInjectable, + () => downloadPlatformUpdateMock, + ); + + mainDi.override( + quitAndInstallUpdateInjectable, + () => quitAndInstallUpdateMock, + ); + + mainDi.override(electronUpdaterIsActiveInjectable, () => true); + mainDi.override(publishIsConfiguredInjectable, () => true); + }); + }); + + describe("when started", () => { + let rendered: RenderResult; + let processCheckingForUpdates: () => Promise; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + + processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + describe('given update channel "alpha" is selected, when checking for updates', () => { + let selectedUpdateChannel: { + value: IComputedValue; + setValue: (channelId: UpdateChannelId) => void; + }; + + beforeEach(() => { + selectedUpdateChannel = applicationBuilder.dis.mainDi.inject( + selectedUpdateChannelInjectable, + ); + + selectedUpdateChannel.setValue(updateChannels.alpha.id); + + processCheckingForUpdates(); + }); + + it('checks updates from update channel "alpha"', () => { + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + updateChannels.alpha, + { allowDowngrade: true }, + ); + }); + + it("when update is discovered, does not check update from other update channels", async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + expect(checkForPlatformUpdatesMock).not.toHaveBeenCalled(); + }); + + describe("when no update is discovered", () => { + beforeEach(async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: false, + }); + }); + + it('checks updates from update channel "beta"', () => { + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + updateChannels.beta, + { allowDowngrade: true }, + ); + }); + + it("when update is discovered, does not check update from other update channels", async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + expect(checkForPlatformUpdatesMock).not.toHaveBeenCalled(); + }); + + describe("when no update is discovered again", () => { + beforeEach(async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: false, + }); + }); + + it('finally checks updates from update channel "latest"', () => { + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + updateChannels.latest, + { allowDowngrade: true }, + ); + }); + + it("when update is discovered, does not check update from other update channels", async () => { + checkForPlatformUpdatesMock.mockClear(); + + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-version", + }); + + expect(checkForPlatformUpdatesMock).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('given update channel "beta" is selected', () => { + let selectedUpdateChannel: { + value: IComputedValue; + setValue: (channelId: UpdateChannelId) => void; + }; + + beforeEach(() => { + selectedUpdateChannel = applicationBuilder.dis.mainDi.inject( + selectedUpdateChannelInjectable, + ); + + selectedUpdateChannel.setValue(updateChannels.beta.id); + }); + + describe("when checking for updates", () => { + beforeEach(() => { + processCheckingForUpdates(); + }); + + describe('when update from "beta" channel is discovered', () => { + beforeEach(async () => { + await checkForPlatformUpdatesMock.resolve({ + updateWasDiscovered: true, + version: "some-beta-version", + }); + }); + + describe("when update is downloaded", () => { + beforeEach(async () => { + await downloadPlatformUpdateMock.resolve({ downloadWasSuccessful: true }); + }); + + it("when user would close the application, installs the update", () => { + expect(setUpdateOnQuitMock).toHaveBeenLastCalledWith(true); + }); + + it('given user changes update channel to "latest", when user would close the application, does not install the update for not being stable enough', () => { + selectedUpdateChannel.setValue(updateChannels.latest.id); + + expect(setUpdateOnQuitMock).toHaveBeenLastCalledWith(false); + }); + + it('given user changes update channel to "alpha", when user would close the application, installs the update for being stable enough', () => { + selectedUpdateChannel.setValue(updateChannels.alpha.id); + + expect(setUpdateOnQuitMock).toHaveBeenLastCalledWith(false); + }); + }); + }); + }); + }); + }); + + it("given valid update channel selection is stored, when checking for updates, checks for updates from the update channel", async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + // TODO: Switch to more natural way of setting initial value + // TODO: UserStore is currently responsible for getting and setting initial value + const selectedUpdateChannel = mainDi.inject(selectedUpdateChannelInjectable); + + selectedUpdateChannel.setValue(updateChannels.beta.id); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.beta, expect.any(Object)); + }); + + it("given invalid update channel selection is stored, when checking for updates, checks for updates from the update channel", async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + // TODO: Switch to more natural way of setting initial value + // TODO: UserStore is currently responsible for getting and setting initial value + const selectedUpdateChannel = mainDi.inject(selectedUpdateChannelInjectable); + + selectedUpdateChannel.setValue("something-invalid" as UpdateChannelId); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.latest, expect.any(Object)); + }); + + it('given no update channel selection is stored and currently using stable release, when user checks for updates, checks for updates from "latest" update channel by default', async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(appVersionInjectable, () => "1.0.0"); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith( + updateChannels.latest, + { allowDowngrade: true }, + ); + }); + + it('given no update channel selection is stored and currently using alpha release, when checking for updates, checks for updates from "alpha" channel', async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(appVersionInjectable, () => "1.0.0-alpha"); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.alpha, expect.any(Object)); + }); + + it('given no update channel selection is stored and currently using beta release, when checking for updates, checks for updates from "beta" channel', async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(appVersionInjectable, () => "1.0.0-beta"); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.beta, expect.any(Object)); + }); + + it("given update channel selection is stored and currently using prerelease, when checking for updates, checks for updates from stored channel", async () => { + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + mainDi.override(appVersionInjectable, () => "1.0.0-alpha"); + + // TODO: Switch to more natural way of setting initial value + // TODO: UserStore is currently responsible for getting and setting initial value + const selectedUpdateChannel = mainDi.inject(selectedUpdateChannelInjectable); + + selectedUpdateChannel.setValue(updateChannels.beta.id); + }); + + await applicationBuilder.render(); + + const processCheckingForUpdates = applicationBuilder.dis.mainDi.inject(processCheckingForUpdatesInjectable); + + processCheckingForUpdates(); + + expect(checkForPlatformUpdatesMock).toHaveBeenCalledWith(updateChannels.beta, expect.any(Object)); + }); +}); diff --git a/src/behaviours/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap b/src/behaviours/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap index 092337ec82..9af01f0969 100644 --- a/src/behaviours/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap +++ b/src/behaviours/cluster/__snapshots__/order-of-sidebar-items.test.tsx.snap @@ -328,6 +328,9 @@ exports[`cluster - order of sidebar items when rendered renders 1`] = `
+
`; @@ -723,5 +726,8 @@ exports[`cluster - order of sidebar items when rendered when parent is expanded
+
`; diff --git a/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap b/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap index ad1f7a8d4c..06d1a1e210 100644 --- a/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap +++ b/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-core.test.tsx.snap @@ -293,6 +293,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -589,6 +592,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -909,6 +915,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -1234,6 +1243,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -1534,6 +1546,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -1854,6 +1869,9 @@ exports[`cluster - sidebar and tab navigation for core given core registrations
+
`; @@ -2150,5 +2168,8 @@ exports[`cluster - sidebar and tab navigation for core given core registrations +
`; diff --git a/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap b/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap index be9c321cd5..19cb615cce 100644 --- a/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap +++ b/src/behaviours/cluster/__snapshots__/sidebar-and-tab-navigation-for-extensions.test.tsx.snap @@ -293,6 +293,9 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit +
`; @@ -589,6 +592,9 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit +
`; @@ -929,6 +935,9 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit +
`; @@ -1313,6 +1322,9 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit +
`; @@ -1697,6 +1709,9 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit +
`; @@ -2036,6 +2051,9 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit +
`; @@ -2376,6 +2394,9 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit +
`; @@ -2672,5 +2693,8 @@ exports[`cluster - sidebar and tab navigation for extensions given extension wit +
`; diff --git a/src/behaviours/cluster/__snapshots__/visibility-of-sidebar-items.test.tsx.snap b/src/behaviours/cluster/__snapshots__/visibility-of-sidebar-items.test.tsx.snap index 4f28c6ecef..cc44f56496 100644 --- a/src/behaviours/cluster/__snapshots__/visibility-of-sidebar-items.test.tsx.snap +++ b/src/behaviours/cluster/__snapshots__/visibility-of-sidebar-items.test.tsx.snap @@ -261,6 +261,9 @@ exports[`cluster - visibility of sidebar items given kube resource for route is +
`; @@ -573,5 +576,8 @@ exports[`cluster - visibility of sidebar items given kube resource for route is +
`; diff --git a/src/behaviours/extensions/__snapshots__/navigation-using-application-menu.test.ts.snap b/src/behaviours/extensions/__snapshots__/navigation-using-application-menu.test.ts.snap index 3a1b1309dd..c14ccb6160 100644 --- a/src/behaviours/extensions/__snapshots__/navigation-using-application-menu.test.ts.snap +++ b/src/behaviours/extensions/__snapshots__/navigation-using-application-menu.test.ts.snap @@ -1,6 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`extensions - navigation using application menu renders 1`] = `
`; +exports[`extensions - navigation using application menu renders 1`] = ` +
+
+
+`; exports[`extensions - navigation using application menu when navigating to extensions using application menu renders 1`] = `
@@ -118,5 +124,8 @@ exports[`extensions - navigation using application menu when navigating to exten
+
`; diff --git a/src/behaviours/extensions/navigation-using-application-menu.test.ts b/src/behaviours/extensions/navigation-using-application-menu.test.ts index 75ffa0ebef..5d05ec31c2 100644 --- a/src/behaviours/extensions/navigation-using-application-menu.test.ts +++ b/src/behaviours/extensions/navigation-using-application-menu.test.ts @@ -6,12 +6,7 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import isAutoUpdateEnabledInjectable from "../../main/is-auto-update-enabled.injectable"; -import extensionsStoreInjectable from "../../extensions/extensions-store/extensions-store.injectable"; -import type { ExtensionsStore } from "../../extensions/extensions-store/extensions-store"; -import fileSystemProvisionerStoreInjectable from "../../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable"; -import type { FileSystemProvisionerStore } from "../../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store"; -import focusWindowInjectable from "../../renderer/ipc-channel-listeners/focus-window.injectable"; +import focusWindowInjectable from "../../renderer/navigation/focus-window.injectable"; // TODO: Make components free of side effects by making them deterministic jest.mock("../../renderer/components/input/input"); @@ -22,11 +17,7 @@ describe("extensions - navigation using application menu", () => { let focusWindowMock: jest.Mock; beforeEach(async () => { - applicationBuilder = getApplicationBuilder().beforeApplicationStart(({ mainDi, rendererDi }) => { - mainDi.override(isAutoUpdateEnabledInjectable, () => () => false); - rendererDi.override(extensionsStoreInjectable, () => ({}) as unknown as ExtensionsStore); - rendererDi.override(fileSystemProvisionerStoreInjectable, () => ({}) as unknown as FileSystemProvisionerStore); - + applicationBuilder = getApplicationBuilder().beforeApplicationStart(({ rendererDi }) => { focusWindowMock = jest.fn(); rendererDi.override(focusWindowInjectable, () => focusWindowMock); diff --git a/src/behaviours/helm-charts/__snapshots__/navigation-to-helm-charts.test.ts.snap b/src/behaviours/helm-charts/__snapshots__/navigation-to-helm-charts.test.ts.snap index 4f1555049d..e323205008 100644 --- a/src/behaviours/helm-charts/__snapshots__/navigation-to-helm-charts.test.ts.snap +++ b/src/behaviours/helm-charts/__snapshots__/navigation-to-helm-charts.test.ts.snap @@ -454,5 +454,8 @@ exports[`helm-charts - navigation to Helm charts when navigating to Helm charts +
`; diff --git a/src/behaviours/preferences/__snapshots__/closing-preferences.test.tsx.snap b/src/behaviours/preferences/__snapshots__/closing-preferences.test.tsx.snap index cb4973203d..5cdee87f39 100644 --- a/src/behaviours/preferences/__snapshots__/closing-preferences.test.tsx.snap +++ b/src/behaviours/preferences/__snapshots__/closing-preferences.test.tsx.snap @@ -356,13 +356,12 @@ exports[`preferences - closing-preferences given accessing preferences directly class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -679,6 +680,9 @@ exports[`preferences - closing-preferences given accessing preferences directly +
`; @@ -687,6 +691,9 @@ exports[`preferences - closing-preferences given accessing preferences directly
Some front page
+
`; @@ -695,6 +702,9 @@ exports[`preferences - closing-preferences given accessing preferences directly
Some front page
+
`; @@ -1054,13 +1064,12 @@ exports[`preferences - closing-preferences given already in a page and then navi class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -1377,6 +1388,9 @@ exports[`preferences - closing-preferences given already in a page and then navi +
`; @@ -1519,6 +1533,9 @@ exports[`preferences - closing-preferences given already in a page and then navi +
`; @@ -1661,5 +1678,8 @@ exports[`preferences - closing-preferences given already in a page and then navi +
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-application-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-application-preferences.test.ts.snap index f67337e80c..7cd89769f5 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-application-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-application-preferences.test.ts.snap @@ -199,6 +199,9 @@ exports[`preferences - navigation to application preferences given in some child +
`; @@ -546,13 +549,12 @@ exports[`preferences - navigation to application preferences given in some child class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-editor-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-editor-preferences.test.ts.snap index 4e92ac4f95..9f99faceb3 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-editor-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-editor-preferences.test.ts.snap @@ -344,13 +344,12 @@ exports[`preferences - navigation to editor preferences given in preferences, wh class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -935,5 +936,8 @@ exports[`preferences - navigation to editor preferences given in preferences, wh +
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-extension-specific-preferences.test.tsx.snap b/src/behaviours/preferences/__snapshots__/navigation-to-extension-specific-preferences.test.tsx.snap index d3f42e6d63..ec00c0e498 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-extension-specific-preferences.test.tsx.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-extension-specific-preferences.test.tsx.snap @@ -344,13 +344,12 @@ exports[`preferences - navigation to extension specific preferences given in pre class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -884,13 +885,12 @@ exports[`preferences - navigation to extension specific preferences given in pre class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -1239,5 +1241,8 @@ exports[`preferences - navigation to extension specific preferences given in pre +
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-kubernetes-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-kubernetes-preferences.test.ts.snap index 2e9b7722aa..8ab873fac4 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-kubernetes-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-kubernetes-preferences.test.ts.snap @@ -344,13 +344,12 @@ exports[`preferences - navigation to kubernetes preferences given in preferences class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -836,7 +837,7 @@ exports[`preferences - navigation to kubernetes preferences given in preferences class="flex gaps" >
+
@@ -969,5 +983,8 @@ exports[`preferences - navigation to kubernetes preferences given in preferences
+
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-proxy-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-proxy-preferences.test.ts.snap index 8c6507ef0a..4710bb1957 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-proxy-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-proxy-preferences.test.ts.snap @@ -344,13 +344,12 @@ exports[`preferences - navigation to proxy preferences given in preferences, whe class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -727,5 +728,8 @@ exports[`preferences - navigation to proxy preferences given in preferences, whe +
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-telemetry-preferences.test.tsx.snap b/src/behaviours/preferences/__snapshots__/navigation-to-telemetry-preferences.test.tsx.snap index 80f5b61bb1..68901d7a4d 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-telemetry-preferences.test.tsx.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-telemetry-preferences.test.tsx.snap @@ -185,6 +185,9 @@ exports[`preferences - navigation to telemetry preferences given URL for Sentry +
`; @@ -532,13 +535,12 @@ exports[`preferences - navigation to telemetry preferences given in preferences, class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -1072,13 +1076,12 @@ exports[`preferences - navigation to telemetry preferences given in preferences, class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -1429,6 +1434,9 @@ exports[`preferences - navigation to telemetry preferences given in preferences, +
`; @@ -1568,5 +1576,8 @@ exports[`preferences - navigation to telemetry preferences given no URL for Sent +
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-to-terminal-preferences.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-to-terminal-preferences.test.ts.snap index 5e5934c3bb..729309888e 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-to-terminal-preferences.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-to-terminal-preferences.test.ts.snap @@ -344,13 +344,12 @@ exports[`preferences - navigation to terminal preferences given in preferences, class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; @@ -845,5 +846,8 @@ exports[`preferences - navigation to terminal preferences given in preferences, +
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-using-application-menu.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-using-application-menu.test.ts.snap index 141279f4e4..367ac869ba 100644 --- a/src/behaviours/preferences/__snapshots__/navigation-using-application-menu.test.ts.snap +++ b/src/behaviours/preferences/__snapshots__/navigation-using-application-menu.test.ts.snap @@ -1,6 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`preferences - navigation using application menu renders 1`] = `
`; +exports[`preferences - navigation using application menu renders 1`] = ` +
+
+
+`; exports[`preferences - navigation using application menu when navigating to preferences using application menu renders 1`] = `
@@ -346,13 +352,12 @@ exports[`preferences - navigation using application menu when navigating to pref class="Select__control css-1s2u09g-control" >
- Select... + Stable
+
`; diff --git a/src/behaviours/preferences/__snapshots__/navigation-using-tray.test.ts.snap b/src/behaviours/preferences/__snapshots__/navigation-using-tray.test.ts.snap new file mode 100644 index 0000000000..57b42580d9 --- /dev/null +++ b/src/behaviours/preferences/__snapshots__/navigation-using-tray.test.ts.snap @@ -0,0 +1,542 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`show-about-using-tray renders 1`] = ` + +
+
+
+ +`; + +exports[`show-about-using-tray when navigating using tray renders 1`] = ` + +
+
+ +
+
+
+

+ Application +

+
+
+ Theme + +
+
+ + +
+
+
+ Select... +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+ Extension Install Registry + +
+
+ + +
+
+
+ Select... +
+
+ +
+
+
+ + +
+
+
+

+ This setting is to change the registry URL for installing extensions by name. + If you are unable to access the default registry (https://registry.npmjs.org) you can change it in your + + .npmrc + + file or in the input below. +

+
+ +
+
+
+
+
+
+ Start-up + +
+ +
+
+
+
+ Update Channel + +
+
+ + +
+
+
+ Stable +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+ Locale Timezone + +
+
+ + +
+
+
+ Select... +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+ + + close + + +
+ +
+
+
+
+
+
+
+ +`; diff --git a/src/behaviours/preferences/navigation-to-terminal-preferences.test.ts b/src/behaviours/preferences/navigation-to-terminal-preferences.test.ts index 5d7e1e08fb..73eff39006 100644 --- a/src/behaviours/preferences/navigation-to-terminal-preferences.test.ts +++ b/src/behaviours/preferences/navigation-to-terminal-preferences.test.ts @@ -5,17 +5,12 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import defaultShellInjectable from "../../renderer/components/+preferences/default-shell.injectable"; describe("preferences - navigation to terminal preferences", () => { let applicationBuilder: ApplicationBuilder; beforeEach(() => { applicationBuilder = getApplicationBuilder(); - - applicationBuilder.beforeApplicationStart(({ rendererDi }) => { - rendererDi.override(defaultShellInjectable, () => "some-default-shell"); - }); }); describe("given in preferences, when rendered", () => { diff --git a/src/behaviours/preferences/navigation-using-tray.test.ts b/src/behaviours/preferences/navigation-using-tray.test.ts new file mode 100644 index 0000000000..065cc54f4b --- /dev/null +++ b/src/behaviours/preferences/navigation-using-tray.test.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { RenderResult } from "@testing-library/react"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; + +describe("show-about-using-tray", () => { + let applicationBuilder: ApplicationBuilder; + let rendered: RenderResult; + + beforeEach(async () => { + applicationBuilder = getApplicationBuilder(); + + rendered = await applicationBuilder.render(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show application preferences page yet", () => { + const actual = rendered.queryByTestId("application-preferences-page"); + + expect(actual).toBeNull(); + }); + + describe("when navigating using tray", () => { + beforeEach(async () => { + await applicationBuilder.tray.click("open-preferences"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows application preferences page", () => { + const actual = rendered.getByTestId("application-preferences-page"); + + expect(actual).not.toBeNull(); + }); + }); +}); diff --git a/src/behaviours/welcome/__snapshots__/navigation-using-application-menu.test.ts.snap b/src/behaviours/welcome/__snapshots__/navigation-using-application-menu.test.ts.snap index d59f7d040a..05eee498bf 100644 --- a/src/behaviours/welcome/__snapshots__/navigation-using-application-menu.test.ts.snap +++ b/src/behaviours/welcome/__snapshots__/navigation-using-application-menu.test.ts.snap @@ -1,6 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`welcome - navigation using application menu renders 1`] = `
`; +exports[`welcome - navigation using application menu renders 1`] = ` +
+
+
+`; exports[`welcome - navigation using application menu when navigating to welcome using application menu renders 1`] = `
@@ -87,5 +93,8 @@ exports[`welcome - navigation using application menu when navigating to welcome
+
`; diff --git a/src/behaviours/welcome/navigation-using-application-menu.test.ts b/src/behaviours/welcome/navigation-using-application-menu.test.ts index e84e5b391f..a9f09c783c 100644 --- a/src/behaviours/welcome/navigation-using-application-menu.test.ts +++ b/src/behaviours/welcome/navigation-using-application-menu.test.ts @@ -6,16 +6,13 @@ import type { RenderResult } from "@testing-library/react"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; -import isAutoUpdateEnabledInjectable from "../../main/is-auto-update-enabled.injectable"; describe("welcome - navigation using application menu", () => { let applicationBuilder: ApplicationBuilder; let rendered: RenderResult; beforeEach(async () => { - applicationBuilder = getApplicationBuilder().beforeApplicationStart(({ mainDi }) => { - mainDi.override(isAutoUpdateEnabledInjectable, () => () => false); - }); + applicationBuilder = getApplicationBuilder(); rendered = await applicationBuilder.render(); }); diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 82d7b97638..ca52f6f1d2 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -369,6 +369,8 @@ users: mockFs(mockOpts); + mainDi.override(appVersionInjectable, () => "3.6.0"); + createCluster = mainDi.inject(createClusterInjectionToken); clusterStore = mainDi.inject(clusterStoreInjectable); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 042b6363f7..238fc5bce8 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -21,7 +21,7 @@ jest.mock("electron", () => ({ }, })); -import { UserStore } from "../user-store"; +import type { UserStore } from "../user-store"; import { Console } from "console"; import { SemVer } from "semver"; import electron from "electron"; @@ -49,14 +49,15 @@ describe("user store tests", () => { di.override(writeFileInjectable, () => () => Promise.resolve()); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - di.override(userStoreInjectable, () => UserStore.createInstance()); - di.permitSideEffects(getConfigurationFileModelInjectable); + di.permitSideEffects(appVersionInjectable); + di.permitSideEffects(userStoreInjectable); + + di.unoverride(userStoreInjectable); }); afterEach(() => { - UserStore.resetInstance(); mockFs.restore(); }); @@ -126,6 +127,8 @@ describe("user store tests", () => { }, }); + di.override(appVersionInjectable, () => "10.0.0"); + userStore = di.inject(userStoreInjectable); }); diff --git a/src/common/app-paths/app-path-injection-token.ts b/src/common/app-paths/app-path-injection-token.ts index 3b03e44daf..e29bcdbebf 100644 --- a/src/common/app-paths/app-path-injection-token.ts +++ b/src/common/app-paths/app-path-injection-token.ts @@ -4,12 +4,9 @@ */ import { getInjectionToken } from "@ogre-tools/injectable"; import type { PathName } from "./app-path-names"; -import { createChannel } from "../ipc-channel/create-channel/create-channel"; export type AppPaths = Record; export const appPathsInjectionToken = getInjectionToken({ id: "app-paths-token" }); -export const appPathsIpcChannel = createChannel("app-paths"); - diff --git a/src/common/app-paths/app-paths-channel.injectable.ts b/src/common/app-paths/app-paths-channel.injectable.ts new file mode 100644 index 0000000000..99fc738b41 --- /dev/null +++ b/src/common/app-paths/app-paths-channel.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 type { AppPaths } from "./app-path-injection-token"; +import type { RequestChannel } from "../utils/channel/request-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type AppPathsChannel = RequestChannel; + +const appPathsChannelInjectable = getInjectable({ + id: "app-paths-channel", + + instantiate: (): AppPathsChannel => ({ + id: "app-paths", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default appPathsChannelInjectable; diff --git a/src/common/application-update/application-update-status-channel.injectable.ts b/src/common/application-update/application-update-status-channel.injectable.ts new file mode 100644 index 0000000000..1365fd19af --- /dev/null +++ b/src/common/application-update/application-update-status-channel.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 type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type ApplicationUpdateStatusEventId = + | "checking-for-updates" + | "no-updates-available" + | "download-for-update-started" + | "download-for-update-failed"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ApplicationUpdateStatusChannelMessage = { eventId: ApplicationUpdateStatusEventId; version?: string }; +export type ApplicationUpdateStatusChannel = MessageChannel; + +const applicationUpdateStatusChannelInjectable = getInjectable({ + id: "application-update-status-channel", + + instantiate: (): ApplicationUpdateStatusChannel => ({ + id: "application-update-status-channel", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default applicationUpdateStatusChannelInjectable; diff --git a/src/common/application-update/discovered-update-version/discovered-update-version.injectable.ts b/src/common/application-update/discovered-update-version/discovered-update-version.injectable.ts new file mode 100644 index 0000000000..60557de211 --- /dev/null +++ b/src/common/application-update/discovered-update-version/discovered-update-version.injectable.ts @@ -0,0 +1,28 @@ +/** + * 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 createSyncBoxInjectable from "../../utils/sync-box/create-sync-box.injectable"; +import type { UpdateChannel } from "../update-channels"; +import { syncBoxInjectionToken } from "../../utils/sync-box/sync-box-injection-token"; + +const discoveredUpdateVersionInjectable = getInjectable({ + id: "discovered-update-version", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox< + | { version: string; updateChannel: UpdateChannel } + | null + >( + "discovered-update-version", + null, + ); + }, + + injectionToken: syncBoxInjectionToken, +}); + +export default discoveredUpdateVersionInjectable; diff --git a/src/common/application-update/progress-of-update-download/progress-of-update-download.injectable.ts b/src/common/application-update/progress-of-update-download/progress-of-update-download.injectable.ts new file mode 100644 index 0000000000..26ecd1d618 --- /dev/null +++ b/src/common/application-update/progress-of-update-download/progress-of-update-download.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 createSyncBoxInjectable from "../../utils/sync-box/create-sync-box.injectable"; +import { syncBoxInjectionToken } from "../../utils/sync-box/sync-box-injection-token"; + +export interface ProgressOfDownload { + percentage: number; +} + +const progressOfUpdateDownloadInjectable = getInjectable({ + id: "progress-of-update-download-state", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("progress-of-update-download", { percentage: 0 }); + }, + + injectionToken: syncBoxInjectionToken, +}); + +export default progressOfUpdateDownloadInjectable; diff --git a/src/common/application-update/selected-update-channel/default-update-channel.injectable.ts b/src/common/application-update/selected-update-channel/default-update-channel.injectable.ts new file mode 100644 index 0000000000..3d9101b672 --- /dev/null +++ b/src/common/application-update/selected-update-channel/default-update-channel.injectable.ts @@ -0,0 +1,27 @@ +/** + * 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 { SemVer } from "semver"; +import appVersionInjectable from "../../get-configuration-file-model/app-version/app-version.injectable"; +import type { UpdateChannelId } from "../update-channels"; +import { updateChannels } from "../update-channels"; + +const defaultUpdateChannelInjectable = getInjectable({ + id: "default-update-channel", + + instantiate: (di) => { + const appVersion = di.inject(appVersionInjectable); + + const currentReleaseChannel = new SemVer(appVersion).prerelease[0]?.toString() as UpdateChannelId; + + if (currentReleaseChannel && updateChannels[currentReleaseChannel]) { + return updateChannels[currentReleaseChannel]; + } + + return updateChannels.latest; + }, +}); + +export default defaultUpdateChannelInjectable; diff --git a/src/common/application-update/selected-update-channel/selected-update-channel.injectable.ts b/src/common/application-update/selected-update-channel/selected-update-channel.injectable.ts new file mode 100644 index 0000000000..ceb47aee5e --- /dev/null +++ b/src/common/application-update/selected-update-channel/selected-update-channel.injectable.ts @@ -0,0 +1,39 @@ +/** + * 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 type { IComputedValue } from "mobx"; +import { action, computed, observable } from "mobx"; +import type { UpdateChannel, UpdateChannelId } from "../update-channels"; +import { updateChannels } from "../update-channels"; +import defaultUpdateChannelInjectable from "./default-update-channel.injectable"; + +export interface SelectedUpdateChannel { + value: IComputedValue; + setValue: (channelId?: UpdateChannelId) => void; +} + +const selectedUpdateChannelInjectable = getInjectable({ + id: "selected-update-channel", + + instantiate: (di): SelectedUpdateChannel => { + const defaultUpdateChannel = di.inject(defaultUpdateChannelInjectable); + const state = observable.box(defaultUpdateChannel); + + return { + value: computed(() => state.get()), + + setValue: action((channelId) => { + const targetUpdateChannel = + channelId && updateChannels[channelId] + ? updateChannels[channelId] + : defaultUpdateChannel; + + state.set(targetUpdateChannel); + }), + }; + }, +}); + +export default selectedUpdateChannelInjectable; diff --git a/src/common/application-update/update-channels.ts b/src/common/application-update/update-channels.ts new file mode 100644 index 0000000000..c5f7b4b8c1 --- /dev/null +++ b/src/common/application-update/update-channels.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export type UpdateChannelId = "alpha" | "beta" | "latest"; + +const latestChannel: UpdateChannel = { + id: "latest", + label: "Stable", + moreStableUpdateChannel: null, +}; + +const betaChannel: UpdateChannel = { + id: "beta", + label: "Beta", + moreStableUpdateChannel: latestChannel, +}; + +const alphaChannel: UpdateChannel = { + id: "alpha", + label: "Alpha", + moreStableUpdateChannel: betaChannel, +}; + +export const updateChannels: Record = { + latest: latestChannel, + beta: betaChannel, + alpha: alphaChannel, +}; + +export interface UpdateChannel { + readonly id: UpdateChannelId; + readonly label: string; + readonly moreStableUpdateChannel: UpdateChannel | null; +} diff --git a/src/common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable.ts b/src/common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable.ts new file mode 100644 index 0000000000..e1701d7952 --- /dev/null +++ b/src/common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 createSyncBoxInjectable from "../../utils/sync-box/create-sync-box.injectable"; +import { syncBoxInjectionToken } from "../../utils/sync-box/sync-box-injection-token"; + +const updateIsBeingDownloadedInjectable = getInjectable({ + id: "update-is-being-downloaded", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("update-is-being-downloaded", false); + }, + + injectionToken: syncBoxInjectionToken, +}); + +export default updateIsBeingDownloadedInjectable; diff --git a/src/common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable.ts b/src/common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable.ts new file mode 100644 index 0000000000..21f1c14bec --- /dev/null +++ b/src/common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 createSyncBoxInjectable from "../../utils/sync-box/create-sync-box.injectable"; +import { syncBoxInjectionToken } from "../../utils/sync-box/sync-box-injection-token"; + +const updatesAreBeingDiscoveredInjectable = getInjectable({ + id: "updates-are-being-discovered", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("updates-are-being-discovered", false); + }, + + injectionToken: syncBoxInjectionToken, +}); + +export default updatesAreBeingDiscoveredInjectable; diff --git a/src/common/ask-boolean/ask-boolean-answer-channel.injectable.ts b/src/common/ask-boolean/ask-boolean-answer-channel.injectable.ts new file mode 100644 index 0000000000..9901c04e30 --- /dev/null +++ b/src/common/ask-boolean/ask-boolean-answer-channel.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type AskBooleanAnswerChannel = MessageChannel<{ id: string; value: boolean }>; + +const askBooleanAnswerChannelInjectable = getInjectable({ + id: "ask-boolean-answer-channel", + + instantiate: (): AskBooleanAnswerChannel => ({ + id: "ask-boolean-answer", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default askBooleanAnswerChannelInjectable; diff --git a/src/common/ask-boolean/ask-boolean-question-channel.injectable.ts b/src/common/ask-boolean/ask-boolean-question-channel.injectable.ts new file mode 100644 index 0000000000..664337158f --- /dev/null +++ b/src/common/ask-boolean/ask-boolean-question-channel.injectable.ts @@ -0,0 +1,23 @@ +/** + * 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 type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type AskBooleanQuestionParameters = { id: string; title: string; question: string }; +export type AskBooleanQuestionChannel = MessageChannel; + +const askBooleanQuestionChannelInjectable = getInjectable({ + id: "ask-boolean-question-channel", + + instantiate: (): AskBooleanQuestionChannel => ({ + id: "ask-boolean-question", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default askBooleanQuestionChannelInjectable; diff --git a/src/common/front-end-routing/app-navigation-channel.injectable.ts b/src/common/front-end-routing/app-navigation-channel.injectable.ts new file mode 100644 index 0000000000..869fbfdecd --- /dev/null +++ b/src/common/front-end-routing/app-navigation-channel.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type AppNavigationChannel = MessageChannel; + +const appNavigationChannelInjectable = getInjectable({ + id: "app-navigation-channel", + + instantiate: (): AppNavigationChannel => ({ + id: IpcRendererNavigationEvents.NAVIGATE_IN_APP, + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default appNavigationChannelInjectable; diff --git a/src/common/front-end-routing/cluster-frame-navigation-channel.injectable.ts b/src/common/front-end-routing/cluster-frame-navigation-channel.injectable.ts new file mode 100644 index 0000000000..596bd6d351 --- /dev/null +++ b/src/common/front-end-routing/cluster-frame-navigation-channel.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; +import type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type ClusterFrameNavigationChannel = MessageChannel; + +const clusterFrameNavigationChannelInjectable = getInjectable({ + id: "cluster-frame-navigation-channel", + + instantiate: (): ClusterFrameNavigationChannel => ({ + id: IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER, + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default clusterFrameNavigationChannelInjectable; diff --git a/src/common/front-end-routing/navigation-ipc-channel.ts b/src/common/front-end-routing/navigation-ipc-channel.ts deleted file mode 100644 index 6094664f81..0000000000 --- a/src/common/front-end-routing/navigation-ipc-channel.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { createChannel } from "../ipc-channel/create-channel/create-channel"; -import { IpcRendererNavigationEvents } from "../../renderer/navigation/events"; - -export const appNavigationIpcChannel = createChannel(IpcRendererNavigationEvents.NAVIGATE_IN_APP); -export const clusterFrameNavigationIpcChannel = createChannel(IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER); diff --git a/src/common/get-configuration-file-model/app-version/app-version.injectable.ts b/src/common/get-configuration-file-model/app-version/app-version.injectable.ts index 0fe3142332..5fdfd30eba 100644 --- a/src/common/get-configuration-file-model/app-version/app-version.injectable.ts +++ b/src/common/get-configuration-file-model/app-version/app-version.injectable.ts @@ -3,12 +3,11 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import packageInfo from "../../../../package.json"; +import packageJsonInjectable from "../../vars/package-json.injectable"; const appVersionInjectable = getInjectable({ id: "app-version", - instantiate: () => packageInfo.version, - causesSideEffects: true, + instantiate: (di) => di.inject(packageJsonInjectable).version, }); export default appVersionInjectable; diff --git a/src/common/ipc-channel/create-channel/create-channel.ts b/src/common/ipc-channel/create-channel/create-channel.ts deleted file mode 100644 index 6b9fe1b0d9..0000000000 --- a/src/common/ipc-channel/create-channel/create-channel.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { Channel } from "../channel"; - -export const createChannel = (name: string): Channel => ({ - name, - _template: null as never, -}); diff --git a/src/common/ipc/index.ts b/src/common/ipc/index.ts index 60ae46438e..bb60ce4f6c 100644 --- a/src/common/ipc/index.ts +++ b/src/common/ipc/index.ts @@ -5,5 +5,4 @@ export * from "./ipc"; export * from "./invalid-kubeconfig"; -export * from "./update-available"; export * from "./type-enforced-ipc"; diff --git a/src/common/ipc/update-available.ts b/src/common/ipc/update-available.ts deleted file mode 100644 index ed5b18b13d..0000000000 --- a/src/common/ipc/update-available.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { UpdateInfo } from "electron-updater"; - -export const UpdateAvailableChannel = "update-available"; -export const AutoUpdateChecking = "auto-update:checking"; -export const AutoUpdateNoUpdateAvailable = "auto-update:no-update"; -export const AutoUpdateLogPrefix = "[UPDATE-CHECKER]"; - -export type UpdateAvailableFromMain = [backChannel: string, updateInfo: UpdateInfo]; - -export function areArgsUpdateAvailableFromMain(args: unknown[]): args is UpdateAvailableFromMain { - if (args.length !== 2) { - return false; - } - - if (typeof args[0] !== "string") { - return false; - } - - if (typeof args[1] !== "object" || args[1] === null) { - // TODO: improve this checking - return false; - } - - return true; -} - -export type BackchannelArg = { - doUpdate: false; -} | { - doUpdate: true; - now: boolean; -}; - -export type UpdateAvailableToBackchannel = [updateDecision: BackchannelArg]; - -export function areArgsUpdateAvailableToBackchannel(args: unknown[]): args is UpdateAvailableToBackchannel { - if (args.length !== 1) { - return false; - } - - if (typeof args[0] !== "object" || args[0] === null) { - // TODO: improve this checking - return false; - } - - return true; -} diff --git a/src/common/root-frame-rendered-channel/root-frame-rendered-channel.injectable.ts b/src/common/root-frame-rendered-channel/root-frame-rendered-channel.injectable.ts new file mode 100644 index 0000000000..a7787c6cc4 --- /dev/null +++ b/src/common/root-frame-rendered-channel/root-frame-rendered-channel.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 type { MessageChannel } from "../utils/channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../utils/channel/message-channel-injection-token"; + +export type RootFrameRenderedChannel = MessageChannel; + +const rootFrameRenderedChannelInjectable = getInjectable({ + id: "root-frame-rendered-channel", + + instantiate: (): RootFrameRenderedChannel => ({ + id: "root-frame-rendered", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default rootFrameRenderedChannelInjectable; diff --git a/src/common/user-store/preferences-helpers.ts b/src/common/user-store/preferences-helpers.ts index 7bb3aee41b..ed3fb7c249 100644 --- a/src/common/user-store/preferences-helpers.ts +++ b/src/common/user-store/preferences-helpers.ts @@ -6,14 +6,11 @@ import moment from "moment-timezone"; import path from "path"; import os from "os"; -import { getAppVersion } from "../utils"; import type { editor } from "monaco-editor"; import merge from "lodash/merge"; -import { SemVer } from "semver"; import { defaultThemeId, defaultEditorFontFamily, defaultFontSize, defaultTerminalFontFamily } from "../vars"; import type { ObservableMap } from "mobx"; import { observable } from "mobx"; -import { readonly } from "../utils/readonly"; export interface KubeconfigSyncEntry extends KubeconfigSyncValue { filePath: string; @@ -296,38 +293,6 @@ const terminalConfig: PreferenceDescription = { }, }; -export interface UpdateChannelInfo { - label: string; -} - -export const updateChannels = readonly(new Map([ - ["latest", { - label: "Stable", - }], - ["beta", { - label: "Beta", - }], - ["alpha", { - label: "Alpha", - }], -])); -export const defaultUpdateChannel = new SemVer(getAppVersion()).prerelease[0]?.toString() || "latest"; - -const updateChannel: PreferenceDescription = { - fromStore(val) { - return !val || !updateChannels.has(val) - ? defaultUpdateChannel - : val; - }, - toStore(val) { - if (!updateChannels.has(val) || val === defaultUpdateChannel) { - return undefined; - } - - return val; - }, -}; - export type ExtensionRegistryLocation = "default" | "npmrc" | "custom"; export type ExtensionRegistry = { @@ -365,7 +330,7 @@ export type UserStoreFlatModel = { export type UserPreferencesModel = { [field in keyof typeof DESCRIPTORS]: PreferencesModelType; -}; +} & { updateChannel: string }; export const DESCRIPTORS = { httpsProxy, @@ -385,6 +350,5 @@ export const DESCRIPTORS = { editorConfiguration, terminalCopyOnSelect, terminalConfig, - updateChannel, extensionRegistryUrl, }; diff --git a/src/common/user-store/user-store.injectable.ts b/src/common/user-store/user-store.injectable.ts index cd44cc60e5..3b4aba0b56 100644 --- a/src/common/user-store/user-store.injectable.ts +++ b/src/common/user-store/user-store.injectable.ts @@ -6,6 +6,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import { ipcMain } from "electron"; import userStoreFileNameMigrationInjectable from "./file-name-migration.injectable"; import { UserStore } from "./user-store"; +import selectedUpdateChannelInjectable from "../application-update/selected-update-channel/selected-update-channel.injectable"; const userStoreInjectable = getInjectable({ id: "user-store", @@ -17,7 +18,9 @@ const userStoreInjectable = getInjectable({ di.inject(userStoreFileNameMigrationInjectable); } - return UserStore.createInstance(); + return UserStore.createInstance({ + selectedUpdateChannel: di.inject(selectedUpdateChannelInjectable), + }); }, causesSideEffects: true, diff --git a/src/common/user-store/user-store.ts b/src/common/user-store/user-store.ts index 3cfd551fd7..b806732735 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -4,7 +4,7 @@ */ import { app } from "electron"; -import semver, { SemVer } from "semver"; +import semver from "semver"; import { action, computed, observable, reaction, makeObservable, isObservableArray, isObservableSet, isObservableMap } from "mobx"; import { BaseStore } from "../base-store"; import migrations from "../../migrations/user-store"; @@ -15,15 +15,22 @@ import { getOrInsertSet, toggle, toJS, object } from "../../renderer/utils"; import { DESCRIPTORS } from "./preferences-helpers"; import type { UserPreferencesModel, StoreType } from "./preferences-helpers"; import logger from "../../main/logger"; +import type { SelectedUpdateChannel } from "../application-update/selected-update-channel/selected-update-channel.injectable"; +import type { UpdateChannelId } from "../application-update/update-channels"; export interface UserStoreModel { lastSeenAppVersion: string; preferences: UserPreferencesModel; } +interface Dependencies { + selectedUpdateChannel: SelectedUpdateChannel; +} + export class UserStore extends BaseStore /* implements UserStoreFlatModel (when strict null is enabled) */ { readonly displayName = "UserStore"; - constructor() { + + constructor(private readonly dependencies: Dependencies) { super({ configName: "lens-user-store", migrations, @@ -63,7 +70,6 @@ export class UserStore extends BaseStore /* implements UserStore @observable kubectlBinariesPath!: StoreType; @observable terminalCopyOnSelect!: StoreType; @observable terminalConfig!: StoreType; - @observable updateChannel!: StoreType; @observable extensionRegistryUrl!: StoreType; /** @@ -100,10 +106,6 @@ export class UserStore extends BaseStore /* implements UserStore return this.shell || process.env.SHELL || process.env.PTYSHELL; } - @computed get isAllowedToDowngrade() { - return new SemVer(getAppVersion()).prerelease[0] !== this.updateChannel; - } - startMainReactions() { // open at system start-up reaction(() => this.openAtLogin, openAtLogin => { @@ -175,6 +177,11 @@ export class UserStore extends BaseStore /* implements UserStore this[key] = newVal; } } + + // TODO: Switch to action-based saving instead saving stores by reaction + if (preferences?.updateChannel) { + this.dependencies.selectedUpdateChannel.setValue(preferences?.updateChannel as UpdateChannelId); + } } toJSON(): UserStoreModel { @@ -185,7 +192,12 @@ export class UserStore extends BaseStore /* implements UserStore return toJS({ lastSeenAppVersion: this.lastSeenAppVersion, - preferences, + + preferences: { + ...preferences, + + updateChannel: this.dependencies.selectedUpdateChannel.value.get().id, + }, }); } } diff --git a/src/common/utils/channel/channel-injection-token.ts b/src/common/utils/channel/channel-injection-token.ts new file mode 100644 index 0000000000..6006290f89 --- /dev/null +++ b/src/common/utils/channel/channel-injection-token.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +export interface Channel { + id: string; + _messageTemplate?: MessageTemplate; + _returnTemplate?: ReturnTemplate; +} + diff --git a/src/common/utils/channel/channel.test.ts b/src/common/utils/channel/channel.test.ts new file mode 100644 index 0000000000..f2748104d7 --- /dev/null +++ b/src/common/utils/channel/channel.test.ts @@ -0,0 +1,273 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { LensWindow } from "../../../main/start-main-application/lens-window/application-window/lens-window-injection-token"; +import { lensWindowInjectionToken } from "../../../main/start-main-application/lens-window/application-window/lens-window-injection-token"; +import type { MessageToChannel } from "./message-to-channel-injection-token"; +import { messageToChannelInjectionToken } from "./message-to-channel-injection-token"; +import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import createLensWindowInjectable from "../../../main/start-main-application/lens-window/application-window/create-lens-window.injectable"; +import closeAllWindowsInjectable from "../../../main/start-main-application/lens-window/hide-all-windows/close-all-windows.injectable"; +import { messageChannelListenerInjectionToken } from "./message-channel-listener-injection-token"; +import type { MessageChannel } from "./message-channel-injection-token"; +import type { RequestFromChannel } from "./request-from-channel-injection-token"; +import { requestFromChannelInjectionToken } from "./request-from-channel-injection-token"; +import type { RequestChannel } from "./request-channel-injection-token"; +import { requestChannelListenerInjectionToken } from "./request-channel-listener-injection-token"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; + +type TestMessageChannel = MessageChannel; +type TestRequestChannel = RequestChannel; + +describe("channel", () => { + describe("messaging from main to renderer, given listener for channel in a window and application has started", () => { + let testMessageChannel: TestMessageChannel; + let messageListenerInWindowMock: jest.Mock; + let mainDi: DiContainer; + let messageToChannel: MessageToChannel; + + beforeEach(async () => { + const applicationBuilder = getApplicationBuilder(); + + mainDi = applicationBuilder.dis.mainDi; + const rendererDi = applicationBuilder.dis.rendererDi; + + messageListenerInWindowMock = jest.fn(); + + const testChannelListenerInTestWindowInjectable = getInjectable({ + id: "test-channel-listener-in-test-window", + + instantiate: (di) => ({ + channel: di.inject(testMessageChannelInjectable), + + handler: messageListenerInWindowMock, + }), + + injectionToken: messageChannelListenerInjectionToken, + }); + + rendererDi.register(testChannelListenerInTestWindowInjectable); + + // Notice how test channel has presence in both DIs, being from common + mainDi.register(testMessageChannelInjectable); + rendererDi.register(testMessageChannelInjectable); + + testMessageChannel = mainDi.inject(testMessageChannelInjectable); + + messageToChannel = mainDi.inject( + messageToChannelInjectionToken, + ); + + await applicationBuilder.render(); + + const closeAllWindows = mainDi.inject(closeAllWindowsInjectable); + + closeAllWindows(); + }); + + describe("given window is shown", () => { + let someWindowFake: LensWindow; + + beforeEach(async () => { + someWindowFake = createTestWindow(mainDi, "some-window"); + + await someWindowFake.show(); + }); + + it("when sending message, triggers listener in window", () => { + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInWindowMock).toHaveBeenCalledWith("some-message"); + }); + + it("given window is hidden, when sending message, does not trigger listener in window", () => { + someWindowFake.close(); + + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInWindowMock).not.toHaveBeenCalled(); + }); + }); + + it("given multiple shown 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(); + + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInWindowMock.mock.calls).toEqual([ + ["some-message"], + ["some-message"], + ]); + }); + }); + + describe("messaging from renderer to main, given listener for channel in a main and application has started", () => { + let testMessageChannel: TestMessageChannel; + let messageListenerInMainMock: jest.Mock; + let rendererDi: DiContainer; + let mainDi: DiContainer; + let messageToChannel: MessageToChannel; + + beforeEach(async () => { + const applicationBuilder = getApplicationBuilder(); + + mainDi = applicationBuilder.dis.mainDi; + rendererDi = applicationBuilder.dis.rendererDi; + + messageListenerInMainMock = jest.fn(); + + const testChannelListenerInMainInjectable = getInjectable({ + id: "test-channel-listener-in-main", + + instantiate: (di) => ({ + channel: di.inject(testMessageChannelInjectable), + + handler: messageListenerInMainMock, + }), + + injectionToken: messageChannelListenerInjectionToken, + }); + + mainDi.register(testChannelListenerInMainInjectable); + + // Notice how test channel has presence in both DIs, being from common + mainDi.register(testMessageChannelInjectable); + rendererDi.register(testMessageChannelInjectable); + + testMessageChannel = rendererDi.inject(testMessageChannelInjectable); + + messageToChannel = rendererDi.inject( + messageToChannelInjectionToken, + ); + + await applicationBuilder.render(); + }); + + it("when sending message, triggers listener in main", () => { + messageToChannel(testMessageChannel, "some-message"); + + expect(messageListenerInMainMock).toHaveBeenCalledWith("some-message"); + }); + }); + + describe("requesting from main in renderer, given listener for channel in a main and application has started", () => { + let testRequestChannel: TestRequestChannel; + let requestListenerInMainMock: AsyncFnMock<(arg: string) => string>; + let rendererDi: DiContainer; + let mainDi: DiContainer; + let requestFromChannel: RequestFromChannel; + + beforeEach(async () => { + const applicationBuilder = getApplicationBuilder(); + + mainDi = applicationBuilder.dis.mainDi; + rendererDi = applicationBuilder.dis.rendererDi; + + requestListenerInMainMock = asyncFn(); + + const testChannelListenerInMainInjectable = getInjectable({ + id: "test-channel-listener-in-main", + + instantiate: (di) => ({ + channel: di.inject(testRequestChannelInjectable), + + handler: requestListenerInMainMock, + }), + + injectionToken: requestChannelListenerInjectionToken, + }); + + mainDi.register(testChannelListenerInMainInjectable); + + // Notice how test channel has presence in both DIs, being from common + mainDi.register(testRequestChannelInjectable); + rendererDi.register(testRequestChannelInjectable); + + testRequestChannel = rendererDi.inject(testRequestChannelInjectable); + + requestFromChannel = rendererDi.inject( + requestFromChannelInjectionToken, + ); + + await applicationBuilder.render(); + }); + + describe("when requesting from channel", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = requestFromChannel(testRequestChannel, "some-request"); + }); + + it("triggers listener in main", () => { + expect(requestListenerInMainMock).toHaveBeenCalledWith("some-request"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when main resolves with response, resolves with response", async () => { + await requestListenerInMainMock.resolve("some-response"); + + const actual = await actualPromise; + + expect(actual).toBe("some-response"); + }); + }); + }); +}); + +const testMessageChannelInjectable = getInjectable({ + id: "some-message-test-channel", + + instantiate: (): TestMessageChannel => ({ + id: "some-message-channel-id", + }), +}); + +const testRequestChannelInjectable = getInjectable({ + id: "some-request-test-channel", + + instantiate: (): TestRequestChannel => ({ + id: "some-request-channel-id", + }), +}); + +const createTestWindow = (di: DiContainer, id: string) => { + const testWindowInjectable = getInjectable({ + id, + + instantiate: (di) => { + const createLensWindow = di.inject(createLensWindowInjectable); + + return createLensWindow({ + id, + title: "Some test window", + defaultHeight: 42, + defaultWidth: 42, + getContentSource: () => ({ url: "some-content-url" }), + resizable: true, + windowFrameUtilitiesAreShown: false, + centered: false, + }); + }, + + injectionToken: lensWindowInjectionToken, + }); + + di.register(testWindowInjectable); + + return di.inject(testWindowInjectable); +}; diff --git a/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts b/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts new file mode 100644 index 0000000000..fa6983e130 --- /dev/null +++ b/src/common/utils/channel/enlist-message-channel-listener-injection-token.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { MessageChannel } from "./message-channel-injection-token"; +import type { MessageChannelListener } from "./message-channel-listener-injection-token"; + +export type EnlistMessageChannelListener = < + TChannel extends MessageChannel, +>(listener: MessageChannelListener) => () => void; + +export const enlistMessageChannelListenerInjectionToken = + getInjectionToken({ + id: "enlist-message-channel-listener", + }); diff --git a/src/common/utils/channel/enlist-request-channel-listener-injection-token.ts b/src/common/utils/channel/enlist-request-channel-listener-injection-token.ts new file mode 100644 index 0000000000..f87082c466 --- /dev/null +++ b/src/common/utils/channel/enlist-request-channel-listener-injection-token.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { RequestChannel } from "./request-channel-injection-token"; +import type { RequestChannelListener } from "./request-channel-listener-injection-token"; + +export type EnlistRequestChannelListener = < + TChannel extends RequestChannel, +>(listener: RequestChannelListener) => () => void; + +export const enlistRequestChannelListenerInjectionToken = + getInjectionToken({ + id: "enlist-request-channel-listener", + }); diff --git a/src/common/utils/channel/listening-of-channels.injectable.ts b/src/common/utils/channel/listening-of-channels.injectable.ts new file mode 100644 index 0000000000..30fee42fb9 --- /dev/null +++ b/src/common/utils/channel/listening-of-channels.injectable.ts @@ -0,0 +1,32 @@ +/** + * 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 { getStartableStoppable } from "../get-startable-stoppable"; +import { disposer } from "../index"; +import { messageChannelListenerInjectionToken } from "./message-channel-listener-injection-token"; +import { requestChannelListenerInjectionToken } from "./request-channel-listener-injection-token"; +import { enlistMessageChannelListenerInjectionToken } from "./enlist-message-channel-listener-injection-token"; +import { enlistRequestChannelListenerInjectionToken } from "./enlist-request-channel-listener-injection-token"; + +const listeningOfChannelsInjectable = getInjectable({ + id: "listening-of-channels", + + instantiate: (di) => { + const enlistMessageChannelListener = di.inject(enlistMessageChannelListenerInjectionToken); + const enlistRequestChannelListener = di.inject(enlistRequestChannelListenerInjectionToken); + const messageChannelListeners = di.injectMany(messageChannelListenerInjectionToken); + const requestChannelListeners = di.injectMany(requestChannelListenerInjectionToken); + + return getStartableStoppable("listening-of-channels", () => { + const messageChannelDisposers = messageChannelListeners.map(enlistMessageChannelListener); + const requestChannelDisposers = requestChannelListeners.map(enlistRequestChannelListener); + + return disposer(...messageChannelDisposers, ...requestChannelDisposers); + }); + }, +}); + + +export default listeningOfChannelsInjectable; diff --git a/src/common/utils/channel/message-channel-injection-token.ts b/src/common/utils/channel/message-channel-injection-token.ts new file mode 100644 index 0000000000..3141acedf3 --- /dev/null +++ b/src/common/utils/channel/message-channel-injection-token.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { JsonValue } from "type-fest"; + +export interface MessageChannel { + id: string; + _messageSignature?: Message; +} + +export const messageChannelInjectionToken = getInjectionToken>({ + id: "message-channel", +}); diff --git a/src/common/utils/channel/message-channel-listener-injection-token.ts b/src/common/utils/channel/message-channel-listener-injection-token.ts new file mode 100644 index 0000000000..8879e19013 --- /dev/null +++ b/src/common/utils/channel/message-channel-listener-injection-token.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { SetRequired } from "type-fest"; +import type { MessageChannel } from "./message-channel-injection-token"; + +export interface MessageChannelListener> { + channel: TChannel; + handler: (value: SetRequired["_messageSignature"]) => void; +} + +export const messageChannelListenerInjectionToken = getInjectionToken>>( + { + id: "message-channel-listener", + }, +); diff --git a/src/common/utils/channel/message-to-channel-injection-token.ts b/src/common/utils/channel/message-to-channel-injection-token.ts new file mode 100644 index 0000000000..8c5f03b9ee --- /dev/null +++ b/src/common/utils/channel/message-to-channel-injection-token.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { SetRequired } from "type-fest"; +import type { MessageChannel } from "./message-channel-injection-token"; + +export interface MessageToChannel { + , TMessage extends void>( + channel: TChannel, + ): void; + + >( + channel: TChannel, + message: SetRequired["_messageSignature"], + ): void; +} + +export const messageToChannelInjectionToken = + getInjectionToken({ + id: "message-to-message-channel", + }); diff --git a/src/common/utils/channel/request-channel-injection-token.ts b/src/common/utils/channel/request-channel-injection-token.ts new file mode 100644 index 0000000000..67044db878 --- /dev/null +++ b/src/common/utils/channel/request-channel-injection-token.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { JsonValue } from "type-fest"; + +export interface RequestChannel< + Request extends JsonValue | void = void, + Response extends JsonValue | void = void, +> { + id: string; + _requestSignature?: Request; + _responseSignature?: Response; +} + +export const requestChannelInjectionToken = getInjectionToken>({ + id: "request-channel", +}); diff --git a/src/common/utils/channel/request-channel-listener-injection-token.ts b/src/common/utils/channel/request-channel-listener-injection-token.ts new file mode 100644 index 0000000000..690b96d9dc --- /dev/null +++ b/src/common/utils/channel/request-channel-listener-injection-token.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { SetRequired } from "type-fest"; +import type { RequestChannel } from "./request-channel-injection-token"; + +export interface RequestChannelListener> { + channel: TChannel; + + handler: ( + request: SetRequired["_requestSignature"] + ) => + | SetRequired["_responseSignature"] + | Promise< + SetRequired["_responseSignature"] + >; +} + +export const requestChannelListenerInjectionToken = getInjectionToken>>( + { + id: "request-channel-listener", + }, +); diff --git a/src/common/utils/channel/request-from-channel-injection-token.ts b/src/common/utils/channel/request-from-channel-injection-token.ts new file mode 100644 index 0000000000..5f4492543f --- /dev/null +++ b/src/common/utils/channel/request-from-channel-injection-token.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { SetRequired } from "type-fest"; +import type { RequestChannel } from "./request-channel-injection-token"; + +export type RequestFromChannel = < + TChannel extends RequestChannel, +>( + channel: TChannel, + ...request: TChannel["_requestSignature"] extends void + ? [] + : [TChannel["_requestSignature"]] +) => Promise["_responseSignature"]>; + +export const requestFromChannelInjectionToken = + getInjectionToken({ + id: "request-from-request-channel", + }); diff --git a/src/common/utils/get-random-id.injectable.ts b/src/common/utils/get-random-id.injectable.ts new file mode 100644 index 0000000000..3b96c50633 --- /dev/null +++ b/src/common/utils/get-random-id.injectable.ts @@ -0,0 +1,14 @@ +/** + * 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 { v4 as getRandomId } from "uuid"; + +const getRandomIdInjectable = getInjectable({ + id: "get-random-id", + instantiate: () => getRandomId, + causesSideEffects: true, +}); + +export default getRandomIdInjectable; diff --git a/src/common/utils/is-promise/is-promise.test.ts b/src/common/utils/is-promise/is-promise.test.ts new file mode 100644 index 0000000000..565f272ed6 --- /dev/null +++ b/src/common/utils/is-promise/is-promise.test.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { isPromise } from "./is-promise"; + +describe("isPromise", () => { + it("given promise, returns true", () => { + const actual = isPromise(new Promise(() => {})); + + expect(actual).toBe(true); + }); + + it("given non-promise, returns false", () => { + const actual = isPromise({}); + + expect(actual).toBe(false); + }); + + it("given thenable, returns false", () => { + const actual = isPromise({ then: () => {} }); + + expect(actual).toBe(false); + }); + + it("given nothing, returns false", () => { + const actual = isPromise(undefined); + + expect(actual).toBe(false); + }); +}); diff --git a/src/common/ipc-channel/channel.ts b/src/common/utils/is-promise/is-promise.ts similarity index 56% rename from src/common/ipc-channel/channel.ts rename to src/common/utils/is-promise/is-promise.ts index 2153134fff..6261f569cd 100644 --- a/src/common/ipc-channel/channel.ts +++ b/src/common/utils/is-promise/is-promise.ts @@ -2,7 +2,6 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -export interface Channel { - name: string; - _template: TInstance; +export function isPromise(reference: any): reference is Promise { + return reference?.constructor === Promise; } diff --git a/src/common/utils/sync-box/create-sync-box.injectable.ts b/src/common/utils/sync-box/create-sync-box.injectable.ts new file mode 100644 index 0000000000..2cf3de6a69 --- /dev/null +++ b/src/common/utils/sync-box/create-sync-box.injectable.ts @@ -0,0 +1,41 @@ +/** + * 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 { computed } from "mobx"; +import syncBoxChannelInjectable from "./sync-box-channel.injectable"; +import { messageToChannelInjectionToken } from "../channel/message-to-channel-injection-token"; +import syncBoxStateInjectable from "./sync-box-state.injectable"; +import type { SyncBox } from "./sync-box-injection-token"; + +const createSyncBoxInjectable = getInjectable({ + id: "create-sync-box", + + instantiate: (di) => { + const syncBoxChannel = di.inject(syncBoxChannelInjectable); + const messageToChannel = di.inject(messageToChannelInjectionToken); + const getSyncBoxState = (id: string) => di.inject(syncBoxStateInjectable, id); + + return (id: string, initialValue: TData): SyncBox => { + const state = getSyncBoxState(id); + + state.set(initialValue); + + return { + id, + + value: computed(() => state.get()), + + set: (value) => { + state.set(value); + + messageToChannel(syncBoxChannel, { id, value }); + }, + }; + }; + }, +}); + +export default createSyncBoxInjectable; + diff --git a/src/common/utils/sync-box/sync-box-channel-listener.injectable.ts b/src/common/utils/sync-box/sync-box-channel-listener.injectable.ts new file mode 100644 index 0000000000..b603c85997 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-channel-listener.injectable.ts @@ -0,0 +1,35 @@ +/** + * 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 type { SyncBoxChannel } from "./sync-box-channel.injectable"; +import syncBoxChannelInjectable from "./sync-box-channel.injectable"; +import syncBoxStateInjectable from "./sync-box-state.injectable"; +import type { MessageChannelListener } from "../channel/message-channel-listener-injection-token"; +import { messageChannelListenerInjectionToken } from "../channel/message-channel-listener-injection-token"; + +const syncBoxChannelListenerInjectable = getInjectable({ + id: "sync-box-channel-listener", + + instantiate: (di): MessageChannelListener => { + const getSyncBoxState = (id: string) => di.inject(syncBoxStateInjectable, id); + const channel = di.inject(syncBoxChannelInjectable); + + return { + channel, + + handler: ({ id, value }) => { + const target = getSyncBoxState(id); + + if (target) { + target.set(value); + } + }, + }; + }, + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default syncBoxChannelListenerInjectable; diff --git a/src/common/utils/sync-box/sync-box-channel.injectable.ts b/src/common/utils/sync-box/sync-box-channel.injectable.ts new file mode 100644 index 0000000000..9389a99867 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-channel.injectable.ts @@ -0,0 +1,21 @@ +/** + * 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 type { MessageChannel } from "../channel/message-channel-injection-token"; +import { messageChannelInjectionToken } from "../channel/message-channel-injection-token"; + +export type SyncBoxChannel = MessageChannel<{ id: string; value: any }>; + +const syncBoxChannelInjectable = getInjectable({ + id: "sync-box-channel", + + instantiate: (): SyncBoxChannel => ({ + id: "sync-box-channel", + }), + + injectionToken: messageChannelInjectionToken, +}); + +export default syncBoxChannelInjectable; diff --git a/src/common/utils/sync-box/sync-box-initial-value-channel.injectable.ts b/src/common/utils/sync-box/sync-box-initial-value-channel.injectable.ts new file mode 100644 index 0000000000..89374c3565 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-initial-value-channel.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 { getInjectable } from "@ogre-tools/injectable"; +import type { RequestChannel } from "../channel/request-channel-injection-token"; +import { requestChannelInjectionToken } from "../channel/request-channel-injection-token"; + +export type SyncBoxInitialValueChannel = RequestChannel< + void, + { id: string; value: any }[] +>; + +const syncBoxInitialValueChannelInjectable = getInjectable({ + id: "sync-box-initial-value-channel", + + instantiate: (): SyncBoxInitialValueChannel => ({ + id: "sync-box-initial-value-channel", + }), + + injectionToken: requestChannelInjectionToken, +}); + +export default syncBoxInitialValueChannelInjectable; diff --git a/src/common/utils/sync-box/sync-box-injection-token.ts b/src/common/utils/sync-box/sync-box-injection-token.ts new file mode 100644 index 0000000000..d35c7d5367 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-injection-token.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { JsonValue } from "type-fest"; + +export interface SyncBox { + id: string; + value: IComputedValue; + set: (value: TValue) => void; +} + +export const syncBoxInjectionToken = getInjectionToken>({ + id: "sync-box", +}); diff --git a/src/common/utils/sync-box/sync-box-state.injectable.ts b/src/common/utils/sync-box/sync-box-state.injectable.ts new file mode 100644 index 0000000000..e695833da4 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-state.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { observable } from "mobx"; + +const syncBoxStateInjectable = getInjectable({ + id: "sync-box-state", + + instantiate: () => observable.box(), + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, id: string) => id, + }), +}); + +export default syncBoxStateInjectable; diff --git a/src/common/utils/sync-box/sync-box.test.ts b/src/common/utils/sync-box/sync-box.test.ts new file mode 100644 index 0000000000..2dccbd87a5 --- /dev/null +++ b/src/common/utils/sync-box/sync-box.test.ts @@ -0,0 +1,179 @@ +/** + * 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 { observe, runInAction } from "mobx"; +import type { ApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../../renderer/components/test-utils/get-application-builder"; +import createSyncBoxInjectable from "./create-sync-box.injectable"; +import { flushPromises } from "../../test-utils/flush-promises"; +import type { SyncBox } from "./sync-box-injection-token"; + +describe("sync-box", () => { + let applicationBuilder: ApplicationBuilder; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.dis.mainDi.register(someInjectable); + applicationBuilder.dis.rendererDi.register(someInjectable); + }); + + // TODO: Separate starting for main application and starting of window in application builder + xdescribe("given application is started, when value is set in main", () => { + let valueInMain: string; + let syncBoxInMain: SyncBox; + + beforeEach(async () => { + syncBoxInMain = applicationBuilder.dis.mainDi.inject(someInjectable); + + // await applicationBuilder.start(); + + observe(syncBoxInMain.value, ({ newValue }) => { + valueInMain = newValue as string; + }, true); + + runInAction(() => { + syncBoxInMain.set("some-value-from-main"); + }); + }); + + it("knows value in main", () => { + expect(valueInMain).toBe("some-value-from-main"); + }); + + describe("when window starts", () => { + let valueInRenderer: string; + let syncBoxInRenderer: SyncBox; + + beforeEach(() => { + // applicationBuilder.renderWindow() + + syncBoxInRenderer = applicationBuilder.dis.rendererDi.inject(someInjectable); + + observe(syncBoxInRenderer.value, ({ newValue }) => { + valueInRenderer = newValue as string; + }, true); + }); + + it("does not have the initial value yet", () => { + expect(valueInRenderer).toBe(undefined); + }); + + describe("when getting initial value resolves", () => { + beforeEach(async () => { + await flushPromises(); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-main"); + }); + + describe("when value is set from renderer", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInRenderer.set("some-value-from-renderer"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-renderer"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-renderer"); + }); + }); + }); + + describe("when value is set from renderer before getting initial value from main resolves", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInRenderer.set("some-value-from-renderer"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-renderer"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-renderer"); + }); + }); + }); + }); + + describe("when application starts with a window", () => { + let valueInRenderer: string; + let valueInMain: string; + let syncBoxInMain: SyncBox; + let syncBoxInRenderer: SyncBox; + + beforeEach(async () => { + syncBoxInMain = applicationBuilder.dis.mainDi.inject(someInjectable); + syncBoxInRenderer = applicationBuilder.dis.rendererDi.inject(someInjectable); + + await applicationBuilder.render(); + + observe(syncBoxInRenderer.value, ({ newValue }) => { + valueInRenderer = newValue as string; + }, true); + + observe(syncBoxInMain.value, ({ newValue }) => { + valueInMain = newValue as string; + }, true); + }); + + it("knows initial value in main", () => { + expect(valueInMain).toBe("some-initial-value"); + }); + + it("knows initial value in renderer", () => { + expect(valueInRenderer).toBe("some-initial-value"); + }); + + describe("when value is set from main", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInMain.set("some-value-from-main"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-main"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-main"); + }); + + describe("when value is set from renderer", () => { + beforeEach(() => { + runInAction(() => { + syncBoxInRenderer.set("some-value-from-renderer"); + }); + }); + + it("has value in main", () => { + expect(valueInMain).toBe("some-value-from-renderer"); + }); + + it("has value in renderer", () => { + expect(valueInRenderer).toBe("some-value-from-renderer"); + }); + }); + }); + }); +}); + +const someInjectable = getInjectable({ + id: "some-injectable", + + instantiate: (di) => { + const createSyncBox = di.inject(createSyncBoxInjectable); + + return createSyncBox("some-sync-box", "some-initial-value"); + }, +}); diff --git a/src/common/utils/tentative-parse-json.ts b/src/common/utils/tentative-parse-json.ts new file mode 100644 index 0000000000..a0cb089a74 --- /dev/null +++ b/src/common/utils/tentative-parse-json.ts @@ -0,0 +1,15 @@ +/** + * 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 { defaultTo } from "lodash/fp"; +import { withErrorSuppression } from "./with-error-suppression/with-error-suppression"; + +export const tentativeParseJson = (toBeParsed: any) => pipeline( + toBeParsed, + withErrorSuppression(JSON.parse), + defaultTo(toBeParsed), +); + + diff --git a/src/common/utils/tentative-stringify-json.ts b/src/common/utils/tentative-stringify-json.ts new file mode 100644 index 0000000000..dc7206be7c --- /dev/null +++ b/src/common/utils/tentative-stringify-json.ts @@ -0,0 +1,15 @@ +/** + * 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 { defaultTo } from "lodash/fp"; +import { withErrorSuppression } from "./with-error-suppression/with-error-suppression"; + +export const tentativeStringifyJson = (toBeParsed: any) => pipeline( + toBeParsed, + withErrorSuppression(JSON.stringify), + defaultTo(toBeParsed), +); + + diff --git a/src/common/utils/with-error-logging/with-error-logging.injectable.ts b/src/common/utils/with-error-logging/with-error-logging.injectable.ts new file mode 100644 index 0000000000..12b48c6204 --- /dev/null +++ b/src/common/utils/with-error-logging/with-error-logging.injectable.ts @@ -0,0 +1,47 @@ +/** + * 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 loggerInjectable from "../../logger.injectable"; +import { isPromise } from "../is-promise/is-promise"; + +export type WithErrorLoggingFor = ( + getErrorMessage: (error: unknown) => string +) => any>( + toBeDecorated: T +) => (...args: Parameters) => ReturnType; + +const withErrorLoggingInjectable = getInjectable({ + id: "with-error-logging", + + instantiate: (di): WithErrorLoggingFor => { + const logger = di.inject(loggerInjectable); + + return (getErrorMessage) => + (toBeDecorated) => + (...args) => { + try { + const returnValue = toBeDecorated(...args); + + if (isPromise(returnValue)) { + returnValue.catch((e) => { + const errorMessage = getErrorMessage(e); + + logger.error(errorMessage, e); + }); + } + + return returnValue; + } catch (e) { + const errorMessage = getErrorMessage(e); + + logger.error(errorMessage, e); + + throw e; + } + }; + }, +}); + +export default withErrorLoggingInjectable; diff --git a/src/common/utils/with-error-logging/with-error-logging.test.ts b/src/common/utils/with-error-logging/with-error-logging.test.ts new file mode 100644 index 0000000000..533374d9ad --- /dev/null +++ b/src/common/utils/with-error-logging/with-error-logging.test.ts @@ -0,0 +1,243 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import loggerInjectable from "../../logger.injectable"; +import type { Logger } from "../../logger"; +import withErrorLoggingInjectable from "./with-error-logging.injectable"; +import { pipeline } from "@ogre-tools/fp"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; + +describe("with-error-logging", () => { + describe("given decorated sync function", () => { + let loggerStub: Logger; + let toBeDecorated: jest.Mock; + let decorated: (a: string, b: string) => number | undefined; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + loggerStub = { + error: jest.fn(), + } as unknown as Logger; + + di.override(loggerInjectable, () => loggerStub); + + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + toBeDecorated = jest.fn(); + + decorated = pipeline( + toBeDecorated, + withErrorLoggingFor((error: any) => `some-error-message-for-${error.message}`), + ); + }); + + describe("when function does not throw and returns value", () => { + let returnValue: number | undefined; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => 42); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("returns the value", () => { + expect(returnValue).toBe(42); + }); + }); + + describe("when function does not throw and returns no value", () => { + let returnValue: number | undefined; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => undefined); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + + describe("when function throws", () => { + let error: Error; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => { + throw new Error("some-error"); + }); + + try { + decorated("some-parameter", "some-other-parameter"); + } catch (e: any) { + error = e; + } + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("logs the error", () => { + expect(loggerStub.error).toHaveBeenCalledWith("some-error-message-for-some-error", error); + }); + + it("throws", () => { + expect(error.message).toBe("some-error"); + }); + }); + }); + + describe("given decorated async function", () => { + let loggerStub: Logger; + let decorated: (a: string, b: string) => Promise; + let toBeDecorated: AsyncFnMock; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + loggerStub = { + error: jest.fn(), + } as unknown as Logger; + + di.override(loggerInjectable, () => loggerStub); + + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + toBeDecorated = asyncFn(); + + decorated = pipeline( + toBeDecorated, + + withErrorLoggingFor( + (error: any) => + `some-error-message-for-${error.message || error.someProperty}`, + ), + ); + }); + + describe("when called", () => { + let returnValuePromise: Promise; + + beforeEach(() => { + returnValuePromise = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not log error yet", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(returnValuePromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when call rejects with error instance", () => { + let error: Error; + + beforeEach(async () => { + try { + await toBeDecorated.reject(new Error("some-error")); + await returnValuePromise; + } catch (e) { + error = e as Error; + } + }); + + it("logs the error", () => { + expect(loggerStub.error).toHaveBeenCalledWith("some-error-message-for-some-error", error); + }); + + it("rejects", () => { + return expect(() => returnValuePromise).rejects.toThrow("some-error"); + }); + }); + + describe("when call rejects with something else than error instance", () => { + let error: unknown; + + beforeEach(async () => { + try { + await toBeDecorated.reject({ someProperty: "some-rejection" }); + await returnValuePromise; + } catch (e) { + error = e; + } + }); + + it("logs the rejection", () => { + expect(loggerStub.error).toHaveBeenCalledWith( + "some-error-message-for-some-rejection", + error, + ); + }); + + it("rejects", () => { + return expect(() => returnValuePromise).rejects.toEqual({ someProperty: "some-rejection" }); + }); + }); + + describe("when call resolves with value", () => { + beforeEach(async () => { + await toBeDecorated.resolve(42); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("resolves with the value", async () => { + const returnValue = await returnValuePromise; + + expect(returnValue).toBe(42); + }); + }); + + describe("when call resolves without value", () => { + beforeEach(async () => { + await toBeDecorated.resolve(undefined); + }); + + it("does not log error", () => { + expect(loggerStub.error).not.toHaveBeenCalled(); + }); + + it("resolves without value", async () => { + const returnValue = await returnValuePromise; + + expect(returnValue).toBeUndefined(); + }); + }); + }); + }); +}); diff --git a/src/common/utils/with-error-suppression/with-error-suppression.test.ts b/src/common/utils/with-error-suppression/with-error-suppression.test.ts new file mode 100644 index 0000000000..db4909fd55 --- /dev/null +++ b/src/common/utils/with-error-suppression/with-error-suppression.test.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../test-utils/get-promise-status"; +import { withErrorSuppression } from "./with-error-suppression"; + +describe("with-error-suppression", () => { + describe("given decorated sync function", () => { + let toBeDecorated: jest.Mock; + let decorated: (a: string, b: string) => void; + + beforeEach(() => { + toBeDecorated = jest.fn(); + + decorated = withErrorSuppression(toBeDecorated); + }); + + describe("when function does not throw", () => { + let returnValue: void; + + beforeEach(() => { + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + + describe("when function throws", () => { + let returnValue: void; + + beforeEach(() => { + // eslint-disable-next-line unused-imports/no-unused-vars-ts + toBeDecorated.mockImplementation((_, __) => { + throw new Error("some-error"); + }); + + returnValue = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("returns nothing", () => { + expect(returnValue).toBeUndefined(); + }); + }); + }); + + describe("given decorated async function", () => { + let decorated: (a: string, b: string) => Promise | Promise; + let toBeDecorated: AsyncFnMock<(a: string, b: string) => number>; + + beforeEach(() => { + toBeDecorated = asyncFn(); + + decorated = withErrorSuppression(toBeDecorated); + }); + + describe("when called", () => { + let returnValuePromise: Promise | Promise; + + beforeEach(() => { + returnValuePromise = decorated("some-parameter", "some-other-parameter"); + }); + + it("passes arguments to decorated function", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-parameter", "some-other-parameter"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(returnValuePromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when call rejects, resolves with nothing", async () => { + await toBeDecorated.reject(new Error("some-error")); + + const returnValue = await returnValuePromise; + + expect(returnValue).toBeUndefined(); + }); + + it("when call resolves, resolves with the value", async () => { + await toBeDecorated.resolve(42); + + const returnValue = await returnValuePromise; + + expect(returnValue).toBe(42); + }); + }); + }); +}); diff --git a/src/common/utils/with-error-suppression/with-error-suppression.ts b/src/common/utils/with-error-suppression/with-error-suppression.ts new file mode 100644 index 0000000000..657ed13c16 --- /dev/null +++ b/src/common/utils/with-error-suppression/with-error-suppression.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { noop } from "lodash/fp"; + +export function withErrorSuppression Promise>(toBeDecorated: TDecorated): (...args: Parameters) => ReturnType | Promise; +export function withErrorSuppression any>(toBeDecorated: TDecorated): (...args: Parameters) => ReturnType | void; + +export function withErrorSuppression(toBeDecorated: any) { + return (...args: any[]) => { + try { + const returnValue = toBeDecorated(...args); + + if (isPromise(returnValue)) { + return returnValue.catch(noop); + } + + return returnValue; + } catch (e) { + return undefined; + } + }; +} + +function isPromise(reference: any): reference is Promise { + return !!reference?.then; +} diff --git a/src/common/utils/with-orphan-promise/with-orphan-promise.injectable.ts b/src/common/utils/with-orphan-promise/with-orphan-promise.injectable.ts new file mode 100644 index 0000000000..42e6cb9a61 --- /dev/null +++ b/src/common/utils/with-orphan-promise/with-orphan-promise.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 withErrorLoggingInjectable from "../with-error-logging/with-error-logging.injectable"; +import { withErrorSuppression } from "../with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; + +const withOrphanPromiseInjectable = getInjectable({ + id: "with-orphan-promise", + + instantiate: (di) => { + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return Promise>(toBeDecorated: T) => + (...args: Parameters): void => { + const decorated = pipeline( + toBeDecorated, + withErrorLoggingFor(() => "Orphan promise rejection encountered"), + withErrorSuppression, + ); + + decorated(...args); + }; + }, +}); + +export default withOrphanPromiseInjectable; diff --git a/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts b/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts new file mode 100644 index 0000000000..cea88b2352 --- /dev/null +++ b/src/common/utils/with-orphan-promise/with-orphan-promise.test.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getDiForUnitTesting } from "../../../main/getDiForUnitTesting"; +import loggerInjectable from "../../logger.injectable"; +import type { Logger } from "../../logger"; +import withOrphanPromiseInjectable from "./with-orphan-promise.injectable"; + +describe("with orphan promise, when called", () => { + let toBeDecorated: AsyncFnMock<(arg1: string, arg2: string) => Promise>; + let actual: void; + let loggerStub: Logger; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + loggerStub = { error: jest.fn() } as unknown as Logger; + + di.override(loggerInjectable, () => loggerStub); + + const withOrphanPromise = di.inject(withOrphanPromiseInjectable); + + toBeDecorated = asyncFn(); + + const decorated = withOrphanPromise(toBeDecorated); + + actual = decorated("some-argument", "some-other-argument"); + }); + + it("calls decorated with arguments", () => { + expect(toBeDecorated).toHaveBeenCalledWith("some-argument", "some-other-argument"); + }); + + it("given promise returned by decorated has not been fulfilled yet, already returns nothing", () => { + expect(actual).toBeUndefined(); + }); + + it("when decorated function resolves, nothing happens", async () => { + await toBeDecorated.resolve("irrelevant"); + // Note: there is no expect, test is here only for documentation. + }); + + describe("when decorated function rejects", () => { + beforeEach(async () => { + await toBeDecorated.reject("some-error"); + }); + + it("logs the rejection", () => { + expect(loggerStub.error).toHaveBeenCalledWith("Orphan promise rejection encountered", "some-error"); + }); + + it("nothing else happens", () => { + // Note: there is no expect, test is here only for documentation. + }); + }); +}); diff --git a/src/common/vars.ts b/src/common/vars.ts index 1d47eac9d8..e11e49a7a2 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -43,8 +43,6 @@ export const isProduction = process.env.NODE_ENV === "production"; */ export const isDevelopment = !isTestEnv && !isProduction; -export const isPublishConfigured = Object.keys(packageInfo.build).includes("publish"); - export const productName = packageInfo.productName; /** diff --git a/src/common/vars/package-json.injectable.ts b/src/common/vars/package-json.injectable.ts new file mode 100644 index 0000000000..fa132be518 --- /dev/null +++ b/src/common/vars/package-json.injectable.ts @@ -0,0 +1,14 @@ +/** + * 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 packageJson from "../../../package.json"; + +const packageJsonInjectable = getInjectable({ + id: "package-json", + instantiate: () => packageJson, + causesSideEffects: true, +}); + +export default packageJsonInjectable; diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index 298c8f351b..16cebb813a 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -46,7 +46,6 @@ import { mock } from "jest-mock-extended"; import { waitUntilUsed } from "tcp-port-used"; import type { Readable } from "stream"; import { EventEmitter } from "stream"; -import { UserStore } from "../../common/user-store"; import { Console } from "console"; import { stdout, stderr } from "process"; import mockFs from "mock-fs"; @@ -120,12 +119,9 @@ describe("kube auth proxy tests", () => { createCluster = di.inject(createClusterInjectionToken); createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable); - - UserStore.createInstance(); }); afterEach(() => { - UserStore.resetInstance(); mockFs.restore(); }); diff --git a/src/main/app-paths/app-name/app-name.injectable.ts b/src/main/app-paths/app-name/app-name.injectable.ts index f4af95cf83..0a1db468d8 100644 --- a/src/main/app-paths/app-name/app-name.injectable.ts +++ b/src/main/app-paths/app-name/app-name.injectable.ts @@ -3,16 +3,17 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import packageInfo from "../../../../package.json"; import isDevelopmentInjectable from "../../../common/vars/is-development.injectable"; +import productNameInjectable from "./product-name.injectable"; const appNameInjectable = getInjectable({ id: "app-name", instantiate: (di) => { const isDevelopment = di.inject(isDevelopmentInjectable); + const productName = di.inject(productNameInjectable); - return `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`; + return `${productName}${isDevelopment ? "Dev" : ""}`; }, causesSideEffects: true, diff --git a/src/main/app-paths/app-name/product-name.injectable.ts b/src/main/app-paths/app-name/product-name.injectable.ts new file mode 100644 index 0000000000..8c5c53bfba --- /dev/null +++ b/src/main/app-paths/app-name/product-name.injectable.ts @@ -0,0 +1,14 @@ +/** + * 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 packageInfo from "../../../../package.json"; + +const productNameInjectable = getInjectable({ + id: "product-name", + instantiate: () => packageInfo.productName, + causesSideEffects: true, +}); + +export default productNameInjectable; diff --git a/src/main/app-paths/app-paths-request-channel-listener.injectable.ts b/src/main/app-paths/app-paths-request-channel-listener.injectable.ts new file mode 100644 index 0000000000..3bd0c95bf7 --- /dev/null +++ b/src/main/app-paths/app-paths-request-channel-listener.injectable.ts @@ -0,0 +1,27 @@ +/** + * 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 type { RequestChannelListener } from "../../common/utils/channel/request-channel-listener-injection-token"; +import { requestChannelListenerInjectionToken } from "../../common/utils/channel/request-channel-listener-injection-token"; +import type { AppPathsChannel } from "../../common/app-paths/app-paths-channel.injectable"; +import appPathsChannelInjectable from "../../common/app-paths/app-paths-channel.injectable"; +import appPathsInjectable from "../../common/app-paths/app-paths.injectable"; + +const appPathsRequestChannelListenerInjectable = getInjectable({ + id: "app-paths-request-channel-listener", + + instantiate: (di): RequestChannelListener => { + const channel = di.inject(appPathsChannelInjectable); + const appPaths = di.inject(appPathsInjectable); + + return { + channel, + handler: () => appPaths, + }; + }, + injectionToken: requestChannelListenerInjectionToken, +}); + +export default appPathsRequestChannelListenerInjectable; diff --git a/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts index 6cb937f45d..8e28c806d7 100644 --- a/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts +++ b/src/main/app-paths/get-electron-app-path/get-electron-app-path.test.ts @@ -6,7 +6,6 @@ import electronAppInjectable from "../../electron-app/electron-app.injectable"; import getElectronAppPathInjectable from "./get-electron-app-path.injectable"; import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import type { App } from "electron"; -import registerChannelInjectable from "../register-channel/register-channel.injectable"; import joinPathsInjectable from "../../../common/path/join-paths.injectable"; import { joinPathsFake } from "../../../common/test-utils/join-paths-fake"; @@ -32,7 +31,6 @@ describe("get-electron-app-path", () => { } as App; di.override(electronAppInjectable, () => appStub); - di.override(registerChannelInjectable, () => () => undefined); di.override(joinPathsInjectable, () => joinPathsFake); getElectronAppPath = di.inject(getElectronAppPathInjectable) as (name: string) => string; diff --git a/src/main/app-paths/register-channel/register-channel.injectable.ts b/src/main/app-paths/register-channel/register-channel.injectable.ts deleted file mode 100644 index d0b517cf25..0000000000 --- a/src/main/app-paths/register-channel/register-channel.injectable.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 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 ipcMainInjectable from "./ipc-main/ipc-main.injectable"; -import { registerChannel } from "./register-channel"; - -const registerChannelInjectable = getInjectable({ - id: "register-channel", - - instantiate: (di) => registerChannel({ - ipcMain: di.inject(ipcMainInjectable), - }), -}); - -export default registerChannelInjectable; diff --git a/src/main/app-paths/register-channel/register-channel.ts b/src/main/app-paths/register-channel/register-channel.ts deleted file mode 100644 index 73f3e13243..0000000000 --- a/src/main/app-paths/register-channel/register-channel.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { IpcMain } from "electron"; -import type { Channel } from "../../../common/ipc-channel/channel"; - -interface Dependencies { - ipcMain: IpcMain; -} - -export const registerChannel = - ({ ipcMain }: Dependencies) => - , TInstance>( - channel: TChannel, - getValue: () => TInstance, - ) => - ipcMain.handle(channel.name, getValue); diff --git a/src/main/app-paths/setup-app-paths.injectable.ts b/src/main/app-paths/setup-app-paths.injectable.ts index 9a4283f063..816c58db8b 100644 --- a/src/main/app-paths/setup-app-paths.injectable.ts +++ b/src/main/app-paths/setup-app-paths.injectable.ts @@ -12,8 +12,6 @@ import appPathsStateInjectable from "../../common/app-paths/app-paths-state.inje import { pathNames } from "../../common/app-paths/app-path-names"; import { fromPairs, map } from "lodash/fp"; import { pipeline } from "@ogre-tools/fp"; -import { appPathsIpcChannel } from "../../common/app-paths/app-path-injection-token"; -import registerChannelInjectable from "./register-channel/register-channel.injectable"; import joinPathsInjectable from "../../common/path/join-paths.injectable"; import { beforeElectronIsReadyInjectionToken } from "../start-main-application/runnable-tokens/before-electron-is-ready-injection-token"; @@ -25,7 +23,6 @@ const setupAppPathsInjectable = getInjectable({ const appName = di.inject(appNameInjectable); const getAppPath = di.inject(getElectronAppPathInjectable); const appPathsState = di.inject(appPathsStateInjectable); - const registerChannel = di.inject(registerChannelInjectable); const directoryForIntegrationTesting = di.inject(directoryForIntegrationTestingInjectable); const joinPaths = di.inject(joinPathsInjectable); @@ -46,8 +43,6 @@ const setupAppPathsInjectable = getInjectable({ ) as AppPaths; appPathsState.set(appPaths); - - registerChannel(appPathsIpcChannel, () => appPaths); }, }; }, diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts deleted file mode 100644 index 77daf6b4b6..0000000000 --- a/src/main/app-updater.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { UpdateInfo } from "electron-updater"; -import { autoUpdater } from "electron-updater"; -import logger from "./logger"; -import { isPublishConfigured, isTestEnv } from "../common/vars"; -import { delay } from "../common/utils"; -import type { UpdateAvailableToBackchannel } from "../common/ipc"; -import { areArgsUpdateAvailableToBackchannel, AutoUpdateChecking, AutoUpdateLogPrefix, AutoUpdateNoUpdateAvailable, broadcastMessage, onceCorrect, UpdateAvailableChannel } from "../common/ipc"; -import { once } from "lodash"; -import { ipcMain } from "electron"; -import { nextUpdateChannel } from "./utils/update-channel"; -import { UserStore } from "../common/user-store"; - -let installVersion: undefined | string; - -export function isAutoUpdateEnabled() { - return autoUpdater.isUpdaterActive() && isPublishConfigured; -} - -function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: UpdateAvailableToBackchannel) { - if (arg.doUpdate) { - if (arg.now) { - logger.info(`${AutoUpdateLogPrefix}: User chose to update now`); - autoUpdater.quitAndInstall(true, true); - } else { - logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`); - } - } else { - logger.info(`${AutoUpdateLogPrefix}: User chose not to update, will update on quit anyway`); - } -} - -autoUpdater.logger = { - info: message => logger.info(`[AUTO-UPDATE]: electron-updater: %s`, message), - warn: message => logger.warn(`[AUTO-UPDATE]: electron-updater: %s`, message), - error: message => logger.error(`[AUTO-UPDATE]: electron-updater: %s`, message), - debug: message => logger.debug(`[AUTO-UPDATE]: electron-updater: %s`, message), -}; - -interface Dependencies { - isAutoUpdateEnabled: () => boolean; -} - -/** - * starts the automatic update checking - * @param interval milliseconds between interval to check on, defaults to 2h - */ -export const startUpdateChecking = ({ isAutoUpdateEnabled } : Dependencies) => once(function (interval = 1000 * 60 * 60 * 2): void { - if (!isAutoUpdateEnabled() || isTestEnv) { - return; - } - - const userStore = UserStore.getInstance(); - - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = true; - autoUpdater.channel = userStore.updateChannel; - autoUpdater.allowDowngrade = userStore.isAllowedToDowngrade; - - autoUpdater - .on("update-available", (info: UpdateInfo) => { - if (installVersion === info.version) { - // same version, don't broadcast - return; - } - - installVersion = info.version; - - autoUpdater.downloadUpdate() - .catch(error => logger.error(`${AutoUpdateLogPrefix}: failed to download update`, { error: String(error) })); - }) - .on("update-downloaded", (info: UpdateInfo) => { - try { - const backchannel = `auto-update:${info.version}`; - - ipcMain.removeAllListeners(backchannel); // only one handler should be present - - // make sure that the handler is in place before broadcasting (prevent race-condition) - onceCorrect({ - source: ipcMain, - channel: backchannel, - listener: handleAutoUpdateBackChannel, - verifier: areArgsUpdateAvailableToBackchannel, - }); - logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version }); - broadcastMessage(UpdateAvailableChannel, backchannel, info); - } catch (error) { - logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); - installVersion = undefined; - } - }) - .on("update-not-available", () => { - const nextChannel = nextUpdateChannel(userStore.updateChannel, autoUpdater.channel); - - logger.info(`${AutoUpdateLogPrefix}: update not available from ${autoUpdater.channel}, will check ${nextChannel} channel next`); - - if (nextChannel !== autoUpdater.channel) { - autoUpdater.channel = nextChannel; - autoUpdater.checkForUpdates() - .catch(error => logger.error(`${AutoUpdateLogPrefix}: failed with an error`, error)); - } else { - broadcastMessage(AutoUpdateNoUpdateAvailable); - } - }); - - async function helper() { - while (true) { - await checkForUpdates(); - await delay(interval); - } - } - - helper(); -}); - -export async function checkForUpdates(): Promise { - const userStore = UserStore.getInstance(); - - try { - logger.info(`📡 Checking for app updates`); - - autoUpdater.channel = userStore.updateChannel; - autoUpdater.allowDowngrade = userStore.isAllowedToDowngrade; - broadcastMessage(AutoUpdateChecking); - await autoUpdater.checkForUpdates(); - } catch (error) { - logger.error(`${AutoUpdateLogPrefix}: failed with an error`, error); - } -} diff --git a/src/main/application-update/check-for-platform-updates/check-for-platform-updates.injectable.ts b/src/main/application-update/check-for-platform-updates/check-for-platform-updates.injectable.ts new file mode 100644 index 0000000000..286999c484 --- /dev/null +++ b/src/main/application-update/check-for-platform-updates/check-for-platform-updates.injectable.ts @@ -0,0 +1,60 @@ +/** + * 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 electronUpdaterInjectable from "../../electron-app/features/electron-updater.injectable"; +import type { UpdateChannel } from "../../../common/application-update/update-channels"; +import loggerInjectable from "../../../common/logger.injectable"; +import type { UpdateCheckResult } from "electron-updater"; + +export type CheckForUpdatesResult = { + updateWasDiscovered: false; +} | { + updateWasDiscovered: true; + version: string; +}; + +export type CheckForPlatformUpdates = (updateChannel: UpdateChannel, opts: { allowDowngrade: boolean }) => Promise; + +const checkForPlatformUpdatesInjectable = getInjectable({ + id: "check-for-platform-updates", + + instantiate: (di): CheckForPlatformUpdates => { + const electronUpdater = di.inject(electronUpdaterInjectable); + const logger = di.inject(loggerInjectable); + + return async (updateChannel, { allowDowngrade }) => { + electronUpdater.channel = updateChannel.id; + electronUpdater.autoDownload = false; + electronUpdater.allowDowngrade = allowDowngrade; + + let result: UpdateCheckResult; + + try { + result = await electronUpdater.checkForUpdates(); + } catch (error) { + logger.error("[UPDATE-APP/CHECK-FOR-UPDATES]", error); + + return { + updateWasDiscovered: false, + }; + } + + const { updateInfo, cancellationToken } = result; + + if (!cancellationToken) { + return { + updateWasDiscovered: false, + }; + } + + return { + updateWasDiscovered: true, + version: updateInfo.version, + }; + }; + }, +}); + +export default checkForPlatformUpdatesInjectable; diff --git a/src/main/application-update/check-for-platform-updates/check-for-platform-updates.test.ts b/src/main/application-update/check-for-platform-updates/check-for-platform-updates.test.ts new file mode 100644 index 0000000000..b826a1a5a7 --- /dev/null +++ b/src/main/application-update/check-for-platform-updates/check-for-platform-updates.test.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import electronUpdaterInjectable from "../../electron-app/features/electron-updater.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { AppUpdater, UpdateCheckResult } from "electron-updater"; +import type { CheckForPlatformUpdates } from "./check-for-platform-updates.injectable"; +import checkForPlatformUpdatesInjectable from "./check-for-platform-updates.injectable"; +import type { UpdateChannel, UpdateChannelId } from "../../../common/application-update/update-channels"; +import { getPromiseStatus } from "../../../common/test-utils/get-promise-status"; +import loggerInjectable from "../../../common/logger.injectable"; +import type { Logger } from "../../../common/logger"; + +describe("check-for-platform-updates", () => { + let checkForPlatformUpdates: CheckForPlatformUpdates; + let electronUpdaterFake: AppUpdater; + let checkForUpdatesMock: AsyncFnMock<() => UpdateCheckResult>; + let logErrorMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting(); + + checkForUpdatesMock = asyncFn(); + + electronUpdaterFake = { + channel: undefined, + autoDownload: undefined, + allowDowngrade: undefined, + + checkForUpdates: checkForUpdatesMock, + } as unknown as AppUpdater; + + di.override(electronUpdaterInjectable, () => electronUpdaterFake); + + logErrorMock = jest.fn(); + + di.override(loggerInjectable, () => ({ error: logErrorMock }) as unknown as Logger); + + checkForPlatformUpdates = di.inject(checkForPlatformUpdatesInjectable); + }); + + describe("when called", () => { + let actualPromise: Promise; + + beforeEach(() => { + const testUpdateChannel: UpdateChannel = { + id: "some-update-channel" as UpdateChannelId, + label: "Some update channel", + moreStableUpdateChannel: null, + }; + + actualPromise = checkForPlatformUpdates(testUpdateChannel, { allowDowngrade: true }); + }); + + it("sets update channel", () => { + expect(electronUpdaterFake.channel).toBe("some-update-channel"); + }); + + it("sets flag for allowing downgrade", () => { + expect(electronUpdaterFake.allowDowngrade).toBe(true); + }); + + it("disables auto downloading for being controlled", () => { + expect(electronUpdaterFake.autoDownload).toBe(false); + }); + + it("checks for updates", () => { + expect(checkForUpdatesMock).toHaveBeenCalled(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when checking for updates resolves with update, resolves with the discovered update", async () => { + await checkForUpdatesMock.resolve({ + updateInfo: { + version: "some-version", + }, + + cancellationToken: "some-cancellation-token", + } as unknown as UpdateCheckResult); + + const actual = await actualPromise; + + expect(actual).toEqual({ updateWasDiscovered: true, version: "some-version" }); + }); + + it("when checking for updates resolves without update, resolves with update not being discovered", async () => { + await checkForUpdatesMock.resolve({ + updateInfo: { + version: "some-version-that-matches-to-current-installed-version", + }, + + cancellationToken: null, + } as unknown as UpdateCheckResult); + + const actual = await actualPromise; + + expect(actual).toEqual({ updateWasDiscovered: false }); + }); + + describe("when checking for updates rejects", () => { + let errorStub: Error; + + beforeEach(() => { + errorStub = new Error("Some error"); + + checkForUpdatesMock.reject(errorStub); + }); + + it("logs errors", () => { + expect(logErrorMock).toHaveBeenCalledWith("[UPDATE-APP/CHECK-FOR-UPDATES]", errorStub); + }); + + it("resolves with update not being discovered", async () => { + const actual = await actualPromise; + + expect(actual).toEqual({ updateWasDiscovered: false }); + }); + }); + }); +}); diff --git a/src/main/application-update/check-for-updates-tray-item.injectable.ts b/src/main/application-update/check-for-updates-tray-item.injectable.ts new file mode 100644 index 0000000000..5ff4be731a --- /dev/null +++ b/src/main/application-update/check-for-updates-tray-item.injectable.ts @@ -0,0 +1,79 @@ +/** + * 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 { computed } from "mobx"; +import updatingIsEnabledInjectable from "./updating-is-enabled.injectable"; +import { trayMenuItemInjectionToken } from "../tray/tray-menu-item/tray-menu-item-injection-token"; +import showApplicationWindowInjectable from "../start-main-application/lens-window/show-application-window.injectable"; +import discoveredUpdateVersionInjectable from "../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import updateIsBeingDownloadedInjectable from "../../common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable"; +import updatesAreBeingDiscoveredInjectable from "../../common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable"; +import progressOfUpdateDownloadInjectable from "../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; +import assert from "assert"; +import processCheckingForUpdatesInjectable from "./check-for-updates/process-checking-for-updates.injectable"; +import { withErrorSuppression } from "../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; +import withErrorLoggingInjectable from "../../common/utils/with-error-logging/with-error-logging.injectable"; + +const checkForUpdatesTrayItemInjectable = getInjectable({ + id: "check-for-updates-tray-item", + + instantiate: (di) => { + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const updatingIsEnabled = di.inject(updatingIsEnabledInjectable); + const progressOfUpdateDownload = di.inject(progressOfUpdateDownloadInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + const downloadingUpdateState = di.inject(updateIsBeingDownloadedInjectable); + const checkingForUpdatesState = di.inject(updatesAreBeingDiscoveredInjectable); + const processCheckingForUpdates = di.inject(processCheckingForUpdatesInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "check-for-updates", + parentId: null, + orderNumber: 30, + + label: computed(() => { + if (downloadingUpdateState.value.get()) { + const discoveredVersion = discoveredVersionState.value.get(); + + assert(discoveredVersion); + + const roundedPercentage = Math.round(progressOfUpdateDownload.value.get().percentage); + + return `Downloading update ${discoveredVersion.version} (${roundedPercentage}%)...`; + } + + if (checkingForUpdatesState.value.get()) { + return "Checking for updates..."; + } + + return "Check for updates"; + }), + + enabled: computed(() => !checkingForUpdatesState.value.get() && !downloadingUpdateState.value.get()), + + visible: computed(() => updatingIsEnabled), + + click: pipeline( + async () => { + await processCheckingForUpdates(); + + await showApplicationWindow(); + }, + + withErrorLoggingFor(() => "[TRAY]: Checking for updates failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default checkForUpdatesTrayItemInjectable; diff --git a/src/main/application-update/check-for-updates/broadcast-change-in-updating-status.injectable.ts b/src/main/application-update/check-for-updates/broadcast-change-in-updating-status.injectable.ts new file mode 100644 index 0000000000..7e9257e966 --- /dev/null +++ b/src/main/application-update/check-for-updates/broadcast-change-in-updating-status.injectable.ts @@ -0,0 +1,23 @@ +/** + * 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 type { ApplicationUpdateStatusChannelMessage } from "../../../common/application-update/application-update-status-channel.injectable"; +import { messageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token"; +import applicationUpdateStatusChannelInjectable from "../../../common/application-update/application-update-status-channel.injectable"; + +const broadcastChangeInUpdatingStatusInjectable = getInjectable({ + id: "broadcast-change-in-updating-status", + + instantiate: (di) => { + const messageToChannel = di.inject(messageToChannelInjectionToken); + const applicationUpdateStatusChannel = di.inject(applicationUpdateStatusChannelInjectable); + + return (data: ApplicationUpdateStatusChannelMessage) => { + messageToChannel(applicationUpdateStatusChannel, data); + }; + }, +}); + +export default broadcastChangeInUpdatingStatusInjectable; diff --git a/src/main/application-update/check-for-updates/check-for-updates-starting-from-channel.injectable.ts b/src/main/application-update/check-for-updates/check-for-updates-starting-from-channel.injectable.ts new file mode 100644 index 0000000000..caf695ff80 --- /dev/null +++ b/src/main/application-update/check-for-updates/check-for-updates-starting-from-channel.injectable.ts @@ -0,0 +1,54 @@ +/** + * 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 type { UpdateChannel } from "../../../common/application-update/update-channels"; +import checkForPlatformUpdatesInjectable from "../check-for-platform-updates/check-for-platform-updates.injectable"; +import updateCanBeDowngradedInjectable from "./update-can-be-downgraded.injectable"; + +export type CheckForUpdatesFromChannelResult = { + updateWasDiscovered: false; +} | { + updateWasDiscovered: true; + version: string; + actualUpdateChannel: UpdateChannel; +}; + +const checkForUpdatesStartingFromChannelInjectable = getInjectable({ + id: "check-for-updates-starting-from-channel", + + instantiate: (di) => { + const checkForPlatformUpdates = di.inject( + checkForPlatformUpdatesInjectable, + ); + + const updateCanBeDowngraded = di.inject(updateCanBeDowngradedInjectable); + + const _recursiveCheck = async ( + updateChannel: UpdateChannel, + ): Promise => { + const result = await checkForPlatformUpdates(updateChannel, { + allowDowngrade: updateCanBeDowngraded.get(), + }); + + if (result.updateWasDiscovered) { + return { + updateWasDiscovered: true, + version: result.version, + actualUpdateChannel: updateChannel, + }; + } + + if (updateChannel.moreStableUpdateChannel) { + return await _recursiveCheck(updateChannel.moreStableUpdateChannel); + } + + return { updateWasDiscovered: false }; + }; + + return _recursiveCheck; + }, +}); + +export default checkForUpdatesStartingFromChannelInjectable; diff --git a/src/main/application-update/check-for-updates/process-checking-for-updates.injectable.ts b/src/main/application-update/check-for-updates/process-checking-for-updates.injectable.ts new file mode 100644 index 0000000000..2688d00d4a --- /dev/null +++ b/src/main/application-update/check-for-updates/process-checking-for-updates.injectable.ts @@ -0,0 +1,93 @@ +/** + * 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 selectedUpdateChannelInjectable from "../../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import updatesAreBeingDiscoveredInjectable from "../../../common/application-update/updates-are-being-discovered/updates-are-being-discovered.injectable"; +import discoveredUpdateVersionInjectable from "../../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import { runInAction } from "mobx"; +import askBooleanInjectable from "../../ask-boolean/ask-boolean.injectable"; +import quitAndInstallUpdateInjectable from "../../electron-app/features/quit-and-install-update.injectable"; +import downloadUpdateInjectable from "../download-update/download-update.injectable"; +import broadcastChangeInUpdatingStatusInjectable from "./broadcast-change-in-updating-status.injectable"; +import checkForUpdatesStartingFromChannelInjectable from "./check-for-updates-starting-from-channel.injectable"; +import withOrphanPromiseInjectable from "../../../common/utils/with-orphan-promise/with-orphan-promise.injectable"; + +const processCheckingForUpdatesInjectable = getInjectable({ + id: "process-checking-for-updates", + + instantiate: (di) => { + const askBoolean = di.inject(askBooleanInjectable); + const quitAndInstallUpdate = di.inject(quitAndInstallUpdateInjectable); + const downloadUpdate = di.inject(downloadUpdateInjectable); + const selectedUpdateChannel = di.inject(selectedUpdateChannelInjectable); + const broadcastChangeInUpdatingStatus = di.inject(broadcastChangeInUpdatingStatusInjectable); + const checkingForUpdatesState = di.inject(updatesAreBeingDiscoveredInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + const checkForUpdatesStartingFromChannel = di.inject(checkForUpdatesStartingFromChannelInjectable); + const withOrphanPromise = di.inject(withOrphanPromiseInjectable); + + return async () => { + broadcastChangeInUpdatingStatus({ eventId: "checking-for-updates" }); + + runInAction(() => { + checkingForUpdatesState.set(true); + }); + + const result = await checkForUpdatesStartingFromChannel(selectedUpdateChannel.value.get()); + + if (!result.updateWasDiscovered) { + broadcastChangeInUpdatingStatus({ eventId: "no-updates-available" }); + + runInAction(() => { + discoveredVersionState.set(null); + checkingForUpdatesState.set(false); + }); + + return; + } + + const { version, actualUpdateChannel } = result; + + broadcastChangeInUpdatingStatus({ + eventId: "download-for-update-started", + version, + }); + + runInAction(() => { + discoveredVersionState.set({ + version, + updateChannel: actualUpdateChannel, + }); + + checkingForUpdatesState.set(false); + }); + + withOrphanPromise(async () => { + const { downloadWasSuccessful } = await downloadUpdate(); + + if (!downloadWasSuccessful) { + broadcastChangeInUpdatingStatus({ + eventId: "download-for-update-failed", + }); + + return; + } + + const userWantsToInstallUpdate = await askBoolean({ + title: "Update Available", + + question: `Version ${version} of Lens IDE is available and ready to be installed. Would you like to update now?\n\n` + + `Lens should restart automatically, if it doesn't please restart manually. Installed extensions might require updating.`, + }); + + if (userWantsToInstallUpdate) { + quitAndInstallUpdate(); + } + })(); + }; + }, +}); + +export default processCheckingForUpdatesInjectable; diff --git a/src/main/application-update/check-for-updates/update-can-be-downgraded.injectable.ts b/src/main/application-update/check-for-updates/update-can-be-downgraded.injectable.ts new file mode 100644 index 0000000000..72ea5c4023 --- /dev/null +++ b/src/main/application-update/check-for-updates/update-can-be-downgraded.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 { computed } from "mobx"; +import selectedUpdateChannelInjectable from "../../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import appVersionInjectable from "../../../common/get-configuration-file-model/app-version/app-version.injectable"; +import { SemVer } from "semver"; + +const updateCanBeDowngradedInjectable = getInjectable({ + id: "update-can-be-downgraded", + + instantiate: (di) => { + const selectedUpdateChannel = di.inject(selectedUpdateChannelInjectable); + const appVersion = di.inject(appVersionInjectable); + + return computed(() => { + const semVer = new SemVer(appVersion); + + return ( + semVer.prerelease[0] !== + selectedUpdateChannel.value.get().id + ); + }); + }, +}); + +export default updateCanBeDowngradedInjectable; diff --git a/src/main/application-update/download-platform-update/download-platform-update.injectable.ts b/src/main/application-update/download-platform-update/download-platform-update.injectable.ts new file mode 100644 index 0000000000..374efd2caf --- /dev/null +++ b/src/main/application-update/download-platform-update/download-platform-update.injectable.ts @@ -0,0 +1,45 @@ +/** + * 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 electronUpdaterInjectable from "../../electron-app/features/electron-updater.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import type { ProgressInfo } from "electron-updater"; +import type { ProgressOfDownload } from "../../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; + +export type DownloadPlatformUpdate = ( + onDownloadProgress: (arg: ProgressOfDownload) => void +) => Promise<{ downloadWasSuccessful: boolean }>; + +const downloadPlatformUpdateInjectable = getInjectable({ + id: "download-platform-update", + + instantiate: (di): DownloadPlatformUpdate => { + const electronUpdater = di.inject(electronUpdaterInjectable); + const logger = di.inject(loggerInjectable); + + return async (onDownloadProgress) => { + onDownloadProgress({ percentage: 0 }); + + const updateDownloadProgress = ({ percent: percentage }: ProgressInfo) => + onDownloadProgress({ percentage }); + + electronUpdater.on("download-progress", updateDownloadProgress); + + try { + await electronUpdater.downloadUpdate(); + } catch(error) { + logger.error("[UPDATE-APP/DOWNLOAD]", error); + + return { downloadWasSuccessful: false }; + } finally { + electronUpdater.off("download-progress", updateDownloadProgress); + } + + return { downloadWasSuccessful: true }; + }; + }, +}); + +export default downloadPlatformUpdateInjectable; diff --git a/src/main/application-update/download-platform-update/download-platform-update.test.ts b/src/main/application-update/download-platform-update/download-platform-update.test.ts new file mode 100644 index 0000000000..04507be980 --- /dev/null +++ b/src/main/application-update/download-platform-update/download-platform-update.test.ts @@ -0,0 +1,155 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import electronUpdaterInjectable from "../../electron-app/features/electron-updater.injectable"; +import type { DownloadPlatformUpdate } from "./download-platform-update.injectable"; +import downloadPlatformUpdateInjectable from "./download-platform-update.injectable"; +import type { AppUpdater } from "electron-updater"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import { getPromiseStatus } from "../../../common/test-utils/get-promise-status"; +import type { DiContainer } from "@ogre-tools/injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import type { Logger } from "../../../common/logger"; + +describe("download-platform-update", () => { + let downloadPlatformUpdate: DownloadPlatformUpdate; + let downloadUpdateMock: AsyncFnMock<() => void>; + let electronUpdaterFake: AppUpdater; + let electronUpdaterOnMock: jest.Mock; + let electronUpdaterOffMock: jest.Mock; + let di: DiContainer; + let logErrorMock: jest.Mock; + + beforeEach(() => { + di = getDiForUnitTesting(); + + downloadUpdateMock = asyncFn(); + electronUpdaterOnMock = jest.fn(); + electronUpdaterOffMock = jest.fn(); + + electronUpdaterFake = { + channel: undefined, + autoDownload: undefined, + + on: electronUpdaterOnMock, + off: electronUpdaterOffMock, + + downloadUpdate: downloadUpdateMock, + } as unknown as AppUpdater; + + di.override(electronUpdaterInjectable, () => electronUpdaterFake); + + logErrorMock = jest.fn(); + di.override(loggerInjectable, () => ({ error: logErrorMock }) as unknown as Logger); + + downloadPlatformUpdate = di.inject(downloadPlatformUpdateInjectable); + }); + + describe("when called", () => { + let actualPromise: Promise<{ downloadWasSuccessful: boolean }>; + let onDownloadProgressMock: jest.Mock; + + beforeEach(() => { + onDownloadProgressMock = jest.fn(); + + actualPromise = downloadPlatformUpdate(onDownloadProgressMock); + }); + + it("calls for downloading of update", () => { + expect(downloadUpdateMock).toHaveBeenCalled(); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("starts progress of download from 0", () => { + expect(onDownloadProgressMock).toHaveBeenCalledWith({ percentage: 0 }); + }); + + describe("when downloading progresses", () => { + beforeEach(() => { + onDownloadProgressMock.mockClear(); + + const [, callback] = electronUpdaterOnMock.mock.calls.find( + ([event]) => event === "download-progress", + ); + + callback({ + percent: 42, + total: 0, + delta: 0, + transferred: 0, + bytesPerSecond: 0, + }); + }); + + it("updates progress of the download", () => { + expect(onDownloadProgressMock).toHaveBeenCalledWith({ percentage: 42 }); + }); + + describe("when downloading resolves", () => { + beforeEach(async () => { + onDownloadProgressMock.mockClear(); + + await downloadUpdateMock.resolve(); + }); + + it("resolves with success", async () => { + const actual = await actualPromise; + + expect(actual).toEqual({ downloadWasSuccessful: true }); + }); + + it("does not reset progress of download yet", () => { + expect(onDownloadProgressMock).not.toHaveBeenCalled(); + }); + + it("stops watching for download progress", () => { + expect(electronUpdaterOffMock).toHaveBeenCalledWith( + "download-progress", + expect.any(Function), + ); + }); + + it("when starting download again, resets progress of download", () => { + downloadPlatformUpdate(onDownloadProgressMock); + + expect(onDownloadProgressMock).toHaveBeenCalledWith({ percentage: 0 }); + }); + }); + + describe("when downloading rejects", () => { + let errorStub: Error; + + beforeEach(() => { + errorStub = new Error("Some error"); + + downloadUpdateMock.reject(errorStub); + }); + + it("logs error", () => { + expect(logErrorMock).toHaveBeenCalledWith("[UPDATE-APP/DOWNLOAD]", errorStub); + }); + + it("stops watching for download progress", () => { + expect(electronUpdaterOffMock).toHaveBeenCalledWith( + "download-progress", + expect.any(Function), + ); + }); + + it("resolves with failure", async () => { + const actual = await actualPromise; + + expect(actual).toEqual({ downloadWasSuccessful: false }); + }); + }); + }); + }); +}); diff --git a/src/main/application-update/download-update/download-update.injectable.ts b/src/main/application-update/download-update/download-update.injectable.ts new file mode 100644 index 0000000000..d0ac141b9c --- /dev/null +++ b/src/main/application-update/download-update/download-update.injectable.ts @@ -0,0 +1,49 @@ +/** + * 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 downloadPlatformUpdateInjectable from "../download-platform-update/download-platform-update.injectable"; +import updateIsBeingDownloadedInjectable from "../../../common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable"; +import discoveredUpdateVersionInjectable from "../../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import { action, runInAction } from "mobx"; +import type { ProgressOfDownload } from "../../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; +import progressOfUpdateDownloadInjectable from "../../../common/application-update/progress-of-update-download/progress-of-update-download.injectable"; + +const downloadUpdateInjectable = getInjectable({ + id: "download-update", + + instantiate: (di) => { + const downloadPlatformUpdate = di.inject(downloadPlatformUpdateInjectable); + const downloadingUpdateState = di.inject(updateIsBeingDownloadedInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + const progressOfUpdateDownload = di.inject(progressOfUpdateDownloadInjectable); + + const updateDownloadProgress = action((progressOfDownload: ProgressOfDownload) => { + progressOfUpdateDownload.set(progressOfDownload); + }); + + return async () => { + runInAction(() => { + progressOfUpdateDownload.set({ percentage: 0 }); + downloadingUpdateState.set(true); + }); + + const { downloadWasSuccessful } = await downloadPlatformUpdate( + updateDownloadProgress, + ); + + runInAction(() => { + if (!downloadWasSuccessful) { + discoveredVersionState.set(null); + } + + downloadingUpdateState.set(false); + }); + + return { downloadWasSuccessful }; + }; + }, +}); + +export default downloadUpdateInjectable; diff --git a/src/main/application-update/install-application-update-tray-item.injectable.ts b/src/main/application-update/install-application-update-tray-item.injectable.ts new file mode 100644 index 0000000000..2f938964f8 --- /dev/null +++ b/src/main/application-update/install-application-update-tray-item.injectable.ts @@ -0,0 +1,56 @@ +/** + * 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 { computed } from "mobx"; +import { trayMenuItemInjectionToken } from "../tray/tray-menu-item/tray-menu-item-injection-token"; +import quitAndInstallUpdateInjectable from "../electron-app/features/quit-and-install-update.injectable"; +import discoveredUpdateVersionInjectable from "../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import updateIsBeingDownloadedInjectable from "../../common/application-update/update-is-being-downloaded/update-is-being-downloaded.injectable"; +import { withErrorSuppression } from "../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; +import withErrorLoggingInjectable from "../../common/utils/with-error-logging/with-error-logging.injectable"; + +const installApplicationUpdateTrayItemInjectable = getInjectable({ + id: "install-update-tray-item", + + instantiate: (di) => { + const quitAndInstallUpdate = di.inject(quitAndInstallUpdateInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + const downloadingUpdateState = di.inject(updateIsBeingDownloadedInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "install-update", + parentId: null, + orderNumber: 50, + + label: computed(() => { + const versionToBeInstalled = discoveredVersionState.value.get()?.version; + + return `Install update ${versionToBeInstalled}`; + }), + + enabled: computed(() => true), + + visible: computed( + () => !!discoveredVersionState.value.get() && !downloadingUpdateState.value.get(), + ), + + click: pipeline( + quitAndInstallUpdate, + + withErrorLoggingFor(() => "[TRAY]: Update installation failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default installApplicationUpdateTrayItemInjectable; diff --git a/src/main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable.ts b/src/main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable.ts new file mode 100644 index 0000000000..394383ee65 --- /dev/null +++ b/src/main/application-update/periodical-check-for-updates/periodical-check-for-updates.injectable.ts @@ -0,0 +1,35 @@ +/** + * 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 { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import processCheckingForUpdatesInjectable from "../check-for-updates/process-checking-for-updates.injectable"; + +const periodicalCheckForUpdatesInjectable = getInjectable({ + id: "periodical-check-for-updates", + + instantiate: (di) => { + const processCheckingForUpdates = di.inject(processCheckingForUpdatesInjectable); + + return getStartableStoppable("periodical-check-for-updates", () => { + const TWO_HOURS = 1000 * 60 * 60 * 2; + + // Note: intentional orphan promise to make checking for updates happen in the background + processCheckingForUpdates(); + + const intervalId = setInterval(() => { + // Note: intentional orphan promise to make checking for updates happen in the background + processCheckingForUpdates(); + }, TWO_HOURS); + + return () => { + clearInterval(intervalId); + }; + }); + }, + + causesSideEffects: true, +}); + +export default periodicalCheckForUpdatesInjectable; diff --git a/src/main/application-update/periodical-check-for-updates/start-checking-for-updates.injectable.ts b/src/main/application-update/periodical-check-for-updates/start-checking-for-updates.injectable.ts new file mode 100644 index 0000000000..9a9b9cf206 --- /dev/null +++ b/src/main/application-update/periodical-check-for-updates/start-checking-for-updates.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 periodicalCheckForUpdatesInjectable from "./periodical-check-for-updates.injectable"; +import { afterRootFrameIsReadyInjectionToken } from "../../start-main-application/runnable-tokens/after-root-frame-is-ready-injection-token"; +import updatingIsEnabledInjectable from "../updating-is-enabled.injectable"; + +const startCheckingForUpdatesInjectable = getInjectable({ + id: "start-checking-for-updates", + + instantiate: (di) => { + const periodicalCheckForUpdates = di.inject(periodicalCheckForUpdatesInjectable); + const updatingIsEnabled = di.inject(updatingIsEnabledInjectable); + + return { + run: async () => { + if (updatingIsEnabled) { + await periodicalCheckForUpdates.start(); + } + }, + }; + }, + + injectionToken: afterRootFrameIsReadyInjectionToken, +}); + +export default startCheckingForUpdatesInjectable; diff --git a/src/main/application-update/periodical-check-for-updates/stop-checking-for-updates.injectable.ts b/src/main/application-update/periodical-check-for-updates/stop-checking-for-updates.injectable.ts new file mode 100644 index 0000000000..13aefe4d96 --- /dev/null +++ b/src/main/application-update/periodical-check-for-updates/stop-checking-for-updates.injectable.ts @@ -0,0 +1,27 @@ +/** + * 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 { beforeQuitOfFrontEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-front-end-injection-token"; +import periodicalCheckForUpdatesInjectable from "./periodical-check-for-updates.injectable"; + +const stopCheckingForUpdatesInjectable = getInjectable({ + id: "stop-checking-for-updates", + + instantiate: (di) => { + const periodicalCheckForUpdates = di.inject(periodicalCheckForUpdatesInjectable); + + return { + run: async () => { + if (periodicalCheckForUpdates.started) { + await periodicalCheckForUpdates.stop(); + } + }, + }; + }, + + injectionToken: beforeQuitOfFrontEndInjectionToken, +}); + +export default stopCheckingForUpdatesInjectable; diff --git a/src/main/application-update/publish-is-configured.injectable.ts b/src/main/application-update/publish-is-configured.injectable.ts new file mode 100644 index 0000000000..321adc8a22 --- /dev/null +++ b/src/main/application-update/publish-is-configured.injectable.ts @@ -0,0 +1,20 @@ +/** + * 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 packageJsonInjectable from "../../common/vars/package-json.injectable"; +import { has } from "lodash/fp"; + +// TOOO: Rename to something less technical +const publishIsConfiguredInjectable = getInjectable({ + id: "publish-is-configured", + + instantiate: (di) => { + const packageJson = di.inject(packageJsonInjectable); + + return has("build.publish", packageJson); + }, +}); + +export default publishIsConfiguredInjectable; diff --git a/src/main/application-update/updating-is-enabled.injectable.ts b/src/main/application-update/updating-is-enabled.injectable.ts new file mode 100644 index 0000000000..df5e264219 --- /dev/null +++ b/src/main/application-update/updating-is-enabled.injectable.ts @@ -0,0 +1,20 @@ +/** + * 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 electronUpdaterIsActiveInjectable from "../electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "./publish-is-configured.injectable"; + +const updatingIsEnabledInjectable = getInjectable({ + id: "updating-is-enabled", + + instantiate: (di) => { + const electronUpdaterIsActive = di.inject(electronUpdaterIsActiveInjectable); + const publishIsConfigured = di.inject(publishIsConfiguredInjectable); + + return electronUpdaterIsActive && publishIsConfigured; + }, +}); + +export default updatingIsEnabledInjectable; diff --git a/src/main/application-update/watch-if-update-should-happen-on-quit/start-watching-if-update-should-happen-on-quit.injectable.ts b/src/main/application-update/watch-if-update-should-happen-on-quit/start-watching-if-update-should-happen-on-quit.injectable.ts new file mode 100644 index 0000000000..ef31cf5db5 --- /dev/null +++ b/src/main/application-update/watch-if-update-should-happen-on-quit/start-watching-if-update-should-happen-on-quit.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import watchIfUpdateShouldHappenOnQuitInjectable from "./watch-if-update-should-happen-on-quit.injectable"; + +const startWatchingIfUpdateShouldHappenOnQuitInjectable = getInjectable({ + id: "start-watching-if-update-should-happen-on-quit", + + instantiate: (di) => { + const watchIfUpdateShouldHappenOnQuit = di.inject(watchIfUpdateShouldHappenOnQuitInjectable); + + return { + run: () => { + watchIfUpdateShouldHappenOnQuit.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startWatchingIfUpdateShouldHappenOnQuitInjectable; diff --git a/src/main/application-update/watch-if-update-should-happen-on-quit/stop-watching-if-update-should-happen-on-quit.injectable.ts b/src/main/application-update/watch-if-update-should-happen-on-quit/stop-watching-if-update-should-happen-on-quit.injectable.ts new file mode 100644 index 0000000000..b66cf927f2 --- /dev/null +++ b/src/main/application-update/watch-if-update-should-happen-on-quit/stop-watching-if-update-should-happen-on-quit.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 watchIfUpdateShouldHappenOnQuitInjectable from "./watch-if-update-should-happen-on-quit.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; + +const stopWatchingIfUpdateShouldHappenOnQuitInjectable = getInjectable({ + id: "stop-watching-if-update-should-happen-on-quit", + + instantiate: (di) => { + const watchIfUpdateShouldHappenOnQuit = di.inject(watchIfUpdateShouldHappenOnQuitInjectable); + + return { + run: () => { + watchIfUpdateShouldHappenOnQuit.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopWatchingIfUpdateShouldHappenOnQuitInjectable; diff --git a/src/main/application-update/watch-if-update-should-happen-on-quit/watch-if-update-should-happen-on-quit.injectable.ts b/src/main/application-update/watch-if-update-should-happen-on-quit/watch-if-update-should-happen-on-quit.injectable.ts new file mode 100644 index 0000000000..12ec2d7c6e --- /dev/null +++ b/src/main/application-update/watch-if-update-should-happen-on-quit/watch-if-update-should-happen-on-quit.injectable.ts @@ -0,0 +1,54 @@ +/** + * 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 { autorun } from "mobx"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import setUpdateOnQuitInjectable from "../../electron-app/features/set-update-on-quit.injectable"; +import selectedUpdateChannelInjectable from "../../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import type { UpdateChannel } from "../../../common/application-update/update-channels"; +import discoveredUpdateVersionInjectable from "../../../common/application-update/discovered-update-version/discovered-update-version.injectable"; + +const watchIfUpdateShouldHappenOnQuitInjectable = getInjectable({ + id: "watch-if-update-should-happen-on-quit", + + instantiate: (di) => { + const setUpdateOnQuit = di.inject(setUpdateOnQuitInjectable); + const selectedUpdateChannel = di.inject(selectedUpdateChannelInjectable); + const discoveredVersionState = di.inject(discoveredUpdateVersionInjectable); + + return getStartableStoppable("watch-if-update-should-happen-on-quit", () => + autorun(() => { + const sufficientlyStableUpdateChannels = + getSufficientlyStableUpdateChannels(selectedUpdateChannel.value.get()); + + const discoveredVersion = discoveredVersionState.value.get(); + + const updateIsDiscoveredFromChannel = discoveredVersion?.updateChannel; + + const updateOnQuit = updateIsDiscoveredFromChannel + ? sufficientlyStableUpdateChannels.includes( + updateIsDiscoveredFromChannel, + ) + : false; + + setUpdateOnQuit(updateOnQuit); + }), + ); + }, +}); + +const getSufficientlyStableUpdateChannels = (updateChannel: UpdateChannel): UpdateChannel[] => { + if (!updateChannel.moreStableUpdateChannel) { + return [updateChannel]; + } + + return [ + updateChannel, + + ...getSufficientlyStableUpdateChannels(updateChannel.moreStableUpdateChannel), + ]; +}; + +export default watchIfUpdateShouldHappenOnQuitInjectable; diff --git a/src/main/ask-boolean/__snapshots__/ask-boolean.test.ts.snap b/src/main/ask-boolean/__snapshots__/ask-boolean.test.ts.snap new file mode 100644 index 0000000000..8176698168 --- /dev/null +++ b/src/main/ask-boolean/__snapshots__/ask-boolean.test.ts.snap @@ -0,0 +1,336 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ask-boolean given started when asking multiple questions renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-title + +

+ Some question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-other-title + +

+ Some other question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`ask-boolean given started when asking multiple questions when answering to first question renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-other-title + +

+ Some other question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`ask-boolean given started when asking question renders 1`] = ` + +
+
+
+
+ + + info_outline + + +
+
+
+ + some-title + +

+ Some question +

+
+ + +
+
+
+
+ + + close + + +
+
+
+
+ +`; + +exports[`ask-boolean given started when asking question when user answers "no" renders 1`] = ` + +
+
+
+ +`; + +exports[`ask-boolean given started when asking question when user answers "yes" renders 1`] = ` + +
+
+
+ +`; + +exports[`ask-boolean given started when asking question when user closes notification without answering the question renders 1`] = ` + +
+
+
+ +`; diff --git a/src/main/ask-boolean/ask-boolean-answer-channel-listener.injectable.ts b/src/main/ask-boolean/ask-boolean-answer-channel-listener.injectable.ts new file mode 100644 index 0000000000..06bc3972eb --- /dev/null +++ b/src/main/ask-boolean/ask-boolean-answer-channel-listener.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 type { AskBooleanAnswerChannel } from "../../common/ask-boolean/ask-boolean-answer-channel.injectable"; +import askBooleanAnswerChannelInjectable from "../../common/ask-boolean/ask-boolean-answer-channel.injectable"; +import askBooleanPromiseInjectable from "./ask-boolean-promise.injectable"; +import type { MessageChannelListener } from "../../common/utils/channel/message-channel-listener-injection-token"; +import { messageChannelListenerInjectionToken } from "../../common/utils/channel/message-channel-listener-injection-token"; + + +const askBooleanAnswerChannelListenerInjectable = getInjectable({ + id: "ask-boolean-answer-channel-listener", + + instantiate: (di): MessageChannelListener => ({ + channel: di.inject(askBooleanAnswerChannelInjectable), + + handler: ({ id, value }) => { + const answerPromise = di.inject(askBooleanPromiseInjectable, id); + + answerPromise.resolve(value); + }, + }), + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default askBooleanAnswerChannelListenerInjectable; diff --git a/src/main/ask-boolean/ask-boolean-promise.injectable.ts b/src/main/ask-boolean/ask-boolean-promise.injectable.ts new file mode 100644 index 0000000000..827714084f --- /dev/null +++ b/src/main/ask-boolean/ask-boolean-promise.injectable.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; + +const askBooleanPromiseInjectable = getInjectable({ + id: "ask-boolean-promise", + + instantiate: (di, questionId: string) => { + void questionId; + + let resolve: (value: boolean) => void; + + const promise = new Promise(_resolve => { + resolve = _resolve; + }); + + return ({ + promise, + + resolve: (value: boolean) => { + resolve(value); + }, + }); + }, + + lifecycle: lifecycleEnum.keyedSingleton({ + getInstanceKey: (di, questionId: string) => questionId, + }), +}); + +export default askBooleanPromiseInjectable; diff --git a/src/main/ask-boolean/ask-boolean.injectable.ts b/src/main/ask-boolean/ask-boolean.injectable.ts new file mode 100644 index 0000000000..1fa54629b2 --- /dev/null +++ b/src/main/ask-boolean/ask-boolean.injectable.ts @@ -0,0 +1,39 @@ +/** + * 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 { messageToChannelInjectionToken } from "../../common/utils/channel/message-to-channel-injection-token"; +import askBooleanQuestionChannelInjectable from "../../common/ask-boolean/ask-boolean-question-channel.injectable"; +import askBooleanPromiseInjectable from "./ask-boolean-promise.injectable"; +import getRandomIdInjectable from "../../common/utils/get-random-id.injectable"; + +export type AskBoolean = ({ + title, + question, +}: { + title: string; + question: string; +}) => Promise; + +const askBooleanInjectable = getInjectable({ + id: "ask-boolean", + + instantiate: (di): AskBoolean => { + const messageToChannel = di.inject(messageToChannelInjectionToken); + const askBooleanChannel = di.inject(askBooleanQuestionChannelInjectable); + const getRandomId = di.inject(getRandomIdInjectable); + + return async ({ title, question }) => { + const id = getRandomId(); + + const returnValuePromise = di.inject(askBooleanPromiseInjectable, id); + + await messageToChannel(askBooleanChannel, { id, title, question }); + + return await returnValuePromise.promise; + }; + }, +}); + +export default askBooleanInjectable; diff --git a/src/main/ask-boolean/ask-boolean.test.ts b/src/main/ask-boolean/ask-boolean.test.ts new file mode 100644 index 0000000000..79fecdceca --- /dev/null +++ b/src/main/ask-boolean/ask-boolean.test.ts @@ -0,0 +1,206 @@ +/** + * 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 { AskBoolean } from "./ask-boolean.injectable"; +import askBooleanInjectable from "./ask-boolean.injectable"; +import { getPromiseStatus } from "../../common/test-utils/get-promise-status"; +import type { RenderResult } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import getRandomIdInjectable from "../../common/utils/get-random-id.injectable"; + +describe("ask-boolean", () => { + let applicationBuilder: ApplicationBuilder; + let askBoolean: AskBoolean; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + const getRandomIdFake = jest + .fn() + .mockReturnValueOnce("some-random-id-1") + .mockReturnValueOnce("some-random-id-2"); + + applicationBuilder.dis.mainDi.override(getRandomIdInjectable, () => getRandomIdFake); + + askBoolean = applicationBuilder.dis.mainDi.inject(askBooleanInjectable); + }); + + describe("given started", () => { + let rendered: RenderResult; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + }); + + describe("when asking question", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = askBoolean({ + title: "some-title", + question: "Some question", + }); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows notification", () => { + const notification = rendered.getByTestId("ask-boolean-some-random-id-1"); + + expect(notification).not.toBeUndefined(); + }); + + describe('when user answers "yes"', () => { + beforeEach(() => { + const button = rendered.getByTestId("ask-boolean-some-random-id-1-button-yes"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-random-id-1"); + + expect(notification).toBeNull(); + }); + + it("resolves", async () => { + const actual = await actualPromise; + + expect(actual).toBe(true); + }); + }); + + describe('when user answers "no"', () => { + beforeEach(() => { + const button = rendered.getByTestId("ask-boolean-some-random-id-1-button-no"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-random-id-1"); + + expect(notification).toBeNull(); + }); + + it("resolves", async () => { + const actual = await actualPromise; + + expect(actual).toBe(false); + }); + }); + + describe("when user closes notification without answering the question", () => { + beforeEach(() => { + const button = rendered.getByTestId("close-notification-for-ask-boolean-for-some-random-id-1"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-random-id-1"); + + expect(notification).toBeNull(); + }); + + it("resolves", async () => { + const actual = await actualPromise; + + expect(actual).toBe(false); + }); + }); + }); + + describe("when asking multiple questions", () => { + let firstQuestionPromise: Promise; + let secondQuestionPromise: Promise; + + beforeEach(() => { + firstQuestionPromise = askBoolean({ + title: "some-title", + question: "Some question", + }); + + secondQuestionPromise = askBoolean({ + title: "some-other-title", + question: "Some other question", + }); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("shows notification for first question", () => { + const notification = rendered.getByTestId("ask-boolean-some-random-id-1"); + + expect(notification).not.toBeUndefined(); + }); + + it("shows notification for second question", () => { + const notification = rendered.getByTestId("ask-boolean-some-random-id-2"); + + expect(notification).not.toBeUndefined(); + }); + + describe("when answering to first question", () => { + beforeEach(() => { + const button = rendered.getByTestId("ask-boolean-some-random-id-1-button-no"); + + fireEvent.click(button); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not show notification for first question anymore", () => { + const notification = rendered.queryByTestId("ask-boolean-some-random-id-1"); + + expect(notification).toBeNull(); + }); + + it("still shows notification for second question", () => { + const notification = rendered.getByTestId("ask-boolean-some-random-id-2"); + + expect(notification).not.toBeUndefined(); + }); + + it("resolves first question", async () => { + const actual = await firstQuestionPromise; + + expect(actual).toBe(false); + }); + + it("does not resolve second question yet", async () => { + const promiseStatus = await getPromiseStatus(secondQuestionPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + }); + }); + }); +}); diff --git a/src/main/catalog-sync-to-renderer/catalog-sync-to-renderer.injectable.ts b/src/main/catalog-sync-to-renderer/catalog-sync-to-renderer.injectable.ts index 37dfd2f7fd..7e588481a8 100644 --- a/src/main/catalog-sync-to-renderer/catalog-sync-to-renderer.injectable.ts +++ b/src/main/catalog-sync-to-renderer/catalog-sync-to-renderer.injectable.ts @@ -17,6 +17,8 @@ const catalogSyncToRendererInjectable = getInjectable({ startCatalogSyncToRenderer(catalogEntityRegistry), ); }, + + causesSideEffects: true, }); export default catalogSyncToRendererInjectable; diff --git a/src/main/electron-app/features/electron-updater-is-active.injectable.ts b/src/main/electron-app/features/electron-updater-is-active.injectable.ts new file mode 100644 index 0000000000..2fe0d7bf06 --- /dev/null +++ b/src/main/electron-app/features/electron-updater-is-active.injectable.ts @@ -0,0 +1,18 @@ +/** + * 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 electronUpdaterInjectable from "./electron-updater.injectable"; + +const electronUpdaterIsActiveInjectable = getInjectable({ + id: "electron-updater-is-active", + + instantiate: (di) => { + const electronUpdater = di.inject(electronUpdaterInjectable); + + return electronUpdater.isUpdaterActive(); + }, +}); + +export default electronUpdaterIsActiveInjectable; diff --git a/src/main/electron-app/features/electron-updater.injectable.ts b/src/main/electron-app/features/electron-updater.injectable.ts new file mode 100644 index 0000000000..f9e3335343 --- /dev/null +++ b/src/main/electron-app/features/electron-updater.injectable.ts @@ -0,0 +1,14 @@ +/** + * 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 { autoUpdater } from "electron-updater"; + +const electronUpdaterInjectable = getInjectable({ + id: "electron-updater", + instantiate: () => autoUpdater, + causesSideEffects: true, +}); + +export default electronUpdaterInjectable; diff --git a/src/main/electron-app/features/quit-and-install-update.injectable.ts b/src/main/electron-app/features/quit-and-install-update.injectable.ts new file mode 100644 index 0000000000..6b313e21b0 --- /dev/null +++ b/src/main/electron-app/features/quit-and-install-update.injectable.ts @@ -0,0 +1,20 @@ +/** + * 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 electronUpdaterInjectable from "./electron-updater.injectable"; + +const quitAndInstallUpdateInjectable = getInjectable({ + id: "quit-and-install-update", + + instantiate: (di) => { + const electronUpdater = di.inject(electronUpdaterInjectable); + + return () => { + electronUpdater.quitAndInstall(true, true); + }; + }, +}); + +export default quitAndInstallUpdateInjectable; diff --git a/src/main/electron-app/features/set-update-on-quit.injectable.ts b/src/main/electron-app/features/set-update-on-quit.injectable.ts new file mode 100644 index 0000000000..43693f8eed --- /dev/null +++ b/src/main/electron-app/features/set-update-on-quit.injectable.ts @@ -0,0 +1,20 @@ +/** + * 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 electronUpdaterInjectable from "./electron-updater.injectable"; + +const setUpdateOnQuitInjectable = getInjectable({ + id: "set-update-on-quit", + + instantiate: (di) => { + const electronUpdater = di.inject(electronUpdaterInjectable); + + return (updateOnQuit: boolean) => { + electronUpdater.autoInstallOnAppQuit = updateOnQuit; + }; + }, +}); + +export default setUpdateOnQuitInjectable; diff --git a/src/main/electron-app/runnables/setup-update-checking.injectable.ts b/src/main/electron-app/runnables/setup-update-checking.injectable.ts deleted file mode 100644 index 918e985265..0000000000 --- a/src/main/electron-app/runnables/setup-update-checking.injectable.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 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 { afterRootFrameIsReadyInjectionToken } from "../../start-main-application/runnable-tokens/after-root-frame-is-ready-injection-token"; -import startUpdateCheckingInjectable from "../../start-update-checking.injectable"; - -const setupUpdateCheckingInjectable = getInjectable({ - id: "setup-update-checking", - - instantiate: (di) => { - const startUpdateChecking = di.inject(startUpdateCheckingInjectable); - - return { - run: () => { - startUpdateChecking(); - }, - }; - }, - - injectionToken: afterRootFrameIsReadyInjectionToken, -}); - -export default setupUpdateCheckingInjectable; diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index 5889e30090..2cc78ec528 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -4,12 +4,11 @@ */ import glob from "glob"; -import { kebabCase, memoize } from "lodash/fp"; +import { kebabCase, memoize, noop } from "lodash/fp"; import type { DiContainer } from "@ogre-tools/injectable"; import { createContainer } from "@ogre-tools/injectable"; import { Environments, setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import appNameInjectable from "./app-paths/app-name/app-name.injectable"; -import registerChannelInjectable from "./app-paths/register-channel/register-channel.injectable"; import writeJsonFileInjectable from "../common/fs/write-json-file.injectable"; import readJsonFileInjectable from "../common/fs/read-json-file.injectable"; import readFileInjectable from "../common/fs/read-file.injectable"; @@ -29,8 +28,6 @@ import { getAbsolutePathFake } from "../common/test-utils/get-absolute-path-fake import joinPathsInjectable from "../common/path/join-paths.injectable"; import { joinPathsFake } from "../common/test-utils/join-paths-fake"; import hotbarStoreInjectable from "../common/hotbars/store.injectable"; -import type { GetDiForUnitTestingOptions } from "../test-utils/get-dis-for-unit-testing"; -import isAutoUpdateEnabledInjectable from "./is-auto-update-enabled.injectable"; import appEventBusInjectable from "../common/app-event-bus/app-event-bus.injectable"; import { EventEmitter } from "../common/event-emitter"; import type { AppEvent } from "../common/app-event-bus/event-bus"; @@ -45,7 +42,6 @@ import setupSentryInjectable from "./start-main-application/runnables/setup-sent import setupShellInjectable from "./start-main-application/runnables/setup-shell.injectable"; import setupSyncingOfWeblinksInjectable from "./start-main-application/runnables/setup-syncing-of-weblinks.injectable"; import stopServicesAndExitAppInjectable from "./stop-services-and-exit-app.injectable"; -import trayInjectable from "./tray/tray.injectable"; import applicationMenuInjectable from "./menu/application-menu.injectable"; import isDevelopmentInjectable from "../common/vars/is-development.injectable"; import setupSystemCaInjectable from "./start-main-application/runnables/setup-system-ca.injectable"; @@ -67,7 +63,7 @@ import type { ClusterFrameInfo } from "../common/cluster-frames"; 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 "./app-paths/register-channel/ipc-main/ipc-main.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 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"; @@ -75,10 +71,21 @@ import broadcastMessageInjectable from "../common/ipc/broadcast-message.injectab import getElectronThemeInjectable from "./electron-app/features/get-electron-theme.injectable"; import syncThemeFromOperatingSystemInjectable from "./electron-app/features/sync-theme-from-operating-system.injectable"; import platformInjectable from "../common/vars/platform.injectable"; -import { noop } from "../renderer/utils"; +import productNameInjectable from "./app-paths/app-name/product-name.injectable"; +import quitAndInstallUpdateInjectable from "./electron-app/features/quit-and-install-update.injectable"; +import electronUpdaterIsActiveInjectable from "./electron-app/features/electron-updater-is-active.injectable"; +import publishIsConfiguredInjectable from "./application-update/publish-is-configured.injectable"; +import checkForPlatformUpdatesInjectable from "./application-update/check-for-platform-updates/check-for-platform-updates.injectable"; import baseBundeledBinariesDirectoryInjectable from "../common/vars/base-bundled-binaries-dir.injectable"; +import setUpdateOnQuitInjectable from "./electron-app/features/set-update-on-quit.injectable"; +import downloadPlatformUpdateInjectable from "./application-update/download-platform-update/download-platform-update.injectable"; +import startCatalogSyncInjectable from "./catalog-sync-to-renderer/start-catalog-sync.injectable"; +import startKubeConfigSyncInjectable from "./start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable"; +import appVersionInjectable from "../common/get-configuration-file-model/app-version/app-version.injectable"; +import getRandomIdInjectable from "../common/utils/get-random-id.injectable"; +import periodicalCheckForUpdatesInjectable from "./application-update/periodical-check-for-updates/periodical-check-for-updates.injectable"; -export function getDiForUnitTesting(opts: GetDiForUnitTestingOptions = {}) { +export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {}) { const { doGeneralOverrides = false, } = opts; @@ -100,10 +107,11 @@ export function getDiForUnitTesting(opts: GetDiForUnitTestingOptions = {}) { di.preventSideEffects(); if (doGeneralOverrides) { + di.override(getRandomIdInjectable, () => () => "some-irrelevant-random-id"); di.override(hotbarStoreInjectable, () => ({ load: () => {} })); - di.override(userStoreInjectable, () => ({ startMainReactions: () => {} }) as UserStore); + di.override(userStoreInjectable, () => ({ startMainReactions: () => {}, extensionRegistryUrl: { customUrl: "some-custom-url" }}) as UserStore); di.override(extensionsStoreInjectable, () => ({ isEnabled: (opts) => (void opts, false) }) as ExtensionsStore); - di.override(clusterStoreInjectable, () => ({ getById: (id) => (void id, {}) as Cluster }) as ClusterStore); + di.override(clusterStoreInjectable, () => ({ provideInitialFromMain: () => {}, getById: (id) => (void id, {}) as Cluster }) as ClusterStore); di.override(fileSystemProvisionerStoreInjectable, () => ({}) as FileSystemProvisionerStore); overrideOperatingSystem(di); @@ -114,19 +122,22 @@ export function getDiForUnitTesting(opts: GetDiForUnitTestingOptions = {}) { di.override(environmentVariablesInjectable, () => ({})); di.override(commandLineArgumentsInjectable, () => []); + di.override(productNameInjectable, () => "some-product-name"); + di.override(appVersionInjectable, () => "1.0.0"); + di.override(clusterFramesInjectable, () => observable.map()); di.override(stopServicesAndExitAppInjectable, () => () => {}); di.override(lensResourcesDirInjectable, () => "/irrelevant"); - di.override(trayInjectable, () => ({ start: () => {}, stop: () => {} })); di.override(applicationMenuInjectable, () => ({ start: () => {}, stop: () => {} })); + di.override(periodicalCheckForUpdatesInjectable, () => ({ start: () => {}, stop: () => {}, started: false })); + // TODO: Remove usages of globally exported appEventBus to get rid of this di.override(appEventBusInjectable, () => new EventEmitter<[AppEvent]>()); di.override(appNameInjectable, () => "some-app-name"); - di.override(registerChannelInjectable, () => () => undefined); di.override(broadcastMessageInjectable, () => (channel) => { throw new Error(`Tried to broadcast message to channel "${channel}" over IPC without explicit override.`); }); @@ -182,6 +193,8 @@ const overrideRunnablesHavingSideEffects = (di: DiContainer) => { setupSystemCaInjectable, setupListenerForCurrentClusterFrameInjectable, setupRunnablesAfterWindowIsOpenedInjectable, + startCatalogSyncInjectable, + startKubeConfigSyncInjectable, ].forEach((injectable) => { di.override(injectable, () => ({ run: () => {} })); }); @@ -215,6 +228,13 @@ const overrideElectronFeatures = (di: DiContainer) => { di.override(ipcMainInjectable, () => ({})); di.override(getElectronThemeInjectable, () => () => "dark"); di.override(syncThemeFromOperatingSystemInjectable, () => ({ start: () => {}, stop: () => {} })); + di.override(quitAndInstallUpdateInjectable, () => () => {}); + di.override(setUpdateOnQuitInjectable, () => () => {}); + di.override(downloadPlatformUpdateInjectable, () => async () => ({ downloadWasSuccessful: true })); + + di.override(checkForPlatformUpdatesInjectable, () => () => { + throw new Error("Tried to check for platform updates without explicit override."); + }); di.override(createElectronWindowForInjectable, () => () => async () => ({ show: () => {}, @@ -234,5 +254,6 @@ const overrideElectronFeatures = (di: DiContainer) => { ); di.override(setElectronAppPathInjectable, () => () => {}); - di.override(isAutoUpdateEnabledInjectable, () => () => false); + di.override(publishIsConfiguredInjectable, () => false); + di.override(electronUpdaterIsActiveInjectable, () => false); }; diff --git a/src/main/is-auto-update-enabled.injectable.ts b/src/main/is-auto-update-enabled.injectable.ts deleted file mode 100644 index c76bd27e45..0000000000 --- a/src/main/is-auto-update-enabled.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 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 { isPublishConfigured } from "../common/vars"; -import { autoUpdater } from "electron-updater"; - -const isAutoUpdateEnabledInjectable = getInjectable({ - id: "is-auto-update-enabled", - - instantiate: () => () => { - return autoUpdater.isUpdaterActive() && isPublishConfigured; - }, - - causesSideEffects: true, -}); - -export default isAutoUpdateEnabledInjectable; diff --git a/src/main/menu/application-menu-items.injectable.ts b/src/main/menu/application-menu-items.injectable.ts index 417c40ac2e..4ea447f675 100644 --- a/src/main/menu/application-menu-items.injectable.ts +++ b/src/main/menu/application-menu-items.injectable.ts @@ -3,7 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { checkForUpdates } from "../app-updater"; import { docsUrl, productName, supportUrl } from "../../common/vars"; import { broadcastMessage } from "../../common/ipc"; import { openBrowser } from "../../common/utils"; @@ -12,7 +11,7 @@ import { webContents } from "electron"; import loggerInjectable from "../../common/logger.injectable"; import appNameInjectable from "../app-paths/app-name/app-name.injectable"; import electronMenuItemsInjectable from "./electron-menu-items.injectable"; -import isAutoUpdateEnabledInjectable from "../is-auto-update-enabled.injectable"; +import updatingIsEnabledInjectable from "../application-update/updating-is-enabled.injectable"; import navigateToPreferencesInjectable from "../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; import navigateToExtensionsInjectable from "../../common/front-end-routing/routes/extensions/navigate-to-extensions.injectable"; import navigateToCatalogInjectable from "../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; @@ -25,6 +24,7 @@ import showAboutInjectable from "./show-about.injectable"; import applicationWindowInjectable from "../start-main-application/lens-window/application-window/application-window.injectable"; import reloadWindowInjectable from "../start-main-application/lens-window/reload-window.injectable"; import showApplicationWindowInjectable from "../start-main-application/lens-window/show-application-window.injectable"; +import processCheckingForUpdatesInjectable from "../application-update/check-for-updates/process-checking-for-updates.injectable"; function ignoreIf(check: boolean, menuItems: MenuItemOpts[]) { return check ? [] : menuItems; @@ -41,7 +41,7 @@ const applicationMenuItemsInjectable = getInjectable({ const logger = di.inject(loggerInjectable); const appName = di.inject(appNameInjectable); const isMac = di.inject(isMacInjectable); - const isAutoUpdateEnabled = di.inject(isAutoUpdateEnabledInjectable); + const updatingIsEnabled = di.inject(updatingIsEnabledInjectable); const electronMenuItems = di.inject(electronMenuItemsInjectable); const showAbout = di.inject(showAboutInjectable); const applicationWindow = di.inject(applicationWindowInjectable); @@ -53,12 +53,11 @@ const applicationMenuItemsInjectable = getInjectable({ const navigateToWelcome = di.inject(navigateToWelcomeInjectable); const navigateToAddCluster = di.inject(navigateToAddClusterInjectable); const stopServicesAndExitApp = di.inject(stopServicesAndExitAppInjectable); + const processCheckingForUpdates = di.inject(processCheckingForUpdatesInjectable); + + logger.info(`[MENU]: autoUpdateEnabled=${updatingIsEnabled}`); return computed((): MenuItemOpts[] => { - const autoUpdateDisabled = !isAutoUpdateEnabled(); - - logger.info(`[MENU]: autoUpdateDisabled=${autoUpdateDisabled}`); - const macAppMenu: MenuItemOpts = { label: appName, id: "root", @@ -70,11 +69,11 @@ const applicationMenuItemsInjectable = getInjectable({ showAbout(); }, }, - ...ignoreIf(autoUpdateDisabled, [ + ...ignoreIf(!updatingIsEnabled, [ { label: "Check for updates", click() { - checkForUpdates().then(() => showApplicationWindow()); + processCheckingForUpdates().then(() => showApplicationWindow()); }, }, ]), @@ -282,11 +281,11 @@ const applicationMenuItemsInjectable = getInjectable({ showAbout(); }, }, - ...ignoreIf(autoUpdateDisabled, [ + ...ignoreIf(!updatingIsEnabled, [ { label: "Check for updates", click() { - checkForUpdates().then(() => + processCheckingForUpdates().then(() => showApplicationWindow(), ); }, 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 dd3feb3020..c63191c71c 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 @@ -11,7 +11,7 @@ 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 "../../../app-paths/register-channel/ipc-main/ipc-main.injectable"; +import ipcMainInjectable from "../../../utils/channel/ipc-main/ipc-main.injectable"; const applicationWindowInjectable = getInjectable({ id: "application-window", 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 c93877ee6a..a44b0ebd4c 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 @@ -58,9 +58,9 @@ const createLensWindowInjectable = getInjectable({ browserWindow?.close(); browserWindow = undefined; }, - send: async (args: SendToViewArgs) => { + send: (args: SendToViewArgs) => { if (!browserWindow) { - browserWindow = await createElectronWindow(); + throw new Error(`Tried to send message to window "${configuration.id}" but the window was closed`); } return browserWindow.send(args); 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 f7273206c9..3e62b0894b 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 @@ -14,7 +14,7 @@ export interface SendToViewArgs { export interface LensWindow { show: () => Promise; close: () => void; - send: (args: SendToViewArgs) => Promise; + send: (args: SendToViewArgs) => void; visible: boolean; } diff --git a/src/main/start-main-application/lens-window/navigate-for-extension.injectable.ts b/src/main/start-main-application/lens-window/navigate-for-extension.injectable.ts index 703ef15f60..7bada3f3bd 100644 --- a/src/main/start-main-application/lens-window/navigate-for-extension.injectable.ts +++ b/src/main/start-main-application/lens-window/navigate-for-extension.injectable.ts @@ -36,7 +36,7 @@ const navigateForExtensionInjectable = getInjectable({ (frameInfo) => frameInfo.frameId === frameId, ); - await applicationWindow.send({ + applicationWindow.send({ channel: "extension:navigate", frameInfo, data: [extId, pageId, params], diff --git a/src/main/start-main-application/lens-window/navigate.injectable.ts b/src/main/start-main-application/lens-window/navigate.injectable.ts index c7cbb10d24..f9d80e4205 100644 --- a/src/main/start-main-application/lens-window/navigate.injectable.ts +++ b/src/main/start-main-application/lens-window/navigate.injectable.ts @@ -29,7 +29,7 @@ const navigateInjectable = getInjectable({ ? IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER : IpcRendererNavigationEvents.NAVIGATE_IN_APP; - await applicationWindow.send({ + applicationWindow.send({ channel, frameInfo, data: [url], diff --git a/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts b/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts index d6d964c7df..80c725e17d 100644 --- a/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts +++ b/src/main/start-main-application/runnables/kube-config-sync/start-kube-config-sync.injectable.ts @@ -25,6 +25,8 @@ const startKubeConfigSyncInjectable = getInjectable({ }; }, + causesSideEffects: true, + injectionToken: afterRootFrameIsReadyInjectionToken, }); diff --git a/src/main/start-main-application/runnables/root-frame-rendered-channel-listener/root-frame-rendered-channel-listener.injectable.ts b/src/main/start-main-application/runnables/root-frame-rendered-channel-listener/root-frame-rendered-channel-listener.injectable.ts new file mode 100644 index 0000000000..83ebf7bf91 --- /dev/null +++ b/src/main/start-main-application/runnables/root-frame-rendered-channel-listener/root-frame-rendered-channel-listener.injectable.ts @@ -0,0 +1,35 @@ +/** + * 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 rootFrameRenderedChannelInjectable from "../../../../common/root-frame-rendered-channel/root-frame-rendered-channel.injectable"; +import { runManyFor } from "../../../../common/runnable/run-many-for"; +import { afterRootFrameIsReadyInjectionToken } from "../../runnable-tokens/after-root-frame-is-ready-injection-token"; +import { messageChannelListenerInjectionToken } from "../../../../common/utils/channel/message-channel-listener-injection-token"; + +const rootFrameRenderedChannelListenerInjectable = getInjectable({ + id: "root-frame-rendered-channel-listener", + + instantiate: (di) => { + const channel = di.inject(rootFrameRenderedChannelInjectable); + + const runMany = runManyFor(di); + + const runRunnablesAfterRootFrameIsReady = runMany( + afterRootFrameIsReadyInjectionToken, + ); + + return { + channel, + + handler: async () => { + await runRunnablesAfterRootFrameIsReady(); + }, + }; + }, + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default rootFrameRenderedChannelListenerInjectable; diff --git a/src/main/start-main-application/runnables/setup-sentry.injectable.ts b/src/main/start-main-application/runnables/setup-sentry.injectable.ts index e9f0587450..d100b93cc6 100644 --- a/src/main/start-main-application/runnables/setup-sentry.injectable.ts +++ b/src/main/start-main-application/runnables/setup-sentry.injectable.ts @@ -5,7 +5,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import { initializeSentryReporting } from "../../../common/sentry"; import { init } from "@sentry/electron/main"; -import { onLoadOfApplicationInjectionToken } from "../runnable-tokens/on-load-of-application-injection-token"; +import { beforeApplicationIsLoadingInjectionToken } from "../runnable-tokens/before-application-is-loading-injection-token"; const setupSentryInjectable = getInjectable({ id: "setup-sentry", @@ -18,7 +18,7 @@ const setupSentryInjectable = getInjectable({ causesSideEffects: true, - injectionToken: onLoadOfApplicationInjectionToken, + injectionToken: beforeApplicationIsLoadingInjectionToken, }); export default setupSentryInjectable; diff --git a/src/main/start-update-checking.injectable.ts b/src/main/start-update-checking.injectable.ts deleted file mode 100644 index 4571e70df4..0000000000 --- a/src/main/start-update-checking.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 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 { startUpdateChecking } from "./app-updater"; -import isAutoUpdateEnabledInjectable from "./is-auto-update-enabled.injectable"; - -const startUpdateCheckingInjectable = getInjectable({ - id: "start-update-checking", - - instantiate: (di) => startUpdateChecking({ - isAutoUpdateEnabled: di.inject(isAutoUpdateEnabledInjectable), - }), - - causesSideEffects: true, -}); - -export default startUpdateCheckingInjectable; diff --git a/src/main/tray/electron-tray/electron-tray.injectable.ts b/src/main/tray/electron-tray/electron-tray.injectable.ts new file mode 100644 index 0000000000..409e7abf3f --- /dev/null +++ b/src/main/tray/electron-tray/electron-tray.injectable.ts @@ -0,0 +1,116 @@ +/** + * 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 { Menu, Tray } from "electron"; +import packageJsonInjectable from "../../../common/vars/package-json.injectable"; +import logger from "../../logger"; +import { TRAY_LOG_PREFIX } from "../tray"; +import showApplicationWindowInjectable from "../../start-main-application/lens-window/show-application-window.injectable"; +import type { TrayMenuItem } from "../tray-menu-item/tray-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { isEmpty, map, filter } from "lodash/fp"; +import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import trayIconPathInjectable from "../tray-icon-path.injectable"; + +const electronTrayInjectable = getInjectable({ + id: "electron-tray", + + instantiate: (di) => { + const packageJson = di.inject(packageJsonInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const isWindows = di.inject(isWindowsInjectable); + const logger = di.inject(loggerInjectable); + const trayIconPath = di.inject(trayIconPathInjectable); + + let tray: Tray; + + return { + start: () => { + tray = new Tray(trayIconPath); + + tray.setToolTip(packageJson.description); + tray.setIgnoreDoubleClickEvents(true); + + if (isWindows) { + tray.on("click", () => { + showApplicationWindow() + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); + }); + } + }, + + stop: () => { + tray.destroy(); + }, + + setMenuItems: (items: TrayMenuItem[]) => { + pipeline( + items, + convertToElectronMenuTemplate, + Menu.buildFromTemplate, + + (template) => { + tray.setContextMenu(template); + }, + ); + }, + }; + }, + + causesSideEffects: true, +}); + +export default electronTrayInjectable; + +const convertToElectronMenuTemplate = (trayMenuItems: TrayMenuItem[]) => { + const _toTrayMenuOptions = (parentId: string | null) => + pipeline( + trayMenuItems, + + filter((item) => item.parentId === parentId), + + map( + (trayMenuItem: TrayMenuItem): Electron.MenuItemConstructorOptions => { + if (trayMenuItem.separator) { + return { id: trayMenuItem.id, type: "separator" }; + } + + const childItems = _toTrayMenuOptions(trayMenuItem.id); + + return { + id: trayMenuItem.id, + label: trayMenuItem.label?.get(), + enabled: trayMenuItem.enabled.get(), + toolTip: trayMenuItem.tooltip, + + ...(isEmpty(childItems) + ? { + type: "normal", + submenu: _toTrayMenuOptions(trayMenuItem.id), + + click: () => { + try { + trayMenuItem.click?.(); + } catch (error) { + logger.error( + `${TRAY_LOG_PREFIX}: clicking item "${trayMenuItem.id} failed."`, + { error }, + ); + } + }, + } + : { + type: "submenu", + submenu: _toTrayMenuOptions(trayMenuItem.id), + }), + + }; + }, + ), + ); + + return _toTrayMenuOptions(null); +}; diff --git a/src/main/tray/electron-tray/start-tray.injectable.ts b/src/main/tray/electron-tray/start-tray.injectable.ts new file mode 100644 index 0000000000..1a223ac3a5 --- /dev/null +++ b/src/main/tray/electron-tray/start-tray.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import electronTrayInjectable from "./electron-tray.injectable"; + +const startTrayInjectable = getInjectable({ + id: "start-tray", + + instantiate: (di) => { + const electronTray = di.inject(electronTrayInjectable); + + return { + run: () => { + electronTray.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startTrayInjectable; diff --git a/src/main/tray/electron-tray/stop-tray.injectable.ts b/src/main/tray/electron-tray/stop-tray.injectable.ts new file mode 100644 index 0000000000..f66ffb3a64 --- /dev/null +++ b/src/main/tray/electron-tray/stop-tray.injectable.ts @@ -0,0 +1,28 @@ +/** + * 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 electronTrayInjectable from "./electron-tray.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; +import stopReactiveTrayMenuItemsInjectable from "../reactive-tray-menu-items/stop-reactive-tray-menu-items.injectable"; + +const stopTrayInjectable = getInjectable({ + id: "stop-tray", + + instantiate: (di) => { + const electronTray = di.inject(electronTrayInjectable); + + return { + run: () => { + electronTray.stop(); + }, + + runAfter: di.inject(stopReactiveTrayMenuItemsInjectable), + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopTrayInjectable; diff --git a/src/main/tray/install-tray.injectable.ts b/src/main/tray/install-tray.injectable.ts deleted file mode 100644 index 716d602101..0000000000 --- a/src/main/tray/install-tray.injectable.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 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 trayInjectable from "./tray.injectable"; -import { onLoadOfApplicationInjectionToken } from "../start-main-application/runnable-tokens/on-load-of-application-injection-token"; - -const installTrayInjectable = getInjectable({ - id: "install-tray", - - instantiate: (di) => { - const trayInitializer = di.inject(trayInjectable); - - return { - run: async () => { - await trayInitializer.start(); - }, - }; - }, - - injectionToken: onLoadOfApplicationInjectionToken, -}); - -export default installTrayInjectable; diff --git a/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.injectable.ts b/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.injectable.ts new file mode 100644 index 0000000000..b11654393a --- /dev/null +++ b/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.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 { getInjectable } from "@ogre-tools/injectable"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import { autorun } from "mobx"; +import trayMenuItemsInjectable from "../tray-menu-item/tray-menu-items.injectable"; +import electronTrayInjectable from "../electron-tray/electron-tray.injectable"; + +const reactiveTrayMenuItemsInjectable = getInjectable({ + id: "reactive-tray-menu-items", + + instantiate: (di) => { + const electronTray = di.inject(electronTrayInjectable); + const trayMenuItems = di.inject(trayMenuItemsInjectable); + + return getStartableStoppable("reactive-tray-menu-items", () => autorun(() => { + electronTray.setMenuItems(trayMenuItems.get()); + })); + }, +}); + +export default reactiveTrayMenuItemsInjectable; diff --git a/src/main/tray/reactive-tray-menu-items/start-reactive-tray-menu-items.injectable.ts b/src/main/tray/reactive-tray-menu-items/start-reactive-tray-menu-items.injectable.ts new file mode 100644 index 0000000000..63025e6a9a --- /dev/null +++ b/src/main/tray/reactive-tray-menu-items/start-reactive-tray-menu-items.injectable.ts @@ -0,0 +1,28 @@ +/** + * 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 reactiveTrayMenuItemsInjectable from "./reactive-tray-menu-items.injectable"; +import { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import startTrayInjectable from "../electron-tray/start-tray.injectable"; + +const startReactiveTrayMenuItemsInjectable = getInjectable({ + id: "start-reactive-tray-menu-items", + + instantiate: (di) => { + const reactiveTrayMenuItems = di.inject(reactiveTrayMenuItemsInjectable); + + return { + run: async () => { + await reactiveTrayMenuItems.start(); + }, + + runAfter: di.inject(startTrayInjectable), + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startReactiveTrayMenuItemsInjectable; diff --git a/src/main/tray/reactive-tray-menu-items/stop-reactive-tray-menu-items.injectable.ts b/src/main/tray/reactive-tray-menu-items/stop-reactive-tray-menu-items.injectable.ts new file mode 100644 index 0000000000..384cdc253a --- /dev/null +++ b/src/main/tray/reactive-tray-menu-items/stop-reactive-tray-menu-items.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 reactiveTrayMenuItemsInjectable from "./reactive-tray-menu-items.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; + +const stopReactiveTrayMenuItemsInjectable = getInjectable({ + id: "stop-reactive-tray-menu-items", + + instantiate: (di) => { + const reactiveTrayMenuItems = di.inject(reactiveTrayMenuItemsInjectable); + + return { + run: async () => { + await reactiveTrayMenuItems.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopReactiveTrayMenuItemsInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts new file mode 100644 index 0000000000..5fb1a9f34f --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/about-app-tray-item.injectable.ts @@ -0,0 +1,51 @@ +/** + * 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 productNameInjectable from "../../../app-paths/app-name/product-name.injectable"; +import showApplicationWindowInjectable from "../../../start-main-application/lens-window/show-application-window.injectable"; +import showAboutInjectable from "../../../menu/show-about.injectable"; +import { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import { computed } from "mobx"; +import withErrorLoggingInjectable from "../../../../common/utils/with-error-logging/with-error-logging.injectable"; +import { withErrorSuppression } from "../../../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; + +const aboutAppTrayItemInjectable = getInjectable({ + id: "about-app-tray-item", + + instantiate: (di) => { + const productName = di.inject(productNameInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const showAbout = di.inject(showAboutInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "about-app", + parentId: null, + orderNumber: 140, + label: computed(() => `About ${productName}`), + enabled: computed(() => true), + visible: computed(() => true), + + click: pipeline( + async () => { + await showApplicationWindow(); + + await showAbout(); + }, + + withErrorLoggingFor(() => "[TRAY]: Opening of show about failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default aboutAppTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.ts new file mode 100644 index 0000000000..ff19d7718a --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/open-app-tray-item.injectable.ts @@ -0,0 +1,47 @@ +/** + * 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 { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import productNameInjectable from "../../../app-paths/app-name/product-name.injectable"; +import showApplicationWindowInjectable from "../../../start-main-application/lens-window/show-application-window.injectable"; +import { computed } from "mobx"; +import withErrorLoggingInjectable from "../../../../common/utils/with-error-logging/with-error-logging.injectable"; +import { withErrorSuppression } from "../../../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; + +const openAppTrayItemInjectable = getInjectable({ + id: "open-app-tray-item", + + instantiate: (di) => { + const productName = di.inject(productNameInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "open-app", + parentId: null, + label: computed(() => `Open ${productName}`), + orderNumber: 10, + enabled: computed(() => true), + visible: computed(() => true), + + click: pipeline( + async () => { + await showApplicationWindow(); + }, + + withErrorLoggingFor(() => "[TRAY]: Opening of application window failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default openAppTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts new file mode 100644 index 0000000000..8c062f6a29 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/open-preferences-tray-item.injectable.ts @@ -0,0 +1,43 @@ +/** + * 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 { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import navigateToPreferencesInjectable from "../../../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; +import { computed } from "mobx"; +import { withErrorSuppression } from "../../../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; +import withErrorLoggingInjectable from "../../../../common/utils/with-error-logging/with-error-logging.injectable"; + +const openPreferencesTrayItemInjectable = getInjectable({ + id: "open-preferences-tray-item", + + instantiate: (di) => { + const navigateToPreferences = di.inject(navigateToPreferencesInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "open-preferences", + parentId: null, + label: computed(() => "Preferences"), + orderNumber: 20, + enabled: computed(() => true), + visible: computed(() => true), + + click: pipeline( + navigateToPreferences, + + withErrorLoggingFor(() => "[TRAY]: Opening of preferences failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default openPreferencesTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.injectable.ts new file mode 100644 index 0000000000..de83a92fe6 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/quit-app-separator-tray-item.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 { getInjectable } from "@ogre-tools/injectable"; +import { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import { computed } from "mobx"; + +const quitAppSeparatorTrayItemInjectable = getInjectable({ + id: "quit-app-separator-tray-item", + + instantiate: () => ({ + id: "quit-app-separator", + parentId: null, + orderNumber: 149, + enabled: computed(() => true), + visible: computed(() => true), + separator: true, + }), + + injectionToken: trayMenuItemInjectionToken, +}); + +export default quitAppSeparatorTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts b/src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts new file mode 100644 index 0000000000..894a823511 --- /dev/null +++ b/src/main/tray/tray-menu-item/implementations/quit-app-tray-item.injectable.ts @@ -0,0 +1,43 @@ +/** + * 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 { trayMenuItemInjectionToken } from "../tray-menu-item-injection-token"; +import { computed } from "mobx"; +import stopServicesAndExitAppInjectable from "../../../stop-services-and-exit-app.injectable"; +import { withErrorSuppression } from "../../../../common/utils/with-error-suppression/with-error-suppression"; +import { pipeline } from "@ogre-tools/fp"; +import withErrorLoggingInjectable from "../../../../common/utils/with-error-logging/with-error-logging.injectable"; + +const quitAppTrayItemInjectable = getInjectable({ + id: "quit-app-tray-item", + + instantiate: (di) => { + const stopServicesAndExitApp = di.inject(stopServicesAndExitAppInjectable); + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + return { + id: "quit-app", + parentId: null, + orderNumber: 150, + label: computed(() => "Quit App"), + enabled: computed(() => true), + visible: computed(() => true), + + click: pipeline( + stopServicesAndExitApp, + + withErrorLoggingFor(() => "[TRAY]: Quitting application failed."), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default quitAppTrayItemInjectable; diff --git a/src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts b/src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts new file mode 100644 index 0000000000..f8e9d7c6cc --- /dev/null +++ b/src/main/tray/tray-menu-item/tray-menu-item-injection-token.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; + +export interface TrayMenuItem { + id: string; + parentId: string | null; + orderNumber: number; + enabled: IComputedValue; + visible: IComputedValue; + + label?: IComputedValue; + click?: () => Promise | void; + tooltip?: string; + separator?: boolean; + extension?: LensMainExtension; +} + +export const trayMenuItemInjectionToken = getInjectionToken({ + id: "tray-menu-item", +}); diff --git a/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts b/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts new file mode 100644 index 0000000000..6cbd9e5d33 --- /dev/null +++ b/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.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 { pipeline } from "@ogre-tools/fp"; +import { flatMap, kebabCase } from "lodash/fp"; +import type { Injectable } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import { computed } from "mobx"; +import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; +import type { TrayMenuItem } from "./tray-menu-item-injection-token"; +import { trayMenuItemInjectionToken } from "./tray-menu-item-injection-token"; +import type { TrayMenuRegistration } from "../tray-menu-registration"; +import { withErrorSuppression } from "../../../common/utils/with-error-suppression/with-error-suppression"; +import type { WithErrorLoggingFor } from "../../../common/utils/with-error-logging/with-error-logging.injectable"; +import withErrorLoggingInjectable from "../../../common/utils/with-error-logging/with-error-logging.injectable"; + +const trayMenuItemRegistratorInjectable = getInjectable({ + id: "tray-menu-item-registrator", + + instantiate: (di) => (extension, installationCounter) => { + const mainExtension = extension as LensMainExtension; + const withErrorLoggingFor = di.inject(withErrorLoggingInjectable); + + pipeline( + mainExtension.trayMenus, + + flatMap(toItemInjectablesFor(mainExtension, installationCounter, withErrorLoggingFor)), + + (injectables) => di.register(...injectables), + ); + }, + + injectionToken: extensionRegistratorInjectionToken, +}); + +export default trayMenuItemRegistratorInjectable; + +const toItemInjectablesFor = (extension: LensMainExtension, installationCounter: number, withErrorLoggingFor: WithErrorLoggingFor) => { + const _toItemInjectables = (parentId: string | null) => (registration: TrayMenuRegistration): Injectable[] => { + const trayItemId = registration.id || kebabCase(registration.label || ""); + const id = `${trayItemId}-tray-menu-item-for-extension-${extension.sanitizedExtensionId}-instance-${installationCounter}`; + + const parentInjectable = getInjectable({ + id, + + instantiate: () => ({ + id, + parentId, + orderNumber: 100, + + separator: registration.type === "separator", + + label: computed(() => registration.label || ""), + tooltip: registration.toolTip, + + click: pipeline( + () => { + registration.click?.(registration); + }, + + withErrorLoggingFor(() => `[TRAY]: Clicking of tray item "${trayItemId}" from extension "${extension.sanitizedExtensionId}" failed.`), + + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + (x) => withErrorSuppression(x), + ), + + enabled: computed(() => !!registration.enabled), + visible: computed(() => true), + }), + + injectionToken: trayMenuItemInjectionToken, + }); + + const childMenuItems = registration.submenu || []; + + const childInjectables = childMenuItems.flatMap(_toItemInjectables(id)); + + return [ + parentInjectable, + ...childInjectables, + ]; + }; + + return _toItemInjectables(null); +}; + + diff --git a/src/main/tray/tray-menu-item/tray-menu-items.injectable.ts b/src/main/tray/tray-menu-item/tray-menu-items.injectable.ts new file mode 100644 index 0000000000..c29482007d --- /dev/null +++ b/src/main/tray/tray-menu-item/tray-menu-items.injectable.ts @@ -0,0 +1,49 @@ +/** + * 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 { computed } from "mobx"; + + +import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable"; +import type { TrayMenuItem } from "./tray-menu-item-injection-token"; +import { trayMenuItemInjectionToken } from "./tray-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { filter, overSome, sortBy } from "lodash/fp"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; + +const trayMenuItemsInjectable = getInjectable({ + id: "tray-menu-items", + + instantiate: (di) => { + const extensions = di.inject(mainExtensionsInjectable); + + return computed(() => { + const enabledExtensions = extensions.get(); + + return pipeline( + di.injectMany(trayMenuItemInjectionToken), + + filter((item) => + overSome([ + isNonExtensionItem, + isEnabledExtensionItemFor(enabledExtensions), + ])(item), + ), + + filter(item => item.visible.get()), + items => sortBy("orderNumber", items), + ); + }); + }, +}); + +const isNonExtensionItem = (item: TrayMenuItem) => !item.extension; + +const isEnabledExtensionItemFor = + (enabledExtensions: LensMainExtension[]) => (item: TrayMenuItem) => + !!enabledExtensions.find((extension) => extension === item.extension); + + +export default trayMenuItemsInjectable; diff --git a/src/main/tray/tray.injectable.ts b/src/main/tray/tray.injectable.ts deleted file mode 100644 index 0e61062d50..0000000000 --- a/src/main/tray/tray.injectable.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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 { initTray } from "./tray"; -import trayMenuItemsInjectable from "./tray-menu-items.injectable"; -import navigateToPreferencesInjectable from "../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; -import stopServicesAndExitAppInjectable from "../stop-services-and-exit-app.injectable"; -import { getStartableStoppable } from "../../common/utils/get-startable-stoppable"; -import isAutoUpdateEnabledInjectable from "../is-auto-update-enabled.injectable"; -import showAboutInjectable from "../menu/show-about.injectable"; -import showApplicationWindowInjectable from "../start-main-application/lens-window/show-application-window.injectable"; -import trayIconPathInjectable from "./tray-icon-path.injectable"; - -const trayInjectable = getInjectable({ - id: "tray", - - instantiate: (di) => { - const trayMenuItems = di.inject(trayMenuItemsInjectable); - const navigateToPreferences = di.inject(navigateToPreferencesInjectable); - const stopServicesAndExitApp = di.inject(stopServicesAndExitAppInjectable); - const isAutoUpdateEnabled = di.inject(isAutoUpdateEnabledInjectable); - const showApplicationWindow = di.inject(showApplicationWindowInjectable); - const showAboutPopup = di.inject(showAboutInjectable); - const trayIconPath = di.inject(trayIconPathInjectable); - - return getStartableStoppable("build-of-tray", () => - initTray( - trayMenuItems, - navigateToPreferences, - stopServicesAndExitApp, - isAutoUpdateEnabled, - showApplicationWindow, - showAboutPopup, - trayIconPath, - ), - ); - }, -}); - -export default trayInjectable; diff --git a/src/main/tray/tray.ts b/src/main/tray/tray.ts index c5d0b47ab1..4d7e39c344 100644 --- a/src/main/tray/tray.ts +++ b/src/main/tray/tray.ts @@ -7,25 +7,22 @@ import packageInfo from "../../../package.json"; import { Menu, Tray } from "electron"; import type { IComputedValue } from "mobx"; import { autorun } from "mobx"; -import { checkForUpdates } from "../app-updater"; import logger from "../logger"; -import { isWindows, productName } from "../../common/vars"; +import { isWindows } from "../../common/vars"; import type { Disposer } from "../../common/utils"; -import { disposer, toJS } from "../../common/utils"; -import type { TrayMenuRegistration } from "./tray-menu-registration"; +import { disposer } from "../../common/utils"; +import type { TrayMenuItem } from "./tray-menu-item/tray-menu-item-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { filter, isEmpty, map } from "lodash/fp"; -const TRAY_LOG_PREFIX = "[TRAY]"; +export const TRAY_LOG_PREFIX = "[TRAY]"; // note: instance of Tray should be saved somewhere, otherwise it disappears export let tray: Tray | null = null; export function initTray( - trayMenuItems: IComputedValue, - navigateToPreferences: () => void, - stopServicesAndExitApp: () => void, - isAutoUpdateEnabled: () => boolean, + trayMenuItems: IComputedValue, showApplicationWindow: () => Promise, - showAbout: () => void, trayIconPath: string, ): Disposer { tray = new Tray(trayIconPath); @@ -42,7 +39,9 @@ export function initTray( return disposer( autorun(() => { try { - const menu = createTrayMenu(toJS(trayMenuItems.get()), navigateToPreferences, stopServicesAndExitApp, isAutoUpdateEnabled, showApplicationWindow, showAbout); + const options = toTrayMenuOptions(trayMenuItems.get()); + + const menu = Menu.buildFromTemplate(options); tray?.setContextMenu(menu); } catch (error) { @@ -56,66 +55,45 @@ export function initTray( ); } -function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions { - return { - ...trayItem, - submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, - click: trayItem.click ? () => { - trayItem.click?.(trayItem); - } : undefined, - }; -} +const toTrayMenuOptions = (trayMenuItems: TrayMenuItem[]) => { + const _toTrayMenuOptions = (parentId: string | null) => + pipeline( + trayMenuItems, -function createTrayMenu( - extensionTrayItems: TrayMenuRegistration[], - navigateToPreferences: () => void, - stopServicesAndExitApp: () => void, - isAutoUpdateEnabled: () => boolean, - showApplicationWindow: () => Promise, - showAbout: () => void, -): Menu { - let template: Electron.MenuItemConstructorOptions[] = [ - { - label: `Open ${productName}`, - click() { - showApplicationWindow().catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); - }, - }, - { - label: "Preferences", - click() { - navigateToPreferences(); - }, - }, - ]; + filter((item) => item.parentId === parentId), - if (isAutoUpdateEnabled()) { - template.push({ - label: "Check for updates", - click() { - checkForUpdates() - .then(() => showApplicationWindow()); - }, - }); - } + map( + (trayMenuItem: TrayMenuItem): Electron.MenuItemConstructorOptions => { + if (trayMenuItem.separator) { + return { id: trayMenuItem.id, type: "separator" }; + } - template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions)); + const childItems = _toTrayMenuOptions(trayMenuItem.id); + + return { + id: trayMenuItem.id, + label: trayMenuItem.label?.get(), + enabled: trayMenuItem.enabled.get(), + toolTip: trayMenuItem.tooltip, + + ...(isEmpty(childItems) + ? { + type: "normal", + submenu: _toTrayMenuOptions(trayMenuItem.id), + + click: () => { + trayMenuItem.click?.(); + }, + } + : { + type: "submenu", + submenu: _toTrayMenuOptions(trayMenuItem.id), + }), + }; + }, + ), + ); + + return _toTrayMenuOptions(null); +}; - return Menu.buildFromTemplate(template.concat([ - { - label: `About ${productName}`, - click() { - showApplicationWindow() - .then(showAbout) - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error })); - }, - }, - { type: "separator" }, - { - label: "Quit App", - click() { - stopServicesAndExitApp(); - }, - }, - ])); -} diff --git a/src/main/tray/uninstall-tray.injectable.ts b/src/main/tray/uninstall-tray.injectable.ts deleted file mode 100644 index 41b3cd676c..0000000000 --- a/src/main/tray/uninstall-tray.injectable.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 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 trayInjectable from "./tray.injectable"; -import { beforeQuitOfBackEndInjectionToken } from "../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; - -const uninstallTrayInjectable = getInjectable({ - id: "uninstall-tray", - - instantiate: (di) => { - const trayInitializer = di.inject(trayInjectable); - - return { - run: async () => { - await trayInitializer.stop(); - }, - }; - }, - - injectionToken: beforeQuitOfBackEndInjectionToken, -}); - -export default uninstallTrayInjectable; diff --git a/src/main/utils/__test__/update-channel.test.ts b/src/main/utils/__test__/update-channel.test.ts deleted file mode 100644 index e0c20e4707..0000000000 --- a/src/main/utils/__test__/update-channel.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import { nextUpdateChannel } from "../update-channel"; - -describe("nextUpdateChannel", () => { - it("returns latest if current channel is latest", () => { - expect(nextUpdateChannel("latest", "latest")).toEqual("latest"); - }); - - it("returns beta if current channel is alpha", () => { - expect(nextUpdateChannel("alpha", "alpha")).toEqual("beta"); - expect(nextUpdateChannel("beta", "alpha")).toEqual("beta"); - expect(nextUpdateChannel("rc", "alpha")).toEqual("beta"); - expect(nextUpdateChannel("latest", "alpha")).toEqual("beta"); - }); - - it("returns latest if current channel is beta", () => { - expect(nextUpdateChannel("alpha", "beta")).toEqual("latest"); - expect(nextUpdateChannel("beta", "beta")).toEqual("latest"); - expect(nextUpdateChannel("rc", "beta")).toEqual("latest"); - expect(nextUpdateChannel("latest", "beta")).toEqual("latest"); - }); - - it("returns default if current channel is unknown", () => { - expect(nextUpdateChannel("alpha", "rc")).toEqual("alpha"); - expect(nextUpdateChannel("beta", "rc")).toEqual("beta"); - expect(nextUpdateChannel("rc", "rc")).toEqual("rc"); - expect(nextUpdateChannel("latest", "rc")).toEqual("latest"); - }); -}); diff --git a/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.injectable.ts b/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.injectable.ts new file mode 100644 index 0000000000..6b7fa9b8df --- /dev/null +++ b/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.injectable.ts @@ -0,0 +1,38 @@ +/** + * 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 type { IpcMainEvent } from "electron"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import { enlistMessageChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { tentativeParseJson } from "../../../../common/utils/tentative-parse-json"; + +const enlistMessageChannelListenerInjectable = getInjectable({ + id: "enlist-message-channel-listener-for-main", + + instantiate: (di) => { + const ipcMain = di.inject(ipcMainInjectable); + + return ({ channel, handler }) => { + const nativeOnCallback = (_: IpcMainEvent, message: unknown) => { + pipeline( + message, + tentativeParseJson, + handler, + ); + }; + + ipcMain.on(channel.id, nativeOnCallback); + + return () => { + ipcMain.off(channel.id, nativeOnCallback); + }; + }; + }, + + injectionToken: enlistMessageChannelListenerInjectionToken, +}); + +export default enlistMessageChannelListenerInjectable; diff --git a/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.test.ts b/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.test.ts new file mode 100644 index 0000000000..3bd0398d8e --- /dev/null +++ b/src/main/utils/channel/channel-listeners/enlist-message-channel-listener.test.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import type { EnlistMessageChannelListener } from "../../../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import { enlistMessageChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import type { IpcMain, IpcMainEvent } from "electron"; + +describe("enlist message channel listener in main", () => { + let enlistMessageChannelListener: EnlistMessageChannelListener; + let ipcMainStub: IpcMain; + let onMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + onMock = jest.fn(); + offMock = jest.fn(); + + ipcMainStub = { + on: onMock, + off: offMock, + } as unknown as IpcMain; + + di.override(ipcMainInjectable, () => ipcMainStub); + + enlistMessageChannelListener = di.inject( + enlistMessageChannelListenerInjectionToken, + ); + }); + + describe("when called", () => { + let handlerMock: jest.Mock; + let disposer: () => void; + + beforeEach(() => { + handlerMock = jest.fn(); + + disposer = enlistMessageChannelListener({ + channel: { id: "some-channel-id" }, + handler: handlerMock, + }); + }); + + it("does not call handler yet", () => { + expect(handlerMock).not.toHaveBeenCalled(); + }); + + it("registers the listener", () => { + expect(onMock).toHaveBeenCalledWith( + "some-channel-id", + expect.any(Function), + ); + }); + + it("does not de-register the listener yet", () => { + expect(offMock).not.toHaveBeenCalled(); + }); + + describe("when message arrives", () => { + beforeEach(() => { + onMock.mock.calls[0][1]({} as IpcMainEvent, "some-message"); + }); + + it("calls the handler with the message", () => { + expect(handlerMock).toHaveBeenCalledWith("some-message"); + }); + + it("when disposing the listener, de-registers the listener", () => { + disposer(); + + expect(offMock).toHaveBeenCalledWith("some-channel-id", expect.any(Function)); + }); + }); + + it("given number as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, 42); + + expect(handlerMock).toHaveBeenCalledWith(42); + }); + + it("given boolean as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, true); + + expect(handlerMock).toHaveBeenCalledWith(true); + }); + + it("given stringified object as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcMainEvent, JSON.stringify({ some: "object" })); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.injectable.ts b/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.injectable.ts new file mode 100644 index 0000000000..6f118288f3 --- /dev/null +++ b/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.injectable.ts @@ -0,0 +1,34 @@ +/** + * 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 type { IpcMainInvokeEvent } from "electron"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import { enlistRequestChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-request-channel-listener-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { tentativeParseJson } from "../../../../common/utils/tentative-parse-json"; +import { tentativeStringifyJson } from "../../../../common/utils/tentative-stringify-json"; + +const enlistRequestChannelListenerInjectable = getInjectable({ + id: "enlist-request-channel-listener-for-main", + + instantiate: (di) => { + const ipcMain = di.inject(ipcMainInjectable); + + return ({ channel, handler }) => { + const nativeHandleCallback = (_: IpcMainInvokeEvent, request: unknown) => + pipeline(request, tentativeParseJson, handler, tentativeStringifyJson); + + ipcMain.handle(channel.id, nativeHandleCallback); + + return () => { + ipcMain.off(channel.id, nativeHandleCallback); + }; + }; + }, + + injectionToken: enlistRequestChannelListenerInjectionToken, +}); + +export default enlistRequestChannelListenerInjectable; diff --git a/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.test.ts b/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.test.ts new file mode 100644 index 0000000000..12a5e9af74 --- /dev/null +++ b/src/main/utils/channel/channel-listeners/enlist-request-channel-listener.test.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import ipcMainInjectable from "../ipc-main/ipc-main.injectable"; +import type { IpcMain, IpcMainInvokeEvent } from "electron"; +import type { EnlistRequestChannelListener } from "../../../../common/utils/channel/enlist-request-channel-listener-injection-token"; +import { enlistRequestChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-request-channel-listener-injection-token"; +import { getPromiseStatus } from "../../../../common/test-utils/get-promise-status"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; + +describe("enlist request channel listener in main", () => { + let enlistRequestChannelListener: EnlistRequestChannelListener; + let ipcMainStub: IpcMain; + let handleMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + handleMock = jest.fn(); + offMock = jest.fn(); + + ipcMainStub = { + handle: handleMock, + off: offMock, + } as unknown as IpcMain; + + di.override(ipcMainInjectable, () => ipcMainStub); + + enlistRequestChannelListener = di.inject( + enlistRequestChannelListenerInjectionToken, + ); + }); + + describe("when called", () => { + let handlerMock: AsyncFnMock<(message: any) => any>; + let disposer: () => void; + + beforeEach(() => { + handlerMock = asyncFn(); + + disposer = enlistRequestChannelListener({ + channel: { id: "some-channel-id" }, + handler: handlerMock, + }); + }); + + it("does not call handler yet", () => { + expect(handlerMock).not.toHaveBeenCalled(); + }); + + it("registers the listener", () => { + expect(handleMock).toHaveBeenCalledWith( + "some-channel-id", + expect.any(Function), + ); + }); + + it("does not de-register the listener yet", () => { + expect(offMock).not.toHaveBeenCalled(); + }); + + describe("when request arrives", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = handleMock.mock.calls[0][1]( + {} as IpcMainInvokeEvent, + "some-request", + ); + }); + + it("calls the handler with the request", () => { + expect(handlerMock).toHaveBeenCalledWith("some-request"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when handler resolves with response, listener resolves with the response", () => { + beforeEach(async () => { + await handlerMock.resolve("some-response"); + }); + + it("resolves with the response", async () => { + const actual = await actualPromise; + + expect(actual).toBe('"some-response"'); + }); + + it("when disposing the listener, de-registers the listener", () => { + disposer(); + + expect(offMock).toHaveBeenCalledWith("some-channel-id", expect.any(Function)); + }); + }); + + it("given number as response, when handler resolves with response, listener resolves with stringified response", async () => { + await handlerMock.resolve(42); + + const actual = await actualPromise; + + expect(actual).toBe("42"); + }); + + it("given boolean as response, when handler resolves with response, listener resolves with stringified response", async () => { + await handlerMock.resolve(true); + + const actual = await actualPromise; + + expect(actual).toBe("true"); + }); + + it("given object as response, when handler resolves with response, listener resolves with stringified response", async () => { + await handlerMock.resolve({ some: "object" }); + + const actual = await actualPromise; + + expect(actual).toBe(JSON.stringify({ some: "object" })); + }); + }); + + it("given number as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, 42); + + expect(handlerMock).toHaveBeenCalledWith(42); + }); + + it("given boolean as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, true); + + expect(handlerMock).toHaveBeenCalledWith(true); + }); + + it("given stringified object as request, when request arrives, calls the handler with the request", () => { + handleMock.mock.calls[0][1]({} as IpcMainInvokeEvent, JSON.stringify({ some: "object" })); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/src/main/utils/channel/channel-listeners/start-listening-of-channels.injectable.ts b/src/main/utils/channel/channel-listeners/start-listening-of-channels.injectable.ts new file mode 100644 index 0000000000..96fea0a2f0 --- /dev/null +++ b/src/main/utils/channel/channel-listeners/start-listening-of-channels.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 { onLoadOfApplicationInjectionToken } from "../../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import listeningOfChannelsInjectable from "../../../../common/utils/channel/listening-of-channels.injectable"; + +const startListeningOfChannelsInjectable = getInjectable({ + id: "start-listening-of-channels-main", + + instantiate: (di) => { + const listeningOfChannels = di.inject(listeningOfChannelsInjectable); + + return { + run: async () => { + await listeningOfChannels.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startListeningOfChannelsInjectable; diff --git a/src/main/app-paths/register-channel/ipc-main/ipc-main.injectable.ts b/src/main/utils/channel/ipc-main/ipc-main.injectable.ts similarity index 100% rename from src/main/app-paths/register-channel/ipc-main/ipc-main.injectable.ts rename to src/main/utils/channel/ipc-main/ipc-main.injectable.ts diff --git a/src/main/utils/channel/message-to-channel.injectable.ts b/src/main/utils/channel/message-to-channel.injectable.ts new file mode 100644 index 0000000000..00e588a16a --- /dev/null +++ b/src/main/utils/channel/message-to-channel.injectable.ts @@ -0,0 +1,39 @@ +/** + * 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"; + +const messageToChannelInjectable = getInjectable({ + id: "message-to-channel", + + instantiate: (di) => { + const getAllLensWindows = () => di.injectMany(lensWindowInjectionToken); + + // 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) => + lensWindow.send({ channel: channel.id, data: stringifiedMessage ? [stringifiedMessage] : [] }), + ); + }; + }, + + injectionToken: messageToChannelInjectionToken, +}); + +export default messageToChannelInjectable; diff --git a/src/main/utils/channel/message-to-channel.test.ts b/src/main/utils/channel/message-to-channel.test.ts new file mode 100644 index 0000000000..cf2fc46549 --- /dev/null +++ b/src/main/utils/channel/message-to-channel.test.ts @@ -0,0 +1,167 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { MessageToChannel } from "../../../common/utils/channel/message-to-channel-injection-token"; +import { messageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token"; +import closeAllWindowsInjectable from "../../start-main-application/lens-window/hide-all-windows/close-all-windows.injectable"; +import type { MessageChannel } from "../../../common/utils/channel/message-channel-injection-token"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import createLensWindowInjectable from "../../start-main-application/lens-window/application-window/create-lens-window.injectable"; +import type { LensWindow } from "../../start-main-application/lens-window/application-window/lens-window-injection-token"; +import { lensWindowInjectionToken } from "../../start-main-application/lens-window/application-window/lens-window-injection-token"; +import type { DiContainer } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import sendToChannelInElectronBrowserWindowInjectable from "../../start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable"; + +describe("message to channel from main", () => { + let messageToChannel: MessageToChannel; + let someTestWindow: LensWindow; + let someOtherTestWindow: LensWindow; + let sendToChannelInBrowserMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + sendToChannelInBrowserMock = jest.fn(); + di.override(sendToChannelInElectronBrowserWindowInjectable, () => sendToChannelInBrowserMock); + + someTestWindow = createTestWindow(di, "some-test-window-id"); + someOtherTestWindow = createTestWindow(di, "some-other-test-window-id"); + + messageToChannel = di.inject(messageToChannelInjectionToken); + + const closeAllWindows = di.inject(closeAllWindowsInjectable); + + closeAllWindows(); + }); + + it("given no visible windows, when messaging to channel, does not message to any window", () => { + messageToChannel(someChannel, "some-message"); + + expect(sendToChannelInBrowserMock).not.toHaveBeenCalled(); + }); + + describe("given visible window", () => { + beforeEach(async () => { + await someTestWindow.show(); + }); + + it("when messaging to channel, messages to window", () => { + messageToChannel(someChannel, "some-message"); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ['"some-message"'], + }, + ], + ]); + }); + + it("given boolean as message, when messaging to channel, messages to window with stringified message", () => { + messageToChannel(someChannel, true); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ["true"], + }, + ], + ]); + }); + + it("given number as message, when messaging to channel, messages to window with stringified message", () => { + messageToChannel(someChannel, 42); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ["42"], + }, + ], + ]); + }); + + it("given object as message, when messaging to channel, messages to window with stringified message", () => { + messageToChannel(someChannel, { some: "object" }); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: [JSON.stringify({ some: "object" })], + }, + ], + ]); + }); + }); + + it("given multiple visible windows, when messaging to channel, messages to window", async () => { + await someTestWindow.show(); + await someOtherTestWindow.show(); + + messageToChannel(someChannel, "some-message"); + + expect(sendToChannelInBrowserMock.mock.calls).toEqual([ + [ + null, + + { + channel: "some-channel", + data: ['"some-message"'], + }, + ], + + [ + null, + + { + channel: "some-channel", + data: ['"some-message"'], + }, + ], + ]); + }); +}); + +const someChannel: MessageChannel = { id: "some-channel" }; + +const createTestWindow = (di: DiContainer, id: string) => { + const testWindowInjectable = getInjectable({ + id, + + instantiate: (di) => { + const createLensWindow = di.inject(createLensWindowInjectable); + + return createLensWindow({ + id, + title: "Some test window", + defaultHeight: 42, + defaultWidth: 42, + getContentSource: () => ({ url: "some-content-url" }), + resizable: true, + windowFrameUtilitiesAreShown: false, + centered: false, + }); + }, + + injectionToken: lensWindowInjectionToken, + }); + + di.register(testWindowInjectable); + + return di.inject(testWindowInjectable); +}; diff --git a/src/main/utils/sync-box/sync-box-initial-value-channel-listener.injectable.ts b/src/main/utils/sync-box/sync-box-initial-value-channel-listener.injectable.ts new file mode 100644 index 0000000000..5eb043291a --- /dev/null +++ b/src/main/utils/sync-box/sync-box-initial-value-channel-listener.injectable.ts @@ -0,0 +1,31 @@ +/** + * 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 syncBoxInitialValueChannelInjectable from "../../../common/utils/sync-box/sync-box-initial-value-channel.injectable"; +import { syncBoxInjectionToken } from "../../../common/utils/sync-box/sync-box-injection-token"; +import { requestChannelListenerInjectionToken } from "../../../common/utils/channel/request-channel-listener-injection-token"; + +const syncBoxInitialValueChannelListenerInjectable = getInjectable({ + id: "sync-box-initial-value-channel-listener", + + instantiate: (di) => { + const channel = di.inject(syncBoxInitialValueChannelInjectable); + const syncBoxes = di.injectMany(syncBoxInjectionToken); + + return { + channel, + + handler: () => + syncBoxes.map((box) => ({ + id: box.id, + value: box.value.get(), + })), + }; + }, + + injectionToken: requestChannelListenerInjectionToken, +}); + +export default syncBoxInitialValueChannelListenerInjectable; diff --git a/src/main/utils/update-channel.ts b/src/main/utils/update-channel.ts deleted file mode 100644 index 598d0f0bfd..0000000000 --- a/src/main/utils/update-channel.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -/** - * Compute the next update channel from the current updating channel - * @param defaultChannel The default (initial) channel to check - * @param channel The current channel that did not have a new version associated with it - * @returns The channel name of the next release version - */ -export function nextUpdateChannel(defaultChannel: string, channel: string | null): string { - switch (channel) { - case "alpha": - return "beta"; - case "beta": - return "latest"; // there is no RC currently - default: - return defaultChannel; - } -} diff --git a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable.ts b/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable.ts deleted file mode 100644 index b9ddee1007..0000000000 --- a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * 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 ipcRendererInjectable from "./ipc-renderer/ipc-renderer.injectable"; -import { getValueFromRegisteredChannel } from "./get-value-from-registered-channel"; -import type { Channel } from "../../../common/ipc-channel/channel"; - -export type GetValueFromRegisteredChannel = , TInstance>(channel: TChannel) => Promise; - -const getValueFromRegisteredChannelInjectable = getInjectable({ - id: "get-value-from-registered-channel", - - instantiate: (di) => getValueFromRegisteredChannel({ - ipcRenderer: di.inject(ipcRendererInjectable), - }), -}); - -export default getValueFromRegisteredChannelInjectable; diff --git a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.ts b/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.ts deleted file mode 100644 index 8e0c953784..0000000000 --- a/src/renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { IpcRenderer } from "electron"; -import type { Channel } from "../../../common/ipc-channel/channel"; - -interface Dependencies { - ipcRenderer: IpcRenderer; -} - -export const getValueFromRegisteredChannel = ({ ipcRenderer }: Dependencies) => - , TInstance>( - channel: TChannel, - ): Promise => - ipcRenderer.invoke(channel.name); diff --git a/src/renderer/app-paths/get-value-from-registered-channel/register-ipc-channel-listener.injectable.ts b/src/renderer/app-paths/get-value-from-registered-channel/register-ipc-channel-listener.injectable.ts deleted file mode 100644 index 151d77f097..0000000000 --- a/src/renderer/app-paths/get-value-from-registered-channel/register-ipc-channel-listener.injectable.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 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 ipcRendererInjectable from "./ipc-renderer/ipc-renderer.injectable"; -import type { - IpcChannelListener, -} from "../../ipc-channel-listeners/ipc-channel-listener-injection-token"; - -const registerIpcChannelListenerInjectable = getInjectable({ - id: "register-ipc-channel-listener", - - instantiate: (di) => { - const ipc = di.inject(ipcRendererInjectable); - - return ({ channel, handle }: IpcChannelListener) => { - ipc.on(channel.name, (_, data) => { - handle(data); - }); - }; - }, -}); - -export default registerIpcChannelListenerInjectable; diff --git a/src/renderer/app-paths/setup-app-paths.injectable.ts b/src/renderer/app-paths/setup-app-paths.injectable.ts index e6cf30f0dd..14242347f4 100644 --- a/src/renderer/app-paths/setup-app-paths.injectable.ts +++ b/src/renderer/app-paths/setup-app-paths.injectable.ts @@ -3,27 +3,29 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { appPathsIpcChannel } from "../../common/app-paths/app-path-injection-token"; -import getValueFromRegisteredChannelInjectable from "./get-value-from-registered-channel/get-value-from-registered-channel.injectable"; import appPathsStateInjectable from "../../common/app-paths/app-paths-state.injectable"; import { beforeFrameStartsInjectionToken } from "../before-frame-starts/before-frame-starts-injection-token"; +import appPathsChannelInjectable from "../../common/app-paths/app-paths-channel.injectable"; +import { requestFromChannelInjectionToken } from "../../common/utils/channel/request-from-channel-injection-token"; const setupAppPathsInjectable = getInjectable({ id: "setup-app-paths", - instantiate: (di) => ({ - run: async () => { - const getValueFromRegisteredChannel = di.inject( - getValueFromRegisteredChannelInjectable, - ); + instantiate: (di) => { + const requestFromChannel = di.inject(requestFromChannelInjectionToken); + const appPathsChannel = di.inject(appPathsChannelInjectable); + const appPathsState = di.inject(appPathsStateInjectable); - const syncAppPaths = await getValueFromRegisteredChannel(appPathsIpcChannel); + return { + run: async () => { + const appPaths = await requestFromChannel( + appPathsChannel, + ); - const appPathsState = di.inject(appPathsStateInjectable); - - appPathsState.set(syncAppPaths); - }, - }), + appPathsState.set(appPaths); + }, + }; + }, injectionToken: beforeFrameStartsInjectionToken, }); diff --git a/src/renderer/application-update/application-update-status-listener.injectable.ts b/src/renderer/application-update/application-update-status-listener.injectable.ts new file mode 100644 index 0000000000..69ba23608c --- /dev/null +++ b/src/renderer/application-update/application-update-status-listener.injectable.ts @@ -0,0 +1,57 @@ +/** + * 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 type { ApplicationUpdateStatusChannel, ApplicationUpdateStatusEventId } from "../../common/application-update/application-update-status-channel.injectable"; +import applicationUpdateStatusChannelInjectable from "../../common/application-update/application-update-status-channel.injectable"; +import showInfoNotificationInjectable from "../components/notifications/show-info-notification.injectable"; +import type { MessageChannelListener } from "../../common/utils/channel/message-channel-listener-injection-token"; +import { messageChannelListenerInjectionToken } from "../../common/utils/channel/message-channel-listener-injection-token"; + +const applicationUpdateStatusListenerInjectable = getInjectable({ + id: "application-update-status-listener", + + instantiate: (di): MessageChannelListener => { + const channel = di.inject(applicationUpdateStatusChannelInjectable); + const showInfoNotification = di.inject(showInfoNotificationInjectable); + + const eventHandlers: Record void }> = { + "checking-for-updates": { + handle: () => { + showInfoNotification("Checking for updates..."); + }, + }, + + "no-updates-available": { + handle: () => { + showInfoNotification("No new updates available"); + }, + }, + + "download-for-update-started": { + handle: (version) => { + showInfoNotification(`Download for version ${version} started...`); + }, + }, + + "download-for-update-failed": { + handle: () => { + showInfoNotification("Download of update failed"); + }, + }, + }; + + return { + channel, + + handler: ({ eventId, version }) => { + eventHandlers[eventId].handle(version); + }, + }; + }, + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default applicationUpdateStatusListenerInjectable; diff --git a/src/renderer/ask-boolean/ask-boolean-question-channel-listener.injectable.tsx b/src/renderer/ask-boolean/ask-boolean-question-channel-listener.injectable.tsx new file mode 100644 index 0000000000..5e9adff4cc --- /dev/null +++ b/src/renderer/ask-boolean/ask-boolean-question-channel-listener.injectable.tsx @@ -0,0 +1,107 @@ +/** + * 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 type { AskBooleanQuestionChannel } from "../../common/ask-boolean/ask-boolean-question-channel.injectable"; +import askBooleanQuestionChannelInjectable from "../../common/ask-boolean/ask-boolean-question-channel.injectable"; +import showInfoNotificationInjectable from "../components/notifications/show-info-notification.injectable"; +import { Button } from "../components/button"; +import React from "react"; +import { messageToChannelInjectionToken } from "../../common/utils/channel/message-to-channel-injection-token"; +import askBooleanAnswerChannelInjectable from "../../common/ask-boolean/ask-boolean-answer-channel.injectable"; +import notificationsStoreInjectable from "../components/notifications/notifications-store.injectable"; +import type { MessageChannelListener } from "../../common/utils/channel/message-channel-listener-injection-token"; +import { messageChannelListenerInjectionToken } from "../../common/utils/channel/message-channel-listener-injection-token"; + +const askBooleanQuestionChannelListenerInjectable = getInjectable({ + id: "ask-boolean-question-channel-listener", + + instantiate: (di): MessageChannelListener => { + const questionChannel = di.inject(askBooleanQuestionChannelInjectable); + const showInfoNotification = di.inject(showInfoNotificationInjectable); + const messageToChannel = di.inject(messageToChannelInjectionToken); + const answerChannel = di.inject(askBooleanAnswerChannelInjectable); + const notificationsStore = di.inject(notificationsStoreInjectable); + + const sendAnswerFor = (id: string) => (value: boolean) => { + messageToChannel(answerChannel, { id, value }); + }; + + const closeNotification = (notificationId: string) => { + notificationsStore.remove(notificationId); + }; + + const sendAnswerAndCloseNotificationFor = (sendAnswer: (value: boolean) => void, notificationId: string) => (value: boolean) => () => { + sendAnswer(value); + closeNotification(notificationId); + }; + + return { + channel: questionChannel, + + handler: ({ id: questionId, title, question }) => { + const notificationId = `ask-boolean-for-${questionId}`; + + const sendAnswer = sendAnswerFor(questionId); + const sendAnswerAndCloseNotification = sendAnswerAndCloseNotificationFor(sendAnswer, notificationId); + + showInfoNotification( + , + + { + id: notificationId, + timeout: 0, + onClose: () => sendAnswer(false), + }, + ); + }, + }; + }, + + injectionToken: messageChannelListenerInjectionToken, +}); + +export default askBooleanQuestionChannelListenerInjectable; + +const AskBoolean = ({ + id, + title, + message, + onNo, + onYes, +}: { + id: string; + title: string; + message: string; + onNo: () => void; + onYes: () => void; +}) => ( +
+ {title} +

{message}

+ +
+
+
+); diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index b2fa7700b9..fabb17cb5a 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -46,8 +46,7 @@ import { init } from "@sentry/electron/renderer"; import kubernetesClusterCategoryInjectable from "../common/catalog/categories/kubernetes-cluster.injectable"; import autoRegistrationInjectable from "../common/k8s-api/api-manager/auto-registration.injectable"; import assert from "assert"; -import { beforeFrameStartsInjectionToken } from "./before-frame-starts/before-frame-starts-injection-token"; -import { runManyFor } from "../common/runnable/run-many-for"; +import startFrameInjectable from "./start-frame/start-frame.injectable"; configurePackages(); // global packages registerCustomThemes(); // monaco editor themes @@ -68,9 +67,9 @@ export async function bootstrap(di: DiContainer) { initializeSentryReporting(init); } - const beforeFrameStarts = runManyFor(di)(beforeFrameStartsInjectionToken); + const startFrame = di.inject(startFrameInjectable); - await beforeFrameStarts(); + await startFrame(); // TODO: Consolidate import time side-effect to setup time bindEvents(); diff --git a/src/renderer/components/+catalog/catalog.test.tsx b/src/renderer/components/+catalog/catalog.test.tsx index 1fd7e3f3f8..808226727c 100644 --- a/src/renderer/components/+catalog/catalog.test.tsx +++ b/src/renderer/components/+catalog/catalog.test.tsx @@ -26,8 +26,6 @@ import appVersionInjectable from "../../../common/get-configuration-file-model/a import type { AppEvent } from "../../../common/app-event-bus/event-bus"; import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable"; import { computed } from "mobx"; -import ipcRendererInjectable from "../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; -import { UserStore } from "../../../common/user-store"; import broadcastMessageInjectable from "../../../common/ipc/broadcast-message.injectable"; mockWindow(); @@ -107,13 +105,7 @@ describe("", () => { catalogEntityItem = createMockCatalogEntity(onRun); catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable); - UserStore.createInstance(); // TODO: replace with DI - di.override(catalogEntityRegistryInjectable, () => catalogEntityRegistry); - di.override(ipcRendererInjectable, () => ({ - on: jest.fn(), - invoke: jest.fn(), // TODO: replace with proper mocking via the IPC bridge - } as never)); emitEvent = jest.fn(); @@ -129,7 +121,6 @@ describe("", () => { afterEach(() => { CatalogEntityDetailRegistry.resetInstance(); - UserStore.resetInstance(); jest.clearAllMocks(); jest.restoreAllMocks(); mockFs.restore(); diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 7ec77838fc..66f0830c3d 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -7,7 +7,6 @@ import "@testing-library/jest-dom/extend-expect"; import { fireEvent, screen, waitFor } from "@testing-library/react"; import fse from "fs-extra"; import React from "react"; -import { UserStore } from "../../../../common/user-store"; import type { ExtensionDiscovery } from "../../../../extensions/extension-discovery/extension-discovery"; import type { ExtensionLoader } from "../../../../extensions/extension-loader"; import { ConfirmDialog } from "../../confirm-dialog"; @@ -96,13 +95,10 @@ describe("Extensions", () => { }); extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve()); - - UserStore.createInstance(); }); afterEach(() => { mockFs.restore(); - UserStore.resetInstance(); }); it("disables uninstall and disable buttons while uninstalling", async () => { diff --git a/src/renderer/components/+preferences/application.tsx b/src/renderer/components/+preferences/application.tsx index bbda49f1a0..d3c0fd4414 100644 --- a/src/renderer/components/+preferences/application.tsx +++ b/src/renderer/components/+preferences/application.tsx @@ -12,7 +12,7 @@ import type { UserStore } from "../../../common/user-store"; import { Input } from "../input"; import { Switch } from "../switch"; import moment from "moment-timezone"; -import { updateChannels, defaultExtensionRegistryUrl, defaultUpdateChannel, defaultLocaleTimezone, defaultExtensionRegistryUrlLocation } from "../../../common/user-store/preferences-helpers"; +import { defaultExtensionRegistryUrl, defaultLocaleTimezone, defaultExtensionRegistryUrlLocation } from "../../../common/user-store/preferences-helpers"; import type { IComputedValue } from "mobx"; import { runInAction } from "mobx"; import { isUrl } from "../input/input_validators"; @@ -24,11 +24,17 @@ import { Preferences } from "./preferences"; import userStoreInjectable from "../../../common/user-store/user-store.injectable"; import themeStoreInjectable from "../../themes/store.injectable"; import { defaultThemeId } from "../../../common/vars"; +import { updateChannels } from "../../../common/application-update/update-channels"; +import { map, toPairs } from "lodash/fp"; +import { pipeline } from "@ogre-tools/fp"; +import type { SelectedUpdateChannel } from "../../../common/application-update/selected-update-channel/selected-update-channel.injectable"; +import selectedUpdateChannelInjectable from "../../../common/application-update/selected-update-channel/selected-update-channel.injectable"; interface Dependencies { appPreferenceItems: IComputedValue; userStore: UserStore; themeStore: ThemeStore; + selectedUpdateChannel: SelectedUpdateChannel; } const timezoneOptions = moment.tz.names() @@ -36,10 +42,16 @@ const timezoneOptions = moment.tz.names() value: timezone, label: timezone.replace("_", " "), })); -const updateChannelOptions = Array.from(updateChannels, ([channel, { label }]) => ({ - value: channel, - label, -})); + +const updateChannelOptions = pipeline( + toPairs(updateChannels), + + map(([, channel]) => ({ + value: channel.id, + label: channel.label, + })), +); + const extensionInstallRegistryOptions = [ { value: "default", @@ -55,7 +67,7 @@ const extensionInstallRegistryOptions = [ }, ] as const; -const NonInjectedApplication: React.FC = ({ appPreferenceItems, userStore, themeStore }) => { +const NonInjectedApplication: React.FC = ({ appPreferenceItems, userStore, themeStore, selectedUpdateChannel }) => { const [customUrl, setCustomUrl] = React.useState(userStore.extensionRegistryUrl.customUrl || ""); const themeOptions = [ { @@ -144,8 +156,8 @@ const NonInjectedApplication: React.FC = ({ appPreferenceItems, us ", () => { di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); di.override(rendererExtensionsInjectable, () => computed(() => [] as LensRendererExtension[])); - di.override(ipcRendererInjectable, () => ({ - on: jest.fn(), - invoke: jest.fn(), // TODO: replace with proper mocking via the IPC bridge - } as never)); - + di.permitSideEffects(getConfigurationFileModelInjectable); di.permitSideEffects(appVersionInjectable); }); diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx index 178874ed76..5c8162cd2d 100644 --- a/src/renderer/components/test-utils/get-application-builder.tsx +++ b/src/renderer/components/test-utils/get-application-builder.tsx @@ -18,14 +18,13 @@ import type { RenderResult } from "@testing-library/react"; import { fireEvent } from "@testing-library/react"; import type { KubeResource } from "../../../common/rbac"; import { Sidebar } from "../layout/sidebar"; -import { getDisForUnitTesting } from "../../../test-utils/get-dis-for-unit-testing"; import type { DiContainer } from "@ogre-tools/injectable"; import clusterStoreInjectable from "../../../common/cluster-store/cluster-store.injectable"; import type { ClusterStore } from "../../../common/cluster-store/cluster-store"; import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable"; import currentRouteComponentInjectable from "../../routes/current-route-component.injectable"; import { pipeline } from "@ogre-tools/fp"; -import { flatMap, compact, join, get, filter } from "lodash/fp"; +import { flatMap, compact, join, get, filter, find, map, matches } from "lodash/fp"; import preferenceNavigationItemsInjectable from "../+preferences/preferences-navigation/preference-navigation-items.injectable"; import navigateToPreferencesInjectable from "../../../common/front-end-routing/routes/preferences/navigate-to-preferences.injectable"; import type { MenuItemOpts } from "../../../main/menu/application-menu-items.injectable"; @@ -44,6 +43,14 @@ 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"; +import type { TrayMenuItem } from "../../../main/tray/tray-menu-item/tray-menu-item-injection-token"; +import electronTrayInjectable from "../../../main/tray/electron-tray/electron-tray.injectable"; +import applicationWindowInjectable from "../../../main/start-main-application/lens-window/application-window/application-window.injectable"; +import { Notifications } from "../notifications/notifications"; +import broadcastThatRootFrameIsRenderedInjectable from "../../frames/root-frame/broadcast-that-root-frame-is-rendered.injectable"; +import { getDiForUnitTesting as getRendererDi } from "../../getDiForUnitTesting"; +import { getDiForUnitTesting as getMainDi } from "../../../main/getDiForUnitTesting"; +import { overrideChannels } from "../../../test-utils/channel-fakes/override-channels"; type Callback = (dis: DiContainers) => void | Promise; @@ -56,6 +63,11 @@ export interface ApplicationBuilder { beforeRender: (callback: Callback) => ApplicationBuilder; render: () => Promise; + tray: { + click: (id: string) => Promise; + get: (id: string) => TrayMenuItem | undefined; + }; + applicationMenu: { click: (path: string) => Promise; }; @@ -80,14 +92,23 @@ interface DiContainers { interface Environment { renderSidebar: () => React.ReactNode; + beforeRender: () => void; onAllowKubeResource: () => void; } export const getApplicationBuilder = () => { - const { rendererDi, mainDi } = getDisForUnitTesting({ + const mainDi = getMainDi({ doGeneralOverrides: true, }); + const overrideChannelsForWindow = overrideChannels(mainDi); + + const rendererDi = getRendererDi({ + doGeneralOverrides: true, + }); + + overrideChannelsForWindow(rendererDi); + const dis = { rendererDi, mainDi }; const clusterStoreStub = { @@ -110,6 +131,12 @@ export const getApplicationBuilder = () => { application: { renderSidebar: () => null, + beforeRender: () => { + const nofifyThatRootFrameIsRendered = rendererDi.inject(broadcastThatRootFrameIsRenderedInjectable); + + nofifyThatRootFrameIsRendered(); + }, + onAllowKubeResource: () => { throw new Error( "Tried to allow kube resource when environment is not cluster frame.", @@ -119,6 +146,7 @@ export const getApplicationBuilder = () => { clusterFrame: { renderSidebar: () => , + beforeRender: () => {}, onAllowKubeResource: () => {}, } as Environment, }; @@ -138,6 +166,17 @@ export const getApplicationBuilder = () => { computed(() => []), ); + let trayMenuItemsStateFake: TrayMenuItem[]; + + mainDi.override(electronTrayInjectable, () => ({ + start: () => {}, + stop: () => {}, + + setMenuItems: (items) => { + trayMenuItemsStateFake = items; + }, + })); + let allowedResourcesState: IObservableArray; let rendered: RenderResult; @@ -180,6 +219,32 @@ export const getApplicationBuilder = () => { }, }, + tray: { + get: (id: string) => { + return trayMenuItemsStateFake.find(matches({ id })); + }, + + click: async (id: string) => { + const menuItem = pipeline( + trayMenuItemsStateFake, + find((menuItem) => menuItem.id === id), + ); + + if (!menuItem) { + const availableIds = pipeline( + trayMenuItemsStateFake, + filter(item => !!item.click), + map(item => item.id), + join(", "), + ); + + throw new Error(`Tried to click tray menu item with ID ${id} which does not exist. Available IDs are: "${availableIds}"`); + } + + await menuItem.click?.(); + }, + }, + preferences: { close: () => { const link = rendered.getByTestId("close-preferences"); @@ -318,6 +383,10 @@ export const getApplicationBuilder = () => { await startMainApplication(); + const applicationWindow = mainDi.inject(applicationWindowInjectable); + + await applicationWindow.show(); + const startFrame = rendererDi.inject(startFrameInjectable); await startFrame(); @@ -330,6 +399,8 @@ export const getApplicationBuilder = () => { await callback(dis); } + environment.beforeRender(); + rendered = render( {environment.renderSidebar()} @@ -345,6 +416,8 @@ export const getApplicationBuilder = () => { return ; }} + + , ); diff --git a/src/renderer/components/update-button/__tests__/update-button.test.tsx b/src/renderer/components/update-button/__tests__/update-button.test.tsx index ff52035a48..8133bd3845 100644 --- a/src/renderer/components/update-button/__tests__/update-button.test.tsx +++ b/src/renderer/components/update-button/__tests__/update-button.test.tsx @@ -3,15 +3,25 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { render, act } from "@testing-library/react"; +import { act } from "@testing-library/react"; import React from "react"; import { UpdateButton } from "../update-button"; import "@testing-library/jest-dom/extend-expect"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import type { DiRender } from "../../test-utils/renderFor"; +import { renderFor } from "../../test-utils/renderFor"; const update = jest.fn(); describe("", () => { + let render: DiRender; + beforeEach(() => { + + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + render = renderFor(di); + update.mockClear(); }); diff --git a/src/renderer/frames/root-frame/broadcast-that-root-frame-is-rendered.injectable.ts b/src/renderer/frames/root-frame/broadcast-that-root-frame-is-rendered.injectable.ts new file mode 100644 index 0000000000..e6493e2832 --- /dev/null +++ b/src/renderer/frames/root-frame/broadcast-that-root-frame-is-rendered.injectable.ts @@ -0,0 +1,22 @@ +/** + * 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 { messageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token"; +import rootFrameIsRenderedChannelInjectable from "../../../common/root-frame-rendered-channel/root-frame-rendered-channel.injectable"; + +const broadcastThatRootFrameIsRenderedInjectable = getInjectable({ + id: "broadcast-that-root-frame-is-rendered", + + instantiate: (di) => { + const messageToChannel = di.inject(messageToChannelInjectionToken); + const rootFrameIsRenderedChannel = di.inject(rootFrameIsRenderedChannelInjectable); + + return () => { + messageToChannel(rootFrameIsRenderedChannel); + }; + }, +}); + +export default broadcastThatRootFrameIsRenderedInjectable; diff --git a/src/renderer/frames/root-frame/init-root-frame/init-root-frame.injectable.ts b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.injectable.ts index b05c557476..b77ed399fc 100644 --- a/src/renderer/frames/root-frame/init-root-frame/init-root-frame.injectable.ts +++ b/src/renderer/frames/root-frame/init-root-frame/init-root-frame.injectable.ts @@ -5,7 +5,7 @@ import { getInjectable } from "@ogre-tools/injectable"; import { initRootFrame } from "./init-root-frame"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; -import ipcRendererInjectable from "../../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; +import ipcRendererInjectable from "../../../utils/channel/ipc-renderer.injectable"; import bindProtocolAddRouteHandlersInjectable from "../../../protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable"; import lensProtocolRouterRendererInjectable from "../../../protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable"; import catalogEntityRegistryInjectable from "../../../api/catalog/entity/registry.injectable"; diff --git a/src/renderer/frames/root-frame/root-frame.tsx b/src/renderer/frames/root-frame/root-frame.tsx index 41fdea10ac..6cb4d016c5 100644 --- a/src/renderer/frames/root-frame/root-frame.tsx +++ b/src/renderer/frames/root-frame/root-frame.tsx @@ -11,17 +11,22 @@ import { ErrorBoundary } from "../../components/error-boundary"; import { Notifications } from "../../components/notifications"; import { ConfirmDialog } from "../../components/confirm-dialog"; import { CommandContainer } from "../../components/command-palette/command-container"; -import { ipcRenderer } from "electron"; -import { IpcRendererNavigationEvents } from "../../navigation/events"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import broadcastThatRootFrameIsRenderedInjectable from "./broadcast-that-root-frame-is-rendered.injectable"; +// Todo: remove import-time side-effect. injectSystemCAs(); +interface Dependencies { + broadcastThatRootFrameIsRendered: () => void; +} + @observer -export class RootFrame extends React.Component { +class NonInjectedRootFrame extends React.Component { static displayName = "RootFrame"; componentDidMount() { - ipcRenderer.send(IpcRendererNavigationEvents.LOADED); + this.props.broadcastThatRootFrameIsRendered(); } render() { @@ -37,3 +42,14 @@ export class RootFrame extends React.Component { ); } } + +export const RootFrame = withInjectables( + NonInjectedRootFrame, + + { + getProps: (di, props) => ({ + broadcastThatRootFrameIsRendered: di.inject(broadcastThatRootFrameIsRenderedInjectable), + ...props, + }), + }, +); diff --git a/src/renderer/getDiForUnitTesting.tsx b/src/renderer/getDiForUnitTesting.tsx index 22ac104e88..3d89322cdf 100644 --- a/src/renderer/getDiForUnitTesting.tsx +++ b/src/renderer/getDiForUnitTesting.tsx @@ -7,12 +7,11 @@ import glob from "glob"; import { memoize, noop } from "lodash/fp"; import { createContainer } from "@ogre-tools/injectable"; import { Environments, setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; -import getValueFromRegisteredChannelInjectable from "./app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable"; +import requestFromChannelInjectable from "./utils/channel/request-from-channel.injectable"; import loggerInjectable from "../common/logger.injectable"; import { overrideFsWithFakes } from "../test-utils/override-fs-with-fakes"; import { createMemoryHistory } from "history"; -import registerIpcChannelListenerInjectable from "./app-paths/get-value-from-registered-channel/register-ipc-channel-listener.injectable"; -import focusWindowInjectable from "./ipc-channel-listeners/focus-window.injectable"; +import focusWindowInjectable from "./navigation/focus-window.injectable"; import extensionsStoreInjectable from "../extensions/extensions-store/extensions-store.injectable"; import type { ExtensionsStore } from "../extensions/extensions-store/extensions-store"; import fileSystemProvisionerStoreInjectable from "../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable"; @@ -32,19 +31,22 @@ import { joinPathsFake } from "../common/test-utils/join-paths-fake"; import hotbarStoreInjectable from "../common/hotbars/store.injectable"; import terminalSpawningPoolInjectable from "./components/dock/terminal/terminal-spawning-pool.injectable"; import hostedClusterIdInjectable from "../common/cluster-store/hosted-cluster-id.injectable"; -import type { GetDiForUnitTestingOptions } from "../test-utils/get-dis-for-unit-testing"; import historyInjectable from "./navigation/history.injectable"; import { ApiManager } from "../common/k8s-api/api-manager"; import lensResourcesDirInjectable from "../common/vars/lens-resources-dir.injectable"; import broadcastMessageInjectable from "../common/ipc/broadcast-message.injectable"; import apiManagerInjectable from "../common/k8s-api/api-manager/manager.injectable"; -import ipcRendererInjectable - from "./app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; +import ipcRendererInjectable from "./utils/channel/ipc-renderer.injectable"; import type { IpcRenderer } from "electron"; import setupOnApiErrorListenersInjectable from "./api/setup-on-api-errors.injectable"; import { observable } from "mobx"; +import defaultShellInjectable from "./components/+preferences/default-shell.injectable"; +import appVersionInjectable from "../common/get-configuration-file-model/app-version/app-version.injectable"; +import provideInitialValuesForSyncBoxesInjectable from "./utils/sync-box/provide-initial-values-for-sync-boxes.injectable"; +import requestAnimationFrameInjectable from "./components/animate/request-animation-frame.injectable"; +import getRandomIdInjectable from "../common/utils/get-random-id.injectable"; -export const getDiForUnitTesting = (opts: GetDiForUnitTestingOptions = {}) => { +export const getDiForUnitTesting = (opts: { doGeneralOverrides?: boolean } = {}) => { const { doGeneralOverrides = false, } = opts; @@ -65,6 +67,7 @@ export const getDiForUnitTesting = (opts: GetDiForUnitTestingOptions = {}) => { di.preventSideEffects(); if (doGeneralOverrides) { + di.override(getRandomIdInjectable, () => () => "some-irrelevant-random-id"); di.override(isMacInjectable, () => true); di.override(isWindowsInjectable, () => false); di.override(isLinuxInjectable, () => false); @@ -75,8 +78,12 @@ export const getDiForUnitTesting = (opts: GetDiForUnitTestingOptions = {}) => { di.override(getAbsolutePathInjectable, () => getAbsolutePathFake); di.override(joinPathsInjectable, () => joinPathsFake); + di.override(appVersionInjectable, () => "1.0.0"); + di.override(historyInjectable, () => createMemoryHistory()); + di.override(requestAnimationFrameInjectable, () => (callback) => callback()); + di.override(lensResourcesDirInjectable, () => "/irrelevant"); di.override(ipcRendererInjectable, () => ({ @@ -99,6 +106,9 @@ export const getDiForUnitTesting = (opts: GetDiForUnitTestingOptions = {}) => { di.override(clusterStoreInjectable, () => ({ getById: (id): Cluster => ({}) as Cluster }) as ClusterStore); di.override(setupOnApiErrorListenersInjectable, () => ({ run: () => {} })); + di.override(provideInitialValuesForSyncBoxesInjectable, () => ({ run: () => {} })); + + di.override(defaultShellInjectable, () => "some-default-shell"); di.override( userStoreInjectable, @@ -114,8 +124,7 @@ export const getDiForUnitTesting = (opts: GetDiForUnitTestingOptions = {}) => { di.override(apiManagerInjectable, () => new ApiManager()); - di.override(getValueFromRegisteredChannelInjectable, () => () => Promise.resolve(undefined as never)); - di.override(registerIpcChannelListenerInjectable, () => () => undefined); + di.override(requestFromChannelInjectable, () => () => Promise.resolve(undefined as never)); overrideFsWithFakes(di); diff --git a/src/renderer/ipc-channel-listeners/ipc-channel-listener-injection-token.ts b/src/renderer/ipc-channel-listeners/ipc-channel-listener-injection-token.ts deleted file mode 100644 index 235b90873c..0000000000 --- a/src/renderer/ipc-channel-listeners/ipc-channel-listener-injection-token.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectionToken } from "@ogre-tools/injectable"; -import type { Channel } from "../../common/ipc-channel/channel"; - - -export interface IpcChannelListener { - channel: Channel; - handle: (value: any) => void; -} - -export const ipcChannelListenerInjectionToken = - getInjectionToken({ - id: "ipc-channel-listener-injection-token", - }); diff --git a/src/renderer/ipc-channel-listeners/register-ipc-channel-listeners.injectable.ts b/src/renderer/ipc-channel-listeners/register-ipc-channel-listeners.injectable.ts deleted file mode 100644 index bf9568b71c..0000000000 --- a/src/renderer/ipc-channel-listeners/register-ipc-channel-listeners.injectable.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * 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 { ipcChannelListenerInjectionToken } from "./ipc-channel-listener-injection-token"; -import registerIpcChannelListenerInjectable from "../app-paths/get-value-from-registered-channel/register-ipc-channel-listener.injectable"; -import { beforeFrameStartsInjectionToken } from "../before-frame-starts/before-frame-starts-injection-token"; - -const registerIpcChannelListenersInjectable = getInjectable({ - id: "register-ipc-channel-listeners", - - instantiate: di => ({ - run: async () => { - const registerIpcChannelListener = di.inject(registerIpcChannelListenerInjectable); - - const listeners = di.injectMany(ipcChannelListenerInjectionToken); - - listeners.forEach(listener => { - registerIpcChannelListener(listener); - }); - }, - }), - - injectionToken: beforeFrameStartsInjectionToken, -}); - -export default registerIpcChannelListenersInjectable; diff --git a/src/renderer/ipc/list-namespaces-forbidden-handler.injectable.tsx b/src/renderer/ipc/list-namespaces-forbidden-handler.injectable.tsx index c787899e84..22434df1b6 100644 --- a/src/renderer/ipc/list-namespaces-forbidden-handler.injectable.tsx +++ b/src/renderer/ipc/list-namespaces-forbidden-handler.injectable.tsx @@ -5,17 +5,19 @@ import { getInjectable } from "@ogre-tools/injectable"; import navigateToEntitySettingsInjectable from "../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable"; import type { ListNamespaceForbiddenArgs } from "../../common/ipc/cluster"; -import { Notifications, notificationsStore } from "../components/notifications"; +import { Notifications } from "../components/notifications"; import { ClusterStore } from "../../common/cluster-store/cluster-store"; import { Button } from "../components/button"; import type { IpcRendererEvent } from "electron"; import React from "react"; +import notificationsStoreInjectable from "../components/notifications/notifications-store.injectable"; const listNamespacesForbiddenHandlerInjectable = getInjectable({ id: "list-namespaces-forbidden-handler", instantiate: (di) => { const navigateToEntitySettings = di.inject(navigateToEntitySettingsInjectable); + const notificationsStore = di.inject(notificationsStoreInjectable); const notificationLastDisplayedAt = new Map(); const intervalBetweenNotifications = 1000 * 60; // 60s diff --git a/src/renderer/ipc/register-listeners.tsx b/src/renderer/ipc/register-listeners.tsx index a9e0e76d2d..4d2ed9f5c8 100644 --- a/src/renderer/ipc/register-listeners.tsx +++ b/src/renderer/ipc/register-listeners.tsx @@ -3,89 +3,14 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import React from "react"; import type { IpcRendererEvent } from "electron"; import { ipcRenderer } from "electron"; -import type { UpdateAvailableFromMain, BackchannelArg } from "../../common/ipc"; -import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, ipcRendererOn, AutoUpdateChecking, AutoUpdateNoUpdateAvailable } from "../../common/ipc"; -import { Notifications, notificationsStore } from "../components/notifications"; -import { Button } from "../components/button"; -import { isMac } from "../../common/vars"; +import { onCorrect } from "../../common/ipc"; +import { Notifications } from "../components/notifications"; import { defaultHotbarCells } from "../../common/hotbars/types"; import { type ListNamespaceForbiddenArgs, clusterListNamespaceForbiddenChannel, isListNamespaceForbiddenArgs } from "../../common/ipc/cluster"; import { hotbarTooManyItemsChannel } from "../../common/ipc/hotbar"; -function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void { - notificationsStore.remove(notificationId); - ipcRenderer.send(backchannel, data); -} - -function RenderYesButtons(props: { backchannel: string; notificationId: string }) { - if (isMac) { - /** - * auto-updater's "installOnQuit" is not applicable for macOS as per their docs. - * - * See: https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/AppUpdater.ts#L27-L32 - */ - return ( -
-
- ), - { - id: notificationId, - onClose() { - sendToBackchannel(backchannel, notificationId, { doUpdate: false }); - }, - }, - ); -} - function HotbarTooManyItemsHandler(): void { Notifications.error(`Cannot have more than ${defaultHotbarCells} items pinned to a hotbar`); } @@ -98,12 +23,6 @@ interface Dependencies { } export const registerIpcListeners = ({ listNamespacesForbiddenHandler }: Dependencies) => () => { - onCorrect({ - source: ipcRenderer, - channel: UpdateAvailableChannel, - listener: UpdateAvailableHandler, - verifier: areArgsUpdateAvailableFromMain, - }); onCorrect({ source: ipcRenderer, channel: clusterListNamespaceForbiddenChannel, @@ -115,11 +34,4 @@ export const registerIpcListeners = ({ listNamespacesForbiddenHandler }: Depende channel: hotbarTooManyItemsChannel, listener: HotbarTooManyItemsHandler, verifier: (args: unknown[]): args is [] => args.length === 0, - }); - ipcRendererOn(AutoUpdateChecking, () => { - Notifications.shortInfo("Checking for updates"); - }); - ipcRendererOn(AutoUpdateNoUpdateAvailable, () => { - Notifications.shortInfo("No update is currently available"); - }); -}; + });}; diff --git a/src/renderer/ipc-channel-listeners/focus-window.injectable.ts b/src/renderer/navigation/focus-window.injectable.ts similarity index 100% rename from src/renderer/ipc-channel-listeners/focus-window.injectable.ts rename to src/renderer/navigation/focus-window.injectable.ts diff --git a/src/renderer/ipc-channel-listeners/navigation-listener.injectable.ts b/src/renderer/navigation/navigation-channel-listener.injectable.ts similarity index 52% rename from src/renderer/ipc-channel-listeners/navigation-listener.injectable.ts rename to src/renderer/navigation/navigation-channel-listener.injectable.ts index 36a1d02559..3a17451071 100644 --- a/src/renderer/ipc-channel-listeners/navigation-listener.injectable.ts +++ b/src/renderer/navigation/navigation-channel-listener.injectable.ts @@ -3,26 +3,29 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; -import { ipcChannelListenerInjectionToken } from "./ipc-channel-listener-injection-token"; -import { appNavigationIpcChannel, clusterFrameNavigationIpcChannel } from "../../common/front-end-routing/navigation-ipc-channel"; import currentlyInClusterFrameInjectable from "../routes/currently-in-cluster-frame.injectable"; -import { navigateToUrlInjectionToken } from "../../common/front-end-routing/navigate-to-url-injection-token"; +import appNavigationChannelInjectable from "../../common/front-end-routing/app-navigation-channel.injectable"; +import clusterFrameNavigationChannelInjectable from "../../common/front-end-routing/cluster-frame-navigation-channel.injectable"; import focusWindowInjectable from "./focus-window.injectable"; +import { navigateToUrlInjectionToken } from "../../common/front-end-routing/navigate-to-url-injection-token"; +import { messageChannelListenerInjectionToken } from "../../common/utils/channel/message-channel-listener-injection-token"; -const navigationListenerInjectable = getInjectable({ - id: "navigation-listener", +const navigationChannelListenerInjectable = getInjectable({ + id: "navigation-channel-listener", instantiate: (di) => { - const navigateToUrl = di.inject(navigateToUrlInjectionToken); const currentlyInClusterFrame = di.inject(currentlyInClusterFrameInjectable); + const appNavigationChannel = di.inject(appNavigationChannelInjectable); + const clusterFrameNavigationChannel = di.inject(clusterFrameNavigationChannelInjectable); const focusWindow = di.inject(focusWindowInjectable); + const navigateToUrl = di.inject(navigateToUrlInjectionToken); return { channel: currentlyInClusterFrame - ? clusterFrameNavigationIpcChannel - : appNavigationIpcChannel, + ? clusterFrameNavigationChannel + : appNavigationChannel, - handle: (url: string) => { + handler: (url: string) => { navigateToUrl(url); if (!currentlyInClusterFrame) { @@ -31,8 +34,7 @@ const navigationListenerInjectable = getInjectable({ }, }; }, - - injectionToken: ipcChannelListenerInjectionToken, + injectionToken: messageChannelListenerInjectionToken, }); -export default navigationListenerInjectable; +export default navigationChannelListenerInjectable; diff --git a/src/renderer/port-forward/about-port-forwarding.injectable.ts b/src/renderer/port-forward/about-port-forwarding.injectable.ts index 4656310964..29dba28962 100644 --- a/src/renderer/port-forward/about-port-forwarding.injectable.ts +++ b/src/renderer/port-forward/about-port-forwarding.injectable.ts @@ -7,18 +7,22 @@ import { aboutPortForwarding } from "./port-forward-notify"; import navigateToPortForwardsInjectable from "../../common/front-end-routing/routes/cluster/network/port-forwards/navigate-to-port-forwards.injectable"; import hostedClusterIdInjectable from "../../common/cluster-store/hosted-cluster-id.injectable"; import assert from "assert"; +import notificationsStoreInjectable from "../components/notifications/notifications-store.injectable"; const aboutPortForwardingInjectable = getInjectable({ id: "about-port-forwarding", instantiate: (di) => { const hostedClusterId = di.inject(hostedClusterIdInjectable); + const notificationsStore = di.inject(notificationsStoreInjectable); + const navigateToPortForwards = di.inject(navigateToPortForwardsInjectable); assert(hostedClusterId, "Only allowed to notify about port forward errors within a cluster frame"); return aboutPortForwarding({ - navigateToPortForwards: di.inject(navigateToPortForwardsInjectable), + navigateToPortForwards, hostedClusterId, + notificationsStore, }); }, }); diff --git a/src/renderer/port-forward/notify-error-port-forwarding.injectable.ts b/src/renderer/port-forward/notify-error-port-forwarding.injectable.ts index 9d4cd5caa7..a0f1ed714f 100644 --- a/src/renderer/port-forward/notify-error-port-forwarding.injectable.ts +++ b/src/renderer/port-forward/notify-error-port-forwarding.injectable.ts @@ -7,18 +7,22 @@ import { notifyErrorPortForwarding } from "./port-forward-notify"; import navigateToPortForwardsInjectable from "../../common/front-end-routing/routes/cluster/network/port-forwards/navigate-to-port-forwards.injectable"; import hostedClusterIdInjectable from "../../common/cluster-store/hosted-cluster-id.injectable"; import assert from "assert"; +import notificationsStoreInjectable from "../components/notifications/notifications-store.injectable"; const notifyErrorPortForwardingInjectable = getInjectable({ id: "notify-error-port-forwarding", instantiate: (di) => { const hostedClusterId = di.inject(hostedClusterIdInjectable); + const notificationsStore = di.inject(notificationsStoreInjectable); + const navigateToPortForwards = di.inject(navigateToPortForwardsInjectable); assert(hostedClusterId, "Only allowed to notify about port forward errors within a cluster frame"); return notifyErrorPortForwarding({ - navigateToPortForwards: di.inject(navigateToPortForwardsInjectable), + navigateToPortForwards, hostedClusterId, + notificationsStore, }); }, }); diff --git a/src/renderer/port-forward/port-forward-notify.tsx b/src/renderer/port-forward/port-forward-notify.tsx index 086771ad46..40dc295602 100644 --- a/src/renderer/port-forward/port-forward-notify.tsx +++ b/src/renderer/port-forward/port-forward-notify.tsx @@ -5,17 +5,20 @@ import React from "react"; import { Button } from "../components/button"; -import { Notifications, notificationsStore } from "../components/notifications"; +import type { NotificationsStore } from "../components/notifications"; +import { Notifications } from "../components/notifications"; import type { NavigateToPortForwards } from "../../common/front-end-routing/routes/cluster/network/port-forwards/navigate-to-port-forwards.injectable"; interface AboutPortForwardingDependencies { navigateToPortForwards: NavigateToPortForwards; hostedClusterId: string; + notificationsStore: NotificationsStore; } export const aboutPortForwarding = ({ navigateToPortForwards, hostedClusterId, + notificationsStore, }: AboutPortForwardingDependencies) => () => { const notificationId = `port-forward-notification-${hostedClusterId}`; @@ -49,12 +52,14 @@ export const aboutPortForwarding = ({ interface NotifyErrorPortForwardingDependencies { navigateToPortForwards: NavigateToPortForwards; hostedClusterId: string; + notificationsStore: NotificationsStore; } export const notifyErrorPortForwarding = ({ navigateToPortForwards, hostedClusterId, + notificationsStore, }: NotifyErrorPortForwardingDependencies) => (msg: string) => { const notificationId = `port-forward-error-notification-${hostedClusterId}`; diff --git a/src/renderer/themes/store.injectable.ts b/src/renderer/themes/store.injectable.ts index d959925caa..a67075584e 100644 --- a/src/renderer/themes/store.injectable.ts +++ b/src/renderer/themes/store.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import userStoreInjectable from "../../common/user-store/user-store.injectable"; -import ipcRendererInjectable from "../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; +import ipcRendererInjectable from "../utils/channel/ipc-renderer.injectable"; import { ThemeStore } from "./store"; const themeStoreInjectable = getInjectable({ diff --git a/src/renderer/utils/channel/channel-listeners/enlist-message-channel-listener.injectable.ts b/src/renderer/utils/channel/channel-listeners/enlist-message-channel-listener.injectable.ts new file mode 100644 index 0000000000..6d76e35340 --- /dev/null +++ b/src/renderer/utils/channel/channel-listeners/enlist-message-channel-listener.injectable.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import ipcRendererInjectable from "../ipc-renderer.injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { IpcRendererEvent } from "electron"; +import { enlistMessageChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import { tentativeParseJson } from "../../../../common/utils/tentative-parse-json"; +import { pipeline } from "@ogre-tools/fp"; + +const enlistMessageChannelListenerInjectable = getInjectable({ + id: "enlist-message-channel-listener-for-renderer", + + instantiate: (di) => { + const ipcRenderer = di.inject(ipcRendererInjectable); + + return ({ channel, handler }) => { + const nativeCallback = (_: IpcRendererEvent, message: unknown) => { + pipeline( + message, + tentativeParseJson, + handler, + ); + }; + + ipcRenderer.on(channel.id, nativeCallback); + + return () => { + ipcRenderer.off(channel.id, nativeCallback); + }; + }; + }, + + injectionToken: enlistMessageChannelListenerInjectionToken, +}); + +export default enlistMessageChannelListenerInjectable; diff --git a/src/renderer/utils/channel/channel-listeners/enlist-message-channel-listener.test.ts b/src/renderer/utils/channel/channel-listeners/enlist-message-channel-listener.test.ts new file mode 100644 index 0000000000..4653dcdd5d --- /dev/null +++ b/src/renderer/utils/channel/channel-listeners/enlist-message-channel-listener.test.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import type { EnlistMessageChannelListener } from "../../../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import { enlistMessageChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-message-channel-listener-injection-token"; +import type { IpcRendererEvent, IpcRenderer } from "electron"; +import ipcRendererInjectable from "../ipc-renderer.injectable"; + +describe("enlist message channel listener in renderer", () => { + let enlistMessageChannelListener: EnlistMessageChannelListener; + let ipcRendererStub: IpcRenderer; + let onMock: jest.Mock; + let offMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + onMock = jest.fn(); + offMock = jest.fn(); + + ipcRendererStub = { + on: onMock, + off: offMock, + } as unknown as IpcRenderer; + + di.override(ipcRendererInjectable, () => ipcRendererStub); + + enlistMessageChannelListener = di.inject( + enlistMessageChannelListenerInjectionToken, + ); + }); + + describe("when called", () => { + let handlerMock: jest.Mock; + let disposer: () => void; + + beforeEach(() => { + handlerMock = jest.fn(); + + disposer = enlistMessageChannelListener({ + channel: { id: "some-channel-id" }, + handler: handlerMock, + }); + }); + + it("does not call handler yet", () => { + expect(handlerMock).not.toHaveBeenCalled(); + }); + + it("registers the listener", () => { + expect(onMock).toHaveBeenCalledWith( + "some-channel-id", + expect.any(Function), + ); + }); + + it("does not de-register the listener yet", () => { + expect(offMock).not.toHaveBeenCalled(); + }); + + describe("when message arrives", () => { + beforeEach(() => { + onMock.mock.calls[0][1]({} as IpcRendererEvent, "some-message"); + }); + + it("calls the handler with the message", () => { + expect(handlerMock).toHaveBeenCalledWith("some-message"); + }); + + it("when disposing the listener, de-registers the listener", () => { + disposer(); + + expect(offMock).toHaveBeenCalledWith("some-channel-id", expect.any(Function)); + }); + }); + + it("given number as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcRendererEvent, 42); + + expect(handlerMock).toHaveBeenCalledWith(42); + }); + + it("given boolean as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcRendererEvent, true); + + expect(handlerMock).toHaveBeenCalledWith(true); + }); + + it("given stringified object as message, when message arrives, calls the handler with the message", () => { + onMock.mock.calls[0][1]({} as IpcRendererEvent, JSON.stringify({ some: "object" })); + + expect(handlerMock).toHaveBeenCalledWith({ some: "object" }); + }); + }); +}); diff --git a/src/renderer/utils/channel/channel-listeners/enlist-request-channel-listener.injectable.ts b/src/renderer/utils/channel/channel-listeners/enlist-request-channel-listener.injectable.ts new file mode 100644 index 0000000000..03253a06f2 --- /dev/null +++ b/src/renderer/utils/channel/channel-listeners/enlist-request-channel-listener.injectable.ts @@ -0,0 +1,19 @@ +/** + * 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 { enlistRequestChannelListenerInjectionToken } from "../../../../common/utils/channel/enlist-request-channel-listener-injection-token"; + +const enlistRequestChannelListenerInjectable = getInjectable({ + id: "enlist-request-channel-listener-for-renderer", + + instantiate: () => { + // Requests from main to renderer are not implemented yet. + return () => () => {}; + }, + + injectionToken: enlistRequestChannelListenerInjectionToken, +}); + +export default enlistRequestChannelListenerInjectable; diff --git a/src/renderer/utils/channel/channel-listeners/start-listening-of-channels.injectable.ts b/src/renderer/utils/channel/channel-listeners/start-listening-of-channels.injectable.ts new file mode 100644 index 0000000000..c37c9b1864 --- /dev/null +++ b/src/renderer/utils/channel/channel-listeners/start-listening-of-channels.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 { beforeFrameStartsInjectionToken } from "../../../before-frame-starts/before-frame-starts-injection-token"; +import listeningOfChannelsInjectable from "../../../../common/utils/channel/listening-of-channels.injectable"; + +const startListeningOfChannelsInjectable = getInjectable({ + id: "start-listening-of-channels-renderer", + + instantiate: (di) => { + const listeningOfChannels = di.inject(listeningOfChannelsInjectable); + + return { + run: async () => { + await listeningOfChannels.start(); + }, + }; + }, + + injectionToken: beforeFrameStartsInjectionToken, +}); + +export default startListeningOfChannelsInjectable; diff --git a/src/renderer/app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable.ts b/src/renderer/utils/channel/ipc-renderer.injectable.ts similarity index 100% rename from src/renderer/app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable.ts rename to src/renderer/utils/channel/ipc-renderer.injectable.ts diff --git a/src/renderer/utils/channel/message-to-channel.injectable.ts b/src/renderer/utils/channel/message-to-channel.injectable.ts new file mode 100644 index 0000000000..3e493fd322 --- /dev/null +++ b/src/renderer/utils/channel/message-to-channel.injectable.ts @@ -0,0 +1,26 @@ +/** + * 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 { messageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token"; +import sendToMainInjectable from "./send-to-main.injectable"; +import type { MessageChannel } from "../../../common/utils/channel/message-channel-injection-token"; + +const messageToChannelInjectable = getInjectable({ + id: "message-to-channel", + + instantiate: (di) => { + const sendToMain = di.inject(sendToMainInjectable); + + // 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) => { + sendToMain(channel.id, message); + }; + }, + + injectionToken: messageToChannelInjectionToken, +}); + +export default messageToChannelInjectable; diff --git a/src/renderer/utils/channel/message-to-channel.test.ts b/src/renderer/utils/channel/message-to-channel.test.ts new file mode 100644 index 0000000000..443abfb0dc --- /dev/null +++ b/src/renderer/utils/channel/message-to-channel.test.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { MessageToChannel } from "../../../common/utils/channel/message-to-channel-injection-token"; +import type { MessageChannel } from "../../../common/utils/channel/message-channel-injection-token"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { messageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token"; +import ipcRendererInjectable from "./ipc-renderer.injectable"; +import type { IpcRenderer } from "electron"; + +describe("message to channel from renderer", () => { + let messageToChannel: MessageToChannel; + let sendMock: jest.Mock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + sendMock = jest.fn(); + + di.override(ipcRendererInjectable, () => ({ + send: sendMock, + }) as unknown as IpcRenderer); + + messageToChannel = di.inject(messageToChannelInjectionToken); + }); + + it("given string as message, when messaging to channel, sends stringified message", () => { + messageToChannel(someChannel, "some-message"); + + expect(sendMock).toHaveBeenCalledWith("some-channel-id", '"some-message"'); + }); + + it("given boolean as message, when messaging to channel, sends stringified message", () => { + messageToChannel(someChannel, true); + + expect(sendMock).toHaveBeenCalledWith("some-channel-id", "true"); + }); + + it("given number as message, when messaging to channel, sends stringified message", () => { + messageToChannel(someChannel, 42); + + expect(sendMock).toHaveBeenCalledWith("some-channel-id", "42"); + }); + + it("given object as message, when messaging to channel, sends stringified message", () => { + messageToChannel(someChannel, { some: "object" }); + + expect(sendMock).toHaveBeenCalledWith( + "some-channel-id", + JSON.stringify({ some: "object" }), + ); + }); +}); + +const someChannel: MessageChannel = { id: "some-channel-id" }; diff --git a/src/renderer/utils/channel/request-from-channel.injectable.ts b/src/renderer/utils/channel/request-from-channel.injectable.ts new file mode 100644 index 0000000000..f77287a2ed --- /dev/null +++ b/src/renderer/utils/channel/request-from-channel.injectable.ts @@ -0,0 +1,30 @@ +/** + * 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 ipcRendererInjectable from "./ipc-renderer.injectable"; +import { requestFromChannelInjectionToken } from "../../../common/utils/channel/request-from-channel-injection-token"; +import { pipeline } from "@ogre-tools/fp"; +import { tentativeStringifyJson } from "../../../common/utils/tentative-stringify-json"; +import { tentativeParseJson } from "../../../common/utils/tentative-parse-json"; + +const requestFromChannelInjectable = getInjectable({ + id: "request-from-channel", + + instantiate: (di) => { + const ipcRenderer = di.inject(ipcRendererInjectable); + + return async (channel, ...[request]) => + await pipeline( + request, + tentativeStringifyJson, + (req) => ipcRenderer.invoke(channel.id, req), + tentativeParseJson, + ); + }, + + injectionToken: requestFromChannelInjectionToken, +}); + +export default requestFromChannelInjectable; diff --git a/src/renderer/utils/channel/request-from-channel.test.ts b/src/renderer/utils/channel/request-from-channel.test.ts new file mode 100644 index 0000000000..d7b343bf02 --- /dev/null +++ b/src/renderer/utils/channel/request-from-channel.test.ts @@ -0,0 +1,121 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { MessageChannel } from "../../../common/utils/channel/message-channel-injection-token"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import ipcRendererInjectable from "./ipc-renderer.injectable"; +import type { IpcRenderer } from "electron"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { RequestFromChannel } from "../../../common/utils/channel/request-from-channel-injection-token"; +import { requestFromChannelInjectionToken } from "../../../common/utils/channel/request-from-channel-injection-token"; +import requestFromChannelInjectable from "./request-from-channel.injectable"; +import { getPromiseStatus } from "../../../common/test-utils/get-promise-status"; + +describe("request from channel in renderer", () => { + let requestFromChannel: RequestFromChannel; + let invokeMock: AsyncFnMock<(channelId: string, request: any) => any>; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.unoverride(requestFromChannelInjectable); + + invokeMock = asyncFn(); + + di.override(ipcRendererInjectable, () => ({ + invoke: invokeMock, + }) as unknown as IpcRenderer); + + requestFromChannel = di.inject(requestFromChannelInjectionToken); + }); + + describe("when messaging to channel", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = requestFromChannel(someChannel, "some-message"); + }); + + it("sends stringified message", () => { + expect(invokeMock).toHaveBeenCalledWith("some-channel-id", '"some-message"'); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when invoking resolves, resolves", async () => { + await invokeMock.resolve("some-response"); + + const actual = await actualPromise; + + expect(actual).toBe("some-response"); + }); + + it("when invoking resolves with stringified string, resolves with string", async () => { + await invokeMock.resolve('"some-response"'); + + const actual = await actualPromise; + + expect(actual).toBe("some-response"); + }); + + it("when invoking resolves with stringified boolean, resolves with boolean", async () => { + await invokeMock.resolve("true"); + + const actual = await actualPromise; + + expect(actual).toBe(true); + }); + + it("when invoking resolves with stringified number, resolves with number", async () => { + await invokeMock.resolve("42"); + + const actual = await actualPromise; + + expect(actual).toBe(42); + }); + + it("when invoking resolves with stringified object, resolves with object", async () => { + await invokeMock.resolve(JSON.stringify({ some: "object" })); + + const actual = await actualPromise; + + expect(actual).toEqual({ some: "object" }); + }); + }); + + it("given string as message, when messaging to channel, sends stringified message", () => { + requestFromChannel(someChannel, "some-message"); + + expect(invokeMock).toHaveBeenCalledWith("some-channel-id", '"some-message"'); + }); + + it("given boolean as message, when messaging to channel, sends stringified message", () => { + requestFromChannel(someChannel, true); + + expect(invokeMock).toHaveBeenCalledWith("some-channel-id", "true"); + }); + + it("given number as message, when messaging to channel, sends stringified message", () => { + requestFromChannel(someChannel, 42); + + expect(invokeMock).toHaveBeenCalledWith("some-channel-id", "42"); + }); + + it("given object as message, when messaging to channel, sends stringified message", () => { + requestFromChannel(someChannel, { some: "object" }); + + expect(invokeMock).toHaveBeenCalledWith( + "some-channel-id", + JSON.stringify({ some: "object" }), + ); + }); +}); + +const someChannel: MessageChannel = { id: "some-channel-id" }; diff --git a/src/renderer/utils/channel/send-to-main.injectable.ts b/src/renderer/utils/channel/send-to-main.injectable.ts new file mode 100644 index 0000000000..0811a78798 --- /dev/null +++ b/src/renderer/utils/channel/send-to-main.injectable.ts @@ -0,0 +1,25 @@ +/** + * 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 type { JsonValue } from "type-fest"; +import ipcRendererInjectable from "./ipc-renderer.injectable"; +import { tentativeStringifyJson } from "../../../common/utils/tentative-stringify-json"; + +const sendToMainInjectable = getInjectable({ + id: "send-to-main", + + instantiate: (di) => { + const ipcRenderer = di.inject(ipcRendererInjectable); + + // TODO: Figure out way to improve typing in internals + return (channelId: string, message: JsonValue extends T ? T : never ) => { + const stringifiedMessage = tentativeStringifyJson(message); + + ipcRenderer.send(channelId, ...(stringifiedMessage ? [stringifiedMessage] : [])); + }; + }, +}); + +export default sendToMainInjectable; diff --git a/src/renderer/utils/sync-box/provide-initial-values-for-sync-boxes.injectable.ts b/src/renderer/utils/sync-box/provide-initial-values-for-sync-boxes.injectable.ts new file mode 100644 index 0000000000..39d63e5d46 --- /dev/null +++ b/src/renderer/utils/sync-box/provide-initial-values-for-sync-boxes.injectable.ts @@ -0,0 +1,33 @@ +/** + * 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 { beforeFrameStartsInjectionToken } from "../../before-frame-starts/before-frame-starts-injection-token"; +import syncBoxInitialValueChannelInjectable from "../../../common/utils/sync-box/sync-box-initial-value-channel.injectable"; +import syncBoxStateInjectable from "../../../common/utils/sync-box/sync-box-state.injectable"; +import { requestFromChannelInjectionToken } from "../../../common/utils/channel/request-from-channel-injection-token"; + +const provideInitialValuesForSyncBoxesInjectable = getInjectable({ + id: "provide-initial-values-for-sync-boxes", + + instantiate: (di) => { + const requestFromChannel = di.inject(requestFromChannelInjectionToken); + const syncBoxInitialValueChannel = di.inject(syncBoxInitialValueChannelInjectable); + const setSyncBoxState = (id: string, state: any) => di.inject(syncBoxStateInjectable, id).set(state); + + return { + run: async () => { + const initialValues = await requestFromChannel(syncBoxInitialValueChannel); + + initialValues.forEach(({ id, value }) => { + setSyncBoxState(id, value); + }); + }, + }; + }, + + injectionToken: beforeFrameStartsInjectionToken, +}); + +export default provideInitialValuesForSyncBoxesInjectable; diff --git a/src/test-utils/channel-fakes/override-channels.ts b/src/test-utils/channel-fakes/override-channels.ts new file mode 100644 index 0000000000..4d7a337e04 --- /dev/null +++ b/src/test-utils/channel-fakes/override-channels.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import { overrideMessagingFromMainToWindow } from "./override-messaging-from-main-to-window"; +import { overrideMessagingFromWindowToMain } from "./override-messaging-from-window-to-main"; +import { overrideRequestingFromWindowToMain } from "./override-requesting-from-window-to-main"; + +export const overrideChannels = (mainDi: DiContainer) => { + const overrideMessagingFromMainToWindowForWindow = overrideMessagingFromMainToWindow(mainDi); + const overrideMessagingFromWindowToForWindow = overrideMessagingFromWindowToMain(mainDi); + const overrideRequestingFromWindowToMainForWindow = overrideRequestingFromWindowToMain(mainDi); + + return (windowDi: DiContainer) => { + overrideMessagingFromMainToWindowForWindow(windowDi); + overrideMessagingFromWindowToForWindow(windowDi); + overrideRequestingFromWindowToMainForWindow(windowDi); + }; +}; diff --git a/src/test-utils/channel-fakes/override-messaging-from-main-to-window.ts b/src/test-utils/channel-fakes/override-messaging-from-main-to-window.ts new file mode 100644 index 0000000000..6ade9b7f0d --- /dev/null +++ b/src/test-utils/channel-fakes/override-messaging-from-main-to-window.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { MessageChannelListener } from "../../common/utils/channel/message-channel-listener-injection-token"; +import type { MessageChannel } from "../../common/utils/channel/message-channel-injection-token"; +import sendToChannelInElectronBrowserWindowInjectable from "../../main/start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable"; +import type { SendToViewArgs } from "../../main/start-main-application/lens-window/application-window/lens-window-injection-token"; +import enlistMessageChannelListenerInjectableInRenderer from "../../renderer/utils/channel/channel-listeners/enlist-message-channel-listener.injectable"; +import type { DiContainer } from "@ogre-tools/injectable"; +import assert from "assert"; +import { tentativeParseJson } from "../../common/utils/tentative-parse-json"; + +export const overrideMessagingFromMainToWindow = (mainDi: DiContainer) => { + const messageChannelListenerFakesForRenderer = new Map< + string, + Set>> + >(); + + mainDi.override( + sendToChannelInElectronBrowserWindowInjectable, + + () => + ( + browserWindow, + { channel: channelId, frameInfo, data = [] }: SendToViewArgs, + ) => { + const listeners = + messageChannelListenerFakesForRenderer.get(channelId) || new Set(); + + if (frameInfo) { + throw new Error( + `Tried to send message to frame "${frameInfo.frameId}" in process "${frameInfo.processId}" using channel "${channelId}" which isn't supported yet.`, + ); + } + + if (data.length > 1) { + throw new Error( + `Tried to send message to channel "${channelId}" with more than one argument which is not supported in MessageChannelListener yet.`, + ); + } + + if (listeners.size === 0) { + throw new Error( + `Tried to send message to channel "${channelId}" but there where no listeners. Current channels with listeners: "${[ + ...messageChannelListenerFakesForRenderer.keys(), + ].join('", "')}"`, + ); + } + + const message = tentativeParseJson(data[0]); + + listeners.forEach((listener) => listener.handler(message)); + }, + ); + + return (windowDi: DiContainer) => { + windowDi.override( + enlistMessageChannelListenerInjectableInRenderer, + + () => (listener) => { + if (!messageChannelListenerFakesForRenderer.has(listener.channel.id)) { + messageChannelListenerFakesForRenderer.set( + listener.channel.id, + new Set(), + ); + } + + const listeners = messageChannelListenerFakesForRenderer.get( + listener.channel.id, + ); + + assert(listeners); + + // TODO: Figure out typing + listeners.add( + listener as unknown as MessageChannelListener>, + ); + + return () => { + // TODO: Figure out typing + listeners.delete( + listener as unknown as MessageChannelListener< + MessageChannel + >, + ); + }; + }, + ); + }; +}; diff --git a/src/test-utils/channel-fakes/override-messaging-from-window-to-main.ts b/src/test-utils/channel-fakes/override-messaging-from-window-to-main.ts new file mode 100644 index 0000000000..ba6813235b --- /dev/null +++ b/src/test-utils/channel-fakes/override-messaging-from-window-to-main.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import assert from "assert"; +import type { MessageChannel } from "../../common/utils/channel/message-channel-injection-token"; +import type { MessageChannelListener } from "../../common/utils/channel/message-channel-listener-injection-token"; +import enlistMessageChannelListenerInjectableInMain from "../../main/utils/channel/channel-listeners/enlist-message-channel-listener.injectable"; +import sendToMainInjectable from "../../renderer/utils/channel/send-to-main.injectable"; + +export const overrideMessagingFromWindowToMain = (mainDi: DiContainer) => { + const messageChannelListenerFakesForMain = new Map< + string, + Set>> + >(); + + mainDi.override( + enlistMessageChannelListenerInjectableInMain, + + () => (listener) => { + const channelId = listener.channel.id; + + if (!messageChannelListenerFakesForMain.has(channelId)) { + messageChannelListenerFakesForMain.set(channelId, new Set()); + } + + const listeners = messageChannelListenerFakesForMain.get( + channelId, + ); + + assert(listeners); + + // TODO: Figure out typing + listeners.add( + listener as unknown as MessageChannelListener>, + ); + + return () => { + // TODO: Figure out typing + listeners.delete( + listener as unknown as MessageChannelListener>, + ); + }; + }, + ); + + return (windowDi: DiContainer) => { + windowDi.override(sendToMainInjectable, () => (channelId, message) => { + const listeners = + messageChannelListenerFakesForMain.get(channelId) || new Set(); + + if (listeners.size === 0) { + throw new Error( + `Tried to send message to channel "${channelId}" but there where no listeners. Current channels with listeners: "${[ + ...messageChannelListenerFakesForMain.keys(), + ].join('", "')}"`, + ); + } + + listeners.forEach((listener) => listener.handler(message)); + }); + }; +}; diff --git a/src/test-utils/channel-fakes/override-requesting-from-window-to-main.ts b/src/test-utils/channel-fakes/override-requesting-from-window-to-main.ts new file mode 100644 index 0000000000..8ee4227289 --- /dev/null +++ b/src/test-utils/channel-fakes/override-requesting-from-window-to-main.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import type { RequestChannel } from "../../common/utils/channel/request-channel-injection-token"; +import type { RequestChannelListener } from "../../common/utils/channel/request-channel-listener-injection-token"; +import enlistRequestChannelListenerInjectableInMain from "../../main/utils/channel/channel-listeners/enlist-request-channel-listener.injectable"; +import requestFromChannelInjectable from "../../renderer/utils/channel/request-from-channel.injectable"; + +export const overrideRequestingFromWindowToMain = (mainDi: DiContainer) => { + const requestChannelListenerFakesForMain = new Map< + string, + RequestChannelListener> + >(); + + mainDi.override( + enlistRequestChannelListenerInjectableInMain, + + () => (listener) => { + if (requestChannelListenerFakesForMain.get(listener.channel.id)) { + throw new Error( + `Tried to enlist listener for channel "${listener.channel.id}", but it was already enlisted`, + ); + } + + requestChannelListenerFakesForMain.set( + listener.channel.id, + + // TODO: Figure out typing + listener as unknown as RequestChannelListener< + RequestChannel + >, + ); + + return () => { + requestChannelListenerFakesForMain.delete(listener.channel.id); + }; + }, + ); + + return (windowDi: DiContainer) => { + windowDi.override( + requestFromChannelInjectable, + + () => async (channel, ...[request]) => { + const requestListener = requestChannelListenerFakesForMain.get(channel.id); + + if (!requestListener) { + throw new Error( + `Tried to get value from channel "${channel.id}", but no listeners were registered`, + ); + } + + return requestListener.handler(request); + }, + ); + }; +}; diff --git a/src/test-utils/get-dis-for-unit-testing.ts b/src/test-utils/get-dis-for-unit-testing.ts deleted file mode 100644 index 2f7d59d036..0000000000 --- a/src/test-utils/get-dis-for-unit-testing.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getDiForUnitTesting as getRendererDi } from "../renderer/getDiForUnitTesting"; -import { getDiForUnitTesting as getMainDi } from "../main/getDiForUnitTesting"; -import { overrideIpcBridge } from "./override-ipc-bridge"; - -export interface GetDiForUnitTestingOptions { - doGeneralOverrides?: boolean; -} - -export const getDisForUnitTesting = (opts?: GetDiForUnitTestingOptions) => { - const rendererDi = getRendererDi(opts); - const mainDi = getMainDi(opts); - - overrideIpcBridge({ rendererDi, mainDi }); - - return { - rendererDi, - mainDi, - }; -}; diff --git a/src/test-utils/override-ipc-bridge.ts b/src/test-utils/override-ipc-bridge.ts deleted file mode 100644 index a914cc66b6..0000000000 --- a/src/test-utils/override-ipc-bridge.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { DiContainer } from "@ogre-tools/injectable"; -import type { Channel } from "../common/ipc-channel/channel"; -import getValueFromRegisteredChannelInjectable from "../renderer/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable"; -import registerChannelInjectable from "../main/app-paths/register-channel/register-channel.injectable"; -import asyncFn from "@async-fn/jest"; -import registerIpcChannelListenerInjectable from "../renderer/app-paths/get-value-from-registered-channel/register-ipc-channel-listener.injectable"; -import type { SendToViewArgs } from "../main/start-main-application/lens-window/application-window/lens-window-injection-token"; -import sendToChannelInElectronBrowserWindowInjectable from "../main/start-main-application/lens-window/application-window/send-to-channel-in-electron-browser-window.injectable"; -import { isEmpty } from "lodash/fp"; - - -export const overrideIpcBridge = ({ - rendererDi, - mainDi, -}: { - rendererDi: DiContainer; - mainDi: DiContainer; -}) => { - const fakeChannelMap = new Map< - Channel, - { promise: Promise; resolve: (arg0: any) => Promise } - >(); - - const mainIpcRegistrations = { - set: , TInstance>( - key: TChannel, - callback: () => TChannel["_template"], - ) => { - if (!fakeChannelMap.has(key)) { - const mockInstance = asyncFn(); - - fakeChannelMap.set(key, { - promise: mockInstance(), - resolve: mockInstance.resolve, - }); - } - - return fakeChannelMap.get(key)?.resolve(callback); - }, - - get: , TInstance>(key: TChannel) => { - if (!fakeChannelMap.has(key)) { - const mockInstance = asyncFn(); - - fakeChannelMap.set(key, { - promise: mockInstance(), - resolve: mockInstance.resolve, - }); - } - - return fakeChannelMap.get(key)?.promise; - }, - }; - - rendererDi.override( - getValueFromRegisteredChannelInjectable, - () => async (channel) => { - const callback = await mainIpcRegistrations.get(channel); - - return callback(); - }, - ); - - mainDi.override(registerChannelInjectable, () => (channel, callback) => { - mainIpcRegistrations.set(channel, callback); - }); - - const rendererIpcFakeHandles = new Map< - string, - ((...args: any[]) => void)[] - >(); - - rendererDi.override( - registerIpcChannelListenerInjectable, - () => - ({ channel, handle }) => { - const existingHandles = rendererIpcFakeHandles.get(channel.name) || []; - - rendererIpcFakeHandles.set(channel.name, [...existingHandles, handle]); - }, - ); - - mainDi.override( - sendToChannelInElectronBrowserWindowInjectable, - () => - (browserWindow, { channel: channelName, data = [] }: SendToViewArgs) => { - const handles = rendererIpcFakeHandles.get(channelName) || []; - - if (isEmpty(handles)) { - throw new Error( - `Tried to send message to channel "${channelName}" but there where no listeners. Current channels with listeners: "${[ - ...rendererIpcFakeHandles.keys(), - ].join('", "')}"`, - ); - } - - handles.forEach((handle) => handle(...data)); - }, - ); -}; diff --git a/yarn.lock b/yarn.lock index 6884aee6b0..3974e6dfb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26,10 +26,10 @@ static-eval "2.0.2" underscore "1.7.0" -"@async-fn/jest@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@async-fn/jest/-/jest-1.6.0.tgz#48980e6f07c4d0d72b468b8b57a1b3be8473a746" - integrity sha512-Jm4kf9qQSzcOZIyiI13C4EM4euSLORA8O4JTOWwy7SwaUr8lhVOn0nVbNLx9jnP35JTYeLsLZHfAyZLhYDIl2g== +"@async-fn/jest@1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@async-fn/jest/-/jest-1.6.1.tgz#ca298832fa1e7fb650ea2abd1a466acbbcf2cd58" + integrity sha512-UZoKtoccMr2VZjNOeRo6JJRg4Cb2O8EQH3bxIg3jl20Ya1KGIFs5kW3bf/0GRLY9XwL1mKlr1VHtp2vXlvcqtg== "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.1", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.8.3": version "7.16.0"