diff --git a/package.json b/package.json index 1d6269b0c0..0591ce4764 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.12.0", "@types/crypto-js": "^3.1.47", + "@types/dateformat": "^3.0.1", "@types/electron-window-state": "^2.0.34", "@types/fs-extra": "^9.0.1", "@types/http-proxy": "^1.17.4", @@ -171,6 +172,7 @@ "@types/marked": "^0.7.4", "@types/mock-fs": "^4.10.0", "@types/node": "^12.12.45", + "@types/node-notifier": "^8.0.0", "@types/proper-lockfile": "^4.1.1", "@types/react-beautiful-dnd": "^13.0.0", "@types/tar": "^4.0.3", @@ -178,6 +180,7 @@ "chalk": "^4.1.0", "conf": "^7.0.1", "crypto-js": "^4.0.0", + "dateformat": "^4.3.1", "electron-updater": "^4.3.1", "electron-window-state": "^5.0.3", "file-type": "^14.7.1", @@ -196,6 +199,7 @@ "mobx-observable-history": "^1.0.3", "mock-fs": "^4.12.0", "node-machine-id": "^1.1.12", + "node-notifier": "^9.0.0", "node-pty": "^0.9.0", "openid-client": "^3.15.2", "path-to-regexp": "^6.1.0", diff --git a/src/common/ipc.ts b/src/common/ipc.ts index 97c4dd05cd..6a6c4104dd 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc.ts @@ -5,6 +5,9 @@ import { ipcMain, ipcRenderer, WebContents, webContents } from "electron" import logger from "../main/logger"; +export const NotificationChannelPrefix: IpcChannel = "notications:"; +export const NotificationChannelAdd: IpcChannel = `${NotificationChannelPrefix}add`; + export type IpcChannel = string; export interface IpcChannelOptions { @@ -50,7 +53,7 @@ export function createIpcChannel({ autoBind = true, once, timeout = 0, handle, c return ipcChannel; } -export interface IpcBroadcastParams { +export interface IpcBroadcastParams { channel: IpcChannel webContentId?: number; // send to single webContents view frameId?: number; // send to inner frame of webContents diff --git a/src/common/user-store.ts b/src/common/user-store.ts index f7d3ade005..28d249d5c8 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -2,7 +2,7 @@ import type { ThemeId } from "../renderer/theme.store"; import { app, remote } from 'electron'; import semver from "semver" import { readFile } from "fs-extra" -import { action, observable, reaction, toJS } from "mobx"; +import { action, IReactionDisposer, observable, reaction, toJS } from "mobx"; import { BaseStore } from "./base-store"; import migrations from "../migrations/user-store" import { getAppVersion } from "./utils/app-version"; @@ -27,6 +27,8 @@ export interface UserPreferences { downloadKubectlBinaries?: boolean; downloadBinariesPath?: string; kubectlBinariesPath?: string; + allowAutoUpdates?: boolean; + allowPrereleaseVersions?: boolean; } export class UserStore extends BaseStore { @@ -59,6 +61,8 @@ export class UserStore extends BaseStore { colorTheme: UserStore.defaultTheme, downloadMirror: "default", downloadKubectlBinaries: true, // Download kubectl binaries matching cluster version + allowAutoUpdates: false, + allowPrereleaseVersions: false, }; get isNewVersion() { diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 7b27a7c3f6..2f009904f4 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -1,19 +1,188 @@ -import { autoUpdater } from "electron-updater" -import logger from "./logger" +import { autoUpdater, UpdateInfo } from "electron-updater"; +import { autorun } from "mobx"; +import { userStore } from "../common/user-store"; +import logger from "./logger"; +import dateFormat from "dateformat"; +import { broadcastIpc, IpcChannel, NotificationChannelAdd, NotificationChannelPrefix } from "../common/ipc"; +import { ipcMain } from "electron"; +import { isDevelopment } from "../common/vars"; +import { SemVer } from "semver"; -export default class AppUpdater { +function delay(duration: number): Promise { + return new Promise(resolve => setTimeout(resolve, duration)); +} - protected updateInterval: number = (1000 * 60 * 60 * 24) // once a day +class NotificationBackchannel { + private static _id = 0; - constructor() { - autoUpdater.logger = logger - } - - public start() { - setInterval(() => { - autoUpdater.checkForUpdatesAndNotify() - }, this.updateInterval) - - return autoUpdater.checkForUpdatesAndNotify() + static nextId(): IpcChannel { + return `${NotificationChannelPrefix}${NotificationBackchannel._id++}` } } + +const title = "Lens Updater"; + +async function autoUpdateNow(): Promise { + const body = "Downloading and installing update."; + broadcastIpc({ + channel: NotificationChannelAdd, + args: [{ + title, + body, + status: "info", + timeout: 5000, + }] + }) + + logger.info("[UPDATE CHECKER]: update downloaded started"); + await autoUpdater.downloadUpdate(); + logger.info("[UPDATE CHECKER]: update downloadeded"); + autoUpdater.quitAndInstall(); +} + +async function autoUpdateCheck(args: UpdateInfo): Promise { + return new Promise(async resolve => { + const body = "Install and restart Lens?"; + const yesNowChannel = NotificationBackchannel.nextId(); + const yesLaterChannel = NotificationBackchannel.nextId(); + const noChannel = NotificationBackchannel.nextId(); + + function cleanupChannels() { + ipcMain.removeAllListeners(yesNowChannel); + ipcMain.removeAllListeners(yesLaterChannel); + ipcMain.removeAllListeners(noChannel); + } + + ipcMain + .on(yesNowChannel, async () => { + cleanupChannels(); + + await autoUpdater.downloadUpdate(); + autoUpdater.quitAndInstall(); + + resolve(); + }) + .on(yesLaterChannel, async () => { + cleanupChannels(); + + await autoUpdater.downloadUpdate(); + autoUpdater.autoInstallOnAppQuit = true; + + resolve(); + }) + .on(noChannel, () => { + cleanupChannels(); + resolve(); + }); + + broadcastIpc({ + channel: NotificationChannelAdd, + args: [{ + title, + body, + status: "info", + buttons: [ + { + label: "Yes, now", + backchannel: yesNowChannel, + style: { + background: "green", + marginRight: "10px" + } + }, + { + label: "Yes, later", + backchannel: yesLaterChannel, + style: { + background: "green", + marginRight: "10px" + } + }, + { + label: "No", + backchannel: noChannel, + accent: true + } + ], + closeChannel: noChannel, + }] + }); + }); +} + +/** + * starts the automatic update checking + * @param interval milliseconds between interval to check on, defaulkts to 24h + */ +export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { + if (isDevelopment) { + return; + } + + autoUpdater.logger = logger; + autoUpdater.autoInstallOnAppQuit = false; + + /** + * GC saftey: This function's lifetime is the lifetime of the application. + * So no need to call the disposer. + */ + autorun(() => { + autoUpdater.autoDownload = userStore.preferences.allowAutoUpdates; + autoUpdater.allowPrerelease = userStore.preferences.allowPrereleaseVersions; + }); + + autoUpdater + .on("update-available", async (args: UpdateInfo) => { + try { + const releaseDate = new Date(args.releaseDate); + const body = `Version ${args.version} was release on ${dateFormat(releaseDate, "dddd, mmmm dS, yyyy")}.`; + broadcastIpc({ + channel: NotificationChannelAdd, + args: [{ + title, + body, + status: "info", + timeout: 5000, + }] + }); + + const version = new SemVer(args.version); + + if (userStore.preferences.allowAutoUpdates && version.prerelease !== null) { + // don't auto update to pre-release versions. + await autoUpdateNow(); + } else { + await autoUpdateCheck(args); + } + } catch (error) { + logger.error("[UPDATE CHECKER]: notification failed", { error: String(error) }) + } + }) + .on("update-not-available", () => { + try { + const stream = userStore.preferences.allowPrereleaseVersions ? "prerelease" : "stable"; + const body = `Lens is running the latest ${stream} version.`; + broadcastIpc({ + channel: NotificationChannelAdd, + args: [{ + title, + body, + status: "info", + timeout: 5000, + }] + }) + } catch (error) { + logger.error("[UPDATE CHECKER]: notification failed", { error: String(error) }) + } + }) + + async function helper() { + while (true) { + await autoUpdater.checkForUpdates(); + await delay(interval); + } + } + + helper() + .catch(error => logger.error("[UPDATE CHECKER]: failed with an error", { error: String(error) })); +} diff --git a/src/main/index.ts b/src/main/index.ts index e4fd246467..4a1d488d89 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,7 +8,7 @@ import path from "path" import { LensProxy } from "./lens-proxy" import { WindowManager } from "./window-manager"; import { ClusterManager } from "./cluster-manager"; -import AppUpdater from "./app-updater" +import { startUpdateChecking } from "./app-updater" import { shellSync } from "./shell-sync" import { getFreePort } from "./port" import { mangleProxyEnv } from "./proxy-env" @@ -39,8 +39,6 @@ async function main() { logger.info(`🚀 Starting Lens from "${workingDir}"`) tracker.event("app", "start"); - const updater = new AppUpdater() - updater.start(); registerFileProtocol("static", __static); @@ -75,6 +73,13 @@ async function main() { // create window manager and open app windowManager = new WindowManager(proxyPort); + + /** + * This depends on: + * 1. userStore: it reads the user's auto update settings + * 2. windowManager: it will send IPC to the main window for notifications + */ + startUpdateChecking(); } app.on("ready", main); diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index 96c3063277..f48954b6e6 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -94,7 +94,7 @@ export class KubeAuthProxy { protected async sendIpcLogMessage(res: KubeAuthProxyLog) { const channel = `kube-auth:${this.cluster.id}` logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() }); - broadcastIpc({ channel: channel, args: [res] }); + broadcastIpc({ channel, args: [res] }); } public exit() { diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 74aeb010ea..c3ee6f9090 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -73,6 +73,10 @@ export class WindowManager { } } + send(channel: string, ...data: any[]) { + this.mainView.webContents.send(channel, ...data); + } + async showMain() { try { await this.showSplash(); diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index f03df719b6..6549616d9d 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -9,6 +9,7 @@ import { i18nStore } from "./i18n"; import { themeStore } from "./theme.store"; import { App } from "./components/app"; import { LensApp } from "./lens-app"; +import { notificationsStore } from "./components/notifications/notifications.store"; type AppComponent = React.ComponentType & { init?(): Promise; @@ -30,6 +31,10 @@ export async function bootstrap(App: AppComponent) { // Register additional store listeners clusterStore.registerIpcListener(); + if (process.isMainFrame) { + notificationsStore.registerIpcListener(); + } + // init app's dependencies if any if (App.init) { await App.init(); diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx index a1b86c19cd..bf9ca17a43 100644 --- a/src/renderer/components/+preferences/preferences.tsx +++ b/src/renderer/components/+preferences/preferences.tsx @@ -194,6 +194,24 @@ export class Preferences extends React.Component { Telemetry & usage data is collected to continuously improve the Lens experience. + +

Updates

+ Allow auto updates} + value={preferences.allowAutoUpdates} + onChange={v => preferences.allowAutoUpdates = v} + /> + + Allow Lens to auto update itself to the latest version. Lens checks on startup and then once a day. + + Allow pre-release versions of Lens} + value={preferences.allowPrereleaseVersions} + onChange={v => preferences.allowPrereleaseVersions = v} + /> + + Allow upgrading Lens to pre-release versions. This means that the update checker will ask about pre release versions but won't auto upgrade to them. + ); diff --git a/src/renderer/components/notifications/notifications.store.ts b/src/renderer/components/notifications/notifications.store.tsx similarity index 54% rename from src/renderer/components/notifications/notifications.store.ts rename to src/renderer/components/notifications/notifications.store.tsx index 1873ae91a4..fff996e954 100644 --- a/src/renderer/components/notifications/notifications.store.ts +++ b/src/renderer/components/notifications/notifications.store.tsx @@ -4,6 +4,10 @@ import { autobind } from "../../utils"; import isObject from "lodash/isObject" import uniqueId from "lodash/uniqueId"; import { JsonApiErrorParsed } from "../../api/json-api"; +import logger from "../../../main/logger"; +import { ipcRenderer } from "electron"; +import { IpcChannel, NotificationChannelAdd } from "../../../common/ipc"; +import { Button, ButtonProps } from "../button"; export type IMessageId = string | number; export type IMessage = React.ReactNode | React.ReactNode[] | JsonApiErrorParsed; @@ -19,6 +23,39 @@ export interface INotification { message: IMessage; status?: NotificationStatus; timeout?: number; // auto-hiding timeout in milliseconds, 0 = no hide + onClose?(): void; // additonal logic on when the notification times out or is closed by the "x" +} + +export interface MainNotification { + title: string; + body: string; + buttons?: ({ + backchannel: IpcChannel; + } & ButtonProps)[]; + status: NotificationStatus; + timeout?: number; + closeChannel?: IpcChannel; +} + +function renderButtons(id: IpcChannel, buttons?: MainNotification["buttons"]): React.ReactNode { + if (!buttons) { + return null; + } + + return ( + <> +
+
+ {buttons.map(({ backchannel, ...props}) => ( +
+ + ) } @autobind() @@ -27,6 +64,29 @@ export class NotificationsStore { protected autoHideTimers = new Map(); + registerIpcListener(): void { + logger.info(`[NOTIFICATION-STORE] start to listen for notifications requests from main`); + ipcRenderer.on(NotificationChannelAdd, (event, model: MainNotification) => { + console.log(model); + const id = uniqueId("notification_"); + this.add({ + message: ( + <> + {model.title} +

{model.body}

+ {renderButtons(id, model.buttons)} + + ), + id, + status: model.status, + timeout: model.timeout, + onClose: () => { + model.closeChannel && ipcRenderer.send(model.closeChannel); + } + }); + }) + } + addAutoHideTimer(notification: INotification) { this.removeAutoHideTimer(notification); const { id, timeout } = notification; diff --git a/src/renderer/components/notifications/notifications.tsx b/src/renderer/components/notifications/notifications.tsx index 35c741505c..6ca687fee4 100644 --- a/src/renderer/components/notifications/notifications.tsx +++ b/src/renderer/components/notifications/notifications.tsx @@ -84,7 +84,10 @@ export class Notifications extends React.Component {
remove(notification))} + onClick={prevDefault(() => { + remove(notification); + notification.onClose?.(); + })} />
diff --git a/src/renderer/theme.store.ts b/src/renderer/theme.store.ts index 5245d2a728..992c280f6c 100644 --- a/src/renderer/theme.store.ts +++ b/src/renderer/theme.store.ts @@ -29,12 +29,8 @@ export class ThemeStore { { id: "kontena-light", type: ThemeType.LIGHT }, ]; - @computed get activeThemeId() { - return userStore.preferences.colorTheme; - } - @computed get activeTheme(): Theme { - const activeTheme = this.themes.find(theme => theme.id === this.activeThemeId) || this.themes[0]; + const activeTheme = this.themes.find(theme => theme.id === userStore.preferences.colorTheme) || this.themes[0]; return { colors: {}, ...activeTheme, @@ -43,9 +39,9 @@ export class ThemeStore { constructor() { // auto-apply active theme - reaction(() => this.activeThemeId, async themeId => { + reaction(() => this.activeTheme, async ({ id }) => { try { - await this.loadTheme(themeId); + await this.loadTheme(id); this.applyTheme(); } catch (err) { userStore.resetTheme(); diff --git a/webpack.renderer.ts b/webpack.renderer.ts index 74978bd152..6112d1af36 100755 --- a/webpack.renderer.ts +++ b/webpack.renderer.ts @@ -159,4 +159,4 @@ export default function (): webpack.Configuration { }), ], } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 300efc58ce..f9b04e5896 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1723,6 +1723,11 @@ resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-3.1.47.tgz#36e549dd3f1322742a3a738e7c113ebe48221860" integrity sha512-eI6gvpcGHLk3dAuHYnRCAjX+41gMv1nz/VP55wAe5HtmAKDOoPSfr3f6vkMc08ov1S0NsjvUBxDtHHxqQY1LGA== +"@types/dateformat@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/dateformat/-/dateformat-3.0.1.tgz#98d747a2e5e9a56070c6bf14e27bff56204e34cc" + integrity sha512-KlPPdikagvL6ELjWsljbyDIPzNCeliYkqRpI+zea99vBBbCIA5JNshZAwQKTON139c87y9qvTFVgkFd14rtS4g== + "@types/debug@^4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" @@ -1966,6 +1971,13 @@ dependencies: "@types/node" "*" +"@types/node-notifier@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@types/node-notifier/-/node-notifier-8.0.0.tgz#51100d67155ed1500a8aaa633987109f59a0637d" + integrity sha512-CseIDQOC/I+yvj/4ItpG4ATcwooQlGPDDJweII8nspjjZg4ZBuvkyHg9P81QkElgU9FpYlb5A27BRggD3idTCQ== + dependencies: + "@types/node" "*" + "@types/node@*": version "14.0.11" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.11.tgz#61d4886e2424da73b7b25547f59fdcb534c165a3" @@ -4299,6 +4311,11 @@ date-fns@^2.0.1, date-fns@^2.14.0: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.14.0.tgz#359a87a265bb34ef2e38f93ecf63ac453f9bc7ba" integrity sha512-1zD+68jhFgDIM0rF05rcwYO8cExdNqxjq4xP1QKM60Q45mnO6zaMWB4tOzrIr4M4GSLntsKeE4c9Bdl2jhL/yw== +dateformat@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.3.1.tgz#e010ca5915f0c7d47e5b4e4287dd5ecb41125a96" + integrity sha512-xhq1wI5BQ0TMJDvio0BLP8lNeYlhAvmh/7H52H9n6kfzqSmRpIhH5KEIjJ7onFEAh5CQVrAP2MAG8wZ6j0BKzQ== + debounce-fn@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/debounce-fn/-/debounce-fn-4.0.0.tgz#ed76d206d8a50e60de0dd66d494d82835ffe61c7" @@ -6760,7 +6777,7 @@ is-wsl@^1.1.0: resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= -is-wsl@^2.1.1: +is-wsl@^2.1.1, is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== @@ -8419,6 +8436,18 @@ node-notifier@^7.0.0: uuid "^7.0.3" which "^2.0.2" +node-notifier@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-9.0.0.tgz#46c5bbecbb796d4a803f646cea5bc91403f2ff38" + integrity sha512-SkwNwGnMMlSPrcoeH4CSo9XyWe72acAHEJGDdPdB+CyBVHsIYaTQ4U/1wk3URsyzC75xZLg2vzU2YaALlqDF1Q== + dependencies: + growly "^1.3.0" + is-wsl "^2.2.0" + semver "^7.3.2" + shellwords "^0.1.1" + uuid "^8.3.0" + which "^2.0.2" + node-pty@^0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.9.0.tgz#8f9bcc0d1c5b970a3184ffd533d862c7eb6590a6" @@ -11758,6 +11787,11 @@ uuid@^8.1.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== +uuid@^8.3.0: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.0.3.tgz#00f7494d2ae2b688cfe2899df6ed2c54bef91dbe"