-
Notifications
You must be signed in to change notification settings - Fork 3.9k
improvement(desktop): surface update actions in a persistent toast #8421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { type ComponentType, StrictMode, useState } from 'react' | ||
| import { Chip, ChipInput, ToastProvider, toast } from '@sim/emcn' | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime' | ||
| import { createRoot } from 'react-dom/client' | ||
|
|
||
| interface UpdateNotificationFixtureProps { | ||
| Notification: ComponentType | ||
| } | ||
|
|
||
| function UpdateNotificationFixture({ Notification }: UpdateNotificationFixtureProps) { | ||
| const [pathname, setPathname] = useState('/workspace/first') | ||
| return ( | ||
| <PathnameContext.Provider value={pathname}> | ||
| <ToastProvider> | ||
| <Notification /> | ||
| <main className='flex flex-col gap-[16px] p-[48px]'> | ||
| <ChipInput aria-label='Work in progress' placeholder='Keep working' /> | ||
| <Chip onClick={() => setPathname('/workspace/second')}>Switch workspace</Chip> | ||
| <Chip | ||
| onClick={() => { | ||
| for (let index = 0; index < 3; index++) { | ||
| toast({ message: `Background task ${index + 1}`, duration: 0 }) | ||
| } | ||
| }} | ||
| > | ||
| Fill notification stack | ||
| </Chip> | ||
| <Chip onClick={() => toast.dismissAll()}>Clear notifications</Chip> | ||
| <output aria-label='Current route'>{pathname}</output> | ||
| </main> | ||
| </ToastProvider> | ||
| </PathnameContext.Provider> | ||
| ) | ||
| } | ||
|
|
||
| /** Supplies the production notification through Sim's module aliases. */ | ||
| export function mountUpdateNotificationFixture(Notification: ComponentType) { | ||
| const root = document.getElementById('root') | ||
| if (!root) throw new Error('Missing fixture root') | ||
| createRoot(root).render( | ||
| <StrictMode> | ||
| <UpdateNotificationFixture Notification={Notification} /> | ||
| </StrictMode> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,262 @@ | ||
| import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' | ||
| import { createServer } from 'node:http' | ||
| import { tmpdir } from 'node:os' | ||
| import { dirname, join } from 'node:path' | ||
| import { fileURLToPath } from 'node:url' | ||
| import { _electron as electron, expect, test } from '@playwright/test' | ||
| import type { DesktopUpdateState } from '@sim/desktop-bridge' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import { sleep } from '@sim/utils/helpers' | ||
| import { build } from 'esbuild' | ||
| import postcss from 'postcss' | ||
| import loadPostcssConfig from 'postcss-load-config' | ||
|
|
||
| const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) | ||
| const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url)) | ||
| const FIXTURE = fileURLToPath(new URL('./fixtures/update-notification.tsx', import.meta.url)) | ||
|
|
||
| test('desktop update actions survive navigation without repeating dismissed or stale offers', async () => { | ||
| const reportPath = | ||
| process.env.DESKTOP_UPDATE_NOTICE_REPORT_PATH ?? test.info().outputPath('update-notice.json') | ||
| const checks: { | ||
| name: string | ||
| status: 'passed' | 'failed' | ||
| durationMs: number | ||
| error?: string | ||
| }[] = [] | ||
| const check = async (name: string, run: () => Promise<void>) => { | ||
| const started = Date.now() | ||
| try { | ||
| await test.step(name, run) | ||
| checks.push({ name, status: 'passed', durationMs: Date.now() - started }) | ||
| } catch (error) { | ||
| checks.push({ | ||
| name, | ||
| status: 'failed', | ||
| durationMs: Date.now() - started, | ||
| error: getErrorMessage(error), | ||
| }) | ||
| throw error | ||
| } | ||
| } | ||
| const userData = mkdtempSync(join(tmpdir(), 'sim-update-notice-e2e-')) | ||
| let app: Awaited<ReturnType<typeof electron.launch>> | undefined | ||
| let passed = false | ||
| let javascript = '' | ||
| let stylesheet = '' | ||
| const server = createServer((request, response) => { | ||
| const path = new URL(request.url ?? '/', 'http://localhost').pathname | ||
| if (path === '/fixture.js' || path === '/fixture.css') { | ||
| response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css') | ||
| response.end(path.endsWith('.js') ? javascript : stylesheet) | ||
| } else if (path.startsWith('/api/')) { | ||
| response.setHeader('Content-Type', 'application/json') | ||
| response.end('{}') | ||
| } else { | ||
| response.setHeader('Content-Type', 'text/html') | ||
| response.end( | ||
| '<!doctype html><html class="dark"><head><link rel="stylesheet" href="/fixture.css"></head><body style="margin:0;background:var(--bg);color:var(--text-primary)"><div id="root"></div><script src="/fixture.js"></script></body></html>' | ||
| ) | ||
| } | ||
| }) | ||
|
|
||
| try { | ||
| await check('load the production notification in Electron', async () => { | ||
| const config = await loadPostcssConfig({}, SIM_DIR) | ||
| const cssPath = join(SIM_DIR, 'app/_styles/globals.css') | ||
| const css = await postcss(config.plugins).process( | ||
| `${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`, | ||
| { from: cssPath } | ||
| ) | ||
| const bundle = await build({ | ||
| stdin: { | ||
| contents: `import { mountUpdateNotificationFixture } from ${JSON.stringify(FIXTURE)}; | ||
| import { DesktopUpdateNotification } from '@/app/_shell/desktop-update-notification'; | ||
| mountUpdateNotificationFixture(DesktopUpdateNotification);`, | ||
| resolveDir: SIM_DIR, | ||
| loader: 'tsx', | ||
| }, | ||
| bundle: true, | ||
| write: false, | ||
| outfile: test.info().outputPath('fixture.js'), | ||
| external: ['node:async_hooks'], | ||
| banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' }, | ||
| format: 'iife', | ||
| platform: 'browser', | ||
| tsconfig: join(SIM_DIR, 'tsconfig.json'), | ||
| define: { 'process.env.NODE_ENV': '"development"' }, | ||
| }) | ||
| javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' | ||
| stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}` | ||
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)) | ||
| const address = server.address() | ||
| if (!address || typeof address === 'string') throw new Error('Missing fixture address') | ||
| app = await electron.launch({ | ||
| args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], | ||
| cwd: DESKTOP_DIR, | ||
| env: { | ||
| ...process.env, | ||
| SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`, | ||
| SIM_DESKTOP_USER_DATA: userData, | ||
| }, | ||
| }) | ||
| }) | ||
| if (!app) throw new Error('Electron did not launch') | ||
| const shell = app | ||
| const page = await shell.firstWindow() | ||
| const errors: string[] = [] | ||
| page.on('pageerror', (error) => errors.push(error.message)) | ||
| await shell.evaluate(({ app, BrowserWindow }) => { | ||
| const window = BrowserWindow.getAllWindows()[0] | ||
| window.setContentSize(1000, 700) | ||
| window.webContents.setBackgroundThrottling(false) | ||
| app.focus({ steal: true }) | ||
| window.focus() | ||
| }) | ||
| const publish = (state: DesktopUpdateState) => | ||
| shell.evaluate(({ BrowserWindow }, next) => { | ||
| BrowserWindow.getAllWindows()[0].webContents.send('desktop:updates:state', next) | ||
| }, state) | ||
| const notice = page.getByRole('list', { name: 'Notifications' }) | ||
| const restart = notice.getByRole('button', { name: 'Restart to update' }) | ||
| const download = notice.getByRole('button', { name: 'Download update' }) | ||
|
|
||
| await check( | ||
| 'initial snapshot survives StrictMode and remains actionable across navigation', | ||
| async () => { | ||
| await shell.evaluate(({ ipcMain }) => { | ||
| ipcMain.removeHandler('desktop:updates:get-state') | ||
| ipcMain.handle('desktop:updates:get-state', () => ({ status: 'ready', version: '2.0.0' })) | ||
| }) | ||
| await page.reload() | ||
| await expect(restart).toHaveCount(1) | ||
| await expect(restart).toBeVisible() | ||
| await page.getByRole('button', { name: 'Switch workspace' }).click() | ||
| await expect(page.getByLabel('Current route')).toHaveText('/workspace/second') | ||
| await sleep(5_500) | ||
| await expect(restart).toBeVisible() | ||
| await page.screenshot({ | ||
| path: test.info().outputPath('update-ready-dark.png'), | ||
| animations: 'disabled', | ||
| }) | ||
| await page.evaluate(() => document.documentElement.classList.remove('dark')) | ||
| await page.screenshot({ | ||
| path: test.info().outputPath('update-ready-light.png'), | ||
| animations: 'disabled', | ||
| }) | ||
| } | ||
| ) | ||
|
|
||
| await check('dismissal survives repeated events and checking the same release', async () => { | ||
| await notice.getByRole('button', { name: 'Dismiss notification' }).click() | ||
| await expect(restart).toHaveCount(0) | ||
| await publish({ status: 'ready', version: '2.0.0' }) | ||
| await publish({ status: 'checking', version: '2.0.0' }) | ||
| await page.evaluate( | ||
| () => | ||
| new Promise<void>((resolve) => | ||
| requestAnimationFrame(() => requestAnimationFrame(() => resolve())) | ||
| ) | ||
| ) | ||
| await publish({ status: 'ready', version: '2.0.0' }) | ||
| await sleep(500) | ||
| await expect(restart).toHaveCount(0) | ||
| }) | ||
|
|
||
| await check( | ||
| 'a new release appears without stealing focus, and stale actions are withdrawn', | ||
| async () => { | ||
| const input = page.getByRole('textbox', { name: 'Work in progress' }) | ||
| await input.fill('Unsaved work') | ||
| await publish({ status: 'ready', version: '2.1.0' }) | ||
| await expect(restart).toBeVisible() | ||
| await expect(input).toBeFocused() | ||
| for (const status of ['checking', 'downloading', 'error', 'idle'] as const) { | ||
| await publish({ status, version: '2.1.0' }) | ||
| await expect(restart).toHaveCount(0) | ||
| await publish({ status: 'ready', version: '2.1.0' }) | ||
| await expect(restart).toHaveCount(1) | ||
| } | ||
| await expect(input).toHaveValue('Unsaved work') | ||
| } | ||
| ) | ||
|
|
||
| await check( | ||
| 'a dismissed download offer can announce that the same release is ready', | ||
| async () => { | ||
| await publish({ status: 'available', version: '2.1.0' }) | ||
| await expect(download).toBeVisible() | ||
| await expect(restart).toHaveCount(0) | ||
| await notice.getByRole('button', { name: 'Dismiss notification' }).click() | ||
| await expect(download).toHaveCount(0) | ||
| await publish({ status: 'ready', version: '2.1.0' }) | ||
| await expect(restart).toBeVisible() | ||
| } | ||
| ) | ||
|
|
||
| await check('stack eviction does not count as dismissing the update offer', async () => { | ||
| await publish({ status: 'checking', version: '2.4.0' }) | ||
| await expect(restart).toHaveCount(0) | ||
| await publish({ status: 'ready', version: '2.4.0' }) | ||
| await expect(restart).toHaveCount(1) | ||
| await page.getByRole('button', { name: 'Fill notification stack' }).click() | ||
| await expect(restart).toHaveCount(0) | ||
| await page.getByRole('button', { name: 'Clear notifications' }).click() | ||
| await expect(notice).toHaveCount(0) | ||
| await publish({ status: 'checking', version: '2.4.0' }) | ||
| await page.evaluate( | ||
| () => | ||
| new Promise<void>((resolve) => | ||
| requestAnimationFrame(() => requestAnimationFrame(() => resolve())) | ||
| ) | ||
| ) | ||
| await publish({ status: 'ready', version: '2.4.0' }) | ||
| await expect(restart).toBeVisible() | ||
| }) | ||
|
|
||
| await check('actions reach the real preload IPC with the correct operation', async () => { | ||
| for (const { state, action, channel } of [ | ||
| { | ||
| state: { status: 'ready', version: '2.4.0' }, | ||
| action: restart, | ||
| channel: 'desktop:updates:install', | ||
| }, | ||
| { | ||
| state: { status: 'available', version: '2.5.0' }, | ||
| action: download, | ||
| channel: 'desktop:updates:check', | ||
| }, | ||
| { | ||
| state: { status: 'available', version: '2.6.0', manual: true }, | ||
| action: download, | ||
| channel: 'desktop:updates:install', | ||
| }, | ||
| ] satisfies { state: DesktopUpdateState; action: typeof restart; channel: string }[]) { | ||
| await publish(state) | ||
| await expect(action).toBeVisible() | ||
| const received = await shell.evaluateHandle(({ ipcMain }, name) => { | ||
| const receipt: { url: string | null } = { url: null } | ||
| ipcMain.once(name, (event) => { | ||
| receipt.url = event.sender.getURL() | ||
| }) | ||
| return receipt | ||
| }, channel) | ||
| try { | ||
| await action.click() | ||
| await expect.poll(() => received.evaluate((receipt) => receipt.url)).toBe(page.url()) | ||
| await expect(action).toHaveCount(0) | ||
| } finally { | ||
| await received.dispose() | ||
| } | ||
| } | ||
| expect(errors).toEqual([]) | ||
| }) | ||
| passed = true | ||
| } finally { | ||
| mkdirSync(dirname(reportPath), { recursive: true }) | ||
| writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2)) | ||
| await app?.close() | ||
| await new Promise<void>((resolve) => server.close(() => resolve())) | ||
| rmSync(userData, { recursive: true, force: true }) | ||
| } | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect, useRef } from 'react' | ||
| import { useToast } from '@sim/emcn' | ||
| import { getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop' | ||
| import { isShellOutdated } from '@/lib/desktop/min-version' | ||
| import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state' | ||
|
|
||
| interface DesktopUpdateNotificationProps { | ||
| [key: string]: never | ||
| } | ||
|
|
||
| /** Keeps optional desktop updates actionable across routes for the current window session. */ | ||
| export function DesktopUpdateNotification(_props: DesktopUpdateNotificationProps) { | ||
| const { status, version, manual } = useDesktopUpdateState() | ||
| const { toast, dismiss } = useToast() | ||
| const dismissedOffer = useRef<string | null>(null) | ||
|
|
||
| useEffect(() => { | ||
| const updates = getDesktopUpdates() | ||
| if ( | ||
| !updates || | ||
| isShellOutdated(getDesktopShellVersion()) || | ||
| (status !== 'available' && status !== 'ready') | ||
| ) { | ||
| return | ||
| } | ||
|
|
||
| const offer = `${version ?? ''}:${status}:${Boolean(manual)}` | ||
| if (dismissedOffer.current === offer) return | ||
|
|
||
| let active = true | ||
| const id = toast({ | ||
| message: | ||
| status === 'ready' ? 'A Sim update is ready to install' : 'A Sim update is available', | ||
| action: { | ||
| label: status === 'ready' ? 'Restart to update' : 'Download update', | ||
| onClick: () => { | ||
| if (!active) return | ||
| if (status === 'ready' || manual) updates.install() | ||
| else updates.check() | ||
| }, | ||
| }, | ||
| persistAcrossRoutes: true, | ||
| onUserDismiss: () => { | ||
| if (active) dismissedOffer.current = offer | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| }, | ||
| }) | ||
|
|
||
| return () => { | ||
| // Ignore clicks while a withdrawn toast finishes its exit animation. | ||
| active = false | ||
| dismiss(id) | ||
| } | ||
| }, [status, version, manual, toast, dismiss]) | ||
|
|
||
| return null | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.