diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 3c928430ed..d5ddc00cac 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -16,8 +16,8 @@ jobs: vmImage: windows-2019 strategy: matrix: - node_14.x: - node_version: 14.x + node: + node_version: 16.x steps: - powershell: | $CI_BUILD_TAG = git describe --tags @@ -64,8 +64,8 @@ jobs: vmImage: macOS-11 strategy: matrix: - node_14.x: - node_version: 14.x + node: + node_version: 16.x steps: - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" @@ -94,9 +94,25 @@ jobs: GH_TOKEN: $(LENS_IDE_GH_TOKEN) displayName: Customize config - - script: make build + - bash: | + set -e + + echo "Importing codesign certificate ..." + echo $CSC_LINK | base64 -D > certificate.p12 + security create-keychain -p $KEYCHAIN_PASSWORD build.keychain + security set-keychain-settings -lut 21600 build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain + security import certificate.p12 -k build.keychain -P $CSC_KEY_PASSWORD -T /usr/bin/codesign -T /usr/bin/security -A + security set-key-partition-list -S apple-tool:,apple: -k $KEYCHAIN_PASSWORD build.keychain + + rm certificate.p12 + echo "Codesign certificate imported!" + + make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" env: + KEYCHAIN_PASSWORD: secretz APPLEID: $(APPLEID) APPLEIDPASS: $(APPLEIDPASS) CSC_LINK: $(CSC_LINK) @@ -112,8 +128,8 @@ jobs: vmImage: ubuntu-18.04 strategy: matrix: - node_14.x: - node_version: 14.x + node: + node_version: 16.x steps: - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index f94a2fda5c..9c942690b0 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -9,7 +9,7 @@ jobs: if: ${{ contains(github.event.pull_request.labels.*.name, 'area/documentation') }} strategy: matrix: - node-version: [14.x] + node-version: [16.x] steps: - name: Checkout Release from lens uses: actions/checkout@v2 diff --git a/.github/workflows/electronegativity.yml b/.github/workflows/electronegativity.yml index 059894530b..6e634082c4 100644 --- a/.github/workflows/electronegativity.yml +++ b/.github/workflows/electronegativity.yml @@ -14,12 +14,12 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: "14" + node-version: "16" - uses: doyensec/electronegativity-action@v1.1 with: input: src/ - electron-version: "14.2.4" + electron-version: "15.5.7" severity: medium - name: Upload sarif diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 18ec977859..ac7ac673ca 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [14.x] + node-version: [16.x] steps: - name: Checkout Release from lens uses: actions/checkout@v2 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 300843633f..2fa32efc65 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [14.x] + node-version: [16.x] steps: - name: Set up Python 3.7 uses: actions/setup-python@v2 @@ -46,7 +46,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [14.x] + node-version: [16.x] needs: verify-docs steps: - name: Set up Python 3.7 diff --git a/.github/workflows/mkdocs-manual.yml b/.github/workflows/mkdocs-manual.yml index a0695d0e7d..cda83b86c3 100644 --- a/.github/workflows/mkdocs-manual.yml +++ b/.github/workflows/mkdocs-manual.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [14.x] + node-version: [16.x] steps: - name: Set up Python 3.7 uses: actions/setup-python@v2 diff --git a/.github/workflows/publish-master-npm.yml b/.github/workflows/publish-master-npm.yml index caccbe9d25..31f86ae6f0 100644 --- a/.github/workflows/publish-master-npm.yml +++ b/.github/workflows/publish-master-npm.yml @@ -14,7 +14,7 @@ jobs: ${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'area/extension') }} strategy: matrix: - node-version: [14.x] + node-version: [16.x] steps: - name: Checkout Release uses: actions/checkout@v2 diff --git a/.github/workflows/publish-release-npm.yml b/.github/workflows/publish-release-npm.yml index 0bfc06a106..79a06c000f 100644 --- a/.github/workflows/publish-release-npm.yml +++ b/.github/workflows/publish-release-npm.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [14.x] + node-version: [16.x] steps: - name: Checkout Release uses: actions/checkout@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44967d192c..a71fc42771 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-18.04, macos-11, windows-2019] - node-version: [14.x] + node-version: [16.x] steps: - name: Checkout Release from lens uses: actions/checkout@v2 diff --git a/.yarnrc b/.yarnrc index c8e7a49bbe..22e66ac2fe 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1,3 +1,3 @@ disturl "https://atom.io/download/electron" -target "14.2.4" +target "15.5.0" runtime "electron" diff --git a/Makefile b/Makefile index 48ce768766..9dfb2cf512 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,7 @@ integration: build build: node_modules binaries/client yarn run npm:fix-build-version $(MAKE) build-extensions -B + yarn run build:tray-icons yarn run compile ifeq "$(DETECTED_OS)" "Windows" # https://github.com/ukoloff/win-ca#clear-pem-folder-on-publish diff --git a/README.md b/README.md index 595def597b..cb9f44b35d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Lens Open Source Project (OpenLens) [![Build Status](https://github.com/lensapp/lens/actions/workflows/test.yml/badge.svg)](https://github.com/lensapp/lens/actions/workflows/test.yml) -[![Chat on Slack](https://img.shields.io/badge/chat-on%20slack-blue.svg?logo=slack&longCache=true&style=flat)](https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI) +[![Chat on Slack](https://img.shields.io/badge/chat-on%20slack-blue.svg?logo=slack&longCache=true&style=flat)](https://join.slack.com/t/k8slens/shared_invite/zt-198iepl92-EPJsCckkJ~f887vWqJcgGA) ## The Repository diff --git a/build/generate-tray-icons.ts b/build/generate-tray-icons.ts index a7ab3bd48b..ed90d27832 100644 --- a/build/generate-tray-icons.ts +++ b/build/generate-tray-icons.ts @@ -3,8 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { readFileSync } from "fs"; -import { ensureDirSync } from "fs-extra"; +import { ensureDir, readFile } from "fs-extra"; import { JSDOM } from "jsdom"; import path from "path"; import sharp from "sharp"; @@ -12,39 +11,120 @@ import sharp from "sharp"; const size = Number(process.env.OUTPUT_SIZE || "16"); const outputFolder = process.env.OUTPUT_DIR || "./build/tray"; const inputFile = process.env.INPUT_SVG_PATH || "./src/renderer/components/icon/logo-lens.svg"; +const noticeFile = process.env.NOTICE_SVG_PATH || "./src/renderer/components/icon/notice.svg"; -const svgData = readFileSync(inputFile, { encoding: "utf-8" }); -const svgDom = new JSDOM(`${svgData}`); -const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0]; +async function ensureOutputFoler() { + await ensureDir(outputFolder); +} -svgRoot.innerHTML += ``; -const lightTemplate = svgRoot.outerHTML; +function getSvgStyling(colouring: "dark" | "light"): string { + return ` + + `; +} -svgRoot.innerHTML += ``; +type TargetSystems = "macos" | "windows-or-linux"; -const darkTemplate = svgRoot.outerHTML; +async function getBaseIconImage(system: TargetSystems) { + const svgData = await readFile(inputFile, { encoding: "utf-8" }); + const dom = new JSDOM(`${svgData}`); + const root = dom.window.document.body.getElementsByTagName("svg")[0]; -console.log("Generating tray icon pngs"); + root.innerHTML += getSvgStyling(system === "macos" ? "light" : "dark"); -ensureDirSync(outputFolder); + return Buffer.from(root.outerHTML); +} -Promise.all([ - sharp(Buffer.from(lightTemplate)) +async function generateImage(image: Buffer, size: number, namePrefix: string) { + sharp(image) .resize({ width: size, height: size }) .png() - .toFile(path.join(outputFolder, "trayIconDarkTemplate.png")), - sharp(Buffer.from(lightTemplate)) - .resize({ width: size*2, height: size*2 }) - .png() - .toFile(path.join(outputFolder, "trayIconDarkTemplate@2x.png")), - sharp(Buffer.from(darkTemplate)) - .resize({ width: size, height: size }) - .png() - .toFile(path.join(outputFolder, "trayIconTemplate.png")), - sharp(Buffer.from(darkTemplate)) - .resize({ width: size*2, height: size*2 }) - .png() - .toFile(path.join(outputFolder, "trayIconTemplate@2x.png")), -]) - .then((resolutions) => console.log(`Generated ${resolutions.length} images`)) - .catch(console.error); + .toFile(path.join(outputFolder, `${namePrefix}.png`)); +} + +async function generateImages(image: Buffer, size: number, name: string) { + await Promise.all([ + generateImage(image, size, name), + generateImage(image, size*2, `${name}@2x`), + generateImage(image, size*3, `${name}@3x`), + generateImage(image, size*4, `${name}@4x`), + ]); +} + +async function generateUpdateAvailableImages(baseImage: Buffer, system: TargetSystems) { + const noticeIconImage = await getNoticeIconImage(system); + const circleBuffer = await sharp(Buffer.from(` + + + + `)) + .toBuffer(); + + return sharp(baseImage) + .resize({ width: 128, height: 128 }) + .composite([ + { + input: circleBuffer, + top: 64, + left: 64, + blend: "dest-out", + }, + { + input: ( + await sharp(noticeIconImage) + .resize({ + width: 60, + height: 60, + }) + .toBuffer() + ), + top: 66, + left: 66, + }, + ]) + .toBuffer(); +} + +async function getNoticeIconImage(system: TargetSystems) { + const svgData = await readFile(noticeFile, { encoding: "utf-8" }); + const root = new JSDOM(svgData).window.document.getElementsByTagName("svg")[0]; + + root.innerHTML += getSvgStyling(system === "macos" ? "light" : "dark"); + + return Buffer.from(root.outerHTML); +} + +async function generateTrayIcons() { + try { + console.log("Generating tray icon pngs"); + await ensureOutputFoler(); + + const baseIconTemplateImage = await getBaseIconImage("macos"); + const updateAvailableTemplateImage = await generateUpdateAvailableImages(baseIconTemplateImage, "macos"); + const baseIconImage = await getBaseIconImage("windows-or-linux"); + const updateAvailableImage = await generateUpdateAvailableImages(baseIconImage, "windows-or-linux"); + + await Promise.all([ + // Templates are for macOS only + generateImages(baseIconTemplateImage, size, "trayIconTemplate"), + generateImages(updateAvailableTemplateImage, size, "trayIconUpdateAvailableTemplate"), + + // Non-templates are for windows and linux + generateImages(baseIconImage, size, "trayIcon"), + generateImages(updateAvailableImage, size, "trayIconUpdateAvailable"), + ]); + + console.log("Generated all images"); + } catch (error) { + console.error(error); + } +} + +generateTrayIcons(); diff --git a/build/tray/trayIconDarkTemplate.png b/build/tray/trayIcon.png similarity index 100% rename from build/tray/trayIconDarkTemplate.png rename to build/tray/trayIcon.png diff --git a/build/tray/trayIconDarkTemplate@2x.png b/build/tray/trayIcon@2x.png similarity index 100% rename from build/tray/trayIconDarkTemplate@2x.png rename to build/tray/trayIcon@2x.png diff --git a/build/tray/trayIcon@3x.png b/build/tray/trayIcon@3x.png new file mode 100644 index 0000000000..c706ec9b3a Binary files /dev/null and b/build/tray/trayIcon@3x.png differ diff --git a/build/tray/trayIcon@4x.png b/build/tray/trayIcon@4x.png new file mode 100644 index 0000000000..22b1c50c28 Binary files /dev/null and b/build/tray/trayIcon@4x.png differ diff --git a/build/tray/trayIconTemplate@3x.png b/build/tray/trayIconTemplate@3x.png new file mode 100644 index 0000000000..2e06ee1a7d Binary files /dev/null and b/build/tray/trayIconTemplate@3x.png differ diff --git a/build/tray/trayIconTemplate@4x.png b/build/tray/trayIconTemplate@4x.png new file mode 100644 index 0000000000..58567e118a Binary files /dev/null and b/build/tray/trayIconTemplate@4x.png differ diff --git a/build/tray/trayIconUpdateAvailable.png b/build/tray/trayIconUpdateAvailable.png new file mode 100644 index 0000000000..88dd098ce7 Binary files /dev/null and b/build/tray/trayIconUpdateAvailable.png differ diff --git a/build/tray/trayIconUpdateAvailable@2x.png b/build/tray/trayIconUpdateAvailable@2x.png new file mode 100644 index 0000000000..b4c1167c04 Binary files /dev/null and b/build/tray/trayIconUpdateAvailable@2x.png differ diff --git a/build/tray/trayIconUpdateAvailable@3x.png b/build/tray/trayIconUpdateAvailable@3x.png new file mode 100644 index 0000000000..30af0bb440 Binary files /dev/null and b/build/tray/trayIconUpdateAvailable@3x.png differ diff --git a/build/tray/trayIconUpdateAvailable@4x.png b/build/tray/trayIconUpdateAvailable@4x.png new file mode 100644 index 0000000000..42d2effc9e Binary files /dev/null and b/build/tray/trayIconUpdateAvailable@4x.png differ diff --git a/build/tray/trayIconUpdateAvailableTemplate.png b/build/tray/trayIconUpdateAvailableTemplate.png new file mode 100644 index 0000000000..72fd9a8cf7 Binary files /dev/null and b/build/tray/trayIconUpdateAvailableTemplate.png differ diff --git a/build/tray/trayIconUpdateAvailableTemplate@2x.png b/build/tray/trayIconUpdateAvailableTemplate@2x.png new file mode 100644 index 0000000000..eed819c648 Binary files /dev/null and b/build/tray/trayIconUpdateAvailableTemplate@2x.png differ diff --git a/build/tray/trayIconUpdateAvailableTemplate@3x.png b/build/tray/trayIconUpdateAvailableTemplate@3x.png new file mode 100644 index 0000000000..4fed4a6d09 Binary files /dev/null and b/build/tray/trayIconUpdateAvailableTemplate@3x.png differ diff --git a/build/tray/trayIconUpdateAvailableTemplate@4x.png b/build/tray/trayIconUpdateAvailableTemplate@4x.png new file mode 100644 index 0000000000..4d1342ea24 Binary files /dev/null and b/build/tray/trayIconUpdateAvailableTemplate@4x.png differ diff --git a/docs/extensions/get-started/anatomy.md b/docs/extensions/get-started/anatomy.md index 481c18ac2c..8cfcd57076 100644 --- a/docs/extensions/get-started/anatomy.md +++ b/docs/extensions/get-started/anatomy.md @@ -79,7 +79,7 @@ Some of the most-important fields include: } ``` -## Webpack configuation +## Webpack configuration The following webpack `externals` are provided by `Lens` and must be used (when available) to make sure that the versions used are in sync. diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index 5d41dff89d..d90a343692 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -224,7 +224,7 @@ export default class ExampleExtension extends Renderer.LensExtension { { id: "bonjour", components: { - Page: () => , + Page: () => , }, }, ]; @@ -250,7 +250,7 @@ export default class ExampleExtension extends Renderer.LensExtension { target: { pageId: "bonjour" }, title: "Bonjour le monde", components: { - Icon: ExempleIcon, + Icon: ExampleIcon, }, }, ]; diff --git a/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb b/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb index cc177204a3..288cd553b1 100644 --- a/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb +++ b/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb @@ -24,11 +24,6 @@ spec: operator: In values: - linux - - matchExpressions: - - key: beta.kubernetes.io/os - operator: In - values: - - linux # <%- if config.node_selector -%> # nodeSelector: # <%- node_selector.to_h.each do |key, value| -%> diff --git a/extensions/metrics-cluster-feature/resources/10-node-exporter-ds.yml.hb b/extensions/metrics-cluster-feature/resources/10-node-exporter-ds.yml.hb index 2ff46d8d0b..c02fb93321 100644 --- a/extensions/metrics-cluster-feature/resources/10-node-exporter-ds.yml.hb +++ b/extensions/metrics-cluster-feature/resources/10-node-exporter-ds.yml.hb @@ -30,11 +30,6 @@ spec: operator: In values: - linux - - matchExpressions: - - key: beta.kubernetes.io/os - operator: In - values: - - linux securityContext: runAsNonRoot: true runAsUser: 65534 diff --git a/extensions/metrics-cluster-feature/resources/14-kube-state-metrics-deployment.yml.hb b/extensions/metrics-cluster-feature/resources/14-kube-state-metrics-deployment.yml.hb index 5eaefe2cf9..0174d5c8f4 100644 --- a/extensions/metrics-cluster-feature/resources/14-kube-state-metrics-deployment.yml.hb +++ b/extensions/metrics-cluster-feature/resources/14-kube-state-metrics-deployment.yml.hb @@ -23,11 +23,6 @@ spec: operator: In values: - linux - - matchExpressions: - - key: beta.kubernetes.io/os - operator: In - values: - - linux serviceAccountName: kube-state-metrics containers: - name: kube-state-metrics diff --git a/package.json b/package.json index 0521858a95..6046fd1ac4 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "sentryDsn": "" }, "engines": { - "node": ">=14 <15" + "node": ">=16 <17" }, "jest": { "collectCoverage": false, @@ -72,6 +72,9 @@ "/src/jest.setup.ts", "jest-canvas-mock" ], + "setupFilesAfterEnv": [ + "/src/jest-after-env.setup.ts" + ], "globals": { "ts-jest": { "isolatedModules": true @@ -225,22 +228,22 @@ "filehound": "^1.17.6", "fs-extra": "^9.0.1", "glob-to-regexp": "^0.4.1", - "got": "^11.8.3", + "got": "^11.8.5", "grapheme-splitter": "^1.0.4", "handlebars": "^4.7.7", "history": "^4.10.1", "http-proxy": "^1.18.1", - "immer": "^9.0.12", + "immer": "^9.0.14", "joi": "^17.6.0", "js-yaml": "^4.1.0", "jsdom": "^16.7.0", "lodash": "^4.17.15", "mac-ca": "^1.0.6", - "marked": "^4.0.15", + "marked": "^4.0.16", "md5-file": "^5.0.0", "mobx": "^6.5.0", "mobx-observable-history": "^2.0.3", - "mobx-react": "^7.3.0", + "mobx-react": "^7.5.0", "mobx-utils": "^6.0.4", "mock-fs": "^5.1.2", "moment": "^2.29.3", @@ -248,7 +251,7 @@ "monaco-editor": "^0.29.1", "monaco-editor-webpack-plugin": "^5.0.0", "node-fetch": "lensapp/node-fetch#2.x", - "node-pty": "^0.10.1", + "node-pty": "^0.11.0-beta19", "npm": "^6.14.17", "p-limit": "^3.1.0", "path-to-regexp": "^6.2.0", @@ -276,14 +279,14 @@ "winston": "^3.7.2", "winston-console-format": "^1.0.8", "winston-transport-browserconsole": "^1.0.5", - "ws": "^8.5.0" + "ws": "^8.7.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", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.5", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", "@sentry/types": "^6.19.7", "@testing-library/dom": "^7.31.2", "@testing-library/jest-dom": "^5.16.4", @@ -292,7 +295,7 @@ "@types/byline": "^4.2.33", "@types/chart.js": "^2.9.36", "@types/circular-dependency-plugin": "5.0.5", - "@types/cli-progress": "^3.9.2", + "@types/cli-progress": "^3.11.0", "@types/color": "^3.0.3", "@types/command-line-args": "^5.2.0", "@types/crypto-js": "^3.1.47", @@ -311,7 +314,7 @@ "@types/md5-file": "^4.0.2", "@types/mini-css-extract-plugin": "^2.4.0", "@types/mock-fs": "^4.13.1", - "@types/node": "14.18.17", + "@types/node": "^16.11.39", "@types/node-fetch": "^2.6.1", "@types/npm": "^2.0.32", "@types/proper-lockfile": "^4.1.2", @@ -321,7 +324,7 @@ "@types/react-dom": "^17.0.16", "@types/react-router": "^5.1.18", "@types/react-router-dom": "^5.3.3", - "@types/react-table": "^7.7.11", + "@types/react-table": "^7.7.12", "@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-window": "^1.8.5", "@types/readable-stream": "^2.3.13", @@ -339,33 +342,33 @@ "@types/uuid": "^8.3.4", "@types/webpack": "^5.28.0", "@types/webpack-dev-server": "^4.7.2", - "@types/webpack-env": "^1.16.4", + "@types/webpack-env": "^1.17.0", "@types/webpack-node-externals": "^2.5.3", - "@typescript-eslint/eslint-plugin": "^5.21.0", - "@typescript-eslint/parser": "^5.17.0", + "@typescript-eslint/eslint-plugin": "^5.27.1", + "@typescript-eslint/parser": "^5.27.0", "ansi_up": "^5.1.0", "chart.js": "^2.9.4", "circular-dependency-plugin": "^5.2.2", - "cli-progress": "^3.11.0", + "cli-progress": "^3.11.1", "color": "^3.2.1", "command-line-args": "^5.2.1", - "concurrently": "^7.1.0", + "concurrently": "^7.2.1", "css-loader": "^6.7.1", "deepdash": "^5.3.9", - "dompurify": "^2.3.6", - "electron": "^14.2.9", + "dompurify": "^2.3.8", + "electron": "^15.5.7", "electron-builder": "^23.0.3", "electron-notarize": "^0.3.0", "esbuild": "^0.14.38", - "esbuild-loader": "^2.18.0", - "eslint": "^8.14.0", + "esbuild-loader": "^2.19.0", + "eslint": "^8.16.0", "eslint-plugin-header": "^3.1.1", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-react": "^7.29.4", + "eslint-plugin-react": "^7.30.0", "eslint-plugin-react-hooks": "^4.5.0", "eslint-plugin-unused-imports": "^2.0.0", "flex.box": "^3.4.4", - "fork-ts-checker-webpack-plugin": "^6.5.0", + "fork-ts-checker-webpack-plugin": "^6.5.2", "gunzip-maybe": "^1.4.2", "html-webpack-plugin": "^5.5.0", "identity-obj-proxy": "^3.0.0", @@ -381,37 +384,36 @@ "node-gyp": "7.1.2", "node-loader": "^2.0.0", "nodemon": "^2.0.16", - "playwright": "^1.20.2", - "postcss": "^8.4.12", + "playwright": "^1.22.2", + "postcss": "^8.4.14", "postcss-loader": "^6.2.1", "randomcolor": "^0.6.2", "react-beautiful-dnd": "^13.1.0", - "react-refresh": "^0.12.0", - "react-refresh-typescript": "^2.0.4", - "react-router-dom": "^5.3.1", + "react-refresh": "^0.13.0", + "react-refresh-typescript": "^2.0.5", + "react-router-dom": "^5.3.3", "react-select": "^5.3.2", "react-select-event": "^5.5.0", - "react-table": "^7.7.0", + "react-table": "^7.8.0", "react-window": "^1.8.7", - "sass": "^1.51.0", + "sass": "^1.52.2", "sass-loader": "^12.6.0", - "sharp": "^0.30.4", + "sharp": "^0.30.6", "style-loader": "^3.3.1", "tailwindcss": "^3.0.23", "tar-stream": "^2.2.0", "ts-jest": "26.5.6", "ts-loader": "^9.2.8", "ts-node": "^10.7.0", - "type-fest": "^2.12.2", + "type-fest": "^2.13.0", "typed-emitter": "^1.4.0", - "typedoc": "0.22.15", + "typedoc": "0.22.17", "typedoc-plugin-markdown": "^3.11.12", - "typeface-roboto": "^1.1.13", "typescript": "^4.5.5", "typescript-plugin-css-modules": "^3.4.0", - "webpack": "^5.72.0", + "webpack": "^5.73.0", "webpack-cli": "^4.9.2", - "webpack-dev-server": "^4.9.0", + "webpack-dev-server": "^4.9.1", "webpack-node-externals": "^3.0.0", "xterm": "^4.18.0", "xterm-addon-fit": "^0.5.0" 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..5c5ebac6e4 --- /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..0cf326d79d --- /dev/null +++ b/src/behaviours/application-update/installing-update-using-tray.test.ts @@ -0,0 +1,262 @@ +/** + * 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"; +import type { TrayIconPaths } from "../../main/tray/tray-icon-path.injectable"; +import trayIconPathsInjectable from "../../main/tray/tray-icon-path.injectable"; + +describe("installing update using tray", () => { + let applicationBuilder: ApplicationBuilder; + let checkForPlatformUpdatesMock: AsyncFnMock; + let downloadPlatformUpdateMock: AsyncFnMock; + let showApplicationWindowMock: jest.Mock; + let trayIconPaths: TrayIconPaths; + + 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); + trayIconPaths = mainDi.inject(trayIconPathsInjectable); + }); + }); + + describe("when started", () => { + let rendered: RenderResult; + + beforeEach(async () => { + rendered = await applicationBuilder.render(); + }); + + it("renders", () => { + expect(rendered.baseElement).toMatchSnapshot(); + }); + + it("should use the normal tray icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.normal); + }); + + it("user cannot install update yet", () => { + expect(applicationBuilder.tray.get("install-update")).toBeNull(); + }); + + 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("should still use the normal tray icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.normal); + }); + + 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")).toBeNull(); + }); + + 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("should still use the normal tray icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.normal); + }); + + it("user cannot install update", () => { + expect(applicationBuilder.tray.get("install-update")).toBeNull(); + }); + + 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("should use the update available icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.updateAvailable); + }); + + 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")).toBeNull(); + }); + + 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"), + ).toBeNull(); + }); + + it("should revert to use the normal tray icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.normal); + }); + + 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("should use the update available icon", () => { + expect(applicationBuilder.tray.getIconPath()).toBe(trayIconPaths.updateAvailable); + }); + + 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..e0187e504b 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
+
`; @@ -770,6 +771,7 @@ exports[`preferences - navigation to terminal preferences given in preferences, >
- -
+ +
+
+
+ Select... +
+
+ +
+
+
+ + +
+
@@ -845,5 +903,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 4f836d5566..ca52f6f1d2 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -22,6 +22,9 @@ import getConfigurationFileModelInjectable from "../get-configuration-file-model import appVersionInjectable from "../get-configuration-file-model/app-version/app-version.injectable"; import assert from "assert"; import directoryForTempInjectable from "../app-paths/directory-for-temp/directory-for-temp.injectable"; +import kubectlBinaryNameInjectable from "../../main/kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../../main/kubectl/normalized-arch.injectable"; +import normalizedPlatformInjectable from "../vars/normalized-platform.injectable"; console = new Console(stdout, stderr); @@ -84,6 +87,9 @@ describe("cluster-store", () => { mainDi.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); mainDi.override(directoryForTempInjectable, () => "some-temp-directory"); + mainDi.override(kubectlBinaryNameInjectable, () => "kubectl"); + mainDi.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + mainDi.override(normalizedPlatformInjectable, () => "darwin"); mainDi.permitSideEffects(getConfigurationFileModelInjectable); mainDi.permitSideEffects(appVersionInjectable); @@ -363,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/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable.ts b/src/common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable.ts deleted file mode 100644 index 9c25407d4d..0000000000 --- a/src/common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable.ts +++ /dev/null @@ -1,13 +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 { baseBinariesDir } from "../../vars"; - -const directoryForBundledBinariesInjectable = getInjectable({ - id: "directory-for-bundled-binaries", - instantiate: () => baseBinariesDir.get(), -}); - -export default directoryForBundledBinariesInjectable; 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..dff1e5879e --- /dev/null +++ b/src/common/application-update/update-channels.ts @@ -0,0 +1,37 @@ +/** + * 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 cdc4144b4a..0000000000 --- a/src/common/ipc/update-available.ts +++ /dev/null @@ -1,53 +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 AutoUpdateQuitAndInstalledChannel = "auto-update:quit-and-install"; -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/k8s-api/__tests__/nodes.test.ts b/src/common/k8s-api/__tests__/node.test.ts similarity index 72% rename from src/common/k8s-api/__tests__/nodes.test.ts rename to src/common/k8s-api/__tests__/node.test.ts index e2527cd9c6..53ffc59d79 100644 --- a/src/common/k8s-api/__tests__/nodes.test.ts +++ b/src/common/k8s-api/__tests__/node.test.ts @@ -8,7 +8,61 @@ import { Node } from "../endpoints"; * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -describe("Nodes tests", () => { +describe("Node tests", () => { + describe("isMasterNode()", () => { + it("given a master node labelled before kubernetes 1.20, should return true", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "node-role.kubernetes.io/master": "NoSchedule", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.isMasterNode()).toBe(true); + }); + + it("given a master node labelled after kubernetes 1.20, should return true", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: { + "node-role.kubernetes.io/control-plane": "NoSchedule", + }, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.isMasterNode()).toBe(true); + }); + + it("given a non master node, should return false", () => { + const node = new Node({ + apiVersion: "foo", + kind: "Node", + metadata: { + name: "bar", + resourceVersion: "1", + uid: "bat", + labels: {}, + selfLink: "/api/v1/nodes/bar", + }, + }); + + expect(node.isMasterNode()).toBe(false); + }); + }); + describe("getRoleLabels()", () => { it("should return empty string if labels is not present", () => { const node = new Node({ diff --git a/src/common/k8s-api/endpoints/custom-resource-definition.api.ts b/src/common/k8s-api/endpoints/custom-resource-definition.api.ts index 03d0903f71..90485a5ab8 100644 --- a/src/common/k8s-api/endpoints/custom-resource-definition.api.ts +++ b/src/common/k8s-api/endpoints/custom-resource-definition.api.ts @@ -10,12 +10,14 @@ import type { BaseKubeObjectCondition, KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; import type { DerivedKubeApiOptions } from "../kube-api"; import { KubeApi } from "../kube-api"; +import type { JSONSchemaProps } from "./types/json-schema-props"; interface AdditionalPrinterColumnsCommon { name: string; type: "integer" | "number" | "string" | "boolean" | "date"; - priority: number; - description: string; + priority?: number; + format?: "int32" | "int64" | "float" | "double" | "byte" | "binary" | "date" | "date-time" | "password"; + description?: string; } export type AdditionalPrinterColumnsV1 = AdditionalPrinterColumnsCommon & { @@ -26,11 +28,15 @@ type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & { JSONPath: string; }; +export interface CustomResourceValidation { + openAPIV3Schema?: JSONSchemaProps; +} + export interface CustomResourceDefinitionVersion { name: string; served: boolean; storage: boolean; - schema?: object; // required in v1 but not present in v1beta + schema?: CustomResourceValidation; // required in v1 but not present in v1beta additionalPrinterColumns?: AdditionalPrinterColumnsV1[]; } diff --git a/src/common/k8s-api/endpoints/node.api.ts b/src/common/k8s-api/endpoints/node.api.ts index 3aab828621..a51db59fa7 100644 --- a/src/common/k8s-api/endpoints/node.api.ts +++ b/src/common/k8s-api/endpoints/node.api.ts @@ -5,7 +5,7 @@ import type { BaseKubeObjectCondition, KubeObjectScope } from "../kube-object"; import { KubeObject } from "../kube-object"; -import { cpuUnitsToNumber, unitsToBytes } from "../../../renderer/utils"; +import { cpuUnitsToNumber, unitsToBytes, isObject } from "../../../renderer/utils"; import type { MetricData } from "./metrics.api"; import { metricsApi } from "./metrics.api"; import type { DerivedKubeApiOptions, IgnoredKubeApiOptions } from "../kube-api"; @@ -69,6 +69,17 @@ export interface NodeCondition extends BaseKubeObjectCondition { lastHeartbeatTime?: string; } +/** + * These role label prefixs are the ones that are for master nodes + * + * The `master` label has been deprecated in Kubernetes 1.20, and will be removed in 1.25 so we + * have to also use the newer `control-plane` label + */ +const masterNodeLabels = [ + "master", + "control-plane", +]; + /** * This regex is used in the `getRoleLabels()` method bellow, but placed here * as factoring out regexes is best practice. @@ -189,15 +200,19 @@ export class Node extends KubeObject masterNodeLabels.includes(roleLabel)); + } + + getRoleLabelItems(): string[] { const { labels } = this.metadata; - - if (!labels || typeof labels !== "object") { - return ""; - } - const roleLabels: string[] = []; + if (!isObject(labels)) { + return roleLabels; + } + for (const labelKey of Object.keys(labels)) { const match = nodeRoleLabelKeyMatcher.match(labelKey); @@ -214,7 +229,11 @@ export class Node extends KubeObject>; + dependencies?: Partial>; + description?: string; + _enum?: object[]; + example?: JsonValue; + + exclusiveMaximum?: boolean; + exclusiveMinimum?: boolean; + externalDocs?: ExternalDocumentation; + + /** + * format is an OpenAPI v3 format string. + * Unknown formats are ignored. + * + * The following formats are validated: + * - bsonobjectid: a bson object ID, i.e. a 24 characters hex string + * - uri: an URI as parsed by Golang net/url.ParseRequestURI + * - email: an email address as parsed by Golang net/mail.ParseAddress + * - hostname: a valid representation for an Internet host name, as defined by RFC 1034, section 3.1 [RFC1034]. + * - ipv4: an IPv4 IP as parsed by Golang net.ParseIP + * - ipv6: an IPv6 IP as parsed by Golang net.ParseIP + * - cidr: a CIDR as parsed by Golang net.ParseCIDR + * - mac: a MAC address as parsed by Golang net.ParseMAC + * - uuid: an UUID that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$ + * - uuid3: an UUID3 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?3[0-9a-f]{3}-?[0-9a-f]{4}-?[0-9a-f]{12}$ + * - uuid4: an UUID4 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$ + * - uuid5: an UUID5 that allows uppercase defined by the regex (?i)^[0-9a-f]{8}-?[0-9a-f]{4}-?5[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$ + * - isbn: an ISBN10 or ISBN13 number string like "0321751043" or "978-0321751041" + * - isbn10: an ISBN10 number string like "0321751043" + * - isbn13: an ISBN13 number string like "978-0321751041" + * - creditcard: a credit card number defined by the regex ^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$ with any non digit characters mixed in + * - ssn: a U.S. social security number following the regex ^\\d{3}[- ]?\\d{2}[- ]?\\d{4}$ + * - hexcolor: an hexadecimal color code like "#FFFFFF: following the regex ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$ + * - rgbcolor: an RGB color code like rgb like "rgb(255,255,2559" + * - byte: base64 encoded binary data + * - password: any kind of string + * - date: a date string like "2006-01-02" as defined by full-date in RFC3339 + * - duration: a duration string like "22 ns" as parsed by Golang time.ParseDuration or compatible with Scala duration format + * - datetime: a date time string like "2014-12-15T19:30:20.000Z" as defined by date-time in RFC3339. + */ + format?: string; + + id?: string; + items?: JSONSchemaProps | JSONSchemaProps[]; + maxItems?: number; + maxLength?: number; + maxProperties?: number; + maximum?: number; + minItems?: number; + minLength?: number; + minProperties?: number; + minimum?: number; + multipleOf?: number; + not?: JSONSchemaProps; + nullable?: boolean; + oneOf?: JSONSchemaProps[]; + pattern?: string; + patternProperties?: Partial>; + properties?: Partial>; + required?: Array; + title?: string; + type?: string; + uniqueItems?: boolean; + x_kubernetes_embedded_resource?: boolean; + x_kubernetes_int_or_string?: boolean; + x_kubernetes_list_map_keys?: string[]; + x_kubernetes_list_type?: string; + x_kubernetes_map_type?: string; + x_kubernetes_preserve_unknown_fields?: boolean; +} diff --git a/src/common/k8s-api/kube-api.ts b/src/common/k8s-api/kube-api.ts index bc1c2f6415..4a487b82d3 100644 --- a/src/common/k8s-api/kube-api.ts +++ b/src/common/k8s-api/kube-api.ts @@ -169,6 +169,13 @@ export interface IRemoteKubeApiConfig { clientCertificateData?: string; clientKeyData?: string; }; + /** + * Custom instance of https.agent to use for the requests + * + * @remarks the custom agent replaced default agent, options skipTLSVerify, + * clientCertificateData, clientKeyData and caData are ignored. + */ + agent?: Agent; } export function forCluster< @@ -240,6 +247,10 @@ export function forRemoteCluster< reqInit.agent = new Agent(agentOptions); } + if (config.agent) { + reqInit.agent = config.agent; + } + const token = config.user.token; const request = new KubeJsonApi({ serverAddress: config.cluster.server, diff --git a/src/common/register-protocol.ts b/src/common/register-protocol.ts deleted file mode 100644 index db00778b2e..0000000000 --- a/src/common/register-protocol.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. - */ - -// Register custom protocols - -import { protocol } from "electron"; -import path from "path"; - -export function registerFileProtocol(name: string, basePath: string) { - protocol.registerFileProtocol(name, (request, callback) => { - const filePath = request.url.replace(`${name}://`, ""); - const absPath = path.resolve(basePath, filePath); - - callback({ path: absPath }); - }); -} 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/terminal/channels.ts b/src/common/terminal/channels.ts new file mode 100644 index 0000000000..f958c9c696 --- /dev/null +++ b/src/common/terminal/channels.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +export enum TerminalChannels { + STDIN = "stdin", + STDOUT = "stdout", + CONNECTED = "connected", + RESIZE = "resize", + PING = "ping", +} + +export type TerminalMessage = { + type: TerminalChannels.STDIN; + data: string; +} | { + type: TerminalChannels.STDOUT; + data: string; +} | { + type: TerminalChannels.CONNECTED; +} | { + type: TerminalChannels.RESIZE; + data: { + width: number; + height: number; + }; +} | { + type: TerminalChannels.PING; +}; 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/utils/is-promise/is-promise.ts b/src/common/utils/is-promise/is-promise.ts new file mode 100644 index 0000000000..6261f569cd --- /dev/null +++ b/src/common/utils/is-promise/is-promise.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +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..76ba0679f3 --- /dev/null +++ b/src/common/utils/sync-box/sync-box-injection-token.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 { getInjectionToken } from "@ogre-tools/injectable"; +import type { IComputedValue } from "mobx"; + +type AsJson = T extends string | number | boolean | null + ? T + : T extends Function + ? never + : T extends Array + ? AsJson[] + : T extends object + ? { [K in keyof T]: AsJson } + : never; + +export interface SyncBox { + id: string; + value: IComputedValue>; + set: (value: AsJson) => 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 efbf32ee67..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; /** @@ -57,6 +55,9 @@ export const defaultThemeId: ThemeId = "lens-dark"; export const defaultFontSize = 12; export const defaultTerminalFontFamily = "RobotoMono"; export const defaultEditorFontFamily = "RobotoMono"; +/** + * @deprecated use `di.inject(normalizedPlatformInjectable)` instead + */ export const normalizedPlatform = (() => { switch (process.platform) { case "darwin": @@ -69,6 +70,9 @@ export const normalizedPlatform = (() => { throw new Error(`platform=${process.platform} is unsupported`); } })(); +/** + * @deprecated use `di.inject(bundledBinariesNormalizedArchInjectable)` instead + */ export const normalizedArch = (() => { switch (process.arch) { case "arm64": @@ -119,16 +123,6 @@ export const helmBinaryName = getBinaryName("helm"); */ export const helmBinaryPath = lazyInitialized(() => path.join(baseBinariesDir.get(), helmBinaryName)); -/** - * @deprecated for being explicit side effect. - */ -export const kubectlBinaryName = getBinaryName("kubectl"); - -/** - * @deprecated for being explicit side effect. - */ -export const kubectlBinaryPath = lazyInitialized(() => path.join(baseBinariesDir.get(), kubectlBinaryName)); - // Apis export const apiPrefix = "/api"; // local router apis export const apiKubePrefix = "/api-kube"; // k8s cluster apis diff --git a/src/common/vars/base-bundled-binaries-dir.injectable.ts b/src/common/vars/base-bundled-binaries-dir.injectable.ts new file mode 100644 index 0000000000..41f5a5e0a3 --- /dev/null +++ b/src/common/vars/base-bundled-binaries-dir.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 path from "path"; +import bundledBinariesNormalizedArchInjectable from "./bundled-binaries-normalized-arch.injectable"; +import bundledResourcesDirectoryInjectable from "./bundled-resources-dir.injectable"; + +const baseBundeledBinariesDirectoryInjectable = getInjectable({ + id: "base-bundeled-binaries-directory", + instantiate: (di) => path.join( + di.inject(bundledResourcesDirectoryInjectable), + di.inject(bundledBinariesNormalizedArchInjectable), + ), +}); + +export default baseBundeledBinariesDirectoryInjectable; diff --git a/src/common/vars/bundled-binaries-normalized-arch.injectable.ts b/src/common/vars/bundled-binaries-normalized-arch.injectable.ts new file mode 100644 index 0000000000..3c838d626c --- /dev/null +++ b/src/common/vars/bundled-binaries-normalized-arch.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"; + +const bundledBinariesNormalizedArchInjectable = getInjectable({ + id: "bundled-binaries-normalized-arch", + instantiate: () => { + switch (process.arch) { + case "arm64": + return "arm64"; + case "x64": + case "amd64": + return "x64"; + case "386": + case "x32": + case "ia32": + return "ia32"; + default: + throw new Error(`arch=${process.arch} is unsupported`); + } + }, + causesSideEffects: true, +}); + +export default bundledBinariesNormalizedArchInjectable; diff --git a/src/common/vars/bundled-resources-dir.injectable.ts b/src/common/vars/bundled-resources-dir.injectable.ts new file mode 100644 index 0000000000..a73ff98e78 --- /dev/null +++ b/src/common/vars/bundled-resources-dir.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 path from "path"; +import isProductionInjectable from "./is-production.injectable"; +import normalizedPlatformInjectable from "./normalized-platform.injectable"; + +const bundledResourcesDirectoryInjectable = getInjectable({ + id: "bundled-resources-directory", + instantiate: (di) => { + const isProduction = di.inject(isProductionInjectable); + const normalizedPlatform = di.inject(normalizedPlatformInjectable); + + return isProduction + ? process.resourcesPath + : path.join(process.cwd(), "binaries", "client", normalizedPlatform); + }, +}); + +export default bundledResourcesDirectoryInjectable; diff --git a/src/common/vars/normalized-platform.injectable.ts b/src/common/vars/normalized-platform.injectable.ts new file mode 100644 index 0000000000..7177678407 --- /dev/null +++ b/src/common/vars/normalized-platform.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"; + +const normalizedPlatformInjectable = getInjectable({ + id: "normalized-platform", + instantiate: () => { + switch (process.platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + throw new Error(`platform=${process.platform} is unsupported`); + } + }, + causesSideEffects: true, +}); + +export default normalizedPlatformInjectable; 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/common/ipc-channel/channel.ts b/src/jest-after-env.setup.ts similarity index 64% rename from src/common/ipc-channel/channel.ts rename to src/jest-after-env.setup.ts index 2153134fff..b9ee36c4cf 100644 --- a/src/common/ipc-channel/channel.ts +++ b/src/jest-after-env.setup.ts @@ -2,7 +2,4 @@ * 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; -} +import "@testing-library/jest-dom/extend-expect"; diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index 0d76513a8f..4086adefbd 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -21,6 +21,9 @@ import type { ClusterContextHandler } from "../context-handler/context-handler"; import { parse } from "url"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; console = new Console(process.stdout, process.stderr); // fix mockFS @@ -59,6 +62,9 @@ describe("create clusters", () => { di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); di.override(directoryForTempInjectable, () => "some-directory-for-temp"); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); di.override(authorizationReviewInjectable, () => () => () => Promise.resolve(true)); di.override(listNamespacesInjectable, () => () => () => Promise.resolve([ "default" ])); di.override(createContextHandlerInjectable, () => (cluster) => ({ diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index d08dadaa4e..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"; @@ -59,6 +58,9 @@ import getConfigurationFileModelInjectable from "../../common/get-configuration- import appVersionInjectable from "../../common/get-configuration-file-model/app-version/app-version.injectable"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; console = new Console(stdout, stderr); @@ -105,6 +107,9 @@ describe("kube auth proxy tests", () => { di.override(directoryForTempInjectable, () => "some-directory-for-temp"); di.override(spawnInjectable, () => mockSpawn); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); di.permitSideEffects(getConfigurationFileModelInjectable); di.permitSideEffects(appVersionInjectable); @@ -114,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/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 436a815686..604608bb25 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -20,6 +20,9 @@ import loggerInjectable from "../../common/logger.injectable"; import type { Logger } from "../../common/logger"; import assert from "assert"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; console = new Console(process.stdout, process.stderr); // fix mockFS @@ -34,6 +37,9 @@ describe("kubeconfig manager tests", () => { di.override(directoryForTempInjectable, () => "some-directory-for-temp"); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); loggerMock = { warn: jest.fn(), 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 bb373f6e95..0000000000 --- a/src/main/app-updater.ts +++ /dev/null @@ -1,137 +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`); - quitAndInstallUpdate(); - } 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); - } -} - -export function quitAndInstallUpdate() { - autoUpdater.quitAndInstall(true, true); -} 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-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 55f318697b..5946a113a0 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -20,6 +20,9 @@ import appVersionInjectable from "../../../common/get-configuration-file-model/a import clusterManagerInjectable from "../../cluster-manager.injectable"; import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForTempInjectable from "../../../common/app-paths/directory-for-temp/directory-for-temp.injectable"; +import kubectlBinaryNameInjectable from "../../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../../kubectl/normalized-arch.injectable"; +import normalizedPlatformInjectable from "../../../common/vars/normalized-platform.injectable"; jest.mock("electron", () => ({ app: { @@ -47,6 +50,9 @@ describe("kubeconfig-sync.source tests", () => { di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); di.override(directoryForTempInjectable, () => "some-directory-for-temp"); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); di.override(clusterStoreInjectable, () => ClusterStore.createInstance({ createCluster: () => null as never }), 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/context-handler/context-handler.ts b/src/main/context-handler/context-handler.ts index cf35a0a67c..3ab4fc3ca7 100644 --- a/src/main/context-handler/context-handler.ts +++ b/src/main/context-handler/context-handler.ts @@ -152,9 +152,6 @@ export class ContextHandler implements ClusterContextHandler { protected async newApiTarget(timeout: number): Promise { const kubeAuthProxy = await this.ensureServerHelper(); - const ca = this.resolveAuthProxyCa(); - const clusterPath = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; - const apiPrefix = `${kubeAuthProxy.apiPrefix}${clusterPath}`; const headers: Record = {}; if (this.clusterUrl.hostname) { @@ -166,8 +163,8 @@ export class ContextHandler implements ClusterContextHandler { protocol: "https:", host: "127.0.0.1", port: kubeAuthProxy.port, - path: apiPrefix, - ca, + path: kubeAuthProxy.apiPrefix, + ca: this.resolveAuthProxyCa(), }, changeOrigin: true, timeout, 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/register-file-protocol.injectable.ts b/src/main/electron-app/features/register-file-protocol.injectable.ts deleted file mode 100644 index dfc75954e3..0000000000 --- a/src/main/electron-app/features/register-file-protocol.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 { protocol } from "electron"; -import getAbsolutePathInjectable from "../../../common/path/get-absolute-path.injectable"; - -const registerFileProtocolInjectable = getInjectable({ - id: "register-file-protocol", - - instantiate: (di) => { - const getAbsolutePath = di.inject(getAbsolutePathInjectable); - - return (name: string, basePath: string) => { - protocol.registerFileProtocol(name, (request, callback) => { - const filePath = request.url.replace(`${name}://`, ""); - const absPath = getAbsolutePath(basePath, filePath); - - callback({ path: absPath }); - }); - }; - }, - - causesSideEffects: true, -}); - -export default registerFileProtocolInjectable; 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 efd065905c..2cc78ec528 100644 --- a/src/main/getDiForUnitTesting.ts +++ b/src/main/getDiForUnitTesting.ts @@ -9,11 +9,9 @@ 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"; -import directoryForBundledBinariesInjectable from "../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable"; import loggerInjectable from "../common/logger.injectable"; import spawnInjectable from "./child-process/spawn.injectable"; import extensionsStoreInjectable from "../extensions/extensions-store/extensions-store.injectable"; @@ -30,15 +28,12 @@ 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"; import commandLineArgumentsInjectable from "./utils/command-line-arguments.injectable"; import initializeExtensionsInjectable from "./start-main-application/runnables/initialize-extensions.injectable"; import lensResourcesDirInjectable from "../common/vars/lens-resources-dir.injectable"; -import registerFileProtocolInjectable from "./electron-app/features/register-file-protocol.injectable"; import environmentVariablesInjectable from "../common/utils/environment-variables.injectable"; import setupIpcMainHandlersInjectable from "./electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable"; import setupLensProxyInjectable from "./start-main-application/runnables/setup-lens-proxy.injectable"; @@ -47,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"; @@ -69,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"; @@ -77,8 +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 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,25 +122,26 @@ 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(directoryForBundledBinariesInjectable, () => "some-bin-directory"); - di.override(broadcastMessageInjectable, () => (channel) => { throw new Error(`Tried to broadcast message to channel "${channel}" over IPC without explicit override.`); }); - + di.override(baseBundeledBinariesDirectoryInjectable, () => "some-bin-directory"); di.override(spawnInjectable, () => () => { return { stderr: { on: jest.fn(), removeAllListeners: jest.fn() }, @@ -184,6 +193,8 @@ const overrideRunnablesHavingSideEffects = (di: DiContainer) => { setupSystemCaInjectable, setupListenerForCurrentClusterFrameInjectable, setupRunnablesAfterWindowIsOpenedInjectable, + startCatalogSyncInjectable, + startKubeConfigSyncInjectable, ].forEach((injectable) => { di.override(injectable, () => ({ run: () => {} })); }); @@ -217,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: () => {}, @@ -236,6 +254,6 @@ const overrideElectronFeatures = (di: DiContainer) => { ); di.override(setElectronAppPathInjectable, () => () => {}); - di.override(isAutoUpdateEnabledInjectable, () => () => false); - di.override(registerFileProtocolInjectable, () => () => {}); + di.override(publishIsConfiguredInjectable, () => false); + di.override(electronUpdaterIsActiveInjectable, () => false); }; diff --git a/src/main/helm/exec.ts b/src/main/helm/exec.ts index 19971a66e5..2847ce6102 100644 --- a/src/main/helm/exec.ts +++ b/src/main/helm/exec.ts @@ -4,7 +4,7 @@ */ import { promiseExecFile } from "../../common/utils/promise-exec"; -import type { BaseEncodingOptions } from "fs"; +import type { ObjectEncodingOptions } from "fs"; import type { ExecFileOptions, ExecFileOptionsWithStringEncoding } from "child_process"; import { helmBinaryPath } from "../../common/vars"; import { UserStore } from "../../common/user-store"; @@ -14,7 +14,7 @@ import { isChildProcessError } from "../../common/utils"; * ExecFile the bundled helm CLI * @returns STDOUT */ -export async function execHelm(args: string[], { encoding, ...rest }: BaseEncodingOptions & ExecFileOptions = {}): Promise { +export async function execHelm(args: string[], { encoding, ...rest }: ObjectEncodingOptions & ExecFileOptions = {}): Promise { const options: ExecFileOptionsWithStringEncoding = { encoding: encoding ?? "utf-8", ...rest, @@ -23,7 +23,7 @@ export async function execHelm(args: string[], { encoding, ...rest }: BaseEncodi try { const opts = { ...options }; - opts.env ??= process.env; + opts.env ??= { ...process.env }; if (!opts.env.HTTPS_PROXY && UserStore.getInstance().httpsProxy) { opts.env.HTTPS_PROXY = UserStore.getInstance().httpsProxy; diff --git a/src/main/helm/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts index 1f12bba051..981c8602e8 100644 --- a/src/main/helm/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -4,7 +4,6 @@ */ import fs from "fs"; -import v8 from "v8"; import * as yaml from "js-yaml"; import type { HelmRepo } from "./helm-repo-manager"; import logger from "../logger"; @@ -15,7 +14,7 @@ import type { SetRequired } from "type-fest"; import { assert } from "console"; interface ChartCacheEntry { - data: Buffer; + data: string; // serialized JSON mtimeMs: number; } @@ -88,7 +87,7 @@ export class HelmChartManager { HelmChartManager.#cache, this.repo.name, { - data: v8.serialize(normalized), + data: JSON.stringify(normalized), mtimeMs: cacheFileStats.mtimeMs, }, ); @@ -107,7 +106,7 @@ export class HelmChartManager { } } - return v8.deserialize(cacheEntry.data); + return JSON.parse(cacheEntry.data); } } 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/kube-auth-proxy/create-kube-auth-proxy.injectable.ts b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts index c3706f7c08..07bb87bd63 100644 --- a/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts +++ b/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts @@ -9,10 +9,10 @@ import type { Cluster } from "../../common/cluster/cluster"; import path from "path"; import selfsigned from "selfsigned"; import { getBinaryName } from "../../common/vars"; -import directoryForBundledBinariesInjectable from "../../common/app-paths/directory-for-bundled-binaries/directory-for-bundled-binaries.injectable"; import spawnInjectable from "../child-process/spawn.injectable"; import { getKubeAuthProxyCertificate } from "./get-kube-auth-proxy-certificate"; import loggerInjectable from "../../common/logger.injectable"; +import baseBundeledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable"; export type CreateKubeAuthProxy = (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy; @@ -25,7 +25,7 @@ const createKubeAuthProxyInjectable = getInjectable({ return (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => { const clusterUrl = new URL(cluster.apiUrl); const dependencies: KubeAuthProxyDependencies = { - proxyBinPath: path.join(di.inject(directoryForBundledBinariesInjectable), binaryName), + proxyBinPath: path.join(di.inject(baseBundeledBinariesDirectoryInjectable), binaryName), proxyCert: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate), spawn: di.inject(spawnInjectable), logger: di.inject(loggerInjectable), diff --git a/src/main/kubectl/binary-name.injectable.ts b/src/main/kubectl/binary-name.injectable.ts new file mode 100644 index 0000000000..66b42a6007 --- /dev/null +++ b/src/main/kubectl/binary-name.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 normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; + +const kubectlBinaryNameInjectable = getInjectable({ + id: "kubectl-binary-name", + instantiate: (di) => { + const platform = di.inject(normalizedPlatformInjectable); + + return platform === "windows" + ? "kubectl.exe" + : "kubectl"; + }, +}); + +export default kubectlBinaryNameInjectable; diff --git a/src/main/kubectl/bundled-binary-path.injectable.ts b/src/main/kubectl/bundled-binary-path.injectable.ts new file mode 100644 index 0000000000..99cb7f2e7e --- /dev/null +++ b/src/main/kubectl/bundled-binary-path.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 path from "path"; +import baseBundeledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable"; +import kubectlBinaryNameInjectable from "./binary-name.injectable"; + +const bundledKubectlBinaryPathInjectable = getInjectable({ + id: "bundled-kubectl-binary-path", + instantiate: (di) => path.join( + di.inject(baseBundeledBinariesDirectoryInjectable), + di.inject(kubectlBinaryNameInjectable), + ), +}); + +export default bundledKubectlBinaryPathInjectable; diff --git a/src/main/kubectl/create-kubectl.injectable.ts b/src/main/kubectl/create-kubectl.injectable.ts index 931f777535..d670e8e665 100644 --- a/src/main/kubectl/create-kubectl.injectable.ts +++ b/src/main/kubectl/create-kubectl.injectable.ts @@ -3,24 +3,31 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import { getInjectable } from "@ogre-tools/injectable"; +import type { KubectlDependencies } from "./kubectl"; import { Kubectl } from "./kubectl"; import directoryForKubectlBinariesInjectable from "../../common/app-paths/directory-for-kubectl-binaries/directory-for-kubectl-binaries.injectable"; import userStoreInjectable from "../../common/user-store/user-store.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "./normalized-arch.injectable"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "./binary-name.injectable"; +import bundledKubectlBinaryPathInjectable from "./bundled-binary-path.injectable"; +import baseBundeledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable"; const createKubectlInjectable = getInjectable({ id: "create-kubectl", instantiate: (di) => { - const dependencies = { + const dependencies: KubectlDependencies = { userStore: di.inject(userStoreInjectable), - - directoryForKubectlBinaries: di.inject( - directoryForKubectlBinariesInjectable, - ), + directoryForKubectlBinaries: di.inject(directoryForKubectlBinariesInjectable), + normalizedDownloadArch: di.inject(kubectlDownloadingNormalizedArchInjectable), + normalizedDownloadPlatform: di.inject(normalizedPlatformInjectable), + kubectlBinaryName: di.inject(kubectlBinaryNameInjectable), + bundledKubectlBinaryPath: di.inject(bundledKubectlBinaryPathInjectable), + baseBundeledBinariesDirectory: di.inject(baseBundeledBinariesDirectoryInjectable), }; - return (clusterVersion: string) => - new Kubectl(dependencies, clusterVersion); + return (clusterVersion: string) => new Kubectl(dependencies, clusterVersion); }, }); diff --git a/src/main/kubectl/kubectl.ts b/src/main/kubectl/kubectl.ts index 06716e50a4..55f7e4675d 100644 --- a/src/main/kubectl/kubectl.ts +++ b/src/main/kubectl/kubectl.ts @@ -10,7 +10,6 @@ import logger from "../logger"; import { ensureDir, pathExists } from "fs-extra"; import * as lockFile from "proper-lockfile"; import { getBundledKubectlVersion } from "../../common/utils/app-version"; -import { normalizedPlatform, normalizedArch, kubectlBinaryName, kubectlBinaryPath, baseBinariesDir } from "../../common/vars"; import { SemVer } from "semver"; import { defaultPackageMirror, packageMirrors } from "../../common/user-store/preferences-helpers"; import got from "got/dist/source"; @@ -40,27 +39,31 @@ const kubectlMap: Map = new Map([ ]); const initScriptVersionString = "# lens-initscript v3"; -interface Dependencies { - directoryForKubectlBinaries: string; - - userStore: { - kubectlBinariesPath?: string; - downloadBinariesPath?: string; - downloadKubectlBinaries: boolean; - downloadMirror: string; +export interface KubectlDependencies { + readonly directoryForKubectlBinaries: string; + readonly normalizedDownloadPlatform: "darwin" | "linux" | "windows"; + readonly normalizedDownloadArch: "amd64" | "arm64" | "386"; + readonly kubectlBinaryName: string; + readonly bundledKubectlBinaryPath: string; + readonly baseBundeledBinariesDirectory: string; + readonly userStore: { + readonly kubectlBinariesPath?: string; + readonly downloadBinariesPath?: string; + readonly downloadKubectlBinaries: boolean; + readonly downloadMirror: string; }; } export class Kubectl { - public kubectlVersion: string; - protected url: string; - protected path: string; - protected dirname: string; + public readonly kubectlVersion: string; + protected readonly url: string; + protected readonly path: string; + protected readonly dirname: string; public static readonly bundledKubectlVersion = bundledVersion; public static invalidBundle = false; - constructor(private dependencies: Dependencies, clusterVersion: string) { + constructor(protected readonly dependencies: KubectlDependencies, clusterVersion: string) { let version: SemVer; try { @@ -83,13 +86,13 @@ export class Kubectl { logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using fallback`); } - this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${normalizedPlatform}/${normalizedArch}/${kubectlBinaryName}`; + this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${this.dependencies.normalizedDownloadPlatform}/${this.dependencies.normalizedDownloadArch}/${this.dependencies.kubectlBinaryName}`; this.dirname = path.normalize(path.join(this.getDownloadDir(), this.kubectlVersion)); - this.path = path.join(this.dirname, kubectlBinaryName); + this.path = path.join(this.dirname, this.dependencies.kubectlBinaryName); } public getBundledPath() { - return kubectlBinaryPath.get(); + return this.dependencies.bundledKubectlBinaryPath; } public getPathFromPreferences() { @@ -156,7 +159,7 @@ export class Kubectl { try { const args = [ "version", - "--client", "true", + "--client", "--output", "json", ]; const { stdout } = await promiseExecFile(path, args); @@ -279,12 +282,11 @@ export class Kubectl { } protected async writeInitScripts() { + const binariesDir = this.dependencies.baseBundeledBinariesDirectory; const kubectlPath = this.dependencies.userStore.downloadKubectlBinaries ? this.dirname : path.dirname(this.getPathFromPreferences()); - const binariesDir = baseBinariesDir.get(); - const bashScriptPath = path.join(this.dirname, ".bash_set_path"); const bashScript = [ initScriptVersionString, @@ -297,7 +299,7 @@ export class Kubectl { "elif test -f \"$HOME/.profile\"; then", " . \"$HOME/.profile\"", "fi", - `export PATH="${binariesDir}:${kubectlPath}:$PATH"`, + `export PATH="${kubectlPath}:${binariesDir}:$PATH"`, 'export KUBECONFIG="$tempkubeconfig"', `NO_PROXY=",\${NO_PROXY:-localhost},"`, `NO_PROXY="\${NO_PROXY//,localhost,/,}"`, @@ -328,7 +330,7 @@ export class Kubectl { "d=\":$PATH:\"", `d=\${d//$p/:}`, `d=\${d/#:/}`, - `export PATH="$binariesDir:$kubectlpath:\${d/%:/}"`, + `export PATH="$kubectlpath:$binariesDir:\${d/%:/}"`, "export KUBECONFIG=\"$tempkubeconfig\"", `NO_PROXY=",\${NO_PROXY:-localhost},"`, `NO_PROXY="\${NO_PROXY//,localhost,/,}"`, diff --git a/src/main/kubectl/normalized-arch.injectable.ts b/src/main/kubectl/normalized-arch.injectable.ts new file mode 100644 index 0000000000..88ec6b1067 --- /dev/null +++ b/src/main/kubectl/normalized-arch.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"; + +const kubectlDownloadingNormalizedArchInjectable = getInjectable({ + id: "kubectl-downloading-normalized-arch", + instantiate: () => { + switch (process.arch) { + case "arm64": + return "arm64"; + case "x64": + case "amd64": + return "amd64"; + case "386": + case "x32": + case "ia32": + return "386"; + default: + throw new Error(`arch=${process.arch} is unsupported`); + } + }, + causesSideEffects: true, +}); + +export default kubectlDownloadingNormalizedArchInjectable; 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/router/router.test.ts b/src/main/router/router.test.ts index 20b8022efc..20d5f4f526 100644 --- a/src/main/router/router.test.ts +++ b/src/main/router/router.test.ts @@ -17,6 +17,9 @@ import mockFs from "mock-fs"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import type { Route } from "./route"; import type { SetRequired } from "type-fest"; +import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; describe("router", () => { let router: Router; @@ -31,6 +34,9 @@ describe("router", () => { di.override(parseRequestInjectable, () => () => Promise.resolve({ payload: "some-payload" })); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); + di.override(kubectlBinaryNameInjectable, () => "kubectl"); + di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + di.override(normalizedPlatformInjectable, () => "darwin"); const injectable = getInjectable({ id: "some-route", diff --git a/src/main/shell-session/local-shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session/local-shell-session.ts index bced1e52f6..9f42c7f242 100644 --- a/src/main/shell-session/local-shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session/local-shell-session.ts @@ -50,11 +50,11 @@ export class LocalShellSession extends ShellSession { switch(path.basename(shell)) { case "powershell.exe": - return ["-NoExit", "-command", `& {$Env:PATH="${baseBinariesDir.get()};${kubectlPathDir};$Env:PATH"}`]; + return ["-NoExit", "-command", `& {$Env:PATH="${kubectlPathDir};${baseBinariesDir.get()};$Env:PATH"}`]; case "bash": return ["--init-file", path.join(await this.kubectlBinDirP, ".bash_set_path")]; case "fish": - return ["--login", "--init-command", `export PATH="${baseBinariesDir.get()}:${kubectlPathDir}:$PATH"; export KUBECONFIG="${await this.kubeconfigPathP}"`]; + return ["--login", "--init-command", `export PATH="${kubectlPathDir}:${baseBinariesDir.get()}:$PATH"; export KUBECONFIG="${await this.kubeconfigPathP}"`]; case "zsh": return ["--login"]; default: diff --git a/src/main/shell-session/node-shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session/node-shell-session.ts index b37e8c25b5..4c619a612c 100644 --- a/src/main/shell-session/node-shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session/node-shell-session.ts @@ -13,8 +13,8 @@ import { get, once } from "lodash"; import { Node, NodeApi } from "../../../common/k8s-api/endpoints"; import { KubeJsonApi } from "../../../common/k8s-api/kube-json-api"; import logger from "../../logger"; -import { TerminalChannels } from "../../../renderer/api/terminal-api"; import type { Kubectl } from "../../kubectl/kubectl"; +import { TerminalChannels } from "../../../common/terminal/channels"; export class NodeShellSession extends ShellSession { ShellType = "node-shell"; diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 93fcb55dd1..42286ee24d 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -16,11 +16,9 @@ import { UserStore } from "../../common/user-store"; import * as pty from "node-pty"; import { appEventBus } from "../../common/app-event-bus/event-bus"; import logger from "../logger"; -import type { TerminalMessage } from "../../renderer/api/terminal-api"; -import { TerminalChannels } from "../../renderer/api/terminal-api"; -import { deserialize, serialize } from "v8"; import { stat } from "fs/promises"; import { getOrInsertWith } from "../../common/utils"; +import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels"; export class ShellOpenError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -161,7 +159,7 @@ export abstract class ShellSession { } protected send(message: TerminalMessage): void { - this.websocket.send(serialize(message)); + this.websocket.send(JSON.stringify(message)); } protected async getCwd(env: Record): Promise { @@ -232,17 +230,19 @@ export abstract class ShellSession { }); this.websocket - .on("message", (data: string | Uint8Array): void => { + .on("message", (rawData: unknown): void => { if (!this.running) { return void logger.debug(`[SHELL-SESSION]: received message from ${this.terminalId}, but shellProcess isn't running`); } - if (typeof data === "string") { - return void logger.silly(`[SHELL-SESSION]: Received message from ${this.terminalId}`, { data }); + if (!(rawData instanceof Buffer)) { + return void logger.error(`[SHELL-SESSION]: Received message non-buffer message.`, { rawData }); } + const data = rawData.toString(); + try { - const message: TerminalMessage = deserialize(data); + const message: TerminalMessage = JSON.parse(data); switch (message.type) { case TerminalChannels.STDIN: @@ -251,6 +251,9 @@ export abstract class ShellSession { case TerminalChannels.RESIZE: shellProcess.resize(message.data.width, message.data.height); break; + case TerminalChannels.PING: + logger.silly(`[SHELL-SESSION]: ${this.terminalId} ping!`); + break; default: logger.warn(`[SHELL-SESSION]: unknown or unhandleable message type for ${this.terminalId}`, message); break; @@ -311,7 +314,7 @@ export abstract class ShellSession { protected async getShellEnv() { const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); - const pathStr = [...this.getPathEntries(), await this.kubectlBinDirP, process.env.PATH].join(path.delimiter); + const pathStr = [await this.kubectlBinDirP, ...this.getPathEntries(), process.env.PATH].join(path.delimiter); const shell = UserStore.getInstance().resolvedShell; delete env.DEBUG; // don't pass DEBUG into shells 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 029bfa7d82..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", @@ -29,7 +29,9 @@ const applicationWindowInjectable = getInjectable({ title: applicationName, defaultHeight: 900, defaultWidth: 1440, - getContentUrl: () => `http://localhost:${lensProxyPort.get()}`, + getContentSource: () => ({ + url: `http://localhost:${lensProxyPort.get()}`, + }), resizable: true, windowFrameUtilitiesAreShown: isMac, titleBarStyle: isMac ? "hiddenInset" : "hidden", diff --git a/src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts b/src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts index 700ab1e103..5279bff30f 100644 --- a/src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts +++ b/src/main/start-main-application/lens-window/application-window/create-electron-window-for.injectable.ts @@ -7,18 +7,26 @@ import loggerInjectable from "../../../../common/logger.injectable"; import applicationWindowStateInjectable from "./application-window-state.injectable"; import { BrowserWindow } from "electron"; import { openBrowser } from "../../../../common/utils"; -import type { SendToViewArgs } from "./lens-window-injection-token"; import sendToChannelInElectronBrowserWindowInjectable from "./send-to-channel-in-electron-browser-window.injectable"; import type { LensWindow } from "./create-lens-window.injectable"; +import type { RequireExactlyOne } from "type-fest"; export type ElectronWindowTitleBarStyle = "hiddenInset" | "hidden" | "default" | "customButtonsOnHover"; +export interface FileSource { + file: string; +} +export interface UrlSource { + url: string; +} +export type ContentSource = RequireExactlyOne; + export interface ElectronWindowConfiguration { id: string; title: string; defaultHeight: number; defaultWidth: number; - getContentUrl: () => string; + getContentSource: () => ContentSource; resizable: boolean; windowFrameUtilitiesAreShown: boolean; centered: boolean; @@ -33,6 +41,10 @@ export interface ElectronWindowConfiguration { export type CreateElectronWindow = () => Promise; export type CreateElectronWindowFor = (config: ElectronWindowConfiguration) => CreateElectronWindow; +function isFileSource(src: ContentSource): src is FileSource { + return typeof (src as FileSource).file === "string"; +} + const createElectronWindowFor = getInjectable({ id: "create-electron-window-for", @@ -147,6 +159,7 @@ const createElectronWindowFor = getInjectable({ // Always disable Node.js integration for all webviews webPreferences.nodeIntegration = false; + webPreferences.nativeWindowOpen = false; }) .setWindowOpenHandler((details) => { @@ -159,17 +172,22 @@ const createElectronWindowFor = getInjectable({ return { action: "deny" }; }); - const contentUrl = configuration.getContentUrl(); + const contentSource = configuration.getContentSource(); - logger.info(`[CREATE-ELECTRON-WINDOW]: Loading content for window "${configuration.id}" from url: ${contentUrl}...`); + if (isFileSource(contentSource)) { + logger.info(`[CREATE-ELECTRON-WINDOW]: Loading content for window "${configuration.id}" from file: ${contentSource.file}...`); + await browserWindow.loadFile(contentSource.file); + } else { + logger.info(`[CREATE-ELECTRON-WINDOW]: Loading content for window "${configuration.id}" from url: ${contentSource.url}...`); + await browserWindow.loadURL(contentSource.url); + } - await browserWindow.loadURL(contentUrl); await configuration.beforeOpen?.(); return { show: () => browserWindow.show(), close: () => browserWindow.close(), - send: (args: SendToViewArgs) => sendToChannelInLensWindow(browserWindow, args), + send: (args) => sendToChannelInLensWindow(browserWindow, args), }; }; }, 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 cea2dace06..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 @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import type { SendToViewArgs } from "./lens-window-injection-token"; -import type { ElectronWindowTitleBarStyle } from "./create-electron-window-for.injectable"; +import type { ContentSource, ElectronWindowTitleBarStyle } from "./create-electron-window-for.injectable"; import createElectronWindowForInjectable from "./create-electron-window-for.injectable"; export interface LensWindow { @@ -13,12 +13,12 @@ export interface LensWindow { send: (args: SendToViewArgs) => void; } -interface LensWindowConfiguration { +export interface LensWindowConfiguration { id: string; title: string; defaultHeight: number; defaultWidth: number; - getContentUrl: () => string; + getContentSource: () => ContentSource; resizable: boolean; windowFrameUtilitiesAreShown: boolean; centered: boolean; @@ -38,12 +38,10 @@ const createLensWindowInjectable = getInjectable({ return (configuration: LensWindowConfiguration) => { let browserWindow: LensWindow | undefined; - const createElectronWindow = createElectronWindowFor(Object.assign( - { - onClose: () => browserWindow = undefined, - }, - configuration, - )); + const createElectronWindow = createElectronWindowFor({ + ...configuration, + onClose: () => browserWindow = undefined, + }); return { get visible() { @@ -60,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/lens-window/splash-window/splash-window.injectable.ts b/src/main/start-main-application/lens-window/splash-window/splash-window.injectable.ts index bbef47f140..ab8c2c0c35 100644 --- a/src/main/start-main-application/lens-window/splash-window/splash-window.injectable.ts +++ b/src/main/start-main-application/lens-window/splash-window/splash-window.injectable.ts @@ -5,17 +5,24 @@ import { getInjectable } from "@ogre-tools/injectable"; import { lensWindowInjectionToken } from "../application-window/lens-window-injection-token"; import createLensWindowInjectable from "../application-window/create-lens-window.injectable"; +import staticFilesDirectoryInjectable from "../../../../common/vars/static-files-directory.injectable"; +import getAbsolutePathInjectable from "../../../../common/path/get-absolute-path.injectable"; const splashWindowInjectable = getInjectable({ id: "splash-window", instantiate: (di) => { const createLensWindow = di.inject(createLensWindowInjectable); + const staticFilesDirectory = di.inject(staticFilesDirectoryInjectable); + const getAbsolutePath = di.inject(getAbsolutePathInjectable); + const splashWindowFile = getAbsolutePath(staticFilesDirectory, "splash.html"); return createLensWindow({ id: "splash", title: "Loading", - getContentUrl: () => "static://splash.html", + getContentSource: () => ({ + file: splashWindowFile, + }), defaultWidth: 500, defaultHeight: 300, resizable: false, 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-file-protocol.injectable.ts b/src/main/start-main-application/runnables/setup-file-protocol.injectable.ts deleted file mode 100644 index 77e76aaf55..0000000000 --- a/src/main/start-main-application/runnables/setup-file-protocol.injectable.ts +++ /dev/null @@ -1,27 +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 registerFileProtocolInjectable from "../../electron-app/features/register-file-protocol.injectable"; -import staticFilesDirectoryInjectable from "../../../common/vars/static-files-directory.injectable"; -import { beforeApplicationIsLoadingInjectionToken } from "../runnable-tokens/before-application-is-loading-injection-token"; - -const setupFileProtocolInjectable = getInjectable({ - id: "setup-file-protocol", - - instantiate: (di) => { - const registerFileProtocol = di.inject(registerFileProtocolInjectable); - const staticFilesDirectory = di.inject(staticFilesDirectoryInjectable); - - return { - run: () => { - registerFileProtocol("static", staticFilesDirectory); - }, - }; - }, - - injectionToken: beforeApplicationIsLoadingInjectionToken, -}); - -export default setupFileProtocolInjectable; 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..a96104f047 --- /dev/null +++ b/src/main/tray/electron-tray/electron-tray.injectable.ts @@ -0,0 +1,68 @@ +/** + * 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 showApplicationWindowInjectable from "../../start-main-application/lens-window/show-application-window.injectable"; +import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import trayIconPathsInjectable from "../tray-icon-path.injectable"; +import type { TrayMenuItem } from "../tray-menu-item/tray-menu-item-injection-token"; +import { convertToElectronMenuTemplate } from "../reactive-tray-menu-items/converters"; + +const TRAY_LOG_PREFIX = "[TRAY]"; + +export interface ElectronTray { + start(): void; + stop(): void; + setMenuItems(menuItems: TrayMenuItem[]): void; + setIconPath(iconPath: string): void; +} + +const electronTrayInjectable = getInjectable({ + id: "electron-tray", + + instantiate: (di): ElectronTray => { + const packageJson = di.inject(packageJsonInjectable); + const showApplicationWindow = di.inject(showApplicationWindowInjectable); + const isWindows = di.inject(isWindowsInjectable); + const logger = di.inject(loggerInjectable); + const trayIconPaths = di.inject(trayIconPathsInjectable); + + let tray: Tray; + + return { + start: () => { + tray = new Tray(trayIconPaths.normal); + + 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: (menuItems) => { + const template = convertToElectronMenuTemplate(menuItems); + const menu = Menu.buildFromTemplate(template); + + tray.setContextMenu(menu); + }, + setIconPath: (iconPath) => { + tray.setImage(iconPath); + }, + }; + }, + + causesSideEffects: true, +}); + +export default electronTrayInjectable; 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/menu-icon/reactive.injectable.ts b/src/main/tray/menu-icon/reactive.injectable.ts new file mode 100644 index 0000000000..42622ff2a8 --- /dev/null +++ b/src/main/tray/menu-icon/reactive.injectable.ts @@ -0,0 +1,37 @@ +/** + * 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 { reaction } from "mobx"; +import discoveredUpdateVersionInjectable from "../../../common/application-update/discovered-update-version/discovered-update-version.injectable"; +import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import electronTrayInjectable from "../electron-tray/electron-tray.injectable"; +import trayIconPathsInjectable from "../tray-icon-path.injectable"; + +const reactiveTrayMenuIconInjectable = getInjectable({ + id: "reactive-tray-menu-icon", + instantiate: (di) => { + const discoveredUpdateVersion = di.inject(discoveredUpdateVersionInjectable); + const electronTray = di.inject(electronTrayInjectable); + const trayIconPaths = di.inject(trayIconPathsInjectable); + + return getStartableStoppable("reactive-tray-menu-icon", () => ( + reaction( + () => discoveredUpdateVersion.value.get(), + updateVersion => { + if (updateVersion) { + electronTray.setIconPath(trayIconPaths.updateAvailable); + } else { + electronTray.setIconPath(trayIconPaths.normal); + } + }, + { + fireImmediately: true, + }, + ) + )); + }, +}); + +export default reactiveTrayMenuIconInjectable; diff --git a/src/main/tray/menu-icon/start-reactivity.injectable.ts b/src/main/tray/menu-icon/start-reactivity.injectable.ts new file mode 100644 index 0000000000..373c3cf8fb --- /dev/null +++ b/src/main/tray/menu-icon/start-reactivity.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 { onLoadOfApplicationInjectionToken } from "../../start-main-application/runnable-tokens/on-load-of-application-injection-token"; +import startTrayInjectable from "../electron-tray/start-tray.injectable"; +import reactiveTrayMenuIconInjectable from "./reactive.injectable"; + +const startReactiveTrayMenuIconInjectable = getInjectable({ + id: "start-reactive-tray-menu-icon", + + instantiate: (di) => { + const reactiveTrayMenuIcon = di.inject(reactiveTrayMenuIconInjectable); + + return { + run: async () => { + await reactiveTrayMenuIcon.start(); + }, + + runAfter: di.inject(startTrayInjectable), + }; + }, + + injectionToken: onLoadOfApplicationInjectionToken, +}); + +export default startReactiveTrayMenuIconInjectable; diff --git a/src/main/tray/menu-icon/stop-reactivity.injectable.ts b/src/main/tray/menu-icon/stop-reactivity.injectable.ts new file mode 100644 index 0000000000..4b60aaaa54 --- /dev/null +++ b/src/main/tray/menu-icon/stop-reactivity.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 { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token"; +import reactiveTrayMenuIconInjectable from "./reactive.injectable"; + +const stopReactiveTrayMenuIconInjectable = getInjectable({ + id: "stop-reactive-tray-menu-icon", + + instantiate: (di) => { + const reactiveTrayMenuIcon = di.inject(reactiveTrayMenuIconInjectable); + + return { + run: async () => { + await reactiveTrayMenuIcon.stop(); + }, + }; + }, + + injectionToken: beforeQuitOfBackEndInjectionToken, +}); + +export default stopReactiveTrayMenuIconInjectable; diff --git a/src/main/tray/reactive-tray-menu-items/converters.ts b/src/main/tray/reactive-tray-menu-items/converters.ts new file mode 100644 index 0000000000..42add7481e --- /dev/null +++ b/src/main/tray/reactive-tray-menu-items/converters.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 type { TrayMenuItem } from "../tray-menu-item/tray-menu-item-injection-token"; + +export function convertToElectronMenuTemplate(trayMenuItems: TrayMenuItem[]): Electron.MenuItemConstructorOptions[] { + const toTrayMenuOptions = (parentId: string | null) => ( + 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, + + ...(childItems.length === 0 + ? { + type: "normal", + submenu: toTrayMenuOptions(trayMenuItem.id), + click: trayMenuItem.click, + } + : { + type: "submenu", + submenu: toTrayMenuOptions(trayMenuItem.id), + }), + + }; + }) + ); + + return toTrayMenuOptions(null); +} 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..22c3d29399 --- /dev/null +++ b/src/main/tray/reactive-tray-menu-items/reactive-tray-menu-items.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 { getStartableStoppable } from "../../../common/utils/get-startable-stoppable"; +import { reaction } from "mobx"; +import electronTrayInjectable from "../electron-tray/electron-tray.injectable"; +import trayMenuItemsInjectable from "../tray-menu-item/tray-menu-items.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", () => ( + reaction( + () => trayMenuItems.get(), + electronTray.setMenuItems, + { + fireImmediately: true, + }, + ) + )); + }, +}); + +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-icon-path.injectable.ts b/src/main/tray/tray-icon-path.injectable.ts index 1eb4d13118..df83a2e31c 100644 --- a/src/main/tray/tray-icon-path.injectable.ts +++ b/src/main/tray/tray-icon-path.injectable.ts @@ -6,21 +6,32 @@ import { getInjectable } from "@ogre-tools/injectable"; import getAbsolutePathInjectable from "../../common/path/get-absolute-path.injectable"; import staticFilesDirectoryInjectable from "../../common/vars/static-files-directory.injectable"; import isDevelopmentInjectable from "../../common/vars/is-development.injectable"; +import isMacInjectable from "../../common/vars/is-mac.injectable"; -const trayIconPathInjectable = getInjectable({ - id: "tray-icon-path", +export interface TrayIconPaths { + normal: string; + updateAvailable: string; +} - instantiate: (di) => { +const trayIconPathsInjectable = getInjectable({ + id: "tray-icon-paths", + + instantiate: (di): TrayIconPaths => { const getAbsolutePath = di.inject(getAbsolutePathInjectable); const staticFilesDirectory = di.inject(staticFilesDirectoryInjectable); const isDevelopment = di.inject(isDevelopmentInjectable); - - return getAbsolutePath( + const isMac = di.inject(isMacInjectable); + const baseIconDirectory = getAbsolutePath( staticFilesDirectory, isDevelopment ? "../build/tray" : "icons", // copied within electron-builder extras - "trayIconTemplate.png", ); + const fileSuffix = isMac ? "Template.png" : ".png"; + + return { + normal: getAbsolutePath(baseIconDirectory, `trayIcon${fileSuffix}`), + updateAvailable: getAbsolutePath(baseIconDirectory, `trayIconUpdateAvailable${fileSuffix}`), + }; }, }); -export default trayIconPathInjectable; +export default trayIconPathsInjectable; 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-menu-items.injectable.ts b/src/main/tray/tray-menu-items.injectable.ts index 8ee9d25e5e..c008c123e0 100644 --- a/src/main/tray/tray-menu-items.injectable.ts +++ b/src/main/tray/tray-menu-items.injectable.ts @@ -12,8 +12,7 @@ const trayItemsInjectable = getInjectable({ instantiate: (di) => { const extensions = di.inject(mainExtensionsInjectable); - return computed(() => - extensions.get().flatMap(extension => extension.trayMenus)); + return computed(() => extensions.get().flatMap(extension => extension.trayMenus)); }, }); 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 deleted file mode 100644 index c5d0b47ab1..0000000000 --- a/src/main/tray/tray.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -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 type { Disposer } from "../../common/utils"; -import { disposer, toJS } from "../../common/utils"; -import type { TrayMenuRegistration } from "./tray-menu-registration"; - -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, - showApplicationWindow: () => Promise, - showAbout: () => void, - trayIconPath: string, -): Disposer { - tray = new Tray(trayIconPath); - tray.setToolTip(packageInfo.description); - tray.setIgnoreDoubleClickEvents(true); - - if (isWindows) { - tray.on("click", () => { - showApplicationWindow() - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); - }); - } - - return disposer( - autorun(() => { - try { - const menu = createTrayMenu(toJS(trayMenuItems.get()), navigateToPreferences, stopServicesAndExitApp, isAutoUpdateEnabled, showApplicationWindow, showAbout); - - tray?.setContextMenu(menu); - } catch (error) { - logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error }); - } - }), - () => { - tray?.destroy(); - tray = null; - }, - ); -} - -function getMenuItemConstructorOptions(trayItem: TrayMenuRegistration): Electron.MenuItemConstructorOptions { - return { - ...trayItem, - submenu: trayItem.submenu ? trayItem.submenu.map(getMenuItemConstructorOptions) : undefined, - click: trayItem.click ? () => { - trayItem.click?.(trayItem); - } : undefined, - }; -} - -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(); - }, - }, - ]; - - if (isAutoUpdateEnabled()) { - template.push({ - label: "Check for updates", - click() { - checkForUpdates() - .then(() => showApplicationWindow()); - }, - }); - } - - template = template.concat(extensionTrayItems.map(getMenuItemConstructorOptions)); - - 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/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 2e41b2f69d..75d907616c 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -10,31 +10,8 @@ import url from "url"; import { makeObservable, observable } from "mobx"; import { ipcRenderer } from "electron"; import logger from "../../common/logger"; -import { deserialize, serialize } from "v8"; import { once } from "lodash"; - -export enum TerminalChannels { - STDIN = "stdin", - STDOUT = "stdout", - CONNECTED = "connected", - RESIZE = "resize", -} - -export type TerminalMessage = { - type: TerminalChannels.STDIN; - data: string; -} | { - type: TerminalChannels.STDOUT; - data: string; -} | { - type: TerminalChannels.CONNECTED; -} | { - type: TerminalChannels.RESIZE; - data: { - width: number; - height: number; - }; -}; +import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels"; enum TerminalColor { RED = "\u001b[31m", @@ -133,13 +110,10 @@ export class TerminalApi extends WebSocketApi { this.prependListener("connected", onReady); super.connect(socketUrl); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.socket!.binaryType = "arraybuffer"; } sendMessage(message: TerminalMessage) { - return this.send(serialize(message)); + return this.send(JSON.stringify(message)); } sendTerminalSize(cols: number, rows: number) { @@ -154,9 +128,9 @@ export class TerminalApi extends WebSocketApi { } } - protected _onMessage({ data, ...evt }: MessageEvent): void { + protected _onMessage({ data, ...evt }: MessageEvent): void { try { - const message: TerminalMessage = deserialize(new Uint8Array(data)); + const message = JSON.parse(data) as TerminalMessage; switch (message.type) { case TerminalChannels.STDOUT: diff --git a/src/renderer/api/websocket-api.ts b/src/renderer/api/websocket-api.ts index 8a7072ec78..748fef5e7b 100644 --- a/src/renderer/api/websocket-api.ts +++ b/src/renderer/api/websocket-api.ts @@ -9,6 +9,7 @@ import type TypedEventEmitter from "typed-emitter"; import type { Arguments } from "typed-emitter"; import { isDevelopment } from "../../common/vars"; import type { Defaulted } from "../utils"; +import { TerminalChannels, type TerminalMessage } from "../../common/terminal/channels"; interface WebsocketApiParams { /** @@ -30,9 +31,9 @@ interface WebsocketApiParams { /** * The message for pinging the websocket * - * @default "PING" + * @default "{type: \"ping\"}" */ - pingMessage?: string | ArrayBufferLike | Blob | ArrayBufferView; + pingMessage?: string; /** * If set to a number > 0, then the API will ping the socket on that interval. @@ -65,7 +66,7 @@ export interface WebSocketEvents { export class WebSocketApi extends (EventEmitter as { new(): TypedEventEmitter }) { protected socket: WebSocket | null = null; - protected pendingCommands: (string | ArrayBufferLike | Blob | ArrayBufferView)[] = []; + protected pendingCommands: string[] = []; protected reconnectTimer?: number; protected pingTimer?: number; protected params: Defaulted; @@ -76,7 +77,7 @@ export class WebSocketApi extends (EventEmitter logging: isDevelopment, reconnectDelay: 10, flushOnOpen: true, - pingMessage: "PING", + pingMessage: JSON.stringify({ type: TerminalChannels.PING } as TerminalMessage), }; constructor(params: WebsocketApiParams) { @@ -149,7 +150,7 @@ export class WebSocketApi extends (EventEmitter } } - send(command: string | ArrayBufferLike | Blob | ArrayBufferView) { + send(command: string) { if (this.getIsConnected()) { this.socket.send(command); } else { 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/+add-cluster/add-cluster.module.scss b/src/renderer/components/+add-cluster/add-cluster.module.scss index 7418b4481d..0a43b2b805 100644 --- a/src/renderer/components/+add-cluster/add-cluster.module.scss +++ b/src/renderer/components/+add-cluster/add-cluster.module.scss @@ -3,6 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +@import "../../components/mixins.scss"; + .AddClusters { --flex-gap: calc(var(--unit) * 2); diff --git a/src/renderer/components/+catalog/__tests__/catalog-entity-store.test.ts b/src/renderer/components/+catalog/__tests__/catalog-entity-store.test.ts new file mode 100644 index 0000000000..6794fed899 --- /dev/null +++ b/src/renderer/components/+catalog/__tests__/catalog-entity-store.test.ts @@ -0,0 +1,163 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { CatalogCategoryMetadata, CatalogCategorySpec } from "../../../../common/catalog"; +import { CatalogEntity, categoryVersion } from "../../../../common/catalog"; +import { CatalogCategory } from "../../../api/catalog-entity"; +import { noop } from "../../../utils"; +import type { CatalogEntityStore } from "../catalog-entity-store/catalog-entity.store"; +import { catalogEntityStore } from "../catalog-entity-store/catalog-entity.store"; + +class TestEntityOne extends CatalogEntity { + public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1"; + public static readonly kind: string = "TestEntityOne"; + + public readonly apiVersion = TestEntityOne.apiVersion; + public readonly kind = TestEntityOne.kind; +} + +class TestEntityTwo extends CatalogEntity { + public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1"; + public static readonly kind: string = "TestEntityTwo"; + + public readonly apiVersion = TestEntityTwo.apiVersion; + public readonly kind = TestEntityTwo.kind; +} + +class TestCategoryOne extends CatalogCategory { + apiVersion = "catalog.k8slens.dev/v1alpha1"; + kind = "CatalogCategory"; + metadata: CatalogCategoryMetadata = { + icon: "dash", + name: "test-one", + }; + spec: CatalogCategorySpec = { + group: "entity.k8slens.dev", + versions: [ + categoryVersion("v1alpha1", TestEntityOne), + ], + names: { + kind: "KubernetesCluster", + }, + }; +} + +class TestCategoryTwo extends CatalogCategory { + apiVersion = "catalog.k8slens.dev/v1alpha1"; + kind = "CatalogCategory"; + metadata: CatalogCategoryMetadata = { + icon: "dash", + name: "test-two", + }; + spec: CatalogCategorySpec = { + group: "entity.k8slens.dev", + versions: [ + categoryVersion("v1alpha1", TestEntityTwo), + ], + names: { + kind: "KubernetesCluster", + }, + }; +} + +describe("CatalogEntityStore", () => { + describe("getTotalCount", () => { + let store: CatalogEntityStore; + let testCategoryOne: TestCategoryOne; + let testCategoryTwo: TestCategoryTwo; + + beforeEach(() => { + const entityItems = [ + new TestEntityOne({ + metadata: { + labels: {}, + name: "my-test-one", + uid: "1", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + new TestEntityOne({ + metadata: { + labels: {}, + name: "my-test-two", + uid: "2", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + new TestEntityTwo({ + metadata: { + labels: {}, + name: "my-test-three", + uid: "3", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + new TestEntityTwo({ + metadata: { + labels: {}, + name: "my-test-four", + uid: "4", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + new TestEntityTwo({ + metadata: { + labels: {}, + name: "my-test-five", + uid: "5", + }, + spec: {}, + status: { + phase: "unknown", + }, + }), + ]; + + testCategoryOne = new TestCategoryOne(); + testCategoryTwo = new TestCategoryTwo(); + store = catalogEntityStore({ + catalogRegistry: { + items: [ + testCategoryOne, + testCategoryTwo, + ], + }, + entityRegistry: { + onRun: noop, + filteredItems: entityItems, + getItemsForCategory: (category: CatalogCategory): T[] => { + return entityItems.filter(item => category.spec.versions.some(version => item instanceof version.entityClass)) as T[]; + }, + }, + }); + }); + + it("given no active category, returns count of all kinds", () => { + expect(store.getTotalCount()).toBe(5); + }); + + it("given active category is TestCategoryOne, only returns count for those declared kinds", () => { + store.activeCategory.set(testCategoryOne); + expect(store.getTotalCount()).toBe(2); + }); + + it("given active category is TestCategoryTwo, only returns count for those declared kinds", () => { + store.activeCategory.set(testCategoryTwo); + expect(store.getTotalCount()).toBe(3); + }); + }); +}); diff --git a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx index f0562fd736..3209428cba 100644 --- a/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity-store/catalog-entity.store.tsx @@ -12,9 +12,12 @@ import type { Disposer } from "../../../../common/utils"; import { disposer } from "../../../../common/utils"; import type { ItemListStore } from "../../item-object-list"; +type EntityRegistry = Pick; +type CatalogRegistry = Pick; + interface Dependencies { - entityRegistry: CatalogEntityRegistry; - catalogRegistry: CatalogCategoryRegistry; + entityRegistry: EntityRegistry; + catalogRegistry: CatalogRegistry; } export type CatalogEntityStore = ItemListStore & { @@ -71,7 +74,7 @@ export function catalogEntityStore({ ), onRun: entity => entityRegistry.onRun(entity), failedLoading: false, - getTotalCount: () => entityRegistry.filteredItems.length, + getTotalCount: () => entities.get().length, isLoaded: true, isSelected: (item) => item.getId() === selectedItemId.get(), isSelectedAll: () => false, 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/+custom-resources/__tests__/__snapshots__/custom-resource-details.test.tsx.snap b/src/renderer/components/+custom-resources/__tests__/__snapshots__/custom-resource-details.test.tsx.snap new file mode 100644 index 0000000000..d3e0cd1d5a --- /dev/null +++ b/src/renderer/components/+custom-resources/__tests__/__snapshots__/custom-resource-details.test.tsx.snap @@ -0,0 +1,225 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` with a CRD with a boolean field should display false in an additionalPrinterColumn as 'false' 1`] = ` +
+
+
+ + Created + + + <unknown> + ago + +
+
+ + Name + + + first-crd + +
+
+ + MyField + + +
    +
  • + false +
  • +
+
+
+
+
+`; + +exports[` with a CRD with a boolean field should display true in an additionalPrinterColumn as 'true' 1`] = ` +
+
+
+ + Created + + + <unknown> + ago + +
+
+ + Name + + + first-crd + +
+
+ + MyField + + +
    +
  • + true +
  • +
+
+
+
+
+`; + +exports[` with a CRD with a number field should display 0 in an additionalPrinterColumn as '0' 1`] = ` +
+
+
+ + Created + + + <unknown> + ago + +
+
+ + Name + + + first-crd + +
+
+ + MyField + + +
    +
  • + 0 +
  • +
+
+
+
+
+`; + +exports[` with a CRD with a number field should display 1234 in an additionalPrinterColumn as '1234' 1`] = ` +
+
+
+ + Created + + + <unknown> + ago + +
+
+ + Name + + + first-crd + +
+
+ + MyField + + +
    +
  • + 1234 +
  • +
+
+
+
+
+`; diff --git a/src/renderer/components/+custom-resources/__tests__/custom-resource-details.test.tsx b/src/renderer/components/+custom-resources/__tests__/custom-resource-details.test.tsx new file mode 100644 index 0000000000..2b8e8b2b1e --- /dev/null +++ b/src/renderer/components/+custom-resources/__tests__/custom-resource-details.test.tsx @@ -0,0 +1,206 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import React from "react"; +import { CustomResourceDefinition } from "../../../../common/k8s-api/endpoints"; +import { KubeObject } from "../../../../common/k8s-api/kube-object"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import type { DiRender } from "../../test-utils/renderFor"; +import { renderFor } from "../../test-utils/renderFor"; +import { CustomResourceDetails } from "../crd-resource-details"; + +describe("", () => { + let render: DiRender; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + render = renderFor(di); + }); + + describe("with a CRD with a boolean field", () => { + let crd: CustomResourceDefinition; + + beforeEach(() => { + crd = new CustomResourceDefinition({ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + metadata: { + name: "my-crd", + resourceVersion: "1", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/my-crd", + uid: "1", + }, + spec: { + versions: [{ + name: "v1", + served: true, + storage: true, + schema: { + openAPIV3Schema: { + type: "object", + properties: { + spec: { + type: "object", + properties: { + "my-field": { + type: "boolean", + }, + }, + }, + }, + }, + }, + additionalPrinterColumns: [ + { + name: "MyField", + jsonPath: ".spec.my-field", + type: "boolean", + }, + ], + }], + group: "stable.lens.dev", + names: { + kind: "MyCrd", + plural: "my-crds", + }, + scope: "Cluster", + }, + }); + }); + + it("should display false in an additionalPrinterColumn as 'false'", () => { + const cr = new KubeObject({ + apiVersion: "stable.lens.dev/v1", + kind: "MyCrd", + metadata: { + name: "first-crd", + resourceVersion: "1", + selfLink: "stable.lens.dev/v1/first-crd", + uid: "2", + }, + spec: { + "my-field": false, + }, + }); + const result = render(); + + expect(result.container).toMatchSnapshot(); + expect(result.getByText("false")).toBeTruthy(); + }); + + it("should display true in an additionalPrinterColumn as 'true'", () => { + const cr = new KubeObject({ + apiVersion: "stable.lens.dev/v1", + kind: "MyCrd", + metadata: { + name: "first-crd", + resourceVersion: "1", + selfLink: "stable.lens.dev/v1/first-crd", + uid: "2", + }, + spec: { + "my-field": true, + }, + }); + const result = render(); + + expect(result.container).toMatchSnapshot(); + expect(result.getByText("true")).toBeTruthy(); + }); + }); + + describe("with a CRD with a number field", () => { + let crd: CustomResourceDefinition; + + beforeEach(() => { + crd = new CustomResourceDefinition({ + apiVersion: "apiextensions.k8s.io/v1", + kind: "CustomResourceDefinition", + metadata: { + name: "my-crd", + resourceVersion: "1", + selfLink: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/my-crd", + uid: "1", + }, + spec: { + versions: [{ + name: "v1", + served: true, + storage: true, + schema: { + openAPIV3Schema: { + type: "object", + properties: { + spec: { + type: "object", + properties: { + "my-field": { + type: "number", + }, + }, + }, + }, + }, + }, + additionalPrinterColumns: [ + { + name: "MyField", + jsonPath: ".spec.my-field", + type: "number", + }, + ], + }], + group: "stable.lens.dev", + names: { + kind: "MyCrd", + plural: "my-crds", + }, + scope: "Cluster", + }, + }); + }); + + it("should display 0 in an additionalPrinterColumn as '0'", () => { + const cr = new KubeObject({ + apiVersion: "stable.lens.dev/v1", + kind: "MyCrd", + metadata: { + name: "first-crd", + resourceVersion: "1", + selfLink: "stable.lens.dev/v1/first-crd", + uid: "2", + }, + spec: { + "my-field": 0, + }, + }); + const result = render(); + + expect(result.container).toMatchSnapshot(); + expect(result.getByText("0")).toBeTruthy(); + }); + + it("should display 1234 in an additionalPrinterColumn as '1234'", () => { + const cr = new KubeObject({ + apiVersion: "stable.lens.dev/v1", + kind: "MyCrd", + metadata: { + name: "first-crd", + resourceVersion: "1", + selfLink: "stable.lens.dev/v1/first-crd", + uid: "2", + }, + spec: { + "my-field": 1234, + }, + }); + const result = render(); + + expect(result.container).toMatchSnapshot(); + expect(result.getByText("1234")).toBeTruthy(); + }); + }); +}); diff --git a/src/renderer/components/+custom-resources/crd-resource-details.tsx b/src/renderer/components/+custom-resources/crd-resource-details.tsx index 36c06de0e3..48049682e4 100644 --- a/src/renderer/components/+custom-resources/crd-resource-details.tsx +++ b/src/renderer/components/+custom-resources/crd-resource-details.tsx @@ -25,7 +25,7 @@ export interface CustomResourceDetailsProps extends KubeObjectDetailsProps @@ -50,18 +50,22 @@ function convertSpecValue(value: any): any { ); } - return value; + if ( + typeof value === "boolean" + || typeof value === "string" + || typeof value === "number" + ) { + return value.toString(); + } + + return null; } @observer export class CustomResourceDetails extends React.Component { renderAdditionalColumns(resource: KubeObject, columns: AdditionalPrinterColumnsV1[]) { return columns.map(({ name, jsonPath }) => ( - + {convertSpecValue(JSONPath.query(resource, convertKubectlJsonPathToNodeJsonPath(jsonPath)))} )); 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/+helm-releases/release-details/release-details.scss b/src/renderer/components/+helm-releases/release-details/release-details.scss index 0a4acfcca3..780d3df401 100644 --- a/src/renderer/components/+helm-releases/release-details/release-details.scss +++ b/src/renderer/components/+helm-releases/release-details/release-details.scss @@ -66,7 +66,7 @@ .notes { white-space: pre-line; - font-family: "RobotoMono", monospace; + font-family: var(--font-monospace); font-size: small; } diff --git a/src/renderer/components/+nodes/store.ts b/src/renderer/components/+nodes/store.ts index 4f53b87c4d..9f4be4b620 100644 --- a/src/renderer/components/+nodes/store.ts +++ b/src/renderer/components/+nodes/store.ts @@ -19,11 +19,11 @@ export class NodeStore extends KubeObjectStore { } @computed get masterNodes() { - return this.items.filter(node => node.getRoleLabels().includes("master")); + return this.items.filter(node => node.isMasterNode()); } @computed get workerNodes() { - return this.items.filter(node => !node.getRoleLabels().includes("master")); + return this.items.filter(node => !node.isMasterNode()); } getWarningsCount(): number { 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 userStore.shell = value} /> @@ -81,18 +104,19 @@ const NonInjectedTerminal = observer(({ theme="round-black" type="number" min={10} - validators={InputValidators.isNumber} - value={userStore.terminalConfig.fontSize.toString()} + max={50} + defaultValue={userStore.terminalConfig.fontSize.toString()} onChange={(value) => userStore.terminalConfig.fontSize = Number(value)} />
- userStore.terminalConfig.fontFamily = value} + options={supportedCustomFonts} + onChange={onFontFamilyChange as any} />
diff --git a/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx b/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx index e221c008ac..0d6b3fab6f 100644 --- a/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx +++ b/src/renderer/components/+user-management/+cluster-role-bindings/__tests__/dialog.test.tsx @@ -12,7 +12,6 @@ import type { DiRender } from "../../../test-utils/renderFor"; import { renderFor } from "../../../test-utils/renderFor"; import clusterRoleStoreInjectable from "../../+cluster-roles/store.injectable"; import storesAndApisCanBeCreatedInjectable from "../../../../stores-apis-can-be-created.injectable"; -import ipcRendererInjectable from "../../../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; describe("ClusterRoleBindingDialog tests", () => { let render: DiRender; @@ -21,10 +20,6 @@ describe("ClusterRoleBindingDialog tests", () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); di.override(storesAndApisCanBeCreatedInjectable, () => true); - di.override(ipcRendererInjectable, () => ({ - on: jest.fn(), - invoke: jest.fn(), // TODO: replace with proper mocking via the IPC bridge - } as never)); render = renderFor(di); diff --git a/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx b/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx index 969de55ea4..82e3a7c9d7 100644 --- a/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx +++ b/src/renderer/components/+user-management/+role-bindings/__tests__/dialog.test.tsx @@ -13,7 +13,6 @@ import { renderFor } from "../../../test-utils/renderFor"; import directoryForUserDataInjectable from "../../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import clusterRoleStoreInjectable from "../../+cluster-roles/store.injectable"; import storesAndApisCanBeCreatedInjectable from "../../../../stores-apis-can-be-created.injectable"; -import ipcRendererInjectable from "../../../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; describe("RoleBindingDialog tests", () => { let render: DiRender; @@ -23,10 +22,6 @@ describe("RoleBindingDialog tests", () => { di.override(storesAndApisCanBeCreatedInjectable, () => true); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - di.override(ipcRendererInjectable, () => ({ - on: jest.fn(), - invoke: jest.fn(), // TODO: replace with proper mocking via the IPC bridge - } as never)); render = renderFor(di); diff --git a/src/renderer/components/+workloads-pods/details/volumes/variants/__snapshots__/projected.test.tsx.snap b/src/renderer/components/+workloads-pods/details/volumes/variants/__snapshots__/projected.test.tsx.snap new file mode 100644 index 0000000000..ef0d3dd261 --- /dev/null +++ b/src/renderer/components/+workloads-pods/details/volumes/variants/__snapshots__/projected.test.tsx.snap @@ -0,0 +1,229 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders 1`] = ` + +
+
+ + Sources + + +
+
+ +`; + +exports[` renders a secret source including overriding mode 1`] = ` + +
+
+ + Default Mount Mode + + + 0o777 + +
+
+ + Sources + + +
+ Secret +
+
+ + Name + + + my-projected-secret + +
+
+ + Items + + +
    +
  • + foo⇢/bar + (0o666) +
  • +
+
+
+
+
+
+ +`; + +exports[` renders a secret source, when provided 1`] = ` + +
+
+ + Default Mount Mode + + + 0o777 + +
+
+ + Sources + + +
+ Secret +
+
+ + Name + + + my-projected-secret + +
+
+ + Items + + +
    +
  • + foo⇢/bar +
  • +
+
+
+
+
+
+ +`; + +exports[` renders default mount mode in octal when provided 1`] = ` + +
+
+ + Default Mount Mode + + + 0o777 + +
+
+ + Sources + + +
+
+ +`; + +exports[` renders when no sources array provided 1`] = ` + +
+
+ + Default Mount Mode + + + 0o777 + +
+
+ + Sources + + +
+
+ +`; diff --git a/src/renderer/components/+workloads-pods/details/volumes/variants/__tests__/__snapshots__/ceph-fs.test.tsx.snap b/src/renderer/components/+workloads-pods/details/volumes/variants/__tests__/__snapshots__/ceph-fs.test.tsx.snap new file mode 100644 index 0000000000..627b2fb1a3 --- /dev/null +++ b/src/renderer/components/+workloads-pods/details/volumes/variants/__tests__/__snapshots__/ceph-fs.test.tsx.snap @@ -0,0 +1,229 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 'false' for Readonly when false is provided 1`] = ` +
+
+ + Monitors + + +
    + +
+
+ + Mount Path + + + / + +
+
+ + Username + + + admin + +
+
+ + Secret Filepath + + + /etc/ceph/user.secret + +
+
+ + Readonly + + + false + +
+
+`; + +exports[` should render 'false' for Readonly when not provided 1`] = ` +
+
+ + Monitors + + +
    + +
+
+ + Mount Path + + + / + +
+
+ + Username + + + admin + +
+
+ + Secret Filepath + + + /etc/ceph/user.secret + +
+
+ + Readonly + + + false + +
+
+`; + +exports[` should render 'true' for Readonly when true is provided 1`] = ` +
+
+ + Monitors + + +
    + +
+
+ + Mount Path + + + / + +
+
+ + Username + + + admin + +
+
+ + Secret Filepath + + + /etc/ceph/user.secret + +
+
+ + Readonly + + + true + +
+
+`; diff --git a/src/renderer/components/+workloads-pods/details/volumes/variants/__tests__/ceph-fs.test.tsx b/src/renderer/components/+workloads-pods/details/volumes/variants/__tests__/ceph-fs.test.tsx new file mode 100644 index 0000000000..5555332328 --- /dev/null +++ b/src/renderer/components/+workloads-pods/details/volumes/variants/__tests__/ceph-fs.test.tsx @@ -0,0 +1,116 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { render } from "@testing-library/react"; +import React from "react"; +import type { CephfsSource } from "../../../../../../../common/k8s-api/endpoints"; +import { Pod } from "../../../../../../../common/k8s-api/endpoints"; +import { CephFs } from "../ceph-fs"; + +describe("", () => { + it("should render 'false' for Readonly when not provided", () => { + const cephfsName = "my-ceph"; + const cephfsVolume: CephfsSource = { + monitors: [], + }; + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "my-pod", + namespace: "default", + resourceVersion: "1", + uid: "123", + selfLink: "/api/v1/pod/default/my-pod", + }, + spec: { + volumes: [{ + name: cephfsName, + cephfs: cephfsVolume, + }], + }, + }); + const result = render(( + + )); + + expect(result.container).toMatchSnapshot(); + expect(result.getByTestId("cephfs-readonly")).toHaveTextContent("false"); + }); + + it("should render 'false' for Readonly when false is provided", () => { + const cephfsName = "my-ceph"; + const cephfsVolume: CephfsSource = { + monitors: [], + readOnly: false, + }; + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "my-pod", + namespace: "default", + resourceVersion: "1", + uid: "123", + selfLink: "/api/v1/pod/default/my-pod", + }, + spec: { + volumes: [{ + name: cephfsName, + cephfs: cephfsVolume, + }], + }, + }); + const result = render(( + + )); + + expect(result.container).toMatchSnapshot(); + expect(result.getByTestId("cephfs-readonly")).toHaveTextContent("false"); + }); + + it("should render 'true' for Readonly when true is provided", () => { + const cephfsName = "my-ceph"; + const cephfsVolume: CephfsSource = { + monitors: [], + readOnly: true, + }; + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "my-pod", + namespace: "default", + resourceVersion: "1", + uid: "123", + selfLink: "/api/v1/pod/default/my-pod", + }, + spec: { + volumes: [{ + name: cephfsName, + cephfs: cephfsVolume, + }], + }, + }); + const result = render(( + + )); + + expect(result.container).toMatchSnapshot(); + expect(result.getByTestId("cephfs-readonly")).toHaveTextContent("true"); + }); +}); diff --git a/src/renderer/components/+workloads-pods/details/volumes/variants/ceph-fs.tsx b/src/renderer/components/+workloads-pods/details/volumes/variants/ceph-fs.tsx index 0b44e7d355..a723f7be92 100644 --- a/src/renderer/components/+workloads-pods/details/volumes/variants/ceph-fs.tsx +++ b/src/renderer/components/+workloads-pods/details/volumes/variants/ceph-fs.tsx @@ -10,7 +10,7 @@ import type { VolumeVariantComponent } from "../variant-helpers"; import { LocalRef } from "../variant-helpers"; export const CephFs: VolumeVariantComponent<"cephfs"> = ( - ({ pod, variant: { monitors, path = "/", user = "admin", secretFile = "/etc/ceph/user.secret", secretRef, readOnly }}) => ( + ({ pod, variant: { monitors, path = "/", user = "admin", secretFile = "/etc/ceph/user.secret", secretRef, readOnly = false }}) => ( <>
    @@ -39,7 +39,7 @@ export const CephFs: VolumeVariantComponent<"cephfs"> = ( ) } - + {readOnly.toString()} diff --git a/src/renderer/components/+workloads-pods/details/volumes/variants/projected.test.tsx b/src/renderer/components/+workloads-pods/details/volumes/variants/projected.test.tsx new file mode 100644 index 0000000000..24405bc1a9 --- /dev/null +++ b/src/renderer/components/+workloads-pods/details/volumes/variants/projected.test.tsx @@ -0,0 +1,197 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { render } from "@testing-library/react"; +import React from "react"; +import type { ProjectedSource } from "../../../../../../common/k8s-api/endpoints"; +import { Pod } from "../../../../../../common/k8s-api/endpoints"; +import { Projected } from "./projected"; + +describe("", () => { + it("renders", () => { + const projectedVolume: ProjectedSource = { }; + const projectedVolumeName = "my-projected"; + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "my-pod", + namespace: "default", + resourceVersion: "1", + uid: "123", + selfLink: "/api/v1/pod/default/my-pod", + }, + spec: { + volumes: [{ + name: projectedVolumeName, + projected: projectedVolume, + }], + }, + }); + const result = render(( + + )); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders default mount mode in octal when provided", () => { + const projectedVolume: ProjectedSource = { + defaultMode: 0o777, + sources: [], + }; + const projectedVolumeName = "my-projected"; + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "my-pod", + namespace: "default", + resourceVersion: "1", + uid: "123", + selfLink: "/api/v1/pod/default/my-pod", + }, + spec: { + volumes: [{ + name: projectedVolumeName, + projected: projectedVolume, + }], + }, + }); + const result = render(( + + )); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders when no sources array provided", () => { + const projectedVolume: ProjectedSource = { + defaultMode: 0o777, + }; + const projectedVolumeName = "my-projected"; + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "my-pod", + namespace: "default", + resourceVersion: "1", + uid: "123", + selfLink: "/api/v1/pod/default/my-pod", + }, + spec: { + volumes: [{ + name: projectedVolumeName, + projected: projectedVolume, + }], + }, + }); + const result = render(( + + )); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders a secret source, when provided", () => { + const projectedVolume: ProjectedSource = { + defaultMode: 0o777, + sources: [{ + secret: { + name: "my-projected-secret", + items: [{ + key: "foo", + path: "/bar", + }], + }, + }], + }; + const projectedVolumeName = "my-projected"; + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "my-pod", + namespace: "default", + resourceVersion: "1", + uid: "123", + selfLink: "/api/v1/pod/default/my-pod", + }, + spec: { + volumes: [{ + name: projectedVolumeName, + projected: projectedVolume, + }], + }, + }); + const result = render(( + + )); + + expect(result.baseElement).toMatchSnapshot(); + expect(result.getByText("foo⇢/bar", { exact: false })).toBeTruthy(); + }); + + it("renders a secret source including overriding mode", () => { + const projectedVolume: ProjectedSource = { + defaultMode: 0o777, + sources: [{ + secret: { + name: "my-projected-secret", + items: [{ + key: "foo", + path: "/bar", + mode: 0o666, + }], + }, + }], + }; + const projectedVolumeName = "my-projected"; + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "my-pod", + namespace: "default", + resourceVersion: "1", + uid: "123", + selfLink: "/api/v1/pod/default/my-pod", + }, + spec: { + volumes: [{ + name: projectedVolumeName, + projected: projectedVolume, + }], + }, + }); + const result = render(( + + )); + + expect(result.baseElement).toMatchSnapshot(); + expect(result.getByText("(0o666)", { exact: false })).toBeTruthy(); + }); +}); diff --git a/src/renderer/components/+workloads-pods/details/volumes/variants/projected.tsx b/src/renderer/components/+workloads-pods/details/volumes/variants/projected.tsx index cd725f1c67..c7ca22c87f 100644 --- a/src/renderer/components/+workloads-pods/details/volumes/variants/projected.tsx +++ b/src/renderer/components/+workloads-pods/details/volumes/variants/projected.tsx @@ -4,18 +4,21 @@ */ import React from "react"; +import { displayMode } from "../../../../../utils"; import { DrawerItem, DrawerTitle } from "../../../../drawer"; import type { VolumeVariantComponent } from "../variant-helpers"; export const Projected: VolumeVariantComponent<"projected"> = ( ({ variant: { sources, defaultMode }}) => ( <> - - {`0o${defaultMode.toString(8)}`} - + {typeof defaultMode === "number" && ( + + {displayMode(defaultMode)} + + )} { - sources.map(({ secret, downwardAPI, configMap, serviceAccountToken }, index) => ( + sources?.map(({ secret, downwardAPI, configMap, serviceAccountToken }, index) => ( {secret && ( <> @@ -25,9 +28,12 @@ export const Projected: VolumeVariantComponent<"projected"> = (
      - {secret.items?.map(({ key, path }) => ( + {secret.items?.map(({ key, path, mode }) => (
    • {`${key}⇢${path}`} + {typeof mode === "number" && ( + ` (${displayMode(mode)})` + )}
    • ))}
    diff --git a/src/renderer/components/animate/animate.tsx b/src/renderer/components/animate/animate.tsx index 920e6b8cef..d642a41fa8 100644 --- a/src/renderer/components/animate/animate.tsx +++ b/src/renderer/components/animate/animate.tsx @@ -8,6 +8,8 @@ import React from "react"; import { observable, makeObservable } from "mobx"; import { observer } from "mobx-react"; import { cssNames, noop } from "../../utils"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import requestAnimationFrameInjectable from "./request-animation-frame.injectable"; export type AnimateName = "opacity" | "slide-right" | "opacity-scale" | string; @@ -21,8 +23,12 @@ export interface AnimateProps { children?: React.ReactNode; } +interface Dependencies { + requestAnimationFrame: (callback: () => void) => void; +} + @observer -class DefaultedAnimate extends React.Component { +class DefaultedAnimate extends React.Component { static defaultProps = { name: "opacity", enter: true, @@ -38,7 +44,7 @@ class DefaultedAnimate extends React.Component { + + this.props.requestAnimationFrame(() => { this.statusClassName.enter = true; this.props.onEnter(); }); @@ -115,4 +122,17 @@ class DefaultedAnimate extends React.Component ; +export const NonInjectedAnimate = (props: AnimateProps & Dependencies) => ; + +export const Animate = withInjectables( + NonInjectedAnimate, + + { + getProps: (di, props) => ({ + requestAnimationFrame: di.inject(requestAnimationFrameInjectable), + ...props, + }), + }, +); + + diff --git a/src/renderer/components/animate/request-animation-frame.injectable.ts b/src/renderer/components/animate/request-animation-frame.injectable.ts new file mode 100644 index 0000000000..f61b2ae874 --- /dev/null +++ b/src/renderer/components/animate/request-animation-frame.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"; + +const requestAnimationFrameInjectable = getInjectable({ + id: "request-animation-frame", + instantiate: () => (callback: () => void) => requestAnimationFrame(callback), + causesSideEffects: true, +}); + +export default requestAnimationFrameInjectable; diff --git a/src/renderer/components/app.scss b/src/renderer/components/app.scss index 97a2057ed2..80317acb59 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -12,12 +12,14 @@ @import "./fonts"; :root { + --flex-gap: #{$padding}; --unit: 8px; --padding: var(--unit); --margin: var(--unit); --border-radius: 3px; --font-main: 'Roboto', 'Helvetica', 'Arial', sans-serif; - --font-monospace: Lucida Console, Monaco, Consolas, monospace; + --font-monospace: Lucida Console, Monaco, Consolas, monospace; // some defaults + --font-terminal: var(--font-monospace); // overridden in terminal.ts, managed by common/user-store.ts --font-size-small: calc(1.5 * var(--unit)); --font-size: calc(1.75 * var(--unit)); --font-size-big: calc(2 * var(--unit)); @@ -63,16 +65,13 @@ color: var(--textColorAccent); } -html { - font-size: 62.5%; // 1 rem == 10px - color: var(--textColorPrimary); - background-color: var(--mainBackground); - --flex-gap: #{$padding}; -} - html, body { height: 100%; overflow: hidden; + color: var(--textColorPrimary); + background-color: var(--mainBackground); + font-size: var(--font-size); + font-family: var(--font-main); } #terminal-init { @@ -93,10 +92,6 @@ html, body { } } -body { - font: $font-size $font-main; -} - fieldset { border: 0; padding: 0; @@ -227,6 +222,22 @@ a { } } +#fonts-preloading { + > span { + position: absolute; + visibility: hidden; + height: 0; + + &:before { + width: 0; + display: block; + overflow: hidden; + content: "text-example"; // some text required to start applying/rendering font in document + font-family: inherit; // font-family must be specified via style="" (see: template.html) + } + } +} + // app's common loading indicator, displaying on the route transitions #loading { position: absolute; diff --git a/src/renderer/components/cluster-manager/cluster-frame-handler.ts b/src/renderer/components/cluster-manager/cluster-frame-handler.ts index 9601317140..cf579cdc09 100644 --- a/src/renderer/components/cluster-manager/cluster-frame-handler.ts +++ b/src/renderer/components/cluster-manager/cluster-frame-handler.ts @@ -45,7 +45,6 @@ export class ClusterFrameHandler { iframe.id = `cluster-frame-${cluster.id}`; iframe.name = cluster.contextName; - iframe.style.display = "none"; iframe.setAttribute("src", getClusterFrameUrl(clusterId)); iframe.addEventListener("load", action(() => { logger.info(`[LENS-VIEW]: frame for clusterId=${clusterId} has loaded`); @@ -95,7 +94,7 @@ export class ClusterFrameHandler { ipcRenderer.send(clusterVisibilityHandler); for (const { frame: view } of this.views.values()) { - view.style.display = "none"; + view.classList.add("hidden"); } const cluster = clusterId @@ -113,9 +112,9 @@ export class ClusterFrameHandler { return undefined; }, - (view) => { + (view: LensView) => { logger.info(`[LENS-VIEW]: cluster id=${clusterId} should now be visible`); - view.frame.style.display = "flex"; + view.frame.classList.remove("hidden"); ipcRenderer.send(clusterVisibilityHandler, clusterId); }, ); diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index e6f5bb43e2..dec5a069a2 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -35,7 +35,20 @@ background-color: var(--mainBackground); iframe { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: flex; flex: 1; + + // when updating font settings in the "Preferences -> Terminal" cluster's iframe + // must be accessible in DOM (e.g. elem.getBoundingClientRect() must work) + &.hidden { + opacity: 0; + pointer-events: none; + } } } } diff --git a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx index a2a0e6a301..ded5cf8098 100644 --- a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx +++ b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx @@ -26,6 +26,9 @@ import { computed } from "mobx"; import { routeSpecificComponentInjectionToken } from "../../../routes/route-specific-component-injection-token"; import { navigateToRouteInjectionToken } from "../../../../common/front-end-routing/navigate-to-route-injection-token"; import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; +import normalizedPlatformInjectable from "../../../../common/vars/normalized-platform.injectable"; +import kubectlBinaryNameInjectable from "../../../../main/kubectl/binary-name.injectable"; +import kubectlDownloadingNormalizedArchInjectable from "../../../../main/kubectl/normalized-arch.injectable"; jest.mock("electron", () => ({ app: { @@ -101,8 +104,11 @@ describe("", () => { applicationBuilder = getApplicationBuilder(); applicationBuilder.beforeApplicationStart(({ mainDi, rendererDi }) => { - mainDi.override(createContextHandlerInjectable, () => () => undefined as any); - mainDi.override(createKubeconfigManagerInjectable, () => () => undefined as any); + mainDi.override(createContextHandlerInjectable, () => () => undefined as never); + mainDi.override(createKubeconfigManagerInjectable, () => () => undefined as never); + mainDi.override(kubectlBinaryNameInjectable, () => "kubectl"); + mainDi.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); + mainDi.override(normalizedPlatformInjectable, () => "darwin"); rendererDi.override(hotbarStoreInjectable, () => ({})); rendererDi.override(storesAndApisCanBeCreatedInjectable, () => true); diff --git a/src/renderer/components/dock/__test__/dock-store.test.ts b/src/renderer/components/dock/__test__/dock-store.test.ts index e554b4dcdc..c9191c4928 100644 --- a/src/renderer/components/dock/__test__/dock-store.test.ts +++ b/src/renderer/components/dock/__test__/dock-store.test.ts @@ -5,7 +5,6 @@ import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import hostedClusterIdInjectable from "../../../../common/cluster-store/hosted-cluster-id.injectable"; -import ipcRendererInjectable from "../../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import type { DockStore, DockTab } from "../dock/store"; import { TabKind } from "../dock/store"; @@ -26,10 +25,6 @@ describe("DockStore", () => { const di = getDiForUnitTesting({ doGeneralOverrides: true }); di.override(hostedClusterIdInjectable, () => "some-cluster-id"); - di.override(ipcRendererInjectable, () => ({ - on: jest.fn(), - invoke: jest.fn(), // TODO: replace with proper mocking via the IPC bridge - } as never)); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); dockStore = di.inject(dockStoreInjectable); diff --git a/src/renderer/components/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx index df9fc08ce2..aa4f755943 100644 --- a/src/renderer/components/dock/__test__/dock-tabs.test.tsx +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -20,7 +20,6 @@ import getConfigurationFileModelInjectable from "../../../../common/get-configur import appVersionInjectable from "../../../../common/get-configuration-file-model/app-version/app-version.injectable"; import assert from "assert"; import hostedClusterIdInjectable from "../../../../common/cluster-store/hosted-cluster-id.injectable"; -import ipcRendererInjectable from "../../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; jest.mock("electron", () => ({ app: { @@ -83,10 +82,6 @@ describe("", () => { directoryForUserDataInjectable, () => "some-test-suite-specific-directory-for-user-data", ); - 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/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index bcd6d19dcf..385d3e0fbc 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -166,11 +166,7 @@ class NonInjectedDock extends React.Component { closeOnScroll={false} > this.props.createTerminalTab()}> - + Terminal session this.props.createResourceTab()}> diff --git a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx index c886b195cd..5fd6161052 100644 --- a/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx +++ b/src/renderer/components/dock/logs/__test__/log-resource-selector.test.tsx @@ -22,7 +22,6 @@ import { SearchStore } from "../../../../search-store/search-store"; import getConfigurationFileModelInjectable from "../../../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../../../../../common/get-configuration-file-model/app-version/app-version.injectable"; import assert from "assert"; -import ipcRendererInjectable from "../../../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; jest.mock("electron", () => ({ app: { @@ -132,10 +131,6 @@ describe("", () => { di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); di.override(callForLogsInjectable, () => () => Promise.resolve("some-logs")); - 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/dock/terminal/dock-tab.tsx b/src/renderer/components/dock/terminal/dock-tab.tsx index 64fce73a03..e6e07f50ee 100644 --- a/src/renderer/components/dock/terminal/dock-tab.tsx +++ b/src/renderer/components/dock/terminal/dock-tab.tsx @@ -67,7 +67,6 @@ class NonInjectedTerminalTab exte } render() { - const tabIcon = ; const className = cssNames("TerminalTab", this.props.className, { disconnected: this.isDisconnected, }); @@ -78,7 +77,7 @@ class NonInjectedTerminalTab exte } moreActions={this.isDisconnected && ( { + return this.dependencies.themeStore.xtermColors; + } + + constructor(protected readonly dependencies: TerminalDependencies, { + tabId, + api, + }: TerminalArguments) { this.tabId = tabId; this.api = api; - const { fontSize, fontFamily } = this.dependencies.terminalConfig.get(); this.xterm = new XTerm({ cursorBlink: true, cursorStyle: "bar", - fontSize, - fontFamily, + fontSize: this.fontSize, + fontFamily: this.fontFamily, }); // enable terminal addons this.xterm.loadAddon(this.fitAddon); @@ -95,17 +109,11 @@ export class Terminal { window.addEventListener("resize", this.onResize); this.disposer.push( - reaction(() => this.dependencies.themeStore.xtermColors, colors => { - this.xterm?.setOption("theme", colors); - }, { - fireImmediately: true, - }), - reaction(() => this.dependencies.terminalConfig.get().fontSize, this.setFontSize, { - fireImmediately: true, - }), - reaction(() => this.dependencies.terminalConfig.get().fontFamily, this.setFontFamily, { + reaction(() => this.theme, colors => this.xterm.setOption("theme", colors), { fireImmediately: true, }), + reaction(() => this.fontSize, this.setFontSize, { fireImmediately: true }), + reaction(() => this.fontFamily, this.setFontFamily, { fireImmediately: true }), () => onDataHandler.dispose(), () => this.fitAddon.dispose(), () => this.api.removeAllListeners(), @@ -120,15 +128,14 @@ export class Terminal { } fit = () => { - // Since this function is debounced we need to read this value as late as possible - if (!this.xterm) { - return; - } - try { - this.fitAddon.fit(); - const { cols, rows } = this.xterm; + const { cols, rows } = this.fitAddon.proposeDimensions(); + // attempt to resize/fit terminal when it's not visible in DOM will crash with exception + // see: https://github.com/xtermjs/xterm.js/issues/3118 + if (isNaN(cols) || isNaN(rows)) return; + + this.fitAddon.fit(); this.api.sendTerminalSize(cols, rows); } catch (error) { // see https://github.com/lensapp/lens/issues/1891 @@ -197,12 +204,21 @@ export class Terminal { } }; - setFontSize = (size: number) => { - this.xterm.options.fontSize = size; + setFontSize = (fontSize: number) => { + logger.info(`[TERMINAL]: set fontSize to ${fontSize}`); + + this.xterm.options.fontSize = fontSize; + this.fit(); }; - setFontFamily = (family: string) => { - this.xterm.options.fontFamily = family; + setFontFamily = (fontFamily: string) => { + logger.info(`[TERMINAL]: set fontFamily to ${fontFamily}`); + + this.xterm.options.fontFamily = fontFamily; + this.fit(); + + // provide css-variable within `:root {}` + document.documentElement.style.setProperty("--font-terminal", fontFamily); }; keyHandler = (evt: KeyboardEvent): boolean => { diff --git a/src/renderer/components/drawer/drawer-item.tsx b/src/renderer/components/drawer/drawer-item.tsx index 737749f783..72a311794c 100644 --- a/src/renderer/components/drawer/drawer-item.tsx +++ b/src/renderer/components/drawer/drawer-item.tsx @@ -5,14 +5,20 @@ import "./drawer-item.scss"; import React from "react"; -import { cssNames, displayBooleans } from "../../utils"; +import { cssNames } from "../../utils"; export interface DrawerItemProps extends React.HTMLAttributes { name: React.ReactNode; title?: string; labelsOnly?: boolean; hidden?: boolean; - renderBoolean?: boolean; // show "true" or "false" for all of the children elements are "typeof boolean" + + /** + * @deprecated This prop is no longer used, you should stringify the booleans yourself. + * + * This was only meant to be an internal prop anyway. + */ + renderBooleans?: boolean; } export function DrawerItem({ @@ -22,7 +28,6 @@ export function DrawerItem({ children, hidden = false, className, - renderBoolean, ...elemProps }: DrawerItemProps) { if (hidden) { @@ -36,7 +41,7 @@ export function DrawerItem({ title={title} > {name} - {displayBooleans(renderBoolean, children)} + {children}
); } diff --git a/src/renderer/components/drawer/drawer-title.tsx b/src/renderer/components/drawer/drawer-title.tsx index 9b83560dd4..d920a9cef9 100644 --- a/src/renderer/components/drawer/drawer-title.tsx +++ b/src/renderer/components/drawer/drawer-title.tsx @@ -9,7 +9,12 @@ import { cssNames } from "../../utils"; export interface DrawerTitleProps { className?: string; - children: React.ReactNode; + children?: React.ReactNode; + + /** + * @deprecated Prefer passing the value as `children` + */ + title?: React.ReactNode; /** * Specifies how large this title is diff --git a/src/renderer/components/fonts.scss b/src/renderer/components/fonts.scss index fc594561d8..40f9e9625b 100644 --- a/src/renderer/components/fonts.scss +++ b/src/renderer/components/fonts.scss @@ -3,34 +3,99 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -// Custom fonts -@import "~typeface-roboto/index.css"; +// App's main font +// Downloaded from: https://fonts.google.com/specimen/Roboto +@font-face { + font-family: "Roboto"; + src: url("../fonts/Roboto-Light.ttf") format("truetype"); + font-display: swap; + font-weight: 300; // "light" +} -// Material Design Icons, used primarily in icon.tsx -// Latest: https://github.com/google/material-design-icons/tree/master/font +@font-face { + font-family: "Roboto"; + src: url("../fonts/Roboto-LightItalic.ttf") format("truetype"); + font-display: swap; + font-weight: 300; // "light" + italic + font-style: italic; +} + +@font-face { + font-family: "Roboto"; + src: url("../fonts/Roboto-Regular.ttf") format("truetype"); + font-display: swap; + font-weight: 400; // "normal" +} + +@font-face { + font-family: "Roboto"; + src: url("../fonts/Roboto-Bold.ttf") format("truetype"); + font-display: swap; + font-weight: 700; // "bold" +} + + +// Icon fonts, see: `icon.tsx` +// Latest version for manual update: https://github.com/google/material-design-icons/tree/master/font @font-face { font-family: "Material Icons"; font-style: normal; font-weight: 400; font-display: block; - src: url("./fonts/MaterialIcons-Regular.ttf") format("truetype"); + src: url("../fonts/MaterialIcons-Regular.ttf") format("truetype"); +} + + +// Terminal fonts (monospaced) +// Source: https://fonts.google.com/?category=Monospace +@font-face { + font-family: "Anonymous Pro"; + src: local("Anonymous Pro"), url("../fonts/AnonymousPro-Regular.ttf") format("truetype"); + font-display: block; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: local("IBM Plex Mono"), url("../fonts/IBMPlexMono-Regular.ttf") format("truetype"); + font-display: block; +} + +@font-face { + font-family: "JetBrains Mono"; + src: local("JetBrains Mono"), url("../fonts/JetBrainsMono-Regular.ttf") format("truetype"); + font-display: block; +} + +@font-face { + font-family: "Red Hat Mono"; + src: local("Red Hat Mono"), url("../fonts/RedHatMono-Regular.ttf") format("truetype"); + font-display: block; +} + + +@font-face { + font-family: "Source Code Pro"; + src: local("Source Code Pro"), url("../fonts/SourceCodePro-Regular.ttf") format("truetype"); + font-display: block; +} + +@font-face { + font-family: "Space Mono"; + src: local("Space Mono"), url("../fonts/SpaceMono-Regular.ttf") format("truetype"); + font-display: block; +} + +@font-face { + font-family: "Ubuntu Mono"; + src: local("Ubuntu Mono"), url("../fonts/UbuntuMono-Regular.ttf") format("truetype"); + font-display: block; } // Patched RobotoMono font with icons // RobotoMono Windows Compatible for using in terminal // https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/RobotoMono @font-face { - font-family: 'RobotoMono'; + font-family: "RobotoMono"; + src: local("RobotoMono"), url("../fonts/Roboto-Mono-nerd.ttf") format("truetype"); font-display: block; - src: local('RobotoMono'), url('./fonts/roboto-mono-nerd.ttf') format('truetype'); -} - -#fonts-preloading { - > .icons { - @include font-preload("Material Icons"); - } - - > .terminal { - @include font-preload("RobotoMono"); - } } diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-100.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-100.woff2 deleted file mode 100644 index f662e6cd2a..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-100.woff2 and /dev/null differ diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-100italic.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-100italic.woff2 deleted file mode 100644 index b1b4f9ada7..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-100italic.woff2 and /dev/null differ diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-300.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-300.woff2 deleted file mode 100644 index 3a052ac03c..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-300.woff2 and /dev/null differ diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-300italic.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-300italic.woff2 deleted file mode 100644 index 93756538fe..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-300italic.woff2 and /dev/null differ diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-500.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-500.woff2 deleted file mode 100644 index fd707809c2..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-500.woff2 and /dev/null differ diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-500italic.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-500italic.woff2 deleted file mode 100644 index 10215999d5..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-500italic.woff2 and /dev/null differ diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-700.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-700.woff2 deleted file mode 100644 index 6c1e8dd09a..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-700.woff2 and /dev/null differ diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-700italic.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-700italic.woff2 deleted file mode 100644 index 27b02021ba..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-700italic.woff2 and /dev/null differ diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-italic.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-italic.woff2 deleted file mode 100644 index 23a1f71b4e..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-italic.woff2 and /dev/null differ diff --git a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-regular.woff2 b/src/renderer/components/fonts/roboto-v20-cyrillic_latin-regular.woff2 deleted file mode 100644 index 6b85564019..0000000000 Binary files a/src/renderer/components/fonts/roboto-v20-cyrillic_latin-regular.woff2 and /dev/null differ diff --git a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx index c7ce186afb..48ee9e63b4 100644 --- a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx +++ b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -19,7 +19,6 @@ import getConfigurationFileModelInjectable from "../../../../common/get-configur import appVersionInjectable from "../../../../common/get-configuration-file-model/app-version/app-version.injectable"; import type { HotbarStore } from "../../../../common/hotbars/store"; import storesAndApisCanBeCreatedInjectable from "../../../stores-apis-can-be-created.injectable"; -import ipcRendererInjectable from "../../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; const mockHotbars: Partial> = { "1": { @@ -47,10 +46,6 @@ describe("", () => { di.override(storesAndApisCanBeCreatedInjectable, () => true); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); - di.override(ipcRendererInjectable, () => ({ - on: jest.fn(), - invoke: jest.fn(), // TODO: replace with proper mocking via the IPC bridge - } as never)); di.permitSideEffects(hotbarStoreInjectable); di.permitSideEffects(getConfigurationFileModelInjectable); diff --git a/src/renderer/components/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index 60cf06f9a6..7655194886 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -12,7 +12,59 @@ import type { LocationDescriptor } from "history"; import { cssNames } from "../../utils"; import { withTooltip } from "../tooltip"; import isNumber from "lodash/isNumber"; -import { decode } from "../../../common/utils/base64"; +import Configuration from "./configuration.svg"; +import Crane from "./crane.svg"; +import Group from "./group.svg"; +import Helm from "./helm.svg"; +import Install from "./install.svg"; +import Kube from "./kube.svg"; +import LensLogo from "./lens-logo.svg"; +import License from "./license.svg"; +import LogoLens from "./logo-lens.svg"; +import Logout from "./logout.svg"; +import Nodes from "./nodes.svg"; +import PushOff from "./push_off.svg"; +import PushPin from "./push_pin.svg"; +import Spinner from "./spinner.svg"; +import Ssh from "./ssh.svg"; +import Storage from "./storage.svg"; +import Terminal from "./terminal.svg"; +import Notice from "./notice.svg"; +import User from "./user.svg"; +import Users from "./users.svg"; +import Wheel from "./wheel.svg"; +import Workloads from "./workloads.svg"; + +/** + * Mapping between the local file names and the svgs + * + * Because we only really want a fixed list of bundled icons, this is safer so that consumers of + * `` cannot pass in a `../some/path`. + */ +const localSvgIcons = new Map([ + ["configuration", Configuration], + ["crane", Crane], + ["group", Group], + ["helm", Helm], + ["install", Install], + ["kube", Kube], + ["lens-logo", LensLogo], + ["license", License], + ["logo-lens", LogoLens], + ["logout", Logout], + ["nodes", Nodes], + ["push_off", PushOff], + ["push_pin", PushPin], + ["spinner", Spinner], + ["ssh", Ssh], + ["storage", Storage], + ["terminal", Terminal], + ["notice", Notice], + ["user", User], + ["users", Users], + ["wheel", Wheel], + ["workloads", Workloads], +]); export interface BaseIconProps { /** @@ -21,7 +73,28 @@ export interface BaseIconProps { material?: string; /** - * Either an SVG data URL or one of the following strings + * Either an SVG XML or one of the following names + * - configuration + * - crane + * - group + * - helm + * - install + * - kube + * - lens-logo + * - license + * - logo-lens + * - logout + * - nodes + * - push_off + * - push_pin + * - spinner + * - ssh + * - storage + * - terminal + * - user + * - users + * - wheel + * - workloads */ svg?: string; @@ -78,8 +151,8 @@ export interface BaseIconProps { export interface IconProps extends React.HTMLAttributes, BaseIconProps {} export function isSvg(content: string): boolean { - // data-url for raw svg-icon - return String(content).includes("svg+xml"); + // source code of the asset + return String(content).includes(" { @@ -131,13 +204,16 @@ const RawIcon = withTooltip((props: IconProps) => { // render as inline svg-icon if (typeof svg === "string") { - const dataUrlPrefix = "data:image/svg+xml;base64,"; - const svgIconDataUrl = svg.startsWith(dataUrlPrefix) ? svg : require(`./${svg}.svg`); - const svgIconText = typeof svgIconDataUrl == "string" // decode xml from data-url - ? decode(svgIconDataUrl.replace(dataUrlPrefix, "")) - : ""; + const svgIconText = isSvg(svg) + ? svg + : localSvgIcons.get(svg) ?? ""; - iconContent = ; + iconContent = ( + + ); } // render as material-icon diff --git a/src/renderer/components/icon/notice.svg b/src/renderer/components/icon/notice.svg new file mode 100644 index 0000000000..2774b185f6 --- /dev/null +++ b/src/renderer/components/icon/notice.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index ffb5fab9ca..9514e796e3 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -14,7 +14,6 @@ import { Tooltip } from "../tooltip"; import * as Validators from "./input_validators"; import type { InputValidator } from "./input_validators"; import isFunction from "lodash/isFunction"; -import isBoolean from "lodash/isBoolean"; import uniqueId from "lodash/uniqueId"; import { debounce } from "lodash"; @@ -24,7 +23,10 @@ export { InputValidators }; export type { InputValidator }; type InputElement = HTMLInputElement | HTMLTextAreaElement; -type InputElementProps = InputHTMLAttributes & TextareaHTMLAttributes & DOMAttributes; +type InputElementProps = + InputHTMLAttributes + & TextareaHTMLAttributes + & DOMAttributes; export interface IconDataFnArg { isDirty: boolean; @@ -173,22 +175,18 @@ export class Input extends React.Component { error => this.getValidatorError(value, validator) || error, ), ); - } else { - if (!validator.validate(value, this.props)) { - errors.push(this.getValidatorError(value, validator)); - } } - const result = validator.validate(value, this.props); + const isValid = validator.validate(value, this.props); - if (isBoolean(result) && !result) { + if (isValid === false) { errors.push(this.getValidatorError(value, validator)); - } else if (result instanceof Promise) { + } else if (isValid instanceof Promise) { if (!validationId) { this.validationId = validationId = uniqueId("validation_id_"); } asyncValidators.push( - result.then( + isValid.then( () => null, // don't consider any valid result from promise since we interested in errors only error => this.getValidatorError(value, validator) || error, ), @@ -266,17 +264,25 @@ export class Input extends React.Component { setDirtyOnChange = debounce(() => this.setDirty(), 500); - onChange(evt: React.ChangeEvent) { - this.props.onChange?.(evt.currentTarget.value, evt); - this.validate(); + async onChange(evt: React.ChangeEvent) { + const newValue = evt.currentTarget.value; + const eventCopy = { ...evt }; + this.autoFitHeight(); this.setDirtyOnChange(); - // re-render component when used as uncontrolled input - // when used @defaultValue instead of @value changing real input.value doesn't call render() - if (this.isUncontrolled && this.showMaxLenIndicator) { - this.forceUpdate(); + // Handle uncontrolled components (`props.defaultValue` must be used instead `value`) + if (this.isUncontrolled) { + // update DOM since render() is not called on input's changes with uncontrolled inputs + if (this.showMaxLenIndicator) this.forceUpdate(); + + // don't propagate changes for invalid values + await this.validate(); + if (!this.state.valid) return; // skip } + + // emit new value update + this.props.onChange?.(newValue, eventCopy); } onKeyDown(evt: React.KeyboardEvent) { @@ -299,7 +305,7 @@ export class Input extends React.Component { this.setDirty(); } - if(this.props.blurOnEnter){ + if (this.props.blurOnEnter) { //pressing enter indicates that the edit is complete, we can unfocus now this.blur(); } @@ -379,7 +385,6 @@ export class Input extends React.Component { multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip, maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id, dirty: _dirty, // excluded from passing to input-element - defaultValue, trim, blurOnEnter, ...inputProps diff --git a/src/renderer/components/input/input_validators.ts b/src/renderer/components/input/input_validators.ts index 4c0befbd29..a9844bfcd8 100644 --- a/src/renderer/components/input/input_validators.ts +++ b/src/renderer/components/input/input_validators.ts @@ -8,7 +8,8 @@ import type { ReactNode } from "react"; import fse from "fs-extra"; import { TypedRegEx } from "typed-regex"; -export class AsyncInputValidationError extends Error {} +export class AsyncInputValidationError extends Error { +} export type InputValidator = { /** @@ -32,7 +33,7 @@ export type InputValidator = { message?: undefined; debounce: number; } -); + ); export function inputValidator(validator: InputValidator): InputValidator { return validator; @@ -52,7 +53,14 @@ export const isEmail = inputValidator({ export const isNumber = inputValidator({ condition: ({ type }) => type === "number", - message: () => `Invalid number`, + message(value, { min, max }) { + const minMax: string = [ + typeof min === "number" ? `min: ${min}` : undefined, + typeof max === "number" ? `max: ${max}` : undefined, + ].filter(Boolean).join(", "); + + return `Invalid number${minMax ? ` (${minMax})` : ""}`; + }, validate: (value, { min, max }) => { const numVal = +value; diff --git a/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap index d071fa21ea..976c92f3d9 100644 --- a/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap +++ b/src/renderer/components/kube-object-menu/__snapshots__/kube-object-menu.test.tsx.snap @@ -130,6 +130,130 @@ exports[`kube-object-menu given kube object when removing kube object renders 1` `; +exports[`kube-object-menu given kube object when rerendered with different kube object renders 1`] = ` + +
+
+ +
+
+ +`; + +exports[`kube-object-menu given kube object when rerendered with different kube object when removing new kube object renders 1`] = ` + +
+
+ +
+
+ + +`; + exports[`kube-object-menu given kube object with namespace when removing kube object, renders confirmation dialog with namespace 1`] = `
diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx index 4da76639b8..9dabdc9da4 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.test.tsx @@ -3,6 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import React from "react"; +import type { RenderResult } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom/extend-expect"; import { KubeObject } from "../../../common/k8s-api/kube-object"; @@ -125,7 +126,7 @@ describe("kube-object-menu", () => { }); describe("given kube object", () => { - let baseElement: Element; + let result: RenderResult; let removeActionMock: AsyncFnMock<() => void>; beforeEach(async () => { @@ -142,8 +143,7 @@ describe("kube-object-menu", () => { }); removeActionMock = asyncFn(); - - ({ baseElement } = render( + result = render((
@@ -152,18 +152,61 @@ describe("kube-object-menu", () => { toolbar={true} removeAction={removeActionMock} /> -
, +
)); }); it("renders", () => { - expect(baseElement).toMatchSnapshot(); + expect(result.baseElement).toMatchSnapshot(); }); it("does not open a confirmation dialog yet", () => { expect(screen.queryByTestId("confirmation-dialog")).toBeNull(); }); + describe("when rerendered with different kube object", () => { + beforeEach(() => { + const newObjectStub = KubeObject.create({ + apiVersion: "some-other-api-version", + kind: "some-other-kind", + metadata: { + uid: "some-other-uid", + name: "some-other-name", + resourceVersion: "some-other-resource-version", + namespace: "some-other-namespace", + selfLink: "some-other-api-version/some-other-kind/some-other-namespace/some-other-name", + }, + }); + + result.rerender( +
+ + + +
, + ); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + describe("when removing new kube object", () => { + beforeEach(async () => { + userEvent.click(await screen.findByTestId("menu-action-delete")); + }); + + it("renders", async () => { + await screen.findByTestId("confirmation-dialog"); + expect(result.baseElement).toMatchSnapshot(); + }); + }); + }); + describe("when removing kube object", () => { beforeEach(async () => { userEvent.click(await screen.findByTestId("menu-action-delete")); @@ -171,7 +214,7 @@ describe("kube-object-menu", () => { it("renders", async () => { await screen.findByTestId("confirmation-dialog"); - expect(baseElement).toMatchSnapshot(); + expect(result.baseElement).toMatchSnapshot(); }); describe("when remove is confirmed", () => { diff --git a/src/renderer/components/kube-object-menu/kube-object-menu.tsx b/src/renderer/components/kube-object-menu/kube-object-menu.tsx index eed957499b..a69fded655 100644 --- a/src/renderer/components/kube-object-menu/kube-object-menu.tsx +++ b/src/renderer/components/kube-object-menu/kube-object-menu.tsx @@ -49,6 +49,12 @@ interface Dependencies { class NonInjectedKubeObjectMenu extends React.Component & Dependencies> { private menuItems = observable.array(); + componentDidUpdate(prevProps: Readonly & Dependencies>): void { + if (prevProps.object !== this.props.object && this.props.object) { + this.emitOnContextMenuOpen(this.props.object); + } + } + private renderRemoveMessage(object: KubeObject) { const breadcrumbParts = [object.getNs(), object.getName()]; const breadcrumb = breadcrumbParts.filter(identity).join("/"); diff --git a/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.module.scss b/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.module.scss index c93374b2f7..c97c926a8f 100644 --- a/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.module.scss +++ b/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.module.scss @@ -3,6 +3,8 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ +@import "../../components/mixins.scss"; + .KubeConfigDialog { :global(.Wizard) { width: 50vw; diff --git a/src/renderer/components/mixins.scss b/src/renderer/components/mixins.scss index dd62568219..4581f0437b 100755 --- a/src/renderer/components/mixins.scss +++ b/src/renderer/components/mixins.scss @@ -59,20 +59,3 @@ @content; // css-modules (*.module.scss) } } - -// Makes custom @font-family available at earlier stages. -// Element must exist in DOM as soon as possible to initiate preloading. -@mixin font-preload($fontFamily) { - position: absolute; - visibility: hidden; - height: 0; - - &:before { - width: 0; - display: block; - overflow: hidden; - content: "x"; // some text required to start applying font in document - font-family: $fontFamily; // imported name in @font-face declaration - @content; - } -} diff --git a/src/renderer/components/monaco-editor/monaco-editor.tsx b/src/renderer/components/monaco-editor/monaco-editor.tsx index 781ac32ae4..4869cb4f48 100644 --- a/src/renderer/components/monaco-editor/monaco-editor.tsx +++ b/src/renderer/components/monaco-editor/monaco-editor.tsx @@ -16,7 +16,6 @@ import { UserStore } from "../../../common/user-store"; import type { ThemeStore } from "../../themes/store"; import { withInjectables } from "@ogre-tools/injectable-react"; import themeStoreInjectable from "../../themes/store.injectable"; -import logger from "../../../main/logger"; export type MonacoEditorId = string; @@ -66,6 +65,11 @@ class NonInjectedMonacoEditor extends React.Component new NotificationsStore(), +}); + +export default notificationsStoreInjectable; diff --git a/src/renderer/components/notifications/notifications.store.tsx b/src/renderer/components/notifications/notifications.store.tsx index 9202a19e31..25774bc9ce 100644 --- a/src/renderer/components/notifications/notifications.store.tsx +++ b/src/renderer/components/notifications/notifications.store.tsx @@ -91,5 +91,3 @@ export class NotificationsStore { } } } - -export const notificationsStore = new NotificationsStore(); diff --git a/src/renderer/components/notifications/notifications.tsx b/src/renderer/components/notifications/notifications.tsx index 521a61e638..bd75619275 100644 --- a/src/renderer/components/notifications/notifications.tsx +++ b/src/renderer/components/notifications/notifications.tsx @@ -10,61 +10,25 @@ import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { JsonApiErrorParsed } from "../../../common/k8s-api/json-api"; import { cssNames, prevDefault } from "../../utils"; -import type { Notification, NotificationMessage } from "./notifications.store"; -import { notificationsStore, NotificationStatus } from "./notifications.store"; +import type { Notification, NotificationMessage, NotificationsStore } from "./notifications.store"; +import { NotificationStatus } from "./notifications.store"; import { Animate } from "../animate"; import { Icon } from "../icon"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api"; +import notificationsStoreInjectable from "./notifications-store.injectable"; + +interface Dependencies { + store: NotificationsStore; +} @observer -export class Notifications extends React.Component { +class NonInjectedNotifications extends React.Component { public elem: HTMLDivElement | null = null; - static ok(message: NotificationMessage) { - return notificationsStore.add({ - message, - timeout: 2_500, - status: NotificationStatus.OK, - }); - } - - static checkedError(message: unknown, fallback: string, customOpts?: Partial>) { - if (typeof message === "string" || message instanceof Error || message instanceof JsonApiErrorParsed) { - return Notifications.error(message, customOpts); - } - - console.warn("Unknown notification error message, falling back to default", message); - - return Notifications.error(fallback, customOpts); - } - - static error(message: NotificationMessage, customOpts: Partial> = {}) { - return notificationsStore.add({ - message, - timeout: 10_000, - status: NotificationStatus.ERROR, - ...customOpts, - }); - } - - static shortInfo(message: NotificationMessage, customOpts: Partial> = {}) { - return this.info(message, { - timeout: 5_000, - ...customOpts, - }); - } - - static info(message: NotificationMessage, customOpts: Partial> = {}) { - return notificationsStore.add({ - status: NotificationStatus.INFO, - timeout: 0, - message, - ...customOpts, - }); - } - componentDidMount() { disposeOnUnmount(this, [ - reaction(() => notificationsStore.notifications.length, () => { + reaction(() => this.props.store.notifications.length, () => { this.scrollToLastNotification(); }, { delay: 250 }), ]); @@ -74,7 +38,7 @@ export class Notifications extends React.Component { if (!this.elem) { return; } - this.elem.scrollTo({ + this.elem.scrollTo?.({ top: this.elem.scrollHeight, behavior: "smooth", }); @@ -91,7 +55,7 @@ export class Notifications extends React.Component { } render() { - const { notifications, remove, addAutoHideTimer, removeAutoHideTimer } = notificationsStore; + const { notifications, remove, addAutoHideTimer, removeAutoHideTimer } = this.props.store; return (
this.elem = e}> @@ -114,6 +78,7 @@ export class Notifications extends React.Component { { remove(id); onClose?.(); @@ -128,3 +93,68 @@ export class Notifications extends React.Component { ); } } + +export const Notifications = withInjectables( + NonInjectedNotifications, + + { + getProps: (di) => ({ + store: di.inject(notificationsStoreInjectable), + }), + }, +) as React.FC & { + ok: (message: NotificationMessage) => () => void; + checkedError: (message: unknown, fallback: string, customOpts?: Partial>) => () => void; + error: (message: NotificationMessage, customOpts?: Partial>) => () => void; + shortInfo: (message: NotificationMessage, customOpts?: Partial>) => () => void; + info: (message: NotificationMessage, customOpts?: Partial>) => () => void; +}; + +/** + * @deprecated + */ +const _notificationStore = asLegacyGlobalForExtensionApi(notificationsStoreInjectable); + +Notifications.ok = (message: NotificationMessage) => { + return _notificationStore.add({ + message, + timeout: 2_500, + status: NotificationStatus.OK, + }); +}; + +Notifications.checkedError = (message, fallback, customOpts = {}) => { + if (typeof message === "string" || message instanceof Error || message instanceof JsonApiErrorParsed) { + return Notifications.error(message, customOpts); + } + + console.warn("Unknown notification error message, falling back to default", message); + + return Notifications.error(fallback, customOpts); +}; + +Notifications.error = (message, customOpts= {}) => { + return _notificationStore.add({ + message, + timeout: 10_000, + status: NotificationStatus.ERROR, + ...customOpts, + }); +}; + +Notifications.shortInfo = (message, customOpts = {}) => { + return Notifications.info(message, { + timeout: 5_000, + ...customOpts, + }); +}; + +Notifications.info = (message, customOpts = {}) => { + return _notificationStore.add({ + status: NotificationStatus.INFO, + timeout: 0, + message, + ...customOpts, + }); +}; + diff --git a/src/renderer/components/notifications/show-info-notification.injectable.ts b/src/renderer/components/notifications/show-info-notification.injectable.ts new file mode 100644 index 0000000000..9a74f5efc6 --- /dev/null +++ b/src/renderer/components/notifications/show-info-notification.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 type { NotificationMessage, Notification } from "./notifications.store"; +import { NotificationStatus } from "./notifications.store"; +import notificationsStoreInjectable from "./notifications-store.injectable"; + +const showInfoNotificationInjectable = getInjectable({ + id: "show-info-notification", + + instantiate: (di) => { + const notificationsStore = di.inject(notificationsStoreInjectable); + + return (message: NotificationMessage, customOpts: Partial> = {}) => + notificationsStore.add({ + status: NotificationStatus.INFO, + timeout: 5000, + message, + ...customOpts, + }); + }, +}); + +export default showInfoNotificationInjectable; diff --git a/src/renderer/components/select/select.test.tsx b/src/renderer/components/select/select.test.tsx index 90506c511f..d9bd53190a 100644 --- a/src/renderer/components/select/select.test.tsx +++ b/src/renderer/components/select/select.test.tsx @@ -17,7 +17,6 @@ import { computed } from "mobx"; import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension"; import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable"; import appVersionInjectable from "../../../common/get-configuration-file-model/app-version/app-version.injectable"; -import ipcRendererInjectable from "../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable"; jest.mock("electron", () => ({ ipcRenderer: { @@ -38,11 +37,7 @@ describe("