From e9308bbafb28d5a606b6facb4ea6028b6310663f Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 19 Aug 2021 18:56:29 -0400 Subject: [PATCH] New option for control over update downloads --- _locales/en/messages.json | 71 +++++- background.html | 1 + images/icons/v2/offline-22.svg | 1 + js/modules/signal.js | 2 + js/views/inbox_view.js | 14 ++ main.js | 2 +- stylesheets/_modules.scss | 106 +++------ stylesheets/components/LeftPaneDialog.scss | 156 +++++++++++++ stylesheets/components/WhatsNew.scss | 7 + stylesheets/manifest.scss | 2 + test/index.html | 1 + ts/background.ts | 3 +- ts/components/AvatarPopup.stories.tsx | 10 + ts/components/AvatarPopup.tsx | 31 ++- ...ies.tsx => DialogExpiredBuild.stories.tsx} | 8 +- ...BuildDialog.tsx => DialogExpiredBuild.tsx} | 14 +- ts/components/DialogNetworkStatus.stories.tsx | 69 ++++++ ...workStatus.tsx => DialogNetworkStatus.tsx} | 38 ++- ts/components/DialogUpdate.stories.tsx | 64 ++++++ ts/components/DialogUpdate.tsx | 179 +++++++++++++++ ts/components/MainHeader.stories.tsx | 8 + ts/components/MainHeader.tsx | 53 +++-- ts/components/NetworkStatus.stories.tsx | 84 ------- ts/components/Preferences.stories.tsx | 2 + ts/components/Preferences.tsx | 13 ++ ts/components/RelinkDialog.tsx | 6 +- ts/components/UpdateDialog.stories.tsx | 85 ------- ts/components/UpdateDialog.tsx | 117 ---------- ts/components/WhatsNew.tsx | 75 ++++++ ts/main/settingsChannel.ts | 1 + ts/services/updateListener.ts | 34 ++- ts/shims/updateIpc.ts | 4 - ts/state/ducks/updates.ts | 118 ++++++---- ts/state/smart/ExpiredBuildDialog.tsx | 4 +- ts/state/smart/MainHeader.tsx | 1 + ts/state/smart/NetworkStatus.tsx | 4 +- ts/state/smart/UpdateDialog.tsx | 4 +- ts/test-node/updater/common_test.ts | 17 +- ts/types/Dialogs.ts | 12 +- ts/types/Storage.d.ts | 1 + ts/updater/common.ts | 201 +++++++--------- ts/updater/index.ts | 9 +- ts/updater/macos.ts | 216 +++++++++--------- ts/updater/windows.ts | 149 +++++++----- ts/util/createIPCEvents.ts | 5 + ts/util/lint/exceptions.json | 14 ++ ts/window.d.ts | 11 +- ts/windows/preload.ts | 1 + ts/windows/settings/preload.ts | 5 + 49 files changed, 1230 insertions(+), 803 deletions(-) create mode 100644 images/icons/v2/offline-22.svg create mode 100644 stylesheets/components/LeftPaneDialog.scss create mode 100644 stylesheets/components/WhatsNew.scss rename ts/components/{ExpiredBuildDialog.stories.tsx => DialogExpiredBuild.stories.tsx} (68%) rename ts/components/{ExpiredBuildDialog.tsx => DialogExpiredBuild.tsx} (60%) create mode 100644 ts/components/DialogNetworkStatus.stories.tsx rename ts/components/{NetworkStatus.tsx => DialogNetworkStatus.tsx} (72%) create mode 100644 ts/components/DialogUpdate.stories.tsx create mode 100644 ts/components/DialogUpdate.tsx delete mode 100644 ts/components/NetworkStatus.stories.tsx delete mode 100644 ts/components/UpdateDialog.stories.tsx delete mode 100644 ts/components/UpdateDialog.tsx create mode 100644 ts/components/WhatsNew.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c56618be6..03007fa35 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -183,6 +183,10 @@ "message": "Chat Color", "description": "One of the menu options available in the Avatar Popup menu" }, + "avatarMenuUpdateAvailable": { + "message": "Update Signal", + "description": "One of the menu options available in the Avatar Popup menu" + }, "loading": { "message": "Loading...", "description": "Message shown on the loading screen before we've loaded any messages" @@ -640,15 +644,15 @@ "description": "Displayed when the desktop client cannot connect to the server." }, "connecting": { - "message": "Connecting", + "message": "Connecting...", "description": "Displayed when the desktop client is currently connecting to the server." }, "connect": { - "message": "Connect", + "message": "Click to reconnect.", "description": "Shown to allow the user to manually attempt a reconnect." }, "connectingHangOn": { - "message": "Shouldn't be long...", + "message": "Shouldn't be long", "description": "Subtext description for when the client is connecting to the server." }, "offline": { @@ -796,6 +800,20 @@ "welcomeToSignal": { "message": "Welcome to Signal" }, + "whatsNew": { + "message": "See $whatsNew$ in this update", + "description": "Shown in the main window", + "placeholders": { + "name": { + "content": "$1", + "example": "what's new" + } + } + }, + "viewReleaseNotes": { + "message": "what's new", + "description": "Clickable link that displays the latest release notes" + }, "selectAContact": { "message": "Select a contact or group to start chatting." }, @@ -1791,7 +1809,7 @@ "description": "Warning notification that this version of the app has expired" }, "upgrade": { - "message": "Upgrade", + "message": "Click to go to signal.org/download", "description": "Label text for button to upgrade the app to the latest version" }, "mediaMessage": { @@ -2210,10 +2228,13 @@ "message": "Relink" }, "autoUpdateNewVersionTitle": { - "message": "Signal update available" + "message": "Update available" }, "autoUpdateNewVersionMessage": { - "message": "There is a new version of Signal available." + "message": "Click to restart Signal" + }, + "downloadNewVersionMessage": { + "message": "Click to download update" }, "autoUpdateNewVersionInstructions": { "message": "Press Restart Signal to apply the updates." @@ -6091,5 +6112,43 @@ "Preferences--typing-indicators": { "message": "Typing indicators", "description": "Label for the typing indicators setting" + }, + "Preferences--updates": { + "message": "Updates", + "description": "Header for settings having to do with updates" + }, + "Preferences__download-update": { + "message": "Automatically download updates", + "description": "Label for checkbox for the auto download updates setting" + }, + "DialogUpdate--version-available": { + "message": "Update to version $version$ available", + "description": "Tooltip for new update available", + "placeholders": { + "status": { + "content": "$1", + "example": "v7.7.7" + } + } + }, + "WhatsNew__v5.15--1": { + "message": "No that's not speck of dust you need to flick off your monitor, there's now a dot for unplayed incoming audio messages.", + "description": "Release notes for v5.15" + }, + "WhatsNew__v5.15--2": { + "message": "The calling lobby got some remodeling and renovations done and we didn't even have to refinance.", + "description": "Release notes for v5.15" + }, + "WhatsNew__v5.15--3": { + "message": "The new preferences window is better and faster. Go ahead and change your zoom level, toggle the theme, set a custom disappearing timer.", + "description": "Release notes for v5.15" + }, + "WhatsNew__v5.15--4": { + "message": "You can now choose when to download and apply new updates for Signal. The dialogs got a small makeover too. Check out the setting in the new preferences window.", + "description": "Release notes for v5.15" + }, + "WhatsNew__v5.15--5": { + "message": "Squashed lots of bugs and there are some performance improvements as well. Thank you all for your reports!", + "description": "Release notes for v5.15" } } diff --git a/background.html b/background.html index 57502a97d..a6701d3ef 100644 --- a/background.html +++ b/background.html @@ -84,6 +84,7 @@

{{ welcomeToSignal }}

+

{{ selectAContact }}

diff --git a/images/icons/v2/offline-22.svg b/images/icons/v2/offline-22.svg new file mode 100644 index 000000000..e464e56fa --- /dev/null +++ b/images/icons/v2/offline-22.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/signal.js b/js/modules/signal.js index 1d5b29d44..66ea3522f 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -59,6 +59,7 @@ const { const { SystemTraySettingsCheckboxes, } = require('../../ts/components/conversation/SystemTraySettingsCheckboxes'); +const { WhatsNew } = require('../../ts/components/WhatsNew'); // State const { createTimeline } = require('../../ts/state/roots/createTimeline'); @@ -359,6 +360,7 @@ exports.setup = (options = {}) => { Types: { Message: MediaGalleryMessage, }, + WhatsNew, }; const Roots = { diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 8f89f2a0f..a745f6ad3 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -93,6 +93,8 @@ model: { window: options.window }, }); + this.renderWhatsNew(); + Whisper.events.on('refreshConversation', ({ oldId, newId }) => { const convo = this.conversation_stack.lastConversation; if (convo && convo.get('id') === oldId) { @@ -153,6 +155,18 @@ events: { click: 'onClick', }, + renderWhatsNew() { + if (this.whatsNewView) { + return; + } + this.whatsNewView = new Whisper.ReactWrapperView({ + Component: window.Signal.Components.WhatsNew, + props: { + i18n: window.i18n, + }, + }); + this.$('.whats-new-placeholder').append(this.whatsNewView.el); + }, setupLeftPane() { if (this.leftPaneView) { return; diff --git a/main.js b/main.js index 45f15e046..c56ff42d3 100644 --- a/main.js +++ b/main.js @@ -666,7 +666,7 @@ async function readyForUpdates() { // Second, start checking for app updates try { - await updater.start(getMainWindow, locale, logger); + await updater.start(getMainWindow, logger); } catch (error) { logger.error( 'Error starting update checks:', diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 8ec66f64d..f9f9cd349 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3683,6 +3683,21 @@ button.module-conversation-details__action-button { &__avatar { -webkit-app-region: no-drag; + + &--container { + position: relative; + } + + &--badged { + background: $color-ultramarine; + border-radius: 100%; + border: 1px solid $color-white; + height: 8px; + width: 8px; + position: absolute; + top: 0; + right: 0; + } } &__search { @@ -7781,79 +7796,6 @@ button.module-image__border-overlay:focus { } } -.module-left-pane-dialog { - background: $color-accent-green; - color: $color-white; - padding: 16px; - - .module-left-pane-dialog__message { - h3 { - @include font-body-1-bold; - padding: 0px; - margin: 0px; - margin-bottom: 8px; - } - span { - @include font-body-1; - display: inline-block; - } - } - - .module-left-pane-dialog__actions { - margin-top: 8px; - text-align: right; - - .module-left-pane-dialog__link { - @include keyboard-mode { - display: inline-block; - outline: 0; - } - } - - button { - background: inherit; - border-radius: 20px; - border: solid 1px $color-white; - color: $color-white; - cursor: pointer; - font-family: inherit; - margin: 0 4px; - padding: 8px 16px; - outline: 0; - - &:focus { - @include keyboard-mode { - box-shadow: 0 0 0 3px $color-ultramarine; - } - } - - &:hover { - @include mouse-mode { - box-shadow: 0 0 0 3px $color-ultramarine; - } - } - } - - .module-left-pane-dialog__button--no-border { - border: none; - } - } - - &.module-left-pane-dialog--error { - background-color: $color-accent-red; - } - - &.module-left-pane-dialog--warning { - background-color: $color-accent-yellow; - color: $color-black; - - button { - border-color: $color-black; - color: $color-black; - } - } -} - // Module: Emoji Picker %module-emoji-picker--ribbon { @@ -8740,6 +8682,15 @@ button.module-image__border-overlay:focus { height: 16px; width: 16px; + + &--update { + @include light-theme { + @include color-svg('../images/icons/v2/refresh-24.svg', $color-gray-75); + } + @include dark-theme { + @include color-svg('../images/icons/v2/refresh-24.svg', $color-gray-15); + } + } } .module-avatar-popup__item__icon-settings { @include light-theme { @@ -8771,9 +8722,18 @@ button.module-image__border-overlay:focus { } .module-avatar-popup__item__text { + flex-grow: 1; margin-left: 8px; } +.module-avatar-popup__item--badge { + background: $color-ultramarine; + border-radius: 100%; + height: 8px; + margin-right: 10px; + width: 8px; +} + // Module: Shortcut Guide Modal .module-shortcut-guide-modal { diff --git a/stylesheets/components/LeftPaneDialog.scss b/stylesheets/components/LeftPaneDialog.scss new file mode 100644 index 000000000..6c5564e96 --- /dev/null +++ b/stylesheets/components/LeftPaneDialog.scss @@ -0,0 +1,156 @@ +@keyframes progress-animation { + 0% { + background-position: 100%; + } + 100% { + background-position: -100%; + } +} + +.LeftPaneDialog { + align-items: center; + background: $color-ultramarine; + color: $color-white; + display: flex; + min-height: 64px; + padding: 12px 18px; + + &__container { + display: flex; + align-items: center; + flex-grow: 1; + } + + &__container-close { + display: flex; + justify-content: flex-end; + } + + &__spinner-container { + margin-right: 18px; + } + + &__spinner { + &__arc { + background-color: $color-black; + } + + &__circle { + background-color: $color-accent-yellow; + } + } + + &__icon { + width: 20px; + height: 20px; + margin-right: 18px; + background-color: $color-white; + + &--network { + -webkit-mask: url('../images/icons/v2/offline-22.svg') no-repeat center; + } + + &--update { + -webkit-mask: url('../images/icons/v2/refresh-24.svg') no-repeat center; + } + } + + &__action-text { + @include button-reset; + text-decoration: none; + } + + &__close-button { + @include button-reset; + + border-radius: 4px; + float: right; + height: 24px; + width: 24px; + + &::before { + -webkit-mask: url('../images/icons/v2/x-24.svg') no-repeat center; + background-color: $color-white; + content: ''; + display: block; + width: 100%; + height: 100%; + } + + &:hover, + &:focus { + background-color: $color-white-alpha-20; + } + &:active { + background-color: $color-white-alpha-20; + } + } + + &__message { + width: 100%; + + h3 { + @include font-body-1-bold; + padding: 0px; + margin: 0px; + margin-bottom: 8px; + } + span { + @include font-body-1; + display: inline-block; + } + a { + font-weight: bold; + text-decoration: none; + } + } + + &--error { + background-color: $color-accent-red; + } + + &--warning { + background-color: $color-accent-yellow; + color: $color-black; + + a { + color: $color-black; + } + + .LeftPaneDialog__icon { + background-color: $color-black; + } + + .LeftPaneDialog__close-button::before { + background-color: $color-black; + } + } + + &__progress { + &--container { + background: $color-white-alpha-20; + border-radius: 2px; + height: 4px; + max-width: 210px; + overflow: hidden; + width: 100%; + } + + &--bar { + animation: progress-animation 2s linear infinite; + background: linear-gradient( + 90deg, + $color-white-alpha-40, + $color-white-alpha-60, + $color-white-alpha-90, + $color-white-alpha-60, + $color-white-alpha-40 + ); + background-size: 200% 100%; + border-radius: 2px; + display: block; + height: 100%; + transition: width 500ms ease-out; + } + } +} diff --git a/stylesheets/components/WhatsNew.scss b/stylesheets/components/WhatsNew.scss new file mode 100644 index 000000000..63e91290a --- /dev/null +++ b/stylesheets/components/WhatsNew.scss @@ -0,0 +1,7 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.WhatsNew { + @include button-reset; + color: $color-ultramarine; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 957a8fd35..2ae1f3154 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -62,6 +62,7 @@ @import './components/GroupInput.scss'; @import './components/IncomingCallBar.scss'; @import './components/Input.scss'; +@import './components/LeftPaneDialog.scss'; @import './components/MediaQualitySelector.scss'; @import './components/MessageAudio.scss'; @import './components/MessageDetail.scss'; @@ -78,3 +79,4 @@ @import './components/Tabs.scss'; @import './components/TimelineWarning.scss'; @import './components/TimelineWarnings.scss'; +@import './components/WhatsNew.scss'; diff --git a/test/index.html b/test/index.html index 8fa5ae514..06164cff4 100644 --- a/test/index.html +++ b/test/index.html @@ -53,6 +53,7 @@

{{ welcomeToSignal }}

+

{{ selectAContact }}

diff --git a/ts/background.ts b/ts/background.ts index 7ac3c7d54..76794d128 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -888,8 +888,7 @@ export async function startApp(): Promise { window.reduxActions.network ); window.Signal.Services.initializeUpdateListener( - window.reduxActions.updates, - window.Whisper.events + window.reduxActions.updates ); window.Signal.Services.calling.initialize( window.reduxActions.calling, diff --git a/ts/components/AvatarPopup.stories.tsx b/ts/components/AvatarPopup.stories.tsx index d8f20fb0d..55d207ef1 100644 --- a/ts/components/AvatarPopup.stories.tsx +++ b/ts/components/AvatarPopup.stories.tsx @@ -36,6 +36,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ conversationTypeMap, overrideProps.conversationType || 'direct' ), + hasPendingUpdate: Boolean(overrideProps.hasPendingUpdate), i18n, isMe: true, name: text('name', overrideProps.name || ''), @@ -47,6 +48,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ profileName: text('profileName', overrideProps.profileName || ''), sharedGroupNames: [], size: 80, + startUpdate: action('startUpdate'), style: {}, title: text('title', overrideProps.title || ''), }); @@ -83,3 +85,11 @@ stories.add('Phone Number', () => { return ; }); + +stories.add('Update Available', () => { + const props = createProps({ + hasPendingUpdate: true, + }); + + return ; +}); diff --git a/ts/components/AvatarPopup.tsx b/ts/components/AvatarPopup.tsx index 757132bfa..bca01a492 100644 --- a/ts/components/AvatarPopup.tsx +++ b/ts/components/AvatarPopup.tsx @@ -12,6 +12,9 @@ import { LocalizerType } from '../types/Util'; export type Props = { readonly i18n: LocalizerType; + hasPendingUpdate: boolean; + startUpdate: () => unknown; + onEditProfile: () => unknown; onViewPreferences: () => unknown; onViewArchive: () => unknown; @@ -23,15 +26,17 @@ export type Props = { export const AvatarPopup = (props: Props): JSX.Element => { const { + hasPendingUpdate, i18n, name, - profileName, - phoneNumber, - title, onEditProfile, - onViewPreferences, onViewArchive, + onViewPreferences, + phoneNumber, + profileName, + startUpdate, style, + title, } = props; const shouldShowNumber = Boolean(name || profileName); @@ -92,6 +97,24 @@ export const AvatarPopup = (props: Props): JSX.Element => { {i18n('avatarMenuViewArchive')} + {hasPendingUpdate && ( + + )} ); }; diff --git a/ts/components/ExpiredBuildDialog.stories.tsx b/ts/components/DialogExpiredBuild.stories.tsx similarity index 68% rename from ts/components/ExpiredBuildDialog.stories.tsx rename to ts/components/DialogExpiredBuild.stories.tsx index 848817a72..dce27058f 100644 --- a/ts/components/ExpiredBuildDialog.stories.tsx +++ b/ts/components/DialogExpiredBuild.stories.tsx @@ -5,17 +5,17 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { boolean } from '@storybook/addon-knobs'; -import { ExpiredBuildDialog } from './ExpiredBuildDialog'; +import { DialogExpiredBuild } from './DialogExpiredBuild'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; const i18n = setupI18n('en', enMessages); -storiesOf('Components/ExpiredBuildDialog', module).add( - 'ExpiredBuildDialog', +storiesOf('Components/DialogExpiredBuild', module).add( + 'DialogExpiredBuild', () => { const hasExpired = boolean('hasExpired', true); - return ; + return ; } ); diff --git a/ts/components/ExpiredBuildDialog.tsx b/ts/components/DialogExpiredBuild.tsx similarity index 60% rename from ts/components/ExpiredBuildDialog.tsx rename to ts/components/DialogExpiredBuild.tsx index 6ae0698bf..500a000b4 100644 --- a/ts/components/ExpiredBuildDialog.tsx +++ b/ts/components/DialogExpiredBuild.tsx @@ -10,7 +10,7 @@ type PropsType = { i18n: LocalizerType; }; -export const ExpiredBuildDialog = ({ +export const DialogExpiredBuild = ({ hasExpired, i18n, }: PropsType): JSX.Element | null => { @@ -19,19 +19,17 @@ export const ExpiredBuildDialog = ({ } return ( -
- {i18n('expiredWarning')} -
+
+
+ {i18n('expiredWarning')}{' '} - + {i18n('upgrade')}
diff --git a/ts/components/DialogNetworkStatus.stories.tsx b/ts/components/DialogNetworkStatus.stories.tsx new file mode 100644 index 000000000..6dd39116a --- /dev/null +++ b/ts/components/DialogNetworkStatus.stories.tsx @@ -0,0 +1,69 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { boolean, select } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; + +import { DialogNetworkStatus } from './DialogNetworkStatus'; +import { SocketStatus } from '../types/SocketStatus'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const defaultProps = { + hasNetworkDialog: true, + i18n, + isOnline: true, + socketStatus: SocketStatus.CONNECTING, + manualReconnect: action('manual-reconnect'), + withinConnectingGracePeriod: false, + challengeStatus: 'idle' as const, +}; + +const story = storiesOf('Components/DialogNetworkStatus', module); + +story.add('Knobs Playground', () => { + const hasNetworkDialog = boolean('hasNetworkDialog', true); + const isOnline = boolean('isOnline', true); + const socketStatus = select( + 'socketStatus', + { + CONNECTING: SocketStatus.CONNECTING, + OPEN: SocketStatus.OPEN, + CLOSING: SocketStatus.CLOSING, + CLOSED: SocketStatus.CLOSED, + }, + SocketStatus.CONNECTING + ); + + return ( + + ); +}); + +story.add('Connecting', () => ( + +)); + +story.add('Closing', () => ( + +)); + +story.add('Closed', () => ( + +)); + +story.add('Offline', () => ( + +)); diff --git a/ts/components/NetworkStatus.tsx b/ts/components/DialogNetworkStatus.tsx similarity index 72% rename from ts/components/NetworkStatus.tsx rename to ts/components/DialogNetworkStatus.tsx index eee1e3808..d8ec8a9be 100644 --- a/ts/components/NetworkStatus.tsx +++ b/ts/components/DialogNetworkStatus.tsx @@ -3,6 +3,7 @@ import React from 'react'; +import { Spinner } from './Spinner'; import { LocalizerType } from '../types/Util'; import { SocketStatus } from '../types/SocketStatus'; import { NetworkStateType } from '../state/ducks/network'; @@ -16,28 +17,42 @@ export type PropsType = NetworkStateType & { }; type RenderDialogTypes = { + isConnecting?: boolean; title: string; subtext: string; renderActionableButton?: () => JSX.Element; }; function renderDialog({ + isConnecting, title, subtext, renderActionableButton, }: RenderDialogTypes): JSX.Element { return ( -
-
+
+ {isConnecting ? ( +
+ +
+ ) : ( +
+ )} +

{title}

{subtext} +
{renderActionableButton && renderActionableButton()}
- {renderActionableButton && renderActionableButton()}
); } -export const NetworkStatus = ({ +export const DialogNetworkStatus = ({ hasNetworkDialog, i18n, isOnline, @@ -75,19 +90,23 @@ export const NetworkStatus = ({ }; const manualReconnectButton = (): JSX.Element => ( -
- -
+ ); if (isConnecting) { return renderDialog({ + isConnecting: true, subtext: i18n('connectingHangOn'), title: i18n('connecting'), }); } + if (!isOnline) { return renderDialog({ renderActionableButton: manualReconnectButton, @@ -114,6 +133,7 @@ export const NetworkStatus = ({ } return renderDialog({ + isConnecting: socketStatus === SocketStatus.CONNECTING, renderActionableButton, subtext, title, diff --git a/ts/components/DialogUpdate.stories.tsx b/ts/components/DialogUpdate.stories.tsx new file mode 100644 index 000000000..66c5d2eac --- /dev/null +++ b/ts/components/DialogUpdate.stories.tsx @@ -0,0 +1,64 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { boolean, select } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; +import { DialogUpdate } from './DialogUpdate'; +import { DialogType } from '../types/Dialogs'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const defaultProps = { + dismissDialog: action('dismiss-dialog'), + downloadSize: 116504357, + downloadedSize: 61003110, + hasNetworkDialog: false, + i18n, + didSnooze: false, + showEventsCount: 0, + snoozeUpdate: action('snooze-update'), + startUpdate: action('start-update'), + version: 'v7.7.7', +}; + +const story = storiesOf('Components/DialogUpdate', module); + +story.add('Knobs Playground', () => { + const dialogType = select('dialogType', DialogType, DialogType.Update); + const hasNetworkDialog = boolean('hasNetworkDialog', false); + const didSnooze = boolean('didSnooze', false); + + return ( + + ); +}); + +story.add('Update', () => ( + +)); + +story.add('Download Ready', () => ( + +)); + +story.add('Downloading', () => ( + +)); + +story.add('Cannot Update', () => ( + +)); + +story.add('macOS RO Error', () => ( + +)); diff --git a/ts/components/DialogUpdate.tsx b/ts/components/DialogUpdate.tsx new file mode 100644 index 000000000..689c60a32 --- /dev/null +++ b/ts/components/DialogUpdate.tsx @@ -0,0 +1,179 @@ +// Copyright 2020-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import formatFileSize from 'filesize'; + +import { DialogType } from '../types/Dialogs'; +import { Intl } from './Intl'; +import { LocalizerType } from '../types/Util'; + +export type PropsType = { + dialogType: DialogType; + didSnooze: boolean; + dismissDialog: () => void; + downloadSize?: number; + downloadedSize?: number; + hasNetworkDialog: boolean; + i18n: LocalizerType; + showEventsCount: number; + snoozeUpdate: () => void; + startUpdate: () => void; + version?: string; +}; + +export const DialogUpdate = ({ + dialogType, + didSnooze, + dismissDialog, + downloadSize, + downloadedSize, + hasNetworkDialog, + i18n, + snoozeUpdate, + startUpdate, + version, +}: PropsType): JSX.Element | null => { + if (hasNetworkDialog) { + return null; + } + + if (dialogType === DialogType.None) { + return null; + } + + if (didSnooze) { + return null; + } + + if (dialogType === DialogType.Cannot_Update) { + return ( +
+
+

{i18n('cannotUpdate')}

+ + + https://signal.org/download/ + , + ]} + i18n={i18n} + id="cannotUpdateDetail" + /> + +
+
+ ); + } + + if (dialogType === DialogType.MacOS_Read_Only) { + return ( +
+
+
+

{i18n('cannotUpdate')}

+ + Signal.app, + folder: /Applications, + }} + i18n={i18n} + id="readOnlyVolume" + /> + +
+
+
+
+
+ ); + } + + let size: string | undefined; + if ( + downloadSize && + (dialogType === DialogType.DownloadReady || + dialogType === DialogType.Downloading) + ) { + size = `(${formatFileSize(downloadSize, { round: 0 })})`; + } + + let updateSubText: JSX.Element; + if (dialogType === DialogType.DownloadReady) { + updateSubText = ( + + ); + } else if (dialogType === DialogType.Downloading) { + const width = Math.ceil( + ((downloadedSize || 1) / (downloadSize || 1)) * 100 + ); + + updateSubText = ( +
+
+
+ ); + } else { + updateSubText = ( + + ); + } + + const versionTitle = version + ? i18n('DialogUpdate--version-available', [version]) + : undefined; + + return ( +
+
+
+
+

+ {i18n('autoUpdateNewVersionTitle')} {size} +

+ {updateSubText} +
+
+
+ {dialogType !== DialogType.Downloading && ( +
+
+ ); +}; diff --git a/ts/components/MainHeader.stories.tsx b/ts/components/MainHeader.stories.tsx index f43dd8b39..f3fed291d 100644 --- a/ts/components/MainHeader.stories.tsx +++ b/ts/components/MainHeader.stories.tsx @@ -45,6 +45,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ title: requiredText('title', overrideProps.title), name: optionalText('name', overrideProps.name), avatarPath: optionalText('avatarPath', overrideProps.avatarPath), + hasPendingUpdate: Boolean(overrideProps.hasPendingUpdate), i18n, @@ -55,6 +56,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ searchInConversation: action('searchInConversation'), clearConversationSearch: action('clearConversationSearch'), clearSearch: action('clearSearch'), + startUpdate: action('startUpdate'), showArchivedConversations: action('showArchivedConversations'), startComposing: action('startComposing'), @@ -115,3 +117,9 @@ story.add('Searching Conversation with Term', () => { return ; }); + +story.add('Update Available', () => { + const props = createProps({ hasPendingUpdate: true }); + + return ; +}); diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index 0b72ae1aa..d3923aeed 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -37,6 +37,7 @@ export type PropsType = { profileName?: string; title: string; avatarPath?: string; + hasPendingUpdate: boolean; i18n: LocalizerType; @@ -59,6 +60,7 @@ export type PropsType = { noteToSelf: string; } ) => void; + startUpdate: () => unknown; clearConversationSearch: () => void; clearSearch: () => void; @@ -342,16 +344,18 @@ export class MainHeader extends React.Component { avatarPath, color, disabled, + hasPendingUpdate, i18n, name, - startComposing, phoneNumber, profileName, - title, searchConversationId, searchConversationName, searchTerm, showArchivedConversations, + startComposing, + startUpdate, + title, toggleProfileEditor, } = this.props; const { showingAvatarPopup, popperRoot } = this.state; @@ -369,25 +373,30 @@ export class MainHeader extends React.Component { {({ ref }) => ( - ` needs it - // to determine blurring. - sharedGroupNames={[]} - size={28} - innerRef={ref} - onClick={this.showAvatarPopup} - /> +
+ ` needs it to determine blurring. + sharedGroupNames={[]} + size={28} + innerRef={ref} + onClick={this.showAvatarPopup} + /> + {hasPendingUpdate && ( +
+ )} +
)} {showingAvatarPopup && popperRoot @@ -408,6 +417,8 @@ export class MainHeader extends React.Component { title={title} avatarPath={avatarPath} size={28} + hasPendingUpdate={hasPendingUpdate} + startUpdate={startUpdate} // See the comment above about `sharedGroupNames`. sharedGroupNames={[]} onEditProfile={() => { diff --git a/ts/components/NetworkStatus.stories.tsx b/ts/components/NetworkStatus.stories.tsx deleted file mode 100644 index e8e0ed71f..000000000 --- a/ts/components/NetworkStatus.stories.tsx +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { boolean, select } from '@storybook/addon-knobs'; -import { action } from '@storybook/addon-actions'; - -import { NetworkStatus } from './NetworkStatus'; -import { SocketStatus } from '../types/SocketStatus'; -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const defaultProps = { - hasNetworkDialog: true, - i18n, - isOnline: true, - socketStatus: SocketStatus.CONNECTING, - manualReconnect: action('manual-reconnect'), - withinConnectingGracePeriod: false, - challengeStatus: 'idle' as const, -}; - -const permutations = [ - { - title: 'Connecting', - props: { - socketStatus: SocketStatus.CONNECTING, - }, - }, - { - title: 'Closing (online)', - props: { - socketStatus: SocketStatus.CLOSING, - }, - }, - { - title: 'Closed (online)', - props: { - socketStatus: SocketStatus.CLOSED, - }, - }, - { - title: 'Offline', - props: { - isOnline: false, - }, - }, -]; - -storiesOf('Components/NetworkStatus', module) - .add('Knobs Playground', () => { - const hasNetworkDialog = boolean('hasNetworkDialog', true); - const isOnline = boolean('isOnline', true); - const socketStatus = select( - 'socketStatus', - { - CONNECTING: SocketStatus.CONNECTING, - OPEN: SocketStatus.OPEN, - CLOSING: SocketStatus.CLOSING, - CLOSED: SocketStatus.CLOSED, - }, - SocketStatus.CONNECTING - ); - - return ( - - ); - }) - .add('Iterations', () => { - return permutations.map(({ props, title }) => ( - <> -

{title}

- - - )); - }); diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index d52e7f267..43d41b1b6 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -69,6 +69,7 @@ const createProps = (): PropsType => ({ defaultConversationColor: DEFAULT_CONVERSATION_COLOR, deviceName: 'Work Windows ME', hasAudioNotifications: true, + hasAutoDownloadUpdate: true, hasAutoLaunch: true, hasCallNotifications: true, hasCallRingtoneNotification: false, @@ -125,6 +126,7 @@ const createProps = (): PropsType => ({ isSystemTraySupported: true, onAudioNotificationsChange: action('onAudioNotificationsChange'), + onAutoDownloadUpdateChange: action('onAutoDownloadUpdateChange'), onAutoLaunchChange: action('onAutoLaunchChange'), onCallNotificationsChange: action('onCallNotificationsChange'), onCallRingtoneNotificationChange: action('onCallRingtoneNotificationChange'), diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index fba133f2e..04c643ccf 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -43,6 +43,7 @@ export type PropsType = { defaultConversationColor: DefaultConversationColorType; deviceName?: string; hasAudioNotifications?: boolean; + hasAutoDownloadUpdate: boolean; hasAutoLaunch: boolean; hasCallNotifications: boolean; hasCallRingtoneNotification: boolean; @@ -104,6 +105,7 @@ export type PropsType = { // Change handlers onAudioNotificationsChange: CheckboxChangeHandlerType; + onAutoDownloadUpdateChange: CheckboxChangeHandlerType; onAutoLaunchChange: CheckboxChangeHandlerType; onCallNotificationsChange: CheckboxChangeHandlerType; onCallRingtoneNotificationChange: CheckboxChangeHandlerType; @@ -161,6 +163,7 @@ export const Preferences = ({ editCustomColor, getConversationsWithCustomColor, hasAudioNotifications, + hasAutoDownloadUpdate, hasAutoLaunch, hasCallNotifications, hasCallRingtoneNotification, @@ -191,6 +194,7 @@ export const Preferences = ({ makeSyncRequest, notificationContent, onAudioNotificationsChange, + onAutoDownloadUpdateChange, onAutoLaunchChange, onCallNotificationsChange, onCallRingtoneNotificationChange, @@ -340,6 +344,15 @@ export const Preferences = ({ onChange={onMediaCameraPermissionsChange} /> + + + ); } else if (page === Page.Appearance) { diff --git a/ts/components/RelinkDialog.tsx b/ts/components/RelinkDialog.tsx index 3005657a0..9d2af3fcd 100644 --- a/ts/components/RelinkDialog.tsx +++ b/ts/components/RelinkDialog.tsx @@ -21,12 +21,12 @@ export const RelinkDialog = ({ } return ( -
-
+
+

{i18n('unlinked')}

{i18n('unlinkedWarning')}
-
+
diff --git a/ts/components/UpdateDialog.stories.tsx b/ts/components/UpdateDialog.stories.tsx deleted file mode 100644 index 57932c9b7..000000000 --- a/ts/components/UpdateDialog.stories.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { boolean, select } from '@storybook/addon-knobs'; -import { action } from '@storybook/addon-actions'; -import { UpdateDialog } from './UpdateDialog'; - -import { setup as setupI18n } from '../../js/modules/i18n'; -import enMessages from '../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -const defaultProps = { - ackRender: action('ack-render'), - dismissDialog: action('dismiss-dialog'), - hasNetworkDialog: false, - i18n, - didSnooze: false, - showEventsCount: 0, - snoozeUpdate: action('snooze-update'), - startUpdate: action('start-update'), -}; - -const permutations = [ - { - title: 'Update', - props: { - dialogType: 1, - }, - }, - { - title: 'Update (didSnooze=true)', - props: { - dialogType: 1, - didSnooze: true, - }, - }, - { - title: 'Cannot Update', - props: { - dialogType: 2, - }, - }, - { - title: 'MacOS Read Only Error', - props: { - dialogType: 3, - }, - }, -]; - -storiesOf('Components/UpdateDialog', module) - .add('Knobs Playground', () => { - const dialogType = select( - 'dialogType', - { - None: 0, - Update: 1, - Cannot_Update: 2, - MacOS_Read_Only: 3, - }, - 1 - ); - const hasNetworkDialog = boolean('hasNetworkDialog', false); - const didSnooze = boolean('didSnooze', false); - - return ( - - ); - }) - .add('Iterations', () => { - return permutations.map(({ props, title }) => ( - <> -

{title}

- - - )); - }); diff --git a/ts/components/UpdateDialog.tsx b/ts/components/UpdateDialog.tsx deleted file mode 100644 index ec5ed7f70..000000000 --- a/ts/components/UpdateDialog.tsx +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright 2020-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; - -import { Dialogs } from '../types/Dialogs'; -import { Intl } from './Intl'; -import { LocalizerType } from '../types/Util'; - -export type PropsType = { - ackRender: () => void; - dialogType: Dialogs; - didSnooze: boolean; - dismissDialog: () => void; - hasNetworkDialog: boolean; - i18n: LocalizerType; - showEventsCount: number; - snoozeUpdate: () => void; - startUpdate: () => void; -}; - -export const UpdateDialog = ({ - ackRender, - dialogType, - didSnooze, - dismissDialog, - hasNetworkDialog, - i18n, - snoozeUpdate, - startUpdate, -}: PropsType): JSX.Element | null => { - React.useEffect(() => { - ackRender(); - }); - - if (hasNetworkDialog) { - return null; - } - - if (dialogType === Dialogs.None) { - return null; - } - - if (dialogType === Dialogs.Cannot_Update) { - return ( -
-
-

{i18n('cannotUpdate')}

- - - https://signal.org/download/ - , - ]} - i18n={i18n} - id="cannotUpdateDetail" - /> - -
-
- ); - } - - if (dialogType === Dialogs.MacOS_Read_Only) { - return ( -
-
-

{i18n('cannotUpdate')}

- - Signal.app, - folder: /Applications, - }} - i18n={i18n} - id="readOnlyVolume" - /> - -
-
- -
-
- ); - } - - return ( -
-
-

{i18n('autoUpdateNewVersionTitle')}

- {i18n('autoUpdateNewVersionMessage')} -
-
- {!didSnooze && ( - - )} - -
-
- ); -}; diff --git a/ts/components/WhatsNew.tsx b/ts/components/WhatsNew.tsx new file mode 100644 index 000000000..2005b916d --- /dev/null +++ b/ts/components/WhatsNew.tsx @@ -0,0 +1,75 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; +import moment from 'moment'; + +import { Modal } from './Modal'; +import { Intl } from './Intl'; +import { LocalizerType } from '../types/Util'; + +export type PropsType = { + i18n: LocalizerType; +}; + +type ReleaseNotesType = { + date: Date; + version: string; + features: Array; +}; + +export const WhatsNew = ({ i18n }: PropsType): JSX.Element => { + const [releaseNotes, setReleaseNotes] = useState< + ReleaseNotesType | undefined + >(); + + const viewReleaseNotes = () => { + setReleaseNotes({ + date: new Date('08/17/2021'), + version: window.getVersion(), + features: [ + 'WhatsNew__v5.15--1', + 'WhatsNew__v5.15--2', + 'WhatsNew__v5.15--3', + 'WhatsNew__v5.15--4', + 'WhatsNew__v5.15--5', + ], + }); + }; + + return ( + <> + {releaseNotes && ( + setReleaseNotes(undefined)} + title={i18n('WhatsNew__modal-title')} + > + <> + + {moment(releaseNotes.date).format('LL')} ·{' '} + {releaseNotes.version} + +
    + {releaseNotes.features.map(featureKey => ( +
  • + +
  • + ))} +
+ +
+ )} + + {i18n('viewReleaseNotes')} + , + ]} + /> + + ); +}; diff --git a/ts/main/settingsChannel.ts b/ts/main/settingsChannel.ts index 304b2ad64..080553669 100644 --- a/ts/main/settingsChannel.ts +++ b/ts/main/settingsChannel.ts @@ -61,6 +61,7 @@ export class SettingsChannel { isEphemeral: true, }); + this.installSetting('autoDownloadUpdate'); this.installSetting('autoLaunch'); this.installSetting('alwaysRelayCalls'); diff --git a/ts/services/updateListener.ts b/ts/services/updateListener.ts index 57f34e4a2..3b2ddf63c 100644 --- a/ts/services/updateListener.ts +++ b/ts/services/updateListener.ts @@ -2,26 +2,24 @@ // SPDX-License-Identifier: AGPL-3.0-only import { ipcRenderer } from 'electron'; -import { Dialogs } from '../types/Dialogs'; -import { ShowUpdateDialogAction } from '../state/ducks/updates'; +import { DialogType } from '../types/Dialogs'; +import { + UpdateDialogOptionsType, + ShowUpdateDialogAction, +} from '../state/ducks/updates'; type UpdatesActions = { - showUpdateDialog: (x: Dialogs) => ShowUpdateDialogAction; + showUpdateDialog: ( + x: DialogType, + options: UpdateDialogOptionsType + ) => ShowUpdateDialogAction; }; -type EventsType = { - once: (ev: string, f: () => void) => void; -}; - -export function initializeUpdateListener( - updatesActions: UpdatesActions, - events: EventsType -): void { - ipcRenderer.on('show-update-dialog', (_, dialogType: Dialogs) => { - updatesActions.showUpdateDialog(dialogType); - }); - - events.once('snooze-update', () => { - updatesActions.showUpdateDialog(Dialogs.Update); - }); +export function initializeUpdateListener(updatesActions: UpdatesActions): void { + ipcRenderer.on( + 'show-update-dialog', + (_, dialogType: DialogType, options: UpdateDialogOptionsType = {}) => { + updatesActions.showUpdateDialog(dialogType, options); + } + ); } diff --git a/ts/shims/updateIpc.ts b/ts/shims/updateIpc.ts index a0cceb22c..6b33a4f65 100644 --- a/ts/shims/updateIpc.ts +++ b/ts/shims/updateIpc.ts @@ -6,7 +6,3 @@ import { ipcRenderer } from 'electron'; export function startUpdate(): void { ipcRenderer.send('start-update'); } - -export function ackRender(): void { - ipcRenderer.send('show-update-dialog-ack'); -} diff --git a/ts/state/ducks/updates.ts b/ts/state/ducks/updates.ts index 9bd20446f..46f7c15b0 100644 --- a/ts/state/ducks/updates.ts +++ b/ts/state/ducks/updates.ts @@ -1,86 +1,110 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { Dialogs } from '../../types/Dialogs'; +import { ThunkAction } from 'redux-thunk'; import * as updateIpc from '../../shims/updateIpc'; -import { trigger } from '../../shims/events'; +import { DialogType } from '../../types/Dialogs'; +import { StateType as RootStateType } from '../reducer'; +import { onTimeout } from '../../services/timers'; // State export type UpdatesStateType = { - dialogType: Dialogs; + dialogType: DialogType; didSnooze: boolean; + downloadSize?: number; + downloadedSize?: number; showEventsCount: number; + version?: string; }; // Actions -const ACK_RENDER = 'updates/ACK_RENDER'; const DISMISS_DIALOG = 'updates/DISMISS_DIALOG'; const SHOW_UPDATE_DIALOG = 'updates/SHOW_UPDATE_DIALOG'; const SNOOZE_UPDATE = 'updates/SNOOZE_UPDATE'; const START_UPDATE = 'updates/START_UPDATE'; +const UNSNOOZE_UPDATE = 'updates/UNSNOOZE_UPDATE'; -type AckRenderAction = { - type: 'updates/ACK_RENDER'; +export type UpdateDialogOptionsType = { + downloadSize?: number; + downloadedSize?: number; + version?: string; }; type DismissDialogAction = { - type: 'updates/DISMISS_DIALOG'; + type: typeof DISMISS_DIALOG; }; export type ShowUpdateDialogAction = { - type: 'updates/SHOW_UPDATE_DIALOG'; - payload: Dialogs; + type: typeof SHOW_UPDATE_DIALOG; + payload: { + dialogType: DialogType; + otherState: UpdateDialogOptionsType; + }; }; type SnoozeUpdateActionType = { - type: 'updates/SNOOZE_UPDATE'; + type: typeof SNOOZE_UPDATE; }; type StartUpdateAction = { - type: 'updates/START_UPDATE'; + type: typeof START_UPDATE; +}; + +type UnsnoozeUpdateActionType = { + type: typeof UNSNOOZE_UPDATE; + payload: DialogType; }; export type UpdatesActionType = - | AckRenderAction | DismissDialogAction | ShowUpdateDialogAction | SnoozeUpdateActionType - | StartUpdateAction; + | StartUpdateAction + | UnsnoozeUpdateActionType; // Action Creators -function ackRender(): AckRenderAction { - updateIpc.ackRender(); - - return { - type: ACK_RENDER, - }; -} - function dismissDialog(): DismissDialogAction { return { type: DISMISS_DIALOG, }; } -function showUpdateDialog(dialogType: Dialogs): ShowUpdateDialogAction { +function showUpdateDialog( + dialogType: DialogType, + updateDialogOptions: UpdateDialogOptionsType = {} +): ShowUpdateDialogAction { return { type: SHOW_UPDATE_DIALOG, - payload: dialogType, + payload: { + dialogType, + otherState: updateDialogOptions, + }, }; } -const SNOOZE_TIMER = 60 * 1000 * 30; +const ONE_DAY = 24 * 60 * 60 * 1000; -function snoozeUpdate(): SnoozeUpdateActionType { - setTimeout(() => { - trigger('snooze-update'); - }, SNOOZE_TIMER); +function snoozeUpdate(): ThunkAction< + void, + RootStateType, + unknown, + SnoozeUpdateActionType | UnsnoozeUpdateActionType +> { + return (dispatch, getState) => { + const { dialogType } = getState().updates; + onTimeout(Date.now() + ONE_DAY, () => { + dispatch({ + type: UNSNOOZE_UPDATE, + payload: dialogType, + }); + }); - return { - type: SNOOZE_UPDATE, + dispatch({ + type: SNOOZE_UPDATE, + }); }; } @@ -93,7 +117,6 @@ function startUpdate(): StartUpdateAction { } export const actions = { - ackRender, dismissDialog, showUpdateDialog, snoozeUpdate, @@ -104,7 +127,7 @@ export const actions = { function getEmptyState(): UpdatesStateType { return { - dialogType: Dialogs.None, + dialogType: DialogType.None, didSnooze: false, showEventsCount: 0, }; @@ -115,37 +138,46 @@ export function reducer( action: Readonly ): UpdatesStateType { if (action.type === SHOW_UPDATE_DIALOG) { + const { dialogType, otherState } = action.payload; + return { - dialogType: action.payload, - didSnooze: state.didSnooze, + ...state, + ...otherState, + dialogType, showEventsCount: state.showEventsCount + 1, }; } if (action.type === SNOOZE_UPDATE) { return { - dialogType: Dialogs.None, + ...state, + dialogType: DialogType.None, didSnooze: true, - showEventsCount: state.showEventsCount, }; } if (action.type === START_UPDATE) { return { - dialogType: Dialogs.None, - didSnooze: state.didSnooze, - showEventsCount: state.showEventsCount, + ...state, + dialogType: DialogType.None, }; } if ( action.type === DISMISS_DIALOG && - state.dialogType === Dialogs.MacOS_Read_Only + state.dialogType === DialogType.MacOS_Read_Only ) { return { - dialogType: Dialogs.None, - didSnooze: state.didSnooze, - showEventsCount: state.showEventsCount, + ...state, + dialogType: DialogType.None, + }; + } + + if (action.type === UNSNOOZE_UPDATE) { + return { + ...state, + dialogType: action.payload, + didSnooze: false, }; } diff --git a/ts/state/smart/ExpiredBuildDialog.tsx b/ts/state/smart/ExpiredBuildDialog.tsx index 43bc87ae1..79541a7c3 100644 --- a/ts/state/smart/ExpiredBuildDialog.tsx +++ b/ts/state/smart/ExpiredBuildDialog.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { mapDispatchToProps } from '../actions'; -import { ExpiredBuildDialog } from '../../components/ExpiredBuildDialog'; +import { DialogExpiredBuild } from '../../components/DialogExpiredBuild'; import { StateType } from '../reducer'; import { getIntl } from '../selectors/user'; @@ -16,4 +16,4 @@ const mapStateToProps = (state: StateType) => { const smart = connect(mapStateToProps, mapDispatchToProps); -export const SmartExpiredBuildDialog = smart(ExpiredBuildDialog); +export const SmartExpiredBuildDialog = smart(DialogExpiredBuild); diff --git a/ts/state/smart/MainHeader.tsx b/ts/state/smart/MainHeader.tsx index f71abdec6..4fbb965f2 100644 --- a/ts/state/smart/MainHeader.tsx +++ b/ts/state/smart/MainHeader.tsx @@ -25,6 +25,7 @@ import { getMe, getSelectedConversation } from '../selectors/conversations'; const mapStateToProps = (state: StateType) => { return { disabled: state.network.challengeStatus !== 'idle', + hasPendingUpdate: Boolean(state.updates.didSnooze), searchTerm: getQuery(state), searchConversationId: getSearchConversationId(state), searchConversationName: getSearchConversationName(state), diff --git a/ts/state/smart/NetworkStatus.tsx b/ts/state/smart/NetworkStatus.tsx index 6a22b5b17..521c71750 100644 --- a/ts/state/smart/NetworkStatus.tsx +++ b/ts/state/smart/NetworkStatus.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { mapDispatchToProps } from '../actions'; -import { NetworkStatus } from '../../components/NetworkStatus'; +import { DialogNetworkStatus } from '../../components/DialogNetworkStatus'; import { StateType } from '../reducer'; import { getIntl } from '../selectors/user'; import { hasNetworkDialog } from '../selectors/network'; @@ -18,4 +18,4 @@ const mapStateToProps = (state: StateType) => { const smart = connect(mapStateToProps, mapDispatchToProps); -export const SmartNetworkStatus = smart(NetworkStatus); +export const SmartNetworkStatus = smart(DialogNetworkStatus); diff --git a/ts/state/smart/UpdateDialog.tsx b/ts/state/smart/UpdateDialog.tsx index d90ed1688..0605ca1ad 100644 --- a/ts/state/smart/UpdateDialog.tsx +++ b/ts/state/smart/UpdateDialog.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { mapDispatchToProps } from '../actions'; -import { UpdateDialog } from '../../components/UpdateDialog'; +import { DialogUpdate } from '../../components/DialogUpdate'; import { StateType } from '../reducer'; import { getIntl } from '../selectors/user'; import { hasNetworkDialog } from '../selectors/network'; @@ -18,4 +18,4 @@ const mapStateToProps = (state: StateType) => { const smart = connect(mapStateToProps, mapDispatchToProps); -export const SmartUpdateDialog = smart(UpdateDialog); +export const SmartUpdateDialog = smart(DialogUpdate); diff --git a/ts/test-node/updater/common_test.ts b/ts/test-node/updater/common_test.ts index 70db04036..30ba11618 100644 --- a/ts/test-node/updater/common_test.ts +++ b/ts/test-node/updater/common_test.ts @@ -9,10 +9,11 @@ import { getVersion, isUpdateFileNameValid, validatePath, + parseYaml, } from '../../updater/common'; describe('updater/signatures', () => { - const windows = `version: 1.23.2 + const windows = parseYaml(`version: 1.23.2 files: - url: signal-desktop-win-1.23.2.exe sha512: hhK+cVAb+QOK/Ln0RBcq8Rb1iPcUC0KZeT4NwLB25PMGoPmakY27XE1bXq4QlkASJN1EkYTbKf3oUJtcllziyQ== @@ -20,8 +21,8 @@ files: path: signal-desktop-win-1.23.2.exe sha512: hhK+cVAb+QOK/Ln0RBcq8Rb1iPcUC0KZeT4NwLB25PMGoPmakY27XE1bXq4QlkASJN1EkYTbKf3oUJtcllziyQ== releaseDate: '2019-03-29T16:58:08.210Z' -`; - const mac = `version: 1.23.2 +`); + const mac = parseYaml(`version: 1.23.2 files: - url: signal-desktop-mac-1.23.2.zip sha512: f4pPo3WulTVi9zBWGsJPNIlvPOTCxPibPPDmRFDoXMmFm6lqJpXZQ9DSWMJumfc4BRp4y/NTQLGYI6b4WuJwhg== @@ -30,8 +31,8 @@ files: path: signal-desktop-mac-1.23.2.zip sha512: f4pPo3WulTVi9zBWGsJPNIlvPOTCxPibPPDmRFDoXMmFm6lqJpXZQ9DSWMJumfc4BRp4y/NTQLGYI6b4WuJwhg== releaseDate: '2019-03-29T16:57:16.997Z' -`; - const windowsBeta = `version: 1.23.2-beta.1 +`); + const windowsBeta = parseYaml(`version: 1.23.2-beta.1 files: - url: signal-desktop-beta-win-1.23.2-beta.1.exe sha512: ZHM1F3y/Y6ulP5NhbFuh7t2ZCpY4lD9BeBhPV+g2B/0p/66kp0MJDeVxTgjR49OakwpMAafA1d6y2QBail4hSQ== @@ -39,8 +40,8 @@ files: path: signal-desktop-beta-win-1.23.2-beta.1.exe sha512: ZHM1F3y/Y6ulP5NhbFuh7t2ZCpY4lD9BeBhPV+g2B/0p/66kp0MJDeVxTgjR49OakwpMAafA1d6y2QBail4hSQ== releaseDate: '2019-03-29T01:56:00.544Z' -`; - const macBeta = `version: 1.23.2-beta.1 +`); + const macBeta = parseYaml(`version: 1.23.2-beta.1 files: - url: signal-desktop-beta-mac-1.23.2-beta.1.zip sha512: h/01N0DD5Jw2Q6M1n4uLGLTCrMFxcn8QOPtLR3HpABsf3w9b2jFtKb56/2cbuJXP8ol8TkTDWKnRV6mnqnLBDw== @@ -49,7 +50,7 @@ files: path: signal-desktop-beta-mac-1.23.2-beta.1.zip sha512: h/01N0DD5Jw2Q6M1n4uLGLTCrMFxcn8QOPtLR3HpABsf3w9b2jFtKb56/2cbuJXP8ol8TkTDWKnRV6mnqnLBDw== releaseDate: '2019-03-29T01:53:23.881Z' -`; +`); describe('#getVersion', () => { it('successfully gets version', () => { diff --git a/ts/types/Dialogs.ts b/ts/types/Dialogs.ts index 7fd594d4e..c6f90280f 100644 --- a/ts/types/Dialogs.ts +++ b/ts/types/Dialogs.ts @@ -3,9 +3,11 @@ /* eslint-disable camelcase */ -export enum Dialogs { - None, - Update, - Cannot_Update, - MacOS_Read_Only, +export enum DialogType { + None = 'None', + Update = 'Update', + Cannot_Update = 'Cannot_Update', + MacOS_Read_Only = 'MacOS_Read_Only', + DownloadReady = 'DownloadReady', + Downloading = 'Downloading', } diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 530289d7e..0d8a5f240 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -33,6 +33,7 @@ export type NotificationSettingType = 'message' | 'name' | 'count' | 'off'; export type StorageAccessType = { 'always-relay-calls': boolean; 'audio-notification': boolean; + 'auto-download-update': boolean; 'badge-count-muted-conversations': boolean; 'blocked-groups': Array; 'blocked-uuids': Array; diff --git a/ts/updater/common.ts b/ts/updater/common.ts index f5e626e12..2bd6702fe 100644 --- a/ts/updater/common.ts +++ b/ts/updater/common.ts @@ -9,6 +9,7 @@ import { } from 'fs'; import { join, normalize } from 'path'; import { tmpdir } from 'os'; +import { throttle } from 'lodash'; import { createParser, ParserConfiguration } from 'dashdash'; import ProxyAgent from 'proxy-agent'; @@ -20,10 +21,10 @@ import { v4 as getGuid } from 'uuid'; import pify from 'pify'; import mkdirp from 'mkdirp'; import rimraf from 'rimraf'; -import { app, BrowserWindow, dialog, ipcMain } from 'electron'; +import { app, BrowserWindow, ipcMain } from 'electron'; import { getTempPath } from '../../app/attachments'; -import { Dialogs } from '../types/Dialogs'; +import { DialogType } from '../types/Dialogs'; import { getUserAgent } from '../util/getUserAgent'; import { isAlpha, isBeta } from '../util/version'; @@ -31,7 +32,6 @@ import * as packageJson from '../../package.json'; import { getSignatureFileName } from './signature'; import { isPathInside } from '../util/isPathInside'; -import { LocaleType } from '../types/I18N'; import { LoggerType } from '../types/Logging'; const writeFile = pify(writeFileCallback); @@ -39,24 +39,40 @@ const mkdirpPromise = pify(mkdirp); const rimrafPromise = pify(rimraf); const { platform } = process; -export const ACK_RENDER_TIMEOUT = 10000; export const GOT_CONNECT_TIMEOUT = 2 * 60 * 1000; export const GOT_LOOKUP_TIMEOUT = 2 * 60 * 1000; export const GOT_SOCKET_TIMEOUT = 2 * 60 * 1000; +type JSONUpdateSchema = { + version: string; + files: Array<{ + url: string; + sha512: string; + size: string; + blockMapSize?: string; + }>; + path: string; + sha512: string; + releaseDate: string; +}; + export type UpdaterInterface = { force(): Promise; }; +export type UpdateInformationType = { + fileName: string; + size: number; + version: string; +}; + export async function checkForUpdates( logger: LoggerType, forceUpdate = false -): Promise<{ - fileName: string; - version: string; -} | null> { +): Promise { const yaml = await getUpdateYaml(); - const version = getVersion(yaml); + const parsedYaml = parseYaml(yaml); + const version = getVersion(parsedYaml); if (!version) { logger.warn('checkForUpdates: no version extracted from downloaded yaml'); @@ -70,8 +86,11 @@ export async function checkForUpdates( `forceUpdate=${forceUpdate}` ); + const fileName = getUpdateFileName(parsedYaml); + return { - fileName: getUpdateFileName(yaml), + fileName, + size: getSize(parsedYaml, fileName), version, }; } @@ -95,7 +114,8 @@ export function validatePath(basePath: string, targetPath: string): void { export async function downloadUpdate( fileName: string, - logger: LoggerType + logger: LoggerType, + mainWindow?: BrowserWindow ): Promise { const baseUrl = getUpdatesBase(); const updateFileUrl = `${baseUrl}/${fileName}`; @@ -121,6 +141,23 @@ export async function downloadUpdate( const writeStream = createWriteStream(targetUpdatePath); await new Promise((resolve, reject) => { + if (mainWindow) { + let downloadedSize = 0; + + const throttledSend = throttle(() => { + mainWindow.webContents.send( + 'show-update-dialog', + DialogType.Downloading, + { downloadedSize } + ); + }, 500); + + downloadStream.on('data', data => { + downloadedSize += data.length; + throttledSend(); + }); + } + downloadStream.on('error', error => { reject(error); }); @@ -144,106 +181,6 @@ export async function downloadUpdate( } } -let showingUpdateDialog = false; - -async function showFallbackUpdateDialog( - mainWindow: BrowserWindow, - locale: LocaleType -): Promise { - if (showingUpdateDialog) { - return false; - } - - const RESTART_BUTTON = 0; - const LATER_BUTTON = 1; - const options = { - type: 'info', - buttons: [ - locale.messages.autoUpdateRestartButtonLabel.message, - locale.messages.autoUpdateLaterButtonLabel.message, - ], - title: locale.messages.autoUpdateNewVersionTitle.message, - message: locale.messages.autoUpdateNewVersionMessage.message, - detail: locale.messages.autoUpdateNewVersionInstructions.message, - defaultId: LATER_BUTTON, - cancelId: LATER_BUTTON, - }; - - showingUpdateDialog = true; - - const { response } = await dialog.showMessageBox(mainWindow, options); - - showingUpdateDialog = false; - - return response === RESTART_BUTTON; -} - -export function showUpdateDialog( - mainWindow: BrowserWindow, - locale: LocaleType, - performUpdateCallback: () => void -): void { - let ack = false; - - ipcMain.once('show-update-dialog-ack', () => { - ack = true; - }); - - mainWindow.webContents.send('show-update-dialog', Dialogs.Update); - - setTimeout(async () => { - if (!ack) { - const shouldUpdate = await showFallbackUpdateDialog(mainWindow, locale); - if (shouldUpdate) { - performUpdateCallback(); - } - } - }, ACK_RENDER_TIMEOUT); -} - -let showingCannotUpdateDialog = false; - -async function showFallbackCannotUpdateDialog( - mainWindow: BrowserWindow, - locale: LocaleType -): Promise { - if (showingCannotUpdateDialog) { - return; - } - - const options = { - type: 'error', - buttons: [locale.messages.ok.message], - title: locale.messages.cannotUpdate.message, - message: locale.i18n('cannotUpdateDetail', ['https://signal.org/download']), - }; - - showingCannotUpdateDialog = true; - - await dialog.showMessageBox(mainWindow, options); - - showingCannotUpdateDialog = false; -} - -export function showCannotUpdateDialog( - mainWindow: BrowserWindow, - locale: LocaleType -): void { - let ack = false; - - ipcMain.once('show-update-dialog-ack', () => { - ack = true; - }); - - mainWindow.webContents.send('show-update-dialog', Dialogs.Cannot_Update); - - setTimeout(async () => { - if (!ack) { - await showFallbackCannotUpdateDialog(mainWindow, locale); - } - }, ACK_RENDER_TIMEOUT); -} - // Helper functions export function getUpdateCheckUrl(): string { @@ -288,9 +225,7 @@ function isVersionNewer(newVersion: string): boolean { return gt(newVersion, version); } -export function getVersion(yaml: string): string | null { - const info = parseYaml(yaml); - +export function getVersion(info: JSONUpdateSchema): string | null { return info && info.version; } @@ -299,11 +234,7 @@ export function isUpdateFileNameValid(name: string): boolean { return validFile.test(name); } -// Reliant on third party parser that returns any -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getUpdateFileName(yaml: string): any { - const info = parseYaml(yaml); - +export function getUpdateFileName(info: JSONUpdateSchema): string { if (!info || !info.path) { throw new Error('getUpdateFileName: No path present in YAML file'); } @@ -318,9 +249,17 @@ export function getUpdateFileName(yaml: string): any { return path; } -// Reliant on third party parser that returns any -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function parseYaml(yaml: string): any { +function getSize(info: JSONUpdateSchema, fileName: string): number { + if (!info || !info.files) { + throw new Error('getUpdateFileName: No files present in YAML file'); + } + + const foundFile = info.files.find(file => file.url === fileName); + + return Number(foundFile?.size) || 0; +} + +export function parseYaml(yaml: string): JSONUpdateSchema { return safeLoad(yaml, { schema: FAILSAFE_SCHEMA, json: true }); } @@ -413,3 +352,21 @@ export function getCliOptions(options: ParserConfiguration['options']): T { export function setUpdateListener(performUpdateCallback: () => void): void { ipcMain.once('start-update', performUpdateCallback); } + +export function getAutoDownloadUpdateSetting( + mainWindow: BrowserWindow +): Promise { + return new Promise((resolve, reject) => { + ipcMain.once( + 'settings:get-success:autoDownloadUpdate', + (_, error, value: boolean) => { + if (error) { + reject(error); + } else { + resolve(value); + } + } + ); + mainWindow.webContents.send('settings:get:autoDownloadUpdate'); + }); +} diff --git a/ts/updater/index.ts b/ts/updater/index.ts index 1ab3005ae..dda3ec79f 100644 --- a/ts/updater/index.ts +++ b/ts/updater/index.ts @@ -7,7 +7,6 @@ import { BrowserWindow } from 'electron'; import { UpdaterInterface } from './common'; import { start as startMacOS } from './macos'; import { start as startWindows } from './windows'; -import { LocaleType } from '../types/I18N'; import { LoggerType } from '../types/Logging'; let initialized = false; @@ -16,7 +15,6 @@ let updater: UpdaterInterface | undefined; export async function start( getMainWindow: () => BrowserWindow, - locale?: LocaleType, logger?: LoggerType ): Promise { const { platform } = process; @@ -26,9 +24,6 @@ export async function start( } initialized = true; - if (!locale) { - throw new Error('updater/start: Must provide locale!'); - } if (!logger) { throw new Error('updater/start: Must provide logger!'); } @@ -42,9 +37,9 @@ export async function start( } if (platform === 'win32') { - updater = await startWindows(getMainWindow, locale, logger); + updater = await startWindows(getMainWindow, logger); } else if (platform === 'darwin') { - updater = await startMacOS(getMainWindow, locale, logger); + updater = await startMacOS(getMainWindow, logger); } else { throw new Error('updater/start: Unsupported platform'); } diff --git a/ts/updater/macos.ts b/ts/updater/macos.ts index 3e912f4c1..ea5d16c24 100644 --- a/ts/updater/macos.ts +++ b/ts/updater/macos.ts @@ -7,27 +7,25 @@ import { AddressInfo } from 'net'; import { dirname } from 'path'; import { v4 as getGuid } from 'uuid'; -import { app, autoUpdater, BrowserWindow, dialog, ipcMain } from 'electron'; +import { app, autoUpdater, BrowserWindow } from 'electron'; import { get as getFromConfig } from 'config'; import { gt } from 'semver'; import got from 'got'; import { - ACK_RENDER_TIMEOUT, checkForUpdates, deleteTempDir, downloadUpdate, + getAutoDownloadUpdateSetting, getPrintableError, setUpdateListener, - showCannotUpdateDialog, - showUpdateDialog, UpdaterInterface, + UpdateInformationType, } from './common'; -import { LocaleType } from '../types/I18N'; import { LoggerType } from '../types/Logging'; import { hexToBinary, verifySignature } from './signature'; import { markShouldQuit } from '../../app/window_state'; -import { Dialogs } from '../types/Dialogs'; +import { DialogType } from '../types/Dialogs'; const SECOND = 1000; const MINUTE = SECOND * 60; @@ -35,7 +33,6 @@ const INTERVAL = MINUTE * 30; export async function start( getMainWindow: () => BrowserWindow, - locale: LocaleType, logger: LoggerType ): Promise { logger.info('macos/start: starting checks...'); @@ -45,19 +42,17 @@ export async function start( setInterval(async () => { try { - await checkDownloadAndInstall(getMainWindow, locale, logger); + await checkForUpdatesMaybeInstall(getMainWindow, logger); } catch (error) { - logger.error('macos/start: error:', getPrintableError(error)); + logger.error(`macos/start: ${getPrintableError(error)}`); } }, INTERVAL); - setUpdateListener(createUpdater(logger)); - - await checkDownloadAndInstall(getMainWindow, locale, logger); + await checkForUpdatesMaybeInstall(getMainWindow, logger); return { async force(): Promise { - return checkDownloadAndInstall(getMainWindow, locale, logger, true); + return checkForUpdatesMaybeInstall(getMainWindow, logger, true); }, }; } @@ -67,39 +62,69 @@ let version: string; let updateFilePath: string; let loggerForQuitHandler: LoggerType; -async function checkDownloadAndInstall( +async function checkForUpdatesMaybeInstall( getMainWindow: () => BrowserWindow, - locale: LocaleType, logger: LoggerType, force = false ) { - logger.info('checkDownloadAndInstall: checking for update...'); - try { - const result = await checkForUpdates(logger, force); - if (!result) { + logger.info('checkForUpdatesMaybeInstall: checking for update...'); + const result = await checkForUpdates(logger, force); + if (!result) { + return; + } + + const { fileName: newFileName, version: newVersion } = result; + + setUpdateListener(createUpdater(getMainWindow, result, logger)); + + if (fileName !== newFileName || !version || gt(newVersion, version)) { + const autoDownloadUpdates = await getAutoDownloadUpdateSetting( + getMainWindow() + ); + if (!autoDownloadUpdates) { + getMainWindow().webContents.send( + 'show-update-dialog', + DialogType.DownloadReady, + { + downloadSize: result.size, + version: result.version, + } + ); return; } + await downloadAndInstall(newFileName, newVersion, getMainWindow, logger); + } +} - const { fileName: newFileName, version: newVersion } = result; - if (fileName !== newFileName || !version || gt(newVersion, version)) { - const oldFileName = fileName; - const oldVersion = version; +async function downloadAndInstall( + newFileName: string, + newVersion: string, + getMainWindow: () => BrowserWindow, + logger: LoggerType, + updateOnProgress?: boolean +) { + try { + const oldFileName = fileName; + const oldVersion = version; - deleteCache(updateFilePath, logger); - fileName = newFileName; - version = newVersion; - try { - updateFilePath = await downloadUpdate(fileName, logger); - } catch (error) { - // Restore state in case of download error - fileName = oldFileName; - version = oldVersion; - throw error; - } + deleteCache(updateFilePath, logger); + fileName = newFileName; + version = newVersion; + try { + updateFilePath = await downloadUpdate( + fileName, + logger, + updateOnProgress ? getMainWindow() : undefined + ); + } catch (error) { + // Restore state in case of download error + fileName = oldFileName; + version = oldVersion; + throw error; } if (!updateFilePath) { - logger.info('checkDownloadAndInstall: no update file path. Skipping!'); + logger.info('downloadAndInstall: no update file path. Skipping!'); return; } @@ -109,7 +134,7 @@ async function checkDownloadAndInstall( // Note: We don't delete the cache here, because we don't want to continually // re-download the broken release. We will download it only once per launch. throw new Error( - `checkDownloadAndInstall: Downloaded update did not pass signature verification (version: '${version}'; fileName: '${fileName}')` + `downloadAndInstall: Downloaded update did not pass signature verification (version: '${version}'; fileName: '${fileName}')` ); } @@ -119,13 +144,19 @@ async function checkDownloadAndInstall( const readOnly = 'Cannot update while running on a read-only volume'; const message: string = error.message || ''; if (message.includes(readOnly)) { - logger.info('checkDownloadAndInstall: showing read-only dialog...'); - showReadOnlyDialog(getMainWindow(), locale); + logger.info('downloadAndInstall: showing read-only dialog...'); + getMainWindow().webContents.send( + 'show-update-dialog', + DialogType.MacOS_Read_Only + ); } else { logger.info( - 'checkDownloadAndInstall: showing general update failure dialog...' + 'downloadAndInstall: showing general update failure dialog...' + ); + getMainWindow().webContents.send( + 'show-update-dialog', + DialogType.Cannot_Update ); - showCannotUpdateDialog(getMainWindow(), locale); } throw error; @@ -133,12 +164,13 @@ async function checkDownloadAndInstall( // At this point, closing the app will cause the update to be installed automatically // because Squirrel has cached the update file and will do the right thing. + logger.info('downloadAndInstall: showing update dialog...'); - logger.info('checkDownloadAndInstall: showing update dialog...'); - - showUpdateDialog(getMainWindow(), locale, createUpdater(logger)); + getMainWindow().webContents.send('show-update-dialog', DialogType.Update, { + version, + }); } catch (error) { - logger.error('checkDownloadAndInstall: error', getPrintableError(error)); + logger.error(`downloadAndInstall: ${getPrintableError(error)}`); } } @@ -152,10 +184,7 @@ function deleteCache(filePath: string | null, logger: LoggerType) { if (filePath) { const tempDir = dirname(filePath); deleteTempDir(tempDir).catch(error => { - logger.error( - 'quitHandler: error deleting temporary directory:', - getPrintableError(error) - ); + logger.error(`quitHandler: ${getPrintableError(error)}`); }); } } @@ -171,10 +200,7 @@ async function handToAutoUpdate( let serverUrl: string; server.on('error', (error: Error) => { - logger.error( - 'handToAutoUpdate: server had error', - getPrintableError(error) - ); + logger.error(`handToAutoUpdate: ${getPrintableError(error)}`); shutdown(server, logger); reject(error); }); @@ -254,8 +280,9 @@ function pipeUpdateToSquirrel( response.on('error', (error: Error) => { logger.error( - 'pipeUpdateToSquirrel: update file download request had an error', - getPrintableError(error) + `pipeUpdateToSquirrel: update file download request had an error ${getPrintableError( + error + )}` ); shutdown(server, logger); reject(error); @@ -263,8 +290,9 @@ function pipeUpdateToSquirrel( readStream.on('error', (error: Error) => { logger.error( - 'pipeUpdateToSquirrel: read stream error response:', - getPrintableError(error) + `pipeUpdateToSquirrel: read stream error response: ${getPrintableError( + error + )}` ); shutdown(server, logger, response); reject(error); @@ -339,7 +367,7 @@ function shutdown( server.close(); } } catch (error) { - logger.error('shutdown: Error closing server', getPrintableError(error)); + logger.error(`shutdown: Error closing server ${getPrintableError(error)}`); } try { @@ -348,62 +376,32 @@ function shutdown( } } catch (endError) { logger.error( - "shutdown: couldn't end response", - getPrintableError(endError) + `shutdown: couldn't end response ${getPrintableError(endError)}` ); } } -export function showReadOnlyDialog( - mainWindow: BrowserWindow, - locale: LocaleType -): void { - let ack = false; - - ipcMain.once('show-update-dialog-ack', () => { - ack = true; - }); - - mainWindow.webContents.send('show-update-dialog', Dialogs.MacOS_Read_Only); - - setTimeout(async () => { - if (!ack) { - await showFallbackReadOnlyDialog(mainWindow, locale); - } - }, ACK_RENDER_TIMEOUT); -} - -let showingReadOnlyDialog = false; - -async function showFallbackReadOnlyDialog( - mainWindow: BrowserWindow, - locale: LocaleType +function createUpdater( + getMainWindow: () => BrowserWindow, + info: Pick, + logger: LoggerType ) { - if (showingReadOnlyDialog) { - return; - } - - const options = { - type: 'warning', - buttons: [locale.messages.ok.message], - title: locale.messages.cannotUpdate.message, - message: locale.i18n('readOnlyVolume', { - app: 'Signal.app', - folder: '/Applications', - }), - }; - - showingReadOnlyDialog = true; - - await dialog.showMessageBox(mainWindow, options); - - showingReadOnlyDialog = false; -} - -function createUpdater(logger: LoggerType) { - return () => { - logger.info('performUpdate: calling quitAndInstall...'); - markShouldQuit(); - autoUpdater.quitAndInstall(); + return async () => { + if (updateFilePath) { + logger.info('performUpdate: calling quitAndInstall...'); + markShouldQuit(); + autoUpdater.quitAndInstall(); + } else { + logger.info( + 'performUpdate: have not downloaded update, going to download' + ); + await downloadAndInstall( + info.fileName, + info.version, + getMainWindow, + logger, + true + ); + } }; } diff --git a/ts/updater/windows.ts b/ts/updater/windows.ts index ba4496f7b..3d401e7e1 100644 --- a/ts/updater/windows.ts +++ b/ts/updater/windows.ts @@ -14,16 +14,16 @@ import { checkForUpdates, deleteTempDir, downloadUpdate, + getAutoDownloadUpdateSetting, getPrintableError, setUpdateListener, - showCannotUpdateDialog, - showUpdateDialog, UpdaterInterface, + UpdateInformationType, } from './common'; -import { LocaleType } from '../types/I18N'; import { LoggerType } from '../types/Logging'; import { hexToBinary, verifySignature } from './signature'; import { markShouldQuit } from '../../app/window_state'; +import { DialogType } from '../types/Dialogs'; const readdir = pify(readdirCallback); const unlink = pify(unlinkCallback); @@ -40,7 +40,6 @@ let loggerForQuitHandler: LoggerType; export async function start( getMainWindow: () => BrowserWindow, - locale: LocaleType, logger: LoggerType ): Promise { logger.info('windows/start: starting checks...'); @@ -48,56 +47,84 @@ export async function start( loggerForQuitHandler = logger; app.once('quit', quitHandler); - setUpdateListener(createUpdater(getMainWindow, locale, logger)); - setInterval(async () => { try { - await checkDownloadAndInstall(getMainWindow, locale, logger); + await checkForUpdatesMaybeInstall(getMainWindow, logger); } catch (error) { - logger.error('windows/start: error:', getPrintableError(error)); + logger.error(`windows/start: ${getPrintableError(error)}`); } }, INTERVAL); await deletePreviousInstallers(logger); - await checkDownloadAndInstall(getMainWindow, locale, logger); + await checkForUpdatesMaybeInstall(getMainWindow, logger); return { async force(): Promise { - return checkDownloadAndInstall(getMainWindow, locale, logger, true); + return checkForUpdatesMaybeInstall(getMainWindow, logger, true); }, }; } -async function checkDownloadAndInstall( +async function checkForUpdatesMaybeInstall( getMainWindow: () => BrowserWindow, - locale: LocaleType, logger: LoggerType, force = false ) { - try { - logger.info('checkDownloadAndInstall: checking for update...'); - const result = await checkForUpdates(logger, force); - if (!result) { + logger.info('checkForUpdatesMaybeInstall: checking for update...'); + const result = await checkForUpdates(logger, force); + if (!result) { + return; + } + + const { fileName: newFileName, version: newVersion } = result; + + setUpdateListener(createUpdater(getMainWindow, result, logger)); + + if (fileName !== newFileName || !version || gt(newVersion, version)) { + const autoDownloadUpdates = await getAutoDownloadUpdateSetting( + getMainWindow() + ); + if (!autoDownloadUpdates) { + getMainWindow().webContents.send( + 'show-update-dialog', + DialogType.DownloadReady, + { + downloadSize: result.size, + version: result.version, + } + ); return; } + await downloadAndInstall(newFileName, newVersion, getMainWindow, logger); + } +} - const { fileName: newFileName, version: newVersion } = result; - if (fileName !== newFileName || !version || gt(newVersion, version)) { - const oldFileName = fileName; - const oldVersion = version; +async function downloadAndInstall( + newFileName: string, + newVersion: string, + getMainWindow: () => BrowserWindow, + logger: LoggerType, + updateOnProgress?: boolean +) { + try { + const oldFileName = fileName; + const oldVersion = version; - deleteCache(updateFilePath, logger); - fileName = newFileName; - version = newVersion; + deleteCache(updateFilePath, logger); + fileName = newFileName; + version = newVersion; - try { - updateFilePath = await downloadUpdate(fileName, logger); - } catch (error) { - // Restore state in case of download error - fileName = oldFileName; - version = oldVersion; - throw error; - } + try { + updateFilePath = await downloadUpdate( + fileName, + logger, + updateOnProgress ? getMainWindow() : undefined + ); + } catch (error) { + // Restore state in case of download error + fileName = oldFileName; + version = oldVersion; + throw error; } const publicKey = hexToBinary(getFromConfig('updatesPublicKey')); @@ -110,14 +137,12 @@ async function checkDownloadAndInstall( ); } - logger.info('checkDownloadAndInstall: showing dialog...'); - showUpdateDialog( - getMainWindow(), - locale, - createUpdater(getMainWindow, locale, logger) - ); + logger.info('downloadAndInstall: showing dialog...'); + getMainWindow().webContents.send('show-update-dialog', DialogType.Update, { + version, + }); } catch (error) { - logger.error('checkDownloadAndInstall: error', getPrintableError(error)); + logger.error(`downloadAndInstall: ${getPrintableError(error)}`); } } @@ -125,10 +150,7 @@ function quitHandler() { if (updateFilePath && !installing) { verifyAndInstall(updateFilePath, version, loggerForQuitHandler).catch( error => { - loggerForQuitHandler.error( - 'quitHandler: error installing:', - getPrintableError(error) - ); + loggerForQuitHandler.error(`quitHandler: ${getPrintableError(error)}`); } ); } @@ -208,10 +230,7 @@ function deleteCache(filePath: string | null, logger: LoggerType) { if (filePath) { const tempDir = dirname(filePath); deleteTempDir(tempDir).catch(error => { - logger.error( - 'deleteCache: error deleting temporary directory', - getPrintableError(error) - ); + logger.error(`deleteCache: ${getPrintableError(error)}`); }); } } @@ -237,23 +256,37 @@ async function spawn( function createUpdater( getMainWindow: () => BrowserWindow, - locale: LocaleType, + info: Pick, logger: LoggerType ) { return async () => { - try { - await verifyAndInstall(updateFilePath, version, logger); - installing = true; - } catch (error) { + if (updateFilePath) { + try { + await verifyAndInstall(updateFilePath, version, logger); + installing = true; + } catch (error) { + logger.info('createUpdater: showing general update failure dialog...'); + getMainWindow().webContents.send( + 'show-update-dialog', + DialogType.Cannot_Update + ); + + throw error; + } + + markShouldQuit(); + app.quit(); + } else { logger.info( - 'checkDownloadAndInstall: showing general update failure dialog...' + 'performUpdate: have not downloaded update, going to download' + ); + await downloadAndInstall( + info.fileName, + info.version, + getMainWindow, + logger, + true ); - showCannotUpdateDialog(getMainWindow(), locale); - - throw error; } - - markShouldQuit(); - app.quit(); }; } diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 994d24bd8..bc3db15b3 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -35,6 +35,7 @@ type NotificationSettingType = 'message' | 'name' | 'count' | 'off'; export type IPCEventsValuesType = { alwaysRelayCalls: boolean | undefined; audioNotification: boolean | undefined; + autoDownloadUpdate: boolean; autoLaunch: boolean; callRingtoneNotification: boolean; callSystemNotification: boolean; @@ -252,6 +253,10 @@ export function createIPCEvents( window.storage.get('typingIndicators', false), // Configurable settings + getAutoDownloadUpdate: () => + window.storage.get('auto-download-update', true), + setAutoDownloadUpdate: value => + window.storage.put('auto-download-update', value), getThemeSetting: () => window.storage.get( 'theme-setting', diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 62ef275af..1ae15d875 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -431,6 +431,13 @@ "reasonCategory": "usageTrusted", "updated": "2021-06-15T23:46:51.629Z" }, + { + "rule": "jQuery-$(", + "path": "js/views/inbox_view.js", + "line": " this.$('.whats-new-placeholder').append(this.whatsNewView.el);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-17T01:37:13.116Z" + }, { "rule": "jQuery-append(", "path": "js/views/inbox_view.js", @@ -439,6 +446,13 @@ "updated": "2021-02-26T18:44:56.450Z", "reasonDetail": "Adding sub-view to DOM" }, + { + "rule": "jQuery-append(", + "path": "js/views/inbox_view.js", + "line": " this.$('.whats-new-placeholder').append(this.whatsNewView.el);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-17T01:37:13.116Z" + }, { "rule": "jQuery-appendTo(", "path": "js/views/inbox_view.js", diff --git a/ts/window.d.ts b/ts/window.d.ts index dce2a49b5..e3b14e76a 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -103,6 +103,7 @@ import { ProgressModal } from './components/ProgressModal'; import { Quote } from './components/conversation/Quote'; import { StagedLinkPreview } from './components/conversation/StagedLinkPreview'; import { DisappearingTimeDialog } from './components/DisappearingTimeDialog'; +import { WhatsNew } from './components/WhatsNew'; import { MIMEType } from './types/MIME'; import { DownloadedAttachmentType } from './types/Attachment'; import { ElectronLocaleType } from './util/mapToSupportLocale'; @@ -298,11 +299,8 @@ declare global { enableStorageService: () => boolean; eraseAllStorageServiceState: () => Promise; initializeGroupCredentialFetcher: () => void; - initializeNetworkObserver: (network: WhatIsThis) => void; - initializeUpdateListener: ( - updates: WhatIsThis, - events: WhatIsThis - ) => void; + initializeNetworkObserver: (network: ReduxActions['network']) => void; + initializeUpdateListener: (updates: ReduxActions['updates']) => void; onTimeout: (timestamp: number, cb: () => void, id?: string) => string; removeTimeout: (uuid: string) => void; retryPlaceholders?: Util.RetryPlaceholders; @@ -420,6 +418,7 @@ declare global { ConfirmationDialog: typeof ConfirmationDialog; ContactDetail: typeof ContactDetail; ContactModal: typeof ContactModal; + DisappearingTimeDialog: typeof DisappearingTimeDialog; ErrorModal: typeof ErrorModal; Lightbox: typeof Lightbox; LightboxGallery: typeof LightboxGallery; @@ -428,7 +427,7 @@ declare global { ProgressModal: typeof ProgressModal; Quote: typeof Quote; StagedLinkPreview: typeof StagedLinkPreview; - DisappearingTimeDialog: typeof DisappearingTimeDialog; + WhatsNew: typeof WhatsNew; }; OS: typeof OS; Workflow: { diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index 42a717644..565dbb444 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -39,6 +39,7 @@ installSetting('typingIndicatorSetting', { installSetting('alwaysRelayCalls'); installSetting('audioNotification'); +installSetting('autoDownloadUpdate'); installSetting('autoLaunch'); installSetting('countMutedConversations'); installSetting('callRingtoneNotification'); diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts index c74f0db9b..057e0eeb8 100644 --- a/ts/windows/settings/preload.ts +++ b/ts/windows/settings/preload.ts @@ -45,6 +45,7 @@ window.getVersion = () => String(config.version); window.i18n = i18n.setup(locale, localeMessages); const settingAudioNotification = createSetting('audioNotification'); +const settingAutoDownloadUpdate = createSetting('autoDownloadUpdate'); const settingAutoLaunch = createSetting('autoLaunch'); const settingCallRingtoneNotification = createSetting( 'callRingtoneNotification' @@ -166,6 +167,7 @@ async function renderPreferences() { blockedCount, deviceName, hasAudioNotifications, + hasAutoDownloadUpdate, hasAutoLaunch, hasCallNotifications, hasCallRingtoneNotification, @@ -201,6 +203,7 @@ async function renderPreferences() { blockedCount: settingBlockedCount.getValue(), deviceName: settingDeviceName.getValue(), hasAudioNotifications: settingAudioNotification.getValue(), + hasAutoDownloadUpdate: settingAutoDownloadUpdate.getValue(), hasAutoLaunch: settingAutoLaunch.getValue(), hasCallNotifications: settingCallSystemNotification.getValue(), hasCallRingtoneNotification: settingCallRingtoneNotification.getValue(), @@ -256,6 +259,7 @@ async function renderPreferences() { defaultConversationColor, deviceName, hasAudioNotifications, + hasAutoDownloadUpdate, hasAutoLaunch, hasCallNotifications, hasCallRingtoneNotification, @@ -310,6 +314,7 @@ async function renderPreferences() { // Change handlers onAudioNotificationsChange: reRender(settingAudioNotification.setValue), + onAutoDownloadUpdateChange: reRender(settingAutoDownloadUpdate.setValue), onAutoLaunchChange: reRender(settingAutoLaunch.setValue), onCallNotificationsChange: reRender(settingCallSystemNotification.setValue), onCallRingtoneNotificationChange: reRender(