Skip to content

Commit 025f2ec

Browse files
authored
improvement(desktop): surface update actions in a persistent toast (#8421)
* improvement(desktop): surface update actions in a persistent toast * fix(desktop): distinguish update toast dismissal from eviction
1 parent 3533353 commit 025f2ec

7 files changed

Lines changed: 382 additions & 1 deletion

File tree

‎.github/workflows/desktop-e2e.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ on:
1010
- '.github/workflows/desktop-e2e.yml'
1111
- '.github/workflows/desktop-release.yml'
1212
- 'apps/desktop/**'
13+
- 'apps/sim/app/_shell/desktop-update-*.tsx'
14+
- 'apps/sim/app/layout.tsx'
15+
- 'apps/sim/hooks/use-desktop-update-state.ts'
16+
- 'apps/sim/lib/desktop/**'
1317
- 'apps/sim/app/workspace/**/browser-session/**'
1418
- 'apps/sim/app/_styles/**'
1519
- 'apps/sim/lib/postcss/**'
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { type ComponentType, StrictMode, useState } from 'react'
2+
import { Chip, ChipInput, ToastProvider, toast } from '@sim/emcn'
3+
import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'
4+
import { createRoot } from 'react-dom/client'
5+
6+
interface UpdateNotificationFixtureProps {
7+
Notification: ComponentType
8+
}
9+
10+
function UpdateNotificationFixture({ Notification }: UpdateNotificationFixtureProps) {
11+
const [pathname, setPathname] = useState('/workspace/first')
12+
return (
13+
<PathnameContext.Provider value={pathname}>
14+
<ToastProvider>
15+
<Notification />
16+
<main className='flex flex-col gap-[16px] p-[48px]'>
17+
<ChipInput aria-label='Work in progress' placeholder='Keep working' />
18+
<Chip onClick={() => setPathname('/workspace/second')}>Switch workspace</Chip>
19+
<Chip
20+
onClick={() => {
21+
for (let index = 0; index < 3; index++) {
22+
toast({ message: `Background task ${index + 1}`, duration: 0 })
23+
}
24+
}}
25+
>
26+
Fill notification stack
27+
</Chip>
28+
<Chip onClick={() => toast.dismissAll()}>Clear notifications</Chip>
29+
<output aria-label='Current route'>{pathname}</output>
30+
</main>
31+
</ToastProvider>
32+
</PathnameContext.Provider>
33+
)
34+
}
35+
36+
/** Supplies the production notification through Sim's module aliases. */
37+
export function mountUpdateNotificationFixture(Notification: ComponentType) {
38+
const root = document.getElementById('root')
39+
if (!root) throw new Error('Missing fixture root')
40+
createRoot(root).render(
41+
<StrictMode>
42+
<UpdateNotificationFixture Notification={Notification} />
43+
</StrictMode>
44+
)
45+
}

‎apps/desktop/e2e/password-autofill.spec.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,9 @@ test.describe('saved password autofill', () => {
311311
await expect
312312
.poll(() => pageScript('document.getElementById("user").value'))
313313
.toBe('account9@example.test')
314+
await expect
315+
.poll(() => app.windows().some((page) => page.url().includes('credential-picker.html')))
316+
.toBe(false)
314317
await clickField()
315318
await picker()
316319
await pickerKey('Escape')
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2+
import { createServer } from 'node:http'
3+
import { tmpdir } from 'node:os'
4+
import { dirname, join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { _electron as electron, expect, test } from '@playwright/test'
7+
import type { DesktopUpdateState } from '@sim/desktop-bridge'
8+
import { getErrorMessage } from '@sim/utils/errors'
9+
import { sleep } from '@sim/utils/helpers'
10+
import { build } from 'esbuild'
11+
import postcss from 'postcss'
12+
import loadPostcssConfig from 'postcss-load-config'
13+
14+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
15+
const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url))
16+
const FIXTURE = fileURLToPath(new URL('./fixtures/update-notification.tsx', import.meta.url))
17+
18+
test('desktop update actions survive navigation without repeating dismissed or stale offers', async () => {
19+
const reportPath =
20+
process.env.DESKTOP_UPDATE_NOTICE_REPORT_PATH ?? test.info().outputPath('update-notice.json')
21+
const checks: {
22+
name: string
23+
status: 'passed' | 'failed'
24+
durationMs: number
25+
error?: string
26+
}[] = []
27+
const check = async (name: string, run: () => Promise<void>) => {
28+
const started = Date.now()
29+
try {
30+
await test.step(name, run)
31+
checks.push({ name, status: 'passed', durationMs: Date.now() - started })
32+
} catch (error) {
33+
checks.push({
34+
name,
35+
status: 'failed',
36+
durationMs: Date.now() - started,
37+
error: getErrorMessage(error),
38+
})
39+
throw error
40+
}
41+
}
42+
const userData = mkdtempSync(join(tmpdir(), 'sim-update-notice-e2e-'))
43+
let app: Awaited<ReturnType<typeof electron.launch>> | undefined
44+
let passed = false
45+
let javascript = ''
46+
let stylesheet = ''
47+
const server = createServer((request, response) => {
48+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
49+
if (path === '/fixture.js' || path === '/fixture.css') {
50+
response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
51+
response.end(path.endsWith('.js') ? javascript : stylesheet)
52+
} else if (path.startsWith('/api/')) {
53+
response.setHeader('Content-Type', 'application/json')
54+
response.end('{}')
55+
} else {
56+
response.setHeader('Content-Type', 'text/html')
57+
response.end(
58+
'<!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>'
59+
)
60+
}
61+
})
62+
63+
try {
64+
await check('load the production notification in Electron', async () => {
65+
const config = await loadPostcssConfig({}, SIM_DIR)
66+
const cssPath = join(SIM_DIR, 'app/_styles/globals.css')
67+
const css = await postcss(config.plugins).process(
68+
`${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`,
69+
{ from: cssPath }
70+
)
71+
const bundle = await build({
72+
stdin: {
73+
contents: `import { mountUpdateNotificationFixture } from ${JSON.stringify(FIXTURE)};
74+
import { DesktopUpdateNotification } from '@/app/_shell/desktop-update-notification';
75+
mountUpdateNotificationFixture(DesktopUpdateNotification);`,
76+
resolveDir: SIM_DIR,
77+
loader: 'tsx',
78+
},
79+
bundle: true,
80+
write: false,
81+
outfile: test.info().outputPath('fixture.js'),
82+
external: ['node:async_hooks'],
83+
banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' },
84+
format: 'iife',
85+
platform: 'browser',
86+
tsconfig: join(SIM_DIR, 'tsconfig.json'),
87+
define: { 'process.env.NODE_ENV': '"development"' },
88+
})
89+
javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? ''
90+
stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}`
91+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
92+
const address = server.address()
93+
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
94+
app = await electron.launch({
95+
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
96+
cwd: DESKTOP_DIR,
97+
env: {
98+
...process.env,
99+
SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`,
100+
SIM_DESKTOP_USER_DATA: userData,
101+
},
102+
})
103+
})
104+
if (!app) throw new Error('Electron did not launch')
105+
const shell = app
106+
const page = await shell.firstWindow()
107+
const errors: string[] = []
108+
page.on('pageerror', (error) => errors.push(error.message))
109+
await shell.evaluate(({ app, BrowserWindow }) => {
110+
const window = BrowserWindow.getAllWindows()[0]
111+
window.setContentSize(1000, 700)
112+
window.webContents.setBackgroundThrottling(false)
113+
app.focus({ steal: true })
114+
window.focus()
115+
})
116+
const publish = (state: DesktopUpdateState) =>
117+
shell.evaluate(({ BrowserWindow }, next) => {
118+
BrowserWindow.getAllWindows()[0].webContents.send('desktop:updates:state', next)
119+
}, state)
120+
const notice = page.getByRole('list', { name: 'Notifications' })
121+
const restart = notice.getByRole('button', { name: 'Restart to update' })
122+
const download = notice.getByRole('button', { name: 'Download update' })
123+
124+
await check(
125+
'initial snapshot survives StrictMode and remains actionable across navigation',
126+
async () => {
127+
await shell.evaluate(({ ipcMain }) => {
128+
ipcMain.removeHandler('desktop:updates:get-state')
129+
ipcMain.handle('desktop:updates:get-state', () => ({ status: 'ready', version: '2.0.0' }))
130+
})
131+
await page.reload()
132+
await expect(restart).toHaveCount(1)
133+
await expect(restart).toBeVisible()
134+
await page.getByRole('button', { name: 'Switch workspace' }).click()
135+
await expect(page.getByLabel('Current route')).toHaveText('/workspace/second')
136+
await sleep(5_500)
137+
await expect(restart).toBeVisible()
138+
await page.screenshot({
139+
path: test.info().outputPath('update-ready-dark.png'),
140+
animations: 'disabled',
141+
})
142+
await page.evaluate(() => document.documentElement.classList.remove('dark'))
143+
await page.screenshot({
144+
path: test.info().outputPath('update-ready-light.png'),
145+
animations: 'disabled',
146+
})
147+
}
148+
)
149+
150+
await check('dismissal survives repeated events and checking the same release', async () => {
151+
await notice.getByRole('button', { name: 'Dismiss notification' }).click()
152+
await expect(restart).toHaveCount(0)
153+
await publish({ status: 'ready', version: '2.0.0' })
154+
await publish({ status: 'checking', version: '2.0.0' })
155+
await page.evaluate(
156+
() =>
157+
new Promise<void>((resolve) =>
158+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
159+
)
160+
)
161+
await publish({ status: 'ready', version: '2.0.0' })
162+
await sleep(500)
163+
await expect(restart).toHaveCount(0)
164+
})
165+
166+
await check(
167+
'a new release appears without stealing focus, and stale actions are withdrawn',
168+
async () => {
169+
const input = page.getByRole('textbox', { name: 'Work in progress' })
170+
await input.fill('Unsaved work')
171+
await publish({ status: 'ready', version: '2.1.0' })
172+
await expect(restart).toBeVisible()
173+
await expect(input).toBeFocused()
174+
for (const status of ['checking', 'downloading', 'error', 'idle'] as const) {
175+
await publish({ status, version: '2.1.0' })
176+
await expect(restart).toHaveCount(0)
177+
await publish({ status: 'ready', version: '2.1.0' })
178+
await expect(restart).toHaveCount(1)
179+
}
180+
await expect(input).toHaveValue('Unsaved work')
181+
}
182+
)
183+
184+
await check(
185+
'a dismissed download offer can announce that the same release is ready',
186+
async () => {
187+
await publish({ status: 'available', version: '2.1.0' })
188+
await expect(download).toBeVisible()
189+
await expect(restart).toHaveCount(0)
190+
await notice.getByRole('button', { name: 'Dismiss notification' }).click()
191+
await expect(download).toHaveCount(0)
192+
await publish({ status: 'ready', version: '2.1.0' })
193+
await expect(restart).toBeVisible()
194+
}
195+
)
196+
197+
await check('stack eviction does not count as dismissing the update offer', async () => {
198+
await publish({ status: 'checking', version: '2.4.0' })
199+
await expect(restart).toHaveCount(0)
200+
await publish({ status: 'ready', version: '2.4.0' })
201+
await expect(restart).toHaveCount(1)
202+
await page.getByRole('button', { name: 'Fill notification stack' }).click()
203+
await expect(restart).toHaveCount(0)
204+
await page.getByRole('button', { name: 'Clear notifications' }).click()
205+
await expect(notice).toHaveCount(0)
206+
await publish({ status: 'checking', version: '2.4.0' })
207+
await page.evaluate(
208+
() =>
209+
new Promise<void>((resolve) =>
210+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
211+
)
212+
)
213+
await publish({ status: 'ready', version: '2.4.0' })
214+
await expect(restart).toBeVisible()
215+
})
216+
217+
await check('actions reach the real preload IPC with the correct operation', async () => {
218+
for (const { state, action, channel } of [
219+
{
220+
state: { status: 'ready', version: '2.4.0' },
221+
action: restart,
222+
channel: 'desktop:updates:install',
223+
},
224+
{
225+
state: { status: 'available', version: '2.5.0' },
226+
action: download,
227+
channel: 'desktop:updates:check',
228+
},
229+
{
230+
state: { status: 'available', version: '2.6.0', manual: true },
231+
action: download,
232+
channel: 'desktop:updates:install',
233+
},
234+
] satisfies { state: DesktopUpdateState; action: typeof restart; channel: string }[]) {
235+
await publish(state)
236+
await expect(action).toBeVisible()
237+
const received = await shell.evaluateHandle(({ ipcMain }, name) => {
238+
const receipt: { url: string | null } = { url: null }
239+
ipcMain.once(name, (event) => {
240+
receipt.url = event.sender.getURL()
241+
})
242+
return receipt
243+
}, channel)
244+
try {
245+
await action.click()
246+
await expect.poll(() => received.evaluate((receipt) => receipt.url)).toBe(page.url())
247+
await expect(action).toHaveCount(0)
248+
} finally {
249+
await received.dispose()
250+
}
251+
}
252+
expect(errors).toEqual([])
253+
})
254+
passed = true
255+
} finally {
256+
mkdirSync(dirname(reportPath), { recursive: true })
257+
writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2))
258+
await app?.close()
259+
await new Promise<void>((resolve) => server.close(() => resolve()))
260+
rmSync(userData, { recursive: true, force: true })
261+
}
262+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
'use client'
2+
3+
import { useEffect, useRef } from 'react'
4+
import { useToast } from '@sim/emcn'
5+
import { getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop'
6+
import { isShellOutdated } from '@/lib/desktop/min-version'
7+
import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
8+
9+
interface DesktopUpdateNotificationProps {
10+
[key: string]: never
11+
}
12+
13+
/** Keeps optional desktop updates actionable across routes for the current window session. */
14+
export function DesktopUpdateNotification(_props: DesktopUpdateNotificationProps) {
15+
const { status, version, manual } = useDesktopUpdateState()
16+
const { toast, dismiss } = useToast()
17+
const dismissedOffer = useRef<string | null>(null)
18+
19+
useEffect(() => {
20+
const updates = getDesktopUpdates()
21+
if (
22+
!updates ||
23+
isShellOutdated(getDesktopShellVersion()) ||
24+
(status !== 'available' && status !== 'ready')
25+
) {
26+
return
27+
}
28+
29+
const offer = `${version ?? ''}:${status}:${Boolean(manual)}`
30+
if (dismissedOffer.current === offer) return
31+
32+
let active = true
33+
const id = toast({
34+
message:
35+
status === 'ready' ? 'A Sim update is ready to install' : 'A Sim update is available',
36+
action: {
37+
label: status === 'ready' ? 'Restart to update' : 'Download update',
38+
onClick: () => {
39+
if (!active) return
40+
if (status === 'ready' || manual) updates.install()
41+
else updates.check()
42+
},
43+
},
44+
persistAcrossRoutes: true,
45+
onUserDismiss: () => {
46+
if (active) dismissedOffer.current = offer
47+
},
48+
})
49+
50+
return () => {
51+
// Ignore clicks while a withdrawn toast finishes its exit animation.
52+
active = false
53+
dismiss(id)
54+
}
55+
}, [status, version, manual, toast, dismiss])
56+
57+
return null
58+
}

0 commit comments

Comments
 (0)