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
returnport
251251
}
252252

253-
exportasyncfunctionprintQRCode(url: string): Promise<void>{
253+
exportasyncfunctionprintQRCode(url: string,{ showURL =false}: {showURL?: boolean}={}): Promise<void>{
254254
const{ renderUnicodeCompact }=awaitimport('uqr')
255+
constcaption=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
exportasyncfunctioncopyURL(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
constDISPLAY_REQUIRED_PLATFORMS=newSet<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-
functionisClipboardAvailable(env: NodeJS.ProcessEnv=process.env): boolean{
284+
functionhasDisplayServer(env: NodeJS.ProcessEnv=process.env): boolean{
283285
if(!DISPLAY_REQUIRED_PLATFORMS.has(process.platform)){
284286
returntrue
285287
}
286288
return!!(env.WSL_DISTRO_NAME||env.WAYLAND_DISPLAY||env.DISPLAY)
287289
}
288290

289-
functioncenterBlock(block: string): string{
291+
functioncenterBlock(block: string,blockWidth?: number): string{
290292
constlines=block.split('\n')
291-
constwidth=Math.max(...lines.map(line=>line.length))
293+
constwidth=blockWidth??Math.max(...lines.map(line=>line.length))
292294
constcolumns=Math.min(process.stdout.columns||80,80)
293295
constindent=' '.repeat(Math.max(0,Math.floor((columns-width)/2)))
294296
returnlines.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+
constBROWSER_LAUNCH_TIMEOUT_MS=3000
349+
345350
exportfunctionopenBrowser(url: string): void{
346351
constresolved=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+
constonFailure=(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+
constchild=spawn(command,args,{stdio: 'ignore',detached: true})
368+
child.once('error',onFailure)
369+
constonExit=(code: number|null)=>{
370+
if(code){
371+
onFailure(newError(`\`${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
constwriteText=vi.hoisted(()=>vi.fn())
1010

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

13-
constspawn=vi.hoisted(()=>vi.fn((_command: string,_args: string[])=>({on: ()=>({unref: ()=>{}})})))
13+
constspawn=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
constmocked=vi.mocked(networkInterfaces)
2327

28+
constrealPlatform=process.platform
29+
constrealEnv={ ...process.env}
30+
31+
/** Pretend to run on `platform`, with only the display variables in `env` set. */
32+
functionstubEnvironment(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+
functionrestoreEnvironment(){
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
constlisteners: 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
awaitPromise.all(listeners.splice(0).map(listener=>listener.close()))
81103
})
82104

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

126148
describe('copyURL',()=>{
127-
constplatform=process.platform
128-
constenv={ ...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-
functionstubPlatform(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
awaitcopyURL('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
awaitcopyURL('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
awaitcopyURL('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(newError('no clipboard tool found'))
168181

169182
awaitexpect(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
awaitpress('qr')
103-
awaitvi.waitFor(()=>expect(printQRCode).toHaveBeenCalledWith('http://localhost:3000/'))
103+
awaitvi.waitFor(()=>expect(printQRCode).toHaveBeenCalledWith('http://localhost:3000/',{showURL: true}))
104104
expect(close).not.toHaveBeenCalled()
105105

106106
awaitpress('q')

0 commit comments

Comments
 (0)