Skip to content

Commit f1c378d

Browse files
committed
fix(dev): warn when a browser cannot be opened and caption qr codes
1 parent 66d21bd commit f1c378d

4 files changed

Lines changed: 93 additions & 30 deletions

File tree

packages/nuxt-cli/src/dev/listen.ts

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -250,14 +250,15 @@ async function resolvePort(requestedPort: number | undefined, hostname: string,
250250
return port
251251
}
252252

253-
export async function printQRCode(url: string): Promise<void> {
253+
export async function printQRCode(url: string, { showURL = false }: { showURL?: boolean } = {}): Promise<void> {
254254
const { renderUnicodeCompact } = await import('uqr')
255+
const caption = showURL ? `\n${centerBlock(colors.cyan(url), url.length)}` : ''
255256
// eslint-disable-next-line no-console
256-
console.log(`\n${centerBlock(renderUnicodeCompact(url))}\n`)
257+
console.log(`\n${centerBlock(renderUnicodeCompact(url))}${caption}\n`)
257258
}
258259

259260
export async function copyURL(url: string): Promise<void> {
260-
if (!isClipboardAvailable()) {
261+
if (!hasDisplayServer()) {
261262
logger.warn('No clipboard is available in this environment.')
262263
return
263264
}
@@ -275,20 +276,21 @@ export async function copyURL(url: string): Promise<void> {
275276
const DISPLAY_REQUIRED_PLATFORMS = new Set<NodeJS.Platform>(['linux', 'freebsd', 'openbsd'])
276277

277278
/**
278-
* Clipboard tools on Linux and BSD (`wl-copy`, `xsel`, `xclip`) need a display
279-
* server. Without one they exit before receiving any input, and the resulting
280-
* `EPIPE` escapes as an uncaught exception from inside the writing library.
279+
* Whether a graphical session exists to receive a clipboard write or a browser
280+
* launch. The tools involved on Linux and BSD (`wl-copy`, `xsel`, `xclip`,
281+
* `xdg-open`) all need one, and fail in unhelpful ways without it: clipboard
282+
* tools exit before reading their input, so the write fails with `EPIPE`.
281283
*/
282-
function isClipboardAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
284+
function hasDisplayServer(env: NodeJS.ProcessEnv = process.env): boolean {
283285
if (!DISPLAY_REQUIRED_PLATFORMS.has(process.platform)) {
284286
return true
285287
}
286288
return !!(env.WSL_DISTRO_NAME || env.WAYLAND_DISPLAY || env.DISPLAY)
287289
}
288290

289-
function centerBlock(block: string): string {
291+
function centerBlock(block: string, blockWidth?: number): string {
290292
const lines = block.split('\n')
291-
const width = Math.max(...lines.map(line => line.length))
293+
const width = blockWidth ?? Math.max(...lines.map(line => line.length))
292294
const columns = Math.min(process.stdout.columns || 80, 80)
293295
const indent = ' '.repeat(Math.max(0, Math.floor((columns - width) / 2)))
294296
return lines.map(line => indent + line).join('\n')
@@ -342,16 +344,41 @@ export function resolveOpenCommand(
342344
return ['xdg-open', [url]]
343345
}
344346

347+
/** How long a launcher has to fail before we assume the browser did open. */
348+
const BROWSER_LAUNCH_TIMEOUT_MS = 3000
349+
345350
export function openBrowser(url: string): void {
346351
const resolved = resolveOpenCommand(url)
347352
if (!resolved) {
348353
return
349354
}
355+
if (!hasDisplayServer()) {
356+
logger.warn(`No browser is available in this environment. Open ${colors.cyan(url)} manually.`)
357+
return
358+
}
359+
350360
const [command, args] = resolved
361+
const onFailure = (error: unknown) => {
362+
debug('Failed to open browser:', error)
363+
logger.warn(`Could not open ${colors.cyan(url)} in a browser.`)
364+
}
365+
351366
try {
352-
spawn(command, args, { stdio: 'ignore', detached: true }).on('error', error => debug('Failed to open browser:', error)).unref()
367+
const child = spawn(command, args, { stdio: 'ignore', detached: true })
368+
child.once('error', onFailure)
369+
const onExit = (code: number | null) => {
370+
if (code) {
371+
onFailure(new Error(`\`${command}\` exited with code ${code}`))
372+
}
373+
}
374+
child.once('exit', onExit)
375+
// `BROWSER` may point at the browser itself rather than a launcher, in which
376+
// case the process lives as long as the browser does and its eventual exit
377+
// code says nothing about whether the URL opened.
378+
setTimeout(() => child.off('exit', onExit), BROWSER_LAUNCH_TIMEOUT_MS).unref()
379+
child.unref()
353380
}
354381
catch (error) {
355-
debug('Failed to open browser:', error)
382+
onFailure(error)
356383
}
357384
}

packages/nuxt-cli/src/dev/shortcuts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const shortcuts: Shortcut[] = [
4747
{
4848
keys: ['qr'],
4949
description: 'show a QR code for the server URL',
50-
action: context => printQRCode(resolveShareableURL(context.listener)),
50+
action: context => printQRCode(resolveShareableURL(context.listener), { showURL: true }),
5151
},
5252
{
5353
keys: ['copy'],

packages/nuxt-cli/test/unit/listen.spec.ts

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@ import type { Listener } from '../../src/dev/listen'
22

33
import { networkInterfaces } from 'node:os'
44

5-
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
import { copyURL, getNetworkAddresses, listen, resolveOpenCommand } from '../../src/dev/listen'
7+
import { copyURL, getNetworkAddresses, listen, openBrowser, resolveOpenCommand } from '../../src/dev/listen'
88

99
const writeText = vi.hoisted(() => vi.fn())
1010

1111
vi.mock('tinyclip', () => ({ writeText }))
1212

13-
const spawn = vi.hoisted(() => vi.fn((_command: string, _args: string[]) => ({ on: () => ({ unref: () => {} }) })))
13+
const spawn = vi.hoisted(() => vi.fn((_command: string, _args: string[]) => ({
14+
once: () => {},
15+
off: () => {},
16+
unref: () => {},
17+
})))
1418

1519
vi.mock('node:child_process', () => ({ spawn }))
1620

@@ -21,6 +25,20 @@ vi.mock('node:os', () => ({
2125

2226
const mocked = vi.mocked(networkInterfaces)
2327

28+
const realPlatform = process.platform
29+
const realEnv = { ...process.env }
30+
31+
/** Pretend to run on `platform`, with only the display variables in `env` set. */
32+
function stubEnvironment(platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {}) {
33+
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
34+
process.env = { ...realEnv, DISPLAY: undefined, WAYLAND_DISPLAY: undefined, WSL_DISTRO_NAME: undefined, ...env }
35+
}
36+
37+
function restoreEnvironment() {
38+
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true })
39+
process.env = { ...realEnv }
40+
}
41+
2442
describe('getNetworkAddresses', () => {
2543
it('should return external IPv4 addresses', () => {
2644
mocked.mockReturnValue({
@@ -76,7 +94,11 @@ describe('resolveOpenCommand', () => {
7694
describe('listen', () => {
7795
const listeners: Listener[] = []
7896

97+
// `openBrowser` refuses to spawn a launcher without a graphical session.
98+
beforeEach(() => stubEnvironment(realPlatform, { DISPLAY: ':0' }))
99+
79100
afterEach(async () => {
101+
restoreEnvironment()
80102
await Promise.all(listeners.splice(0).map(listener => listener.close()))
81103
})
82104

@@ -124,48 +146,62 @@ describe('listen', () => {
124146
})
125147

126148
describe('copyURL', () => {
127-
const platform = process.platform
128-
const env = { ...process.env }
129-
130149
afterEach(() => {
131-
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
132-
process.env = { ...env }
150+
restoreEnvironment()
133151
vi.clearAllMocks()
134152
})
135153

136-
function stubPlatform(value: NodeJS.Platform, overrides: NodeJS.ProcessEnv = {}) {
137-
Object.defineProperty(process, 'platform', { value, configurable: true })
138-
process.env = { ...env, DISPLAY: undefined, WAYLAND_DISPLAY: undefined, WSL_DISTRO_NAME: undefined, ...overrides }
139-
}
140-
141154
it('should skip copying without a display server on linux', async () => {
142-
stubPlatform('linux')
155+
stubEnvironment('linux')
143156

144157
await copyURL('http://localhost:3000/')
145158

146159
expect(writeText).not.toHaveBeenCalled()
147160
})
148161

149162
it('should copy when a display server is available', async () => {
150-
stubPlatform('linux', { DISPLAY: ':0' })
163+
stubEnvironment('linux', { DISPLAY: ':0' })
151164

152165
await copyURL('http://localhost:3000/')
153166

154167
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/')
155168
})
156169

157170
it('should copy on platforms that do not need a display server', async () => {
158-
stubPlatform('darwin')
171+
stubEnvironment('darwin')
159172

160173
await copyURL('http://localhost:3000/')
161174

162175
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/')
163176
})
164177

165178
it('should warn rather than throw when copying fails', async () => {
166-
stubPlatform('darwin')
179+
stubEnvironment('darwin')
167180
writeText.mockRejectedValueOnce(new Error('no clipboard tool found'))
168181

169182
await expect(copyURL('http://localhost:3000/')).resolves.toBeUndefined()
170183
})
171184
})
185+
186+
describe('openBrowser', () => {
187+
afterEach(() => {
188+
restoreEnvironment()
189+
vi.clearAllMocks()
190+
})
191+
192+
it('should not spawn a launcher without a display server', () => {
193+
stubEnvironment('linux')
194+
195+
openBrowser('http://localhost:3000/')
196+
197+
expect(spawn).not.toHaveBeenCalled()
198+
})
199+
200+
it('should spawn a launcher when a display server is available', () => {
201+
stubEnvironment('linux', { DISPLAY: ':0' })
202+
203+
openBrowser('http://localhost:3000/')
204+
205+
expect(spawn).toHaveBeenCalledWith('xdg-open', ['http://localhost:3000/'], expect.anything())
206+
})
207+
})

packages/nuxt-cli/test/unit/shortcuts.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ describe('setupShortcuts', () => {
100100
const { press } = setup({ close })
101101

102102
await press('qr')
103-
await vi.waitFor(() => expect(printQRCode).toHaveBeenCalledWith('http://localhost:3000/'))
103+
await vi.waitFor(() => expect(printQRCode).toHaveBeenCalledWith('http://localhost:3000/', { showURL: true }))
104104
expect(close).not.toHaveBeenCalled()
105105

106106
await press('q')

0 commit comments

Comments
 (0)