1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Fix auto-update to use Lens notifications, and add update confirmation (#1831)

- Add auto-update and pre-release update user settings

- Add settings in user preferences for auto-updating (default false) and
  for allowing pre-release versions (default false)

- Use in-Lens notifications instead of OS notifications as those were
  found to be flaky

- Add rudimentary main->renderer notification system.

- Remove options, always confirm, never auto prelease

- Changed "yes later" to "yes on quit"

- move register IpcHandlers

- use moment instead of dateformat

- moved formatting notification buttons to renderer

- move to RenderButtons as function component

- explicitly only send notifications to main view

- move delay to utils, always retry even if check failed

- fix notification rendering and disabled the auto-updater for integration tests

- update integration runner to output logs on failure

- pin minikube version

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-01-11 09:08:47 -05:00 committed by GitHub
parent 1a4b6cddb5
commit df47d1713c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 295 additions and 61 deletions

View File

@ -37,7 +37,7 @@ jobs:
displayName: Cache Yarn packages
- script: make install-deps
displayName: Install dependencies
- script: make integration-win
- script: yarn integration:win
displayName: Run integration tests
- script: make build
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
@ -76,7 +76,7 @@ jobs:
displayName: Install dependencies
- script: make test
displayName: Run tests
- script: make integration-mac
- script: yarn integration:mac
displayName: Run integration tests
- script: make build
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
@ -126,13 +126,13 @@ jobs:
- bash: |
sudo apt-get update
sudo apt-get install libgconf-2-4 conntrack -y
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
curl -LO https://storage.googleapis.com/minikube/releases/v1.15.1/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube
sudo minikube start --driver=none
# Although the kube and minikube config files are in placed $HOME they are owned by root
sudo chown -R $USER $HOME/.kube $HOME/.minikube
displayName: Install integration test dependencies
- script: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux
- script: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' yarn integration:linux
displayName: Run integration tests
- bash: |
sudo chown root:root /

View File

@ -31,18 +31,6 @@ lint:
test: download-bins
yarn test
integration-linux:
yarn build:linux
yarn integration
integration-mac:
yarn build:mac
yarn integration
integration-win:
yarn build:win
yarn integration
test-app:
yarn test

View File

@ -38,7 +38,7 @@ describe("Lens integration tests", () => {
beforeAll(appStart, 20000)
afterAll(async () => {
if (app && app.isRunning()) {
if (app?.isRunning()) {
return util.tearDown(app)
}
})

View File

@ -23,7 +23,10 @@
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens",
"test": "jest --env=jsdom src $@",
"integration": "jest --coverage integration $@",
"integration-runner": "jest --coverage integration $@",
"integration:linux": "scripts/integration.sh linux",
"integration:mac": "scripts/integration.sh mac",
"integration:win": "scripts/integration.sh win",
"dist": "yarn compile && electron-builder --publish onTag",
"dist:win": "yarn compile && electron-builder --publish onTag --x64 --ia32",
"dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null",
@ -195,6 +198,7 @@
"mobx": "^5.15.5",
"mobx-observable-history": "^1.0.3",
"mock-fs": "^4.12.0",
"moment": "^2.26.0",
"node-machine-id": "^1.1.12",
"node-pty": "^0.9.0",
"openid-client": "^3.15.2",
@ -294,7 +298,6 @@
"material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.9.0",
"mobx-react": "^6.2.2",
"moment": "^2.26.0",
"node-loader": "^0.6.0",
"node-sass": "^4.14.1",
"nodemon": "^2.0.4",

30
scripts/integration.sh Executable file
View File

@ -0,0 +1,30 @@
case $1 in
mac)
find ~/Library/Logs/Lens -type f -name *.log -delete
;;
linux)
find ~/.config/Logs/Lens -type f -name *.log -delete
;;
win)
find %APPDATA%/Logs/Lens -type f -name *.log -delete
;;
esac
yarn build:$1
DEBUG=true yarn integration-runner
if [ $? -ne 0 ]; then
case $1 in
mac)
find ~/Library/Logs/Lens -type f -name *.log -exec cat >&2 {} \;
;;
linux)
find ~/.config/Logs/Lens -type f -name *.log -exec cat >&2 {} \;
;;
win)
find %APPDATA%/Logs/Lens -type f -name *.log -exec cat >&2 {} \;
;;
esac
exit 1
fi

View File

@ -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<A extends any[] = any> {
export interface IpcBroadcastParams<A extends any[] = any[]> {
channel: IpcChannel
webContentId?: number; // send to single webContents view
frameId?: number; // send to inner frame of webContents

View File

@ -0,0 +1,3 @@
export function delay(duration: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, duration));
}

View File

@ -5,3 +5,4 @@ export * from "./camelCase"
export * from "./splitArray"
export * from "./getRandId"
export * from "./cloneJson"
export * from "./delay"

View File

@ -1,19 +1,140 @@
import { autoUpdater } from "electron-updater"
import logger from "./logger"
import { autoUpdater, UpdateInfo } from "electron-updater";
import logger from "./logger";
import { IpcChannel, NotificationChannelAdd, NotificationChannelPrefix } from "../common/ipc";
import { ipcMain } from "electron";
import { isDevelopment, isTestEnv } from "../common/vars";
import { SemVer } from "semver";
import moment from "moment";
import { WindowManager } from "./window-manager"
import { delay } from "../common/utils";
export default class AppUpdater {
class NotificationBackchannel {
private static _id = 0;
protected updateInterval: number = (1000 * 60 * 60 * 24) // once a day
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 autoUpdateCheck(windowManager: WindowManager): Promise<void> {
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 () => {
logger.info("[UPDATE CHECKER]: User chose to update immediately");
cleanupChannels();
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall();
resolve();
})
.on(yesLaterChannel, async () => {
logger.info("[UPDATE CHECKER]: User chose to update on quit");
cleanupChannels();
await autoUpdater.downloadUpdate();
autoUpdater.autoInstallOnAppQuit = true;
resolve();
})
.on(noChannel, () => {
logger.info("[UPDATE CHECKER]: User chose not to update");
cleanupChannels();
resolve();
});
windowManager.mainView.webContents.send(NotificationChannelAdd, {
title,
body,
status: "info",
buttons: [
{
label: "Yes, now",
backchannel: yesNowChannel,
action: true,
},
{
label: "Yes, on quit",
backchannel: yesLaterChannel,
action: true,
},
{
label: "No",
backchannel: noChannel,
secondary: true
}
],
closeChannel: noChannel,
});
});
}
/**
* starts the automatic update checking
* @param interval milliseconds between interval to check on, defaults to 24h
*/
export function startUpdateChecking(windowManager: WindowManager, interval = 1000 * 60 * 60 * 24): void {
if (isDevelopment || isTestEnv) {
return;
}
autoUpdater.logger = logger;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater
.on("update-available", async (args: UpdateInfo) => {
try {
const releaseDate = moment(args.releaseDate);
const body = `Version ${args.version} was released on ${releaseDate.format("dddd, MMMM Do, yyyy")}.`;
windowManager.mainView.webContents.send(NotificationChannelAdd, {
title,
body,
status: "info",
timeout: 5000,
});
await autoUpdateCheck(windowManager);
} catch (error) {
logger.error("[UPDATE CHECKER]: notification failed", { error: String(error) })
}
})
.on("update-not-available", (args: UpdateInfo) => {
try {
const version = new SemVer(args.version);
const stream = version.prerelease === null ? "stable" : "prerelease";
const body = `Lens is running the latest ${stream} version.`;
windowManager.mainView.webContents.send(NotificationChannelAdd, {
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()
.catch(error => logger.error("[UPDATE CHECKER]: failed with an error", { error: String(error) }));
await delay(interval);
}
}
helper();
}

View File

@ -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,8 @@ async function main() {
// create window manager and open app
windowManager = new WindowManager(proxyPort);
startUpdateChecking(windowManager);
}
app.on("ready", main);

View File

@ -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() {

View File

@ -7,7 +7,7 @@ import { initMenu } from "./menu";
import { tracker } from "../common/tracker";
export class WindowManager {
protected mainView: BrowserWindow;
public readonly mainView: BrowserWindow;
protected splashWindow: BrowserWindow;
protected windowState: windowStateKeeper.State;

View File

@ -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<void>;

View File

@ -20,10 +20,19 @@
&.primary {
background: $buttonPrimaryBackground;
}
&.accent {
background: $buttonAccentBackground;
}
&.action {
background: $buttonActionBackground;
}
&.secondary {
background: $buttonSecondaryBackground;
}
&.plain {
color: inherit;
background: transparent;

View File

@ -8,6 +8,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<any>, TooltipDecorator
waiting?: boolean;
primary?: boolean;
accent?: boolean;
secondary?: boolean;
action?: boolean;
plain?: boolean;
hidden?: boolean;
active?: boolean;
@ -23,12 +25,15 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
private button: HTMLButtonElement;
render() {
const { className, waiting, label, primary, accent, plain, hidden, active, big, round, tooltip, children, ...props } = this.props;
const {
className, waiting, label, primary, accent, plain, hidden, active, big,
round, tooltip, children, secondary, action, ...props
} = this.props;
const btnProps = props as Partial<ButtonProps>;
if (hidden) return null;
btnProps.className = cssNames('Button', className, {
waiting, primary, accent, plain, active, big, round,
waiting, primary, accent, plain, active, big, round, secondary, action,
});
const btnContent: ReactNode = (

View File

@ -25,6 +25,10 @@
margin-bottom: $margin * 2;
}
.ButtonPannel button:not(:last-of-type) {
margin-right: $margin;
}
> .message {
white-space: pre-line;
padding-left: $padding;

View File

@ -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,38 @@ 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, buttons }: { id: IpcChannel, buttons?: MainNotification["buttons"] }) {
if (!buttons) {
return null;
}
return (
<>
<br />
<div className="ButtonPannel flex row align-right box grow">
{buttons.map(({ backchannel, ...props}) => (
<Button {...props} onClick={() => {
ipcRenderer.send(backchannel);
notificationsStore.remove(id);
}} />
))}
</div>
</>
)
}
@autobind()
@ -27,6 +63,28 @@ export class NotificationsStore {
protected autoHideTimers = new Map<IMessageId, number>();
registerIpcListener(): void {
logger.info(`[NOTIFICATION-STORE] start to listen for notifications requests from main`);
ipcRenderer.on(NotificationChannelAdd, (event, model: MainNotification) => {
const id = uniqueId("notification_");
this.add({
message: (
<>
<b>{model.title}</b>
<p>{model.body}</p>
<RenderButtons id={id} buttons={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;

View File

@ -84,7 +84,10 @@ export class Notifications extends React.Component {
<div className="box center">
<Icon
material="close" className="close"
onClick={prevDefault(() => remove(notification))}
onClick={prevDefault(() => {
remove(notification);
notification.onClose?.();
})}
/>
</div>
</div>

View File

@ -12,6 +12,7 @@ import { ErrorBoundary } from "./components/error-boundary";
import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
import { Notifications } from "./components/notifications";
import { ConfirmDialog } from "./components/confirm-dialog";
import { notificationsStore } from "./components/notifications/notifications.store";
@observer
export class LensApp extends React.Component {
@ -22,6 +23,8 @@ export class LensApp extends React.Component {
window.addEventListener("online", () => {
ipcRenderer.send("network:online")
})
notificationsStore.registerIpcListener();
}
render() {

View File

@ -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();

View File

@ -26,6 +26,8 @@
"buttonPrimaryBackground": "#3d90ce",
"buttonDefaultBackground": "#414448",
"buttonAccentBackground": "#e85555",
"buttonActionBackground": "#1dcc1f",
"buttonSecondaryBackground": "#edd70c",
"buttonDisabledBackground": "#808080",
"tableBgcStripe": "#2a2d33",
"tableBgcSelected": "#383c42",

View File

@ -27,6 +27,8 @@
"buttonPrimaryBackground": "#3d90ce",
"buttonDefaultBackground": "#414448",
"buttonAccentBackground": "#e85555",
"buttonActionBackground": "#1dcc1f",
"buttonSecondaryBackground": "#edd70c",
"buttonDisabledBackground": "#808080",
"tableBgcStripe": "#f8f8f8",
"tableBgcSelected": "#f4f5f5",

View File

@ -35,6 +35,8 @@ $sidebarBackground: var(--sidebarBackground);
$buttonPrimaryBackground: var(--buttonPrimaryBackground);
$buttonDefaultBackground: var(--buttonDefaultBackground);
$buttonAccentBackground: var(--buttonAccentBackground);
$buttonSecondaryBackground: var(--buttonSecondaryBackground);
$buttonActionBackground: var(--buttonActionBackground);
$buttonDisabledBackground: var(--buttonDisabledBackground);
// Tables