diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 7af93d960..5ddde72c5 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -14,31 +14,14 @@ jobs: strategy: matrix: include: - - os: ubuntu-latest - command: make:intel - tag: x64 - dist: /apps/desktop/out/make - - os: ubuntu-latest - command: make:arm - tag: arm64 - dist: /apps/desktop/out/make - - os: windows-latest - command: make:intel - tag: x64 - dist: /apps/desktop/out/make/squirrel.windows/x64/*Setup.exe - - os: macos-13 - command: make:intel - tag: x64 - dist: /apps/desktop/out/make - - os: macos-13 - command: make:arm - tag: arm - dist: /apps/desktop/out/make - - os: macos-13 - command: make:universal - tag: universal - dist: /apps/desktop/out/make - + - os: macos-13 + command: make:arm + tag: arm + dist: /apps/desktop/out/make + - os: windows-latest + command: make:intel + tag: x64 + dist: /apps/desktop/out/make/squirrel.windows runs-on: ${{ matrix.os }} timeout-minutes: 30 env: @@ -100,84 +83,51 @@ jobs: - name: Upload zip distributives to artifacts uses: actions/upload-artifact@v4 - if: runner.os == 'Windows' with: - name: Tonkeeper Desktop ${{ runner.os }} x64 archive + name: Tonkeeper-mac-${{ matrix.tag }}-zip retention-days: 10 path: | ${{ github.workspace }}/apps/desktop/out/make/zip/**/*.zip - - name: Upload distributives to artifacts + - name: Upload mac DMG (optional) + if: runner.os == 'macOS' uses: actions/upload-artifact@v4 with: - name: Tonkeeper Desktop ${{ runner.os }} ${{ matrix.tag }} + name: Tonkeeper-mac-${{ matrix.tag }}-dmg retention-days: 10 path: | - ${{ github.workspace }}${{ matrix.dist }} - + ${{ github.workspace }}/apps/desktop/out/make/*.dmg + ${{ github.workspace }}/apps/desktop/out/make/**/dmg/**/*.dmg + + + - name: List Squirrel output + if: runner.os == 'Windows' + shell: pwsh + run: | + Get-ChildItem -Recurse -File apps\desktop\out\make\squirrel.windows | + Select-Object FullName, Length | Format-Table -AutoSize + + - name: Upload Windows Squirrel artifacts (RELEASES + full nupkg + delta nupkg) + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: Tonkeeper-win-x64-squirrel + retention-days: 10 + if-no-files-found: error + path: | + apps/desktop/out/make/squirrel.windows/**/RELEASES + apps/desktop/out/make/squirrel.windows/**/*-full.nupkg + apps/desktop/out/make/squirrel.windows/**/*-delta.nupkg + + - name: Upload Windows Setup (optional) + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: Tonkeeper-win-x64-setup + retention-days: 10 + if-no-files-found: error + path: | + apps/desktop/out/make/squirrel.windows/**/*.exe - name: Clean-up credentials if: always() && runner.os == 'macOS' - run: | - rm ${{ github.workspace }}/AuthKey.p8 - - web-build: - uses: ./.github/workflows/web-build.yaml - with: - environment: ${{ github.head_ref }} - secrets: inherit - - extension-build: - name: extension-build - runs-on: macos-14 - timeout-minutes: 10 - - steps: - - name: Checkout to git repository - uses: actions/checkout@v4 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.node-version }} - - - name: Enable Corepack - run: | - corepack enable - - - name: Yarn cache - uses: actions/cache@v4 - with: - path: ./.yarn - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Run install - uses: borales/actions-yarn@v5 - with: - cmd: install - - - name: Run build - uses: borales/actions-yarn@v5 - with: - cmd: build:extension - - - name: Upload Extension Chrome to artifacts - uses: actions/upload-artifact@v4 - with: - name: Extension Chrome - retention-days: 10 - path: | - ${{ github.workspace }}/apps/extension/dist/chrome - - - name: Upload Extension Firefox to artifacts - uses: actions/upload-artifact@v4 - with: - name: Extension Firefox - retention-days: 10 - path: | - ${{ github.workspace }}/apps/extension/dist/firefox - - ipad-build: - uses: ./.github/workflows/ipad-build.yaml - secrets: inherit + run: rm -f "$GITHUB_WORKSPACE/AuthKey.p8" diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index bf9d77347..988851d94 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -70,7 +70,7 @@ const config: ForgeConfig = { iconUrl: 'https://tonkeeper.com/assets/icon.ico', setupIcon: path.join(process.cwd(), 'public', 'icon.ico'), loadingGif: path.join(process.cwd(), 'public', 'install.gif'), - remoteReleases: 'https://github.com/tonkeeper/tonkeeper-web' + noDelta: true }, ['win32'] ), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0be2f37cb..fc6952dc8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@tonkeeper/desktop", "license": "Apache-2.0", - "version": "4.3.0", + "version": "4.3.2", "description": "Your desktop wallet on The Open Network", "main": ".webpack/main", "repository": { diff --git a/apps/desktop/src/backgroud.d.ts b/apps/desktop/src/backgroud.d.ts index 29147688b..14e048b43 100644 --- a/apps/desktop/src/backgroud.d.ts +++ b/apps/desktop/src/backgroud.d.ts @@ -9,6 +9,7 @@ interface BackgroundApi { onTonConnectRequest: (callback: (value: TonConnectAppRequestPayload) => void) => void; onTonConnectDisconnect: (callback: (value: AccountConnection) => void) => void; onRefresh: (callback: () => void) => void; + onAutoUpdateAvailable: (callback: (version: string) => void) => void; } declare global { diff --git a/apps/desktop/src/electron/autoUpdate.ts b/apps/desktop/src/electron/autoUpdate.ts index 280361231..0aa96756b 100644 --- a/apps/desktop/src/electron/autoUpdate.ts +++ b/apps/desktop/src/electron/autoUpdate.ts @@ -1,51 +1,80 @@ -import { app, autoUpdater, BrowserWindow, webContents } from 'electron'; +import { autoUpdater, BrowserWindow } from 'electron'; +import log from 'electron-log/main'; +import packageJson from '../../package.json'; +import { mainStorage } from './storageService'; -export default class AppUpdate { - constructor() { - autoUpdater.addListener('update-available', function (event: any) { - console.log('update available'); - }); - autoUpdater.addListener( - 'update-downloaded', - function (event, releaseNotes, releaseName, releaseDate, updateURL) { - notify( - 'A new update is ready to install', - `Version ${releaseName} is downloaded and will be automatically installed on Quit` - ); - } - ); - - const appVersion = app.getVersion(); // Get the app version dynamically - const platform = process.platform; // Get the platform dynamically (e.g., 'darwin', 'win32') - const arch = process.arch; // Get the architecture dynamically (e.g., 'arm64', 'x64') - - autoUpdater.addListener('error', function (error) { - console.log(error); - }); - autoUpdater.addListener('checking-for-update', function (event: any) { - console.log('checking-for-update'); - }); +export class AutoUpdateManager { + public static versionDownloadedKey = 'versionDownloaded'; + + private readonly channel = 'stable'; + + private newAvailableVersion: string | undefined = undefined; - // autoUpdater.addListener('update-not-available', function (event: any) { - // notify('Tonkeeper Pro is up to date', `Version ${releaseName}`); - // }); + private win: BrowserWindow; + + // private feedBaseUrl = 'https://update.electronjs.org'; + private feedBaseUrl = 'https://tonkeeper-web-updater-test.nkuznetsov.workers.dev'; + + public static async quitAndInstallIfFlagged() { + const flagged = await mainStorage.get(AutoUpdateManager.versionDownloadedKey); + if (flagged) { + return AutoUpdateManager.quitAndInstall(); + } + } + + public static async quitAndInstall() { + await mainStorage.delete(AutoUpdateManager.versionDownloadedKey); + setImmediate(() => autoUpdater.quitAndInstall()); + return true; + } - // // Build the feed URL - // const feedURL = `https://update.electronjs.org/tonkeeper/tonkeeper-web/${platform}-${arch}/${appVersion}`; + constructor(win: BrowserWindow) { + this.win = win; - // autoUpdater.setFeedURL({ url: feedURL }); + this.init(); } - check() { + private async init() { + const feedURL = `${this.feedBaseUrl}/${this.getRepoUrl()}/${process.platform}/${ + packageJson.version + }/${this.channel}`; + autoUpdater.setFeedURL({ url: feedURL }); + this.listenDownload(); + + const exited = await AutoUpdateManager.quitAndInstallIfFlagged(); + if (exited) { + return; + } + autoUpdater.checkForUpdates(); + setInterval(() => { + autoUpdater.checkForUpdates(); + }, 15 * 60_000); + } + + private getRepoUrl(): string { + return packageJson.repository.url + .replace(/^git\+/, '') + .replace(/^https:\/\/github\.com\//, '') + .replace(/\.git$/, '') + .trim(); } -} -function notify(title: string, message: string) { - let windows = BrowserWindow.getAllWindows(); - if (windows.length == 0) { - return; + public getNewVersionAvailable() { + return this.newAvailableVersion; } - // window[0].webContents.send('notify', title, message); + private listenDownload() { + autoUpdater.on('update-downloaded', (_, releaseNotes, releaseName) => { + const version = process.platform === 'win32' ? releaseNotes : releaseName; + this.newAvailableVersion = version; + this.win.webContents.send('app-update::ready', { version }); + mainStorage.set(AutoUpdateManager.versionDownloadedKey, version); + log.log('[AutoUpdate] updater new version fetched:', version); + }); + + autoUpdater.on('error', err => { + log.error('[AutoUpdate] updater error:', err); + }); + } } diff --git a/apps/desktop/src/electron/background.ts b/apps/desktop/src/electron/background.ts index 5f017b712..cc2c37146 100644 --- a/apps/desktop/src/electron/background.ts +++ b/apps/desktop/src/electron/background.ts @@ -7,6 +7,8 @@ import { tonConnectSSE } from './sseEvetns'; import { isValidUrlProtocol } from '@tonkeeper/core/dist/utils/common'; import * as electron from 'electron'; import BaseWindow = Electron.BaseWindow; +import { AutoUpdateManager } from './autoUpdate'; +import { assertUnreachable } from '@tonkeeper/core/dist/utils/types'; const service = 'tonkeeper.com'; @@ -15,6 +17,7 @@ const authorizedOpenUrlProtocols = ['http:', 'https:', 'tg:', 'mailto:']; // eslint-disable-next-line complexity export const handleBackgroundMessage = async ( window: BaseWindow, + autoUpdateManager: AutoUpdateManager, message: Message ): Promise => { switch (message.king) { @@ -110,7 +113,13 @@ export const handleBackgroundMessage = async ( case 'show-confirm-dialog': { return electron.dialog.showMessageBoxSync(window, message.options); } + case 'auto-update-install': { + return autoUpdateManager.quitAndInstall(); + } + case 'auto-update-get-has-new-version': { + return autoUpdateManager.getNewVersionAvailable(); + } default: - throw new Error(`Unknown message: ${JSON.stringify(message)}`); + assertUnreachable(message); } }; diff --git a/apps/desktop/src/electron/mainWindow.ts b/apps/desktop/src/electron/mainWindow.ts index 85c71a1a2..e11422661 100644 --- a/apps/desktop/src/electron/mainWindow.ts +++ b/apps/desktop/src/electron/mainWindow.ts @@ -7,7 +7,7 @@ import { handleBackgroundMessage } from '../electron/background'; import { Message } from '../libs/message'; import { createAppMenu } from './menu'; import { cookieJar } from './cookie'; -import AppUpdate from './autoUpdate'; +import { AutoUpdateManager } from './autoUpdate'; // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack // plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on @@ -50,9 +50,11 @@ export abstract class MainWindow { resizable: true }); - const menu = Menu.buildFromTemplate(createAppMenu(new AppUpdate())); + const menu = Menu.buildFromTemplate(createAppMenu()); Menu.setApplicationMenu(menu); + const updater = new AutoUpdateManager(this.mainWindow); + // and load the index.html of the app. this.mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY); @@ -68,7 +70,7 @@ export abstract class MainWindow { ipcMain.handle('message', async (event, message: Message) => { try { - return await handleBackgroundMessage(this.mainWindow, message); + return await handleBackgroundMessage(this.mainWindow, updater, message); } catch (e) { return e; } diff --git a/apps/desktop/src/electron/menu.ts b/apps/desktop/src/electron/menu.ts index c42ba8fb7..28a912c0b 100644 --- a/apps/desktop/src/electron/menu.ts +++ b/apps/desktop/src/electron/menu.ts @@ -1,7 +1,6 @@ -import AppUpdate from './autoUpdate'; import * as osLocale from 'os-locale'; import resources from '@tonkeeper/locales/dist/i18n/resources.json'; -import { Dict } from 'styled-components/dist/types'; +import type { Dict } from 'styled-components/dist/types'; const locale = osLocale.osLocaleSync(); @@ -72,7 +71,7 @@ const WindowMenu: Electron.MenuItemConstructorOptions = { ] }; -const getDarwinMenu = (update: AppUpdate): Electron.MenuItemConstructorOptions => { +const getDarwinMenu = (): Electron.MenuItemConstructorOptions => { return { label: 'Tonkeeper', submenu: [ @@ -118,7 +117,7 @@ const getDarwinMenu = (update: AppUpdate): Electron.MenuItemConstructorOptions = }; }; -const getWinMenu = (update: AppUpdate): Electron.MenuItemConstructorOptions => { +const getWinMenu = (): Electron.MenuItemConstructorOptions => { return { label: 'Tonkeeper', submenu: [ @@ -194,11 +193,9 @@ const ViewMenu: Electron.MenuItemConstructorOptions = { ] }; -export const createAppMenu = ( - update: AppUpdate -): (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] => { +export const createAppMenu = (): (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] => { return [ - process.platform === 'darwin' ? getDarwinMenu(update) : getWinMenu(update), + process.platform === 'darwin' ? getDarwinMenu() : getWinMenu(), EditMenu, ViewMenu, WindowMenu diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts index 80c12b487..677a4c771 100644 --- a/apps/desktop/src/index.ts +++ b/apps/desktop/src/index.ts @@ -1,7 +1,6 @@ import { delay, hideSensitiveData } from '@tonkeeper/core/dist/utils/common'; import { BrowserWindow, app, powerMonitor } from 'electron'; import log from 'electron-log/main'; -import { updateElectronApp } from 'update-electron-app'; import { MainWindow } from './electron/mainWindow'; import { setDefaultProtocolClient, @@ -9,6 +8,7 @@ import { setProtocolHandlerWindowsLinux } from './electron/protocol'; import { tonConnectSSE } from './electron/sseEvetns'; +import { AutoUpdateManager } from './electron/autoUpdate'; app.setName('Tonkeeper Pro'); @@ -43,14 +43,27 @@ if (process.platform !== 'linux') { } app.on('before-quit', async e => { - e.preventDefault(); - tonConnectSSE.destroy(); - if (process.platform !== 'linux') { - powerMonitor.off('unlock-screen', onUnLock); + try { + e.preventDefault(); + tonConnectSSE.destroy(); + if (process.platform !== 'linux') { + powerMonitor.off('unlock-screen', onUnLock); + } + } catch (e) { + console.error(e); } await delay(100); - app.exit(); + + try { + const exited = AutoUpdateManager.quitAndInstallIfFlagged(); + if (!exited) { + app.exit(); + } + } catch (e) { + console.error(e); + app.exit(); + } }); setDefaultProtocolClient(); @@ -88,8 +101,3 @@ app.on('activate', () => { MainWindow.openMainWindow(); } }); - -// In this file you can include the rest of your app's specific main process -// code. You can also put them in separate files and import them here. - -updateElectronApp({ logger: log }); diff --git a/apps/desktop/src/libs/appSdk.ts b/apps/desktop/src/libs/appSdk.ts index 6178d4ee3..394842588 100644 --- a/apps/desktop/src/libs/appSdk.ts +++ b/apps/desktop/src/libs/appSdk.ts @@ -13,6 +13,7 @@ import { sendBackground } from './backgroudService'; import { DesktopStorage } from './storage'; import { KeychainDesktop } from './keychain'; import { isValidUrlProtocol } from '@tonkeeper/core/dist/utils/common'; +import { atom } from '@tonkeeper/core/dist/entries/atom'; export class CookieDesktop implements CookieService { cleanUp = async () => { @@ -99,4 +100,21 @@ export class DesktopAppSdk extends BaseApp implements IAppSdk { storeCountryCode: null }; } + + autoUpdater = new AutoUpdater(); +} + +class AutoUpdater { + newVersionAvailable = atom(undefined); + + constructor() { + window.backgroundApi.onAutoUpdateAvailable(val => this.newVersionAvailable.next(val)); + sendBackground({ + king: 'auto-update-get-has-new-version' + }).then((val: string | undefined) => this.newVersionAvailable.next(val)); + } + + installAndQuit() { + sendBackground({ king: 'auto-update-install' }); + } } diff --git a/apps/desktop/src/libs/message.ts b/apps/desktop/src/libs/message.ts index 39d4db55c..aa31f399d 100644 --- a/apps/desktop/src/libs/message.ts +++ b/apps/desktop/src/libs/message.ts @@ -87,6 +87,14 @@ export interface ConfirmDialog { options: MessageBoxSyncOptions; } +export interface AutoUpdateQuitAndInstall { + king: 'auto-update-install'; +} + +export interface AutoUpdateGetHasNewVersion { + king: 'auto-update-get-has-new-version'; +} + export type Message = | GetStorageMessage | SetStorageMessage @@ -105,4 +113,6 @@ export type Message = | TonConnectSendDisconnectMessage | CleanCookieMessage | GetDeviceCountry - | ConfirmDialog; + | ConfirmDialog + | AutoUpdateQuitAndInstall + | AutoUpdateGetHasNewVersion; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index bee6d5be5..8d8aeb30b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -10,6 +10,9 @@ import { atom } from '@tonkeeper/core/dist/entries/atom'; const tcRequests$ = atom(undefined); ipcRenderer.on('tc', (_event, value) => tcRequests$.next(value)); +const autoUpdateAvailable$ = atom(undefined); +ipcRenderer.on('app-update::ready', (_event, value) => autoUpdateAvailable$.next(value.version)); + contextBridge.exposeInMainWorld('backgroundApi', { platform: () => process.platform, arch: () => process.arch, @@ -27,5 +30,11 @@ contextBridge.exposeInMainWorld('backgroundApi', { ipcRenderer.on('tonConnectRequest', (_event, value) => callback(value)), onTonConnectDisconnect: (callback: (value: AccountConnection) => void) => ipcRenderer.on('disconnect', (_event, value) => callback(value)), - onRefresh: (callback: () => void) => ipcRenderer.on('refresh', _event => callback()) + onRefresh: (callback: () => void) => ipcRenderer.on('refresh', _event => callback()), + onAutoUpdateAvailable: (callback: (version: string) => void) => { + autoUpdateAvailable$.subscribe(callback); + if (autoUpdateAvailable$.value !== undefined) { + callback(autoUpdateAvailable$.value); + } + } }); diff --git a/packages/core/src/AppSdk.ts b/packages/core/src/AppSdk.ts index a799c76ee..a7f95b6af 100644 --- a/packages/core/src/AppSdk.ts +++ b/packages/core/src/AppSdk.ts @@ -205,6 +205,11 @@ export interface IAppSdk { }; getAppCountryInfo(): Promise; + + autoUpdater?: { + newVersionAvailable: ReadonlyAtom; + installAndQuit: () => void; + }; } export interface IDappBrowser { diff --git a/packages/uikit/src/components/desktop/aside/AsideMenu.tsx b/packages/uikit/src/components/desktop/aside/AsideMenu.tsx index adde46a43..b63e25365 100644 --- a/packages/uikit/src/components/desktop/aside/AsideMenu.tsx +++ b/packages/uikit/src/components/desktop/aside/AsideMenu.tsx @@ -41,6 +41,7 @@ import { useAppSdk } from '../../../hooks/appSdk'; import { ErrorBoundary } from '../../shared/ErrorBoundary'; import { useHideActiveBrowserTab } from '../../../state/dapp-browser'; import { useManageFolderNotification } from '../../modals/ManageFolderNotificationControlled'; +import { AsideUpdateAvailable } from './AsideUpdateAvailable'; const AsideContainer = styled.div<{ width: number }>` display: flex; @@ -421,6 +422,7 @@ const AsideMenuPayload: FC<{ className?: string }> = ({ className }) => { {t('aside_settings')} + diff --git a/packages/uikit/src/components/desktop/aside/AsideUpdateAvailable.tsx b/packages/uikit/src/components/desktop/aside/AsideUpdateAvailable.tsx new file mode 100644 index 000000000..aff0e22c8 --- /dev/null +++ b/packages/uikit/src/components/desktop/aside/AsideUpdateAvailable.tsx @@ -0,0 +1,49 @@ +import styled from 'styled-components'; +import { Body3, Label2 } from '../../Text'; +import { useTranslation } from '../../../hooks/translation'; +import { RefreshIcon } from '../../Icon'; +import { useAppSdk } from '../../../hooks/appSdk'; +import { useMayBeAtomValue } from '../../../libs/useAtom'; + +const Wrapper = styled.button` + margin-top: 6px; + border: none; + background: ${p => p.theme.backgroundContentTint}; + border-radius: ${p => p.theme.corner2xSmall}; + padding: 6px 12px 6px 10px; + display: flex; + align-items: center; + gap: 10px; +`; + +const TextWrapper = styled.div` + display: flex; + align-items: flex-start; + flex-direction: column; + text-align: start; + + ${Body3} { + color: ${p => p.theme.textSecondary}; + } +`; + +export const AsideUpdateAvailable = () => { + const { t } = useTranslation(); + + const autoUpdater = useAppSdk()?.autoUpdater; + const newVersionAvailable = useMayBeAtomValue(autoUpdater?.newVersionAvailable); + + if (!newVersionAvailable) { + return null; + } + + return ( + autoUpdater!.installAndQuit()}> + + + Tonkeeper {newVersionAvailable} + {t('update_click_to_install')} + + + ); +}; diff --git a/packages/uikit/src/libs/useAtom.ts b/packages/uikit/src/libs/useAtom.ts index b40181225..a20fa1492 100644 --- a/packages/uikit/src/libs/useAtom.ts +++ b/packages/uikit/src/libs/useAtom.ts @@ -24,6 +24,19 @@ export function useAtom(a: Atom): [T, (value: T | ((prev: T) => T)) => voi return [value, next]; } +export function useMayBeAtomValue(a: ReadonlyAtom | undefined): T | undefined { + const [value, setValue] = useState(a?.value); + + useEffect(() => { + setValue(a?.value); + return a?.subscribe(v => { + setValue(v); + }); + }, [a]); + + return value; +} + export function useAtomValue(a: ReadonlyAtom): T { const [value, setValue] = useState(a.value);