diff --git a/src/behaviours/update-app/__snapshots__/trigger-updating-using-tray.test.ts.snap b/src/behaviours/update-app/__snapshots__/trigger-updating-using-tray.test.ts.snap new file mode 100644 index 0000000000..fce1a17509 --- /dev/null +++ b/src/behaviours/update-app/__snapshots__/trigger-updating-using-tray.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`trigger updating using tray given no update available, when started renders 1`] = ` + +
+ +`; + +exports[`trigger updating using tray given no update available, when started when an update becomes available renders 1`] = ` + +
+ +`; + +exports[`trigger updating using tray given no update available, when started when an update becomes available when triggering installation of the update renders 1`] = ` + +
+ +`; + +exports[`trigger updating using tray given no update available, when started when an update becomes available when update becomes unavailable renders 1`] = ` + +
+ +`; diff --git a/src/behaviours/update-app/trigger-updating-using-tray.test.ts b/src/behaviours/update-app/trigger-updating-using-tray.test.ts new file mode 100644 index 0000000000..369cc7a047 --- /dev/null +++ b/src/behaviours/update-app/trigger-updating-using-tray.test.ts @@ -0,0 +1,96 @@ +/** + * 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"; + +describe("trigger updating using tray", () => { + let applicationBuilder: ApplicationBuilder; + let quitAndInstallUpdateMock: jest.Mock; + + beforeEach(() => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + quitAndInstallUpdateMock = jest.fn(); + + mainDi.override(quitAndInstallUpdateInjectable, () => quitAndInstallUpdateMock); + }); + }); + + describe("given no update available, when started", () => { + let rendered: RenderResult; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not quit and install update yet", () => { + expect(quitAndInstallUpdateMock).not.toHaveBeenCalled(); + }); + + it("does not have possibility to trigger installation of an update", () => { + const trayItem = applicationBuilder.tray.get("trigger-application-update"); + + expect(trayItem).toBe(undefined); + }); + + describe("when an update becomes available", () => { + beforeEach(() => { + applicationBuilder.applicationUpdater.makeUpdateAvailable(true); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not quit and install update yet", () => { + expect(quitAndInstallUpdateMock).not.toHaveBeenCalled(); + }); + + it("has possibility to trigger installation of the update", () => { + const trayItem = applicationBuilder.tray.get("trigger-application-update"); + + expect(trayItem).not.toBe(undefined); + }); + + describe("when triggering installation of the update", () => { + beforeEach(() => { + applicationBuilder.tray.click("trigger-application-update"); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("quits application and installs update", () => { + expect(quitAndInstallUpdateMock).toHaveBeenCalled(); + }); + }); + + describe("when update becomes unavailable", () => { + beforeEach(async () => { + applicationBuilder.applicationUpdater.makeUpdateAvailable(false); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("does not have possibility to trigger installation of the update", () => { + const trayItem = applicationBuilder.tray.get("trigger-application-update"); + + expect(trayItem).toBe(undefined); + }); + }); + }); + }); +}); 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..a36c390bc4 --- /dev/null +++ b/src/main/electron-app/features/quit-and-install-update.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 { autoUpdater } from "electron-updater"; + +const quitAndInstallUpdateInjectable = getInjectable({ + id: "quit-and-install-update", + + instantiate: () => () => { + autoUpdater.quitAndInstall(true, true); + }, + + causesSideEffects: true, +}); + +export default quitAndInstallUpdateInjectable; diff --git a/src/main/electron-app/runnables/update-application/start-synchronizing-update-is-available-state.injectable.ts b/src/main/electron-app/runnables/update-application/start-synchronizing-update-is-available-state.injectable.ts new file mode 100644 index 0000000000..282ac6fdb7 --- /dev/null +++ b/src/main/electron-app/runnables/update-application/start-synchronizing-update-is-available-state.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 synchronizeUpdateIsAvailableStateInjectable from "./synchronize-update-is-available-state.injectable"; +import { onLoadOfApplicationInjectionToken } from "../../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; + +const startSynchronizingUpdateIsAvailableStateInjectable = getInjectable({ + id: "start-synchronizing-update-is-available-state", + + instantiate: (di) => { + const synchronizeUpdateIsAvailableState = di.inject(synchronizeUpdateIsAvailableStateInjectable); + + return { + run: () => { + synchronizeUpdateIsAvailableState.start(); + }, + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startSynchronizingUpdateIsAvailableStateInjectable; diff --git a/src/main/electron-app/runnables/update-application/stop-synchronizing-update-is-available-state.injectable.ts b/src/main/electron-app/runnables/update-application/stop-synchronizing-update-is-available-state.injectable.ts new file mode 100644 index 0000000000..4862ca7f02 --- /dev/null +++ b/src/main/electron-app/runnables/update-application/stop-synchronizing-update-is-available-state.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 synchronizeUpdateIsAvailableStateInjectable from "./synchronize-update-is-available-state.injectable"; +import { beforeQuitOfBackEndInjectionToken } from "../../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; + +const stopSynchronizingUpdateIsAvailableStateInjectable = getInjectable({ + id: "stop-synchronizing-update-is-available-state", + + instantiate: (di) => { + const synchronizeUpdateIsAvailableState = di.inject(synchronizeUpdateIsAvailableStateInjectable); + + return { + run: () => { + synchronizeUpdateIsAvailableState.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopSynchronizingUpdateIsAvailableStateInjectable; diff --git a/src/main/electron-app/runnables/update-application/synchronize-update-is-available-state.injectable.ts b/src/main/electron-app/runnables/update-application/synchronize-update-is-available-state.injectable.ts new file mode 100644 index 0000000000..611c1f0db6 --- /dev/null +++ b/src/main/electron-app/runnables/update-application/synchronize-update-is-available-state.injectable.ts @@ -0,0 +1,40 @@ +/** + * 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 electronUpdaterInjectable from "../../features/electron-updater.injectable"; +import updateIsAvailableStateInjectable from "../../../update-app/update-is-available-state.injectable"; + +const synchronizeUpdateIsAvailableStateInjectable = getInjectable({ + id: "synchronize-update-is-available-state", + + instantiate: (di) => { + const electronUpdater = di.inject(electronUpdaterInjectable); + const updateIsAvailableState = di.inject(updateIsAvailableStateInjectable); + + const makeUpdateAvailableFor = (available: boolean) => () => { + updateIsAvailableState.set(available); + }; + + return getStartableStoppable( + "synchronize-update-is-available-state", + () => { + + const makeUpdateAvailable = makeUpdateAvailableFor(true); + const makeUpdateUnavailable = makeUpdateAvailableFor(false); + + electronUpdater.on("update-downloaded", makeUpdateAvailable); + electronUpdater.on("update-not-available", makeUpdateUnavailable); + + return () => { + electronUpdater.off("update-downloaded", makeUpdateAvailable); + electronUpdater.off("update-not-available", makeUpdateUnavailable); + }; + }, + ); + }, +}); + +export default synchronizeUpdateIsAvailableStateInjectable; diff --git a/src/main/getDiForUnitTesting.ts b/src/main/getDiForUnitTesting.ts index 26a1ea5963..ac48a89228 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -78,6 +78,8 @@ import getElectronThemeInjectable from "./electron-app/features/get-electron-the import syncThemeFromOperatingSystemInjectable from "./electron-app/features/sync-theme-from-operating-system.injectable"; import platformInjectable from "../common/vars/platform.injectable"; import productNameInjectable from "./app-paths/app-name/product-name.injectable"; +import synchronizeUpdateIsAvailableStateInjectable from "./electron-app/runnables/update-application/synchronize-update-is-available-state.injectable"; +import quitAndInstallUpdateInjectable from "./electron-app/features/quit-and-install-update.injectable"; export function getDiForUnitTesting(opts: GetDiForUnitTestingOptions = {}) { const { @@ -220,6 +222,8 @@ const overrideElectronFeatures = (di: DiContainer) => { di.override(ipcMainInjectable, () => ({})); di.override(getElectronThemeInjectable, () => () => "dark"); di.override(syncThemeFromOperatingSystemInjectable, () => ({ start: () => {}, stop: () => {} })); + di.override(synchronizeUpdateIsAvailableStateInjectable, () => ({ start: () => {}, stop: () => {} })); + di.override(quitAndInstallUpdateInjectable, () => () => {}); di.override(createElectronWindowForInjectable, () => () => async () => ({ show: () => {}, diff --git a/src/main/update-app/trigger-application-update-tray-item.injectable.ts b/src/main/update-app/trigger-application-update-tray-item.injectable.ts new file mode 100644 index 0000000000..edd4f72c29 --- /dev/null +++ b/src/main/update-app/trigger-application-update-tray-item.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 { computed } from "mobx"; +import { trayMenuItemInjectionToken } from "../tray/tray-menu-item/tray-menu-item-injection-token"; +import updateIsAvailableInjectable from "./update-is-available.injectable"; +import triggerApplicationUpdateInjectable from "./trigger-application-update.injectable"; + +const triggerApplicationUpdateTrayItemInjectable = getInjectable({ + id: "trigger-application-update-tray-item", + + instantiate: (di) => { + const updateIsAvailable = di.inject(updateIsAvailableInjectable); + const triggerApplicationUpdate = di.inject(triggerApplicationUpdateInjectable); + + return { + id: "trigger-application-update", + parentId: null, + orderNumber: 50, + label: "Trigger update", + enabled: computed(() => true), + visible: computed(() => updateIsAvailable.get()), + + click: () => { + triggerApplicationUpdate(); + }, + }; + }, + + injectionToken: trayMenuItemInjectionToken, +}); + +export default triggerApplicationUpdateTrayItemInjectable; diff --git a/src/main/update-app/trigger-application-update.injectable.ts b/src/main/update-app/trigger-application-update.injectable.ts new file mode 100644 index 0000000000..1867814b41 --- /dev/null +++ b/src/main/update-app/trigger-application-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 quitAndInstallUpdateInjectable from "../electron-app/features/quit-and-install-update.injectable"; + +const triggerApplicationUpdateInjectable = getInjectable({ + id: "trigger-application-update", + + instantiate: (di) => { + const quitAndInstallUpdate = di.inject(quitAndInstallUpdateInjectable); + + return () => { + quitAndInstallUpdate(); + }; + }, +}); + +export default triggerApplicationUpdateInjectable; diff --git a/src/main/update-app/update-is-available-state.injectable.ts b/src/main/update-app/update-is-available-state.injectable.ts new file mode 100644 index 0000000000..d19a1064eb --- /dev/null +++ b/src/main/update-app/update-is-available-state.injectable.ts @@ -0,0 +1,13 @@ +/** + * 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 { observable } from "mobx"; + +const updateIsAvailableState = getInjectable({ + id: "update-is-available-state", + instantiate: () => observable.box(false), +}); + +export default updateIsAvailableState; diff --git a/src/main/update-app/update-is-available.injectable.ts b/src/main/update-app/update-is-available.injectable.ts new file mode 100644 index 0000000000..6716156bbd --- /dev/null +++ b/src/main/update-app/update-is-available.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 { computed } from "mobx"; +import updateIsAvailableStateInjectable from "./update-is-available-state.injectable"; + +const updateIsAvailableInjectable = getInjectable({ + id: "update-is-available", + + instantiate: (di) => { + const updateIsAvailableState = di.inject(updateIsAvailableStateInjectable); + + return computed(() => updateIsAvailableState.get()); + }, +}); + +export default updateIsAvailableInjectable; diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx index 5e6ca0396e..1a7f7ba736 100644 --- a/src/renderer/components/test-utils/get-application-builder.tsx +++ b/src/renderer/components/test-utils/get-application-builder.tsx @@ -7,7 +7,7 @@ import rendererExtensionsInjectable from "../../../extensions/renderer-extension import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable"; import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token"; import type { IObservableArray } from "mobx"; -import { computed, observable, runInAction } from "mobx"; +import { action, computed, observable, runInAction } from "mobx"; import { renderFor } from "./renderFor"; import React from "react"; import { Router } from "react-router"; @@ -25,7 +25,7 @@ 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, find, map } 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"; @@ -45,6 +45,8 @@ import type { NamespaceStore } from "../+namespaces/store"; import namespaceStoreInjectable from "../+namespaces/store.injectable"; import historyInjectable from "../../navigation/history.injectable"; import trayMenuItemsInjectable from "../../../main/tray/tray-menu-item/tray-menu-items.injectable"; +import type { TrayMenuItem } from "../../../main/tray/tray-menu-item/tray-menu-item-injection-token"; +import updateIsAvailableStateInjectable from "../../../main/update-app/update-is-available-state.injectable"; type Callback = (dis: DiContainers) => void | Promise; @@ -57,8 +59,13 @@ export interface ApplicationBuilder { beforeRender: (callback: Callback) => ApplicationBuilder; render: () => Promise; + applicationUpdater: { + makeUpdateAvailable: (available: boolean) => void; + }; + tray: { click: (id: string) => Promise; + get: (id: string) => TrayMenuItem | undefined; }; applicationMenu: { @@ -149,6 +156,14 @@ export const getApplicationBuilder = () => { const builder: ApplicationBuilder = { dis, + applicationUpdater: { + makeUpdateAvailable: action((available: boolean) => { + const updateIsAvailableState = mainDi.inject(updateIsAvailableStateInjectable); + + updateIsAvailableState.set(available); + }), + }, + applicationMenu: { click: async (path: string) => { const applicationMenuItems = mainDi.inject( @@ -186,6 +201,14 @@ export const getApplicationBuilder = () => { }, tray: { + get: (id: string) => { + const trayMenuItems = mainDi.inject( + trayMenuItemsInjectable, + ); + + return trayMenuItems.get().find(matches({ id })); + }, + click: async (id: string) => { const trayMenuItems = mainDi.inject( trayMenuItemsInjectable,