You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
	
	
		
			325 lines
		
	
	
		
			8.2 KiB
		
	
	
	
		
			TypeScript
		
	
		
		
			
		
	
	
			325 lines
		
	
	
		
			8.2 KiB
		
	
	
	
		
			TypeScript
		
	
| 
											7 years ago
										 | import { createReadStream, statSync } from 'fs'; | ||
|  | import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; | ||
|  | import { AddressInfo } from 'net'; | ||
|  | import { dirname } from 'path'; | ||
|  | 
 | ||
|  | import { v4 as getGuid } from 'uuid'; | ||
|  | import { app, autoUpdater, BrowserWindow, dialog } from 'electron'; | ||
|  | import { get as getFromConfig } from 'config'; | ||
|  | import { gt } from 'semver'; | ||
|  | 
 | ||
|  | import { | ||
|  |   checkForUpdates, | ||
|  |   deleteTempDir, | ||
|  |   downloadUpdate, | ||
|  |   getPrintableError, | ||
|  |   LoggerType, | ||
|  |   MessagesType, | ||
|  |   showCannotUpdateDialog, | ||
|  |   showUpdateDialog, | ||
|  | } from './common'; | ||
|  | import { hexToBinary, verifySignature } from './signature'; | ||
|  | import { markShouldQuit } from '../../app/window_state'; | ||
|  | 
 | ||
|  | let isChecking = false; | ||
|  | const SECOND = 1000; | ||
|  | const MINUTE = SECOND * 60; | ||
|  | const INTERVAL = MINUTE * 30; | ||
|  | 
 | ||
|  | export async function start( | ||
|  |   getMainWindow: () => BrowserWindow, | ||
|  |   messages: MessagesType, | ||
|  |   logger: LoggerType | ||
|  | ) { | ||
|  |   logger.info('macos/start: starting checks...'); | ||
|  | 
 | ||
|  |   loggerForQuitHandler = logger; | ||
|  |   app.once('quit', quitHandler); | ||
|  | 
 | ||
|  |   setInterval(async () => { | ||
|  |     try { | ||
|  |       await checkDownloadAndInstall(getMainWindow, messages, logger); | ||
|  |     } catch (error) { | ||
|  |       logger.error('macos/start: error:', getPrintableError(error)); | ||
|  |     } | ||
|  |   }, INTERVAL); | ||
|  | 
 | ||
|  |   await checkDownloadAndInstall(getMainWindow, messages, logger); | ||
|  | } | ||
|  | 
 | ||
|  | let fileName: string; | ||
|  | let version: string; | ||
|  | let updateFilePath: string; | ||
|  | let loggerForQuitHandler: LoggerType; | ||
|  | 
 | ||
|  | async function checkDownloadAndInstall( | ||
|  |   getMainWindow: () => BrowserWindow, | ||
|  |   messages: MessagesType, | ||
|  |   logger: LoggerType | ||
|  | ) { | ||
|  |   if (isChecking) { | ||
|  |     return; | ||
|  |   } | ||
|  | 
 | ||
|  |   logger.info('checkDownloadAndInstall: checking for update...'); | ||
|  |   try { | ||
|  |     isChecking = true; | ||
|  | 
 | ||
|  |     const result = await checkForUpdates(logger); | ||
|  |     if (!result) { | ||
|  |       return; | ||
|  |     } | ||
|  | 
 | ||
|  |     const { fileName: newFileName, version: newVersion } = result; | ||
|  |     if (fileName !== newFileName || !version || gt(newVersion, version)) { | ||
|  |       deleteCache(updateFilePath, logger); | ||
|  |       fileName = newFileName; | ||
|  |       version = newVersion; | ||
|  |       updateFilePath = await downloadUpdate(fileName, logger); | ||
|  |     } | ||
|  | 
 | ||
|  |     const publicKey = hexToBinary(getFromConfig('updatesPublicKey')); | ||
|  |     const verified = verifySignature(updateFilePath, version, publicKey); | ||
|  |     if (!verified) { | ||
|  |       // Note: We don't delete the cache here, because we don't want to continually
 | ||
|  |       //   re-download the broken release. We will download it only once per launch.
 | ||
|  |       throw new Error( | ||
|  |         `checkDownloadAndInstall: Downloaded update did not pass signature verification (version: '${version}'; fileName: '${fileName}')` | ||
|  |       ); | ||
|  |     } | ||
|  | 
 | ||
|  |     try { | ||
|  |       await handToAutoUpdate(updateFilePath, logger); | ||
|  |     } catch (error) { | ||
|  |       const readOnly = 'Cannot update while running on a read-only volume'; | ||
|  |       const message: string = error.message || ''; | ||
|  |       if (message.includes(readOnly)) { | ||
|  |         logger.info('checkDownloadAndInstall: showing read-only dialog...'); | ||
|  |         await showReadOnlyDialog(getMainWindow(), messages); | ||
|  |       } else { | ||
|  |         logger.info( | ||
|  |           'checkDownloadAndInstall: showing general update failure dialog...' | ||
|  |         ); | ||
|  |         await showCannotUpdateDialog(getMainWindow(), messages); | ||
|  |       } | ||
|  | 
 | ||
|  |       throw error; | ||
|  |     } | ||
|  | 
 | ||
|  |     // At this point, closing the app will cause the update to be installed automatically
 | ||
|  |     //   because Squirrel has cached the update file and will do the right thing.
 | ||
|  | 
 | ||
|  |     logger.info('checkDownloadAndInstall: showing update dialog...'); | ||
|  |     const shouldUpdate = await showUpdateDialog(getMainWindow(), messages); | ||
|  |     if (!shouldUpdate) { | ||
|  |       return; | ||
|  |     } | ||
|  | 
 | ||
|  |     logger.info('checkDownloadAndInstall: calling quitAndInstall...'); | ||
|  |     markShouldQuit(); | ||
|  |     autoUpdater.quitAndInstall(); | ||
|  |   } catch (error) { | ||
|  |     logger.error('checkDownloadAndInstall: error', getPrintableError(error)); | ||
|  |   } finally { | ||
|  |     isChecking = false; | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | function quitHandler() { | ||
|  |   deleteCache(updateFilePath, loggerForQuitHandler); | ||
|  | } | ||
|  | 
 | ||
|  | // Helpers
 | ||
|  | 
 | ||
|  | function deleteCache(filePath: string | null, logger: LoggerType) { | ||
|  |   if (filePath) { | ||
|  |     const tempDir = dirname(filePath); | ||
|  |     deleteTempDir(tempDir).catch(error => { | ||
|  |       logger.error( | ||
|  |         'quitHandler: error deleting temporary directory:', | ||
|  |         getPrintableError(error) | ||
|  |       ); | ||
|  |     }); | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | async function handToAutoUpdate( | ||
|  |   filePath: string, | ||
|  |   logger: LoggerType | ||
|  | ): Promise<void> { | ||
|  |   return new Promise((resolve, reject) => { | ||
|  |     const updateFileUrl = generateFileUrl(); | ||
|  |     const server = createServer(); | ||
|  |     let serverUrl: string; | ||
|  | 
 | ||
|  |     server.on('error', (error: Error) => { | ||
|  |       logger.error( | ||
|  |         'handToAutoUpdate: server had error', | ||
|  |         getPrintableError(error) | ||
|  |       ); | ||
|  |       shutdown(server, logger); | ||
|  |       reject(error); | ||
|  |     }); | ||
|  | 
 | ||
|  |     server.on( | ||
|  |       'request', | ||
|  |       (request: IncomingMessage, response: ServerResponse) => { | ||
|  |         const { url } = request; | ||
|  | 
 | ||
|  |         if (url === '/') { | ||
|  |           const absoluteUrl = `${serverUrl}${updateFileUrl}`; | ||
|  |           writeJSONResponse(absoluteUrl, response); | ||
|  | 
 | ||
|  |           return; | ||
|  |         } | ||
|  | 
 | ||
|  |         if (!url || !url.startsWith(updateFileUrl)) { | ||
|  |           write404(url, response, logger); | ||
|  | 
 | ||
|  |           return; | ||
|  |         } | ||
|  | 
 | ||
|  |         pipeUpdateToSquirrel(filePath, server, response, logger, reject); | ||
|  |       } | ||
|  |     ); | ||
|  | 
 | ||
|  |     server.listen(0, '127.0.0.1', () => { | ||
|  |       serverUrl = getServerUrl(server); | ||
|  | 
 | ||
|  |       autoUpdater.on('error', (error: Error) => { | ||
|  |         logger.error('autoUpdater: error', getPrintableError(error)); | ||
|  |         reject(error); | ||
|  |       }); | ||
|  |       autoUpdater.on('update-downloaded', () => { | ||
|  |         logger.info('autoUpdater: update-downloaded event fired'); | ||
|  |         shutdown(server, logger); | ||
|  |         resolve(); | ||
|  |       }); | ||
|  | 
 | ||
|  |       autoUpdater.setFeedURL({ | ||
|  |         url: serverUrl, | ||
|  |         headers: { 'Cache-Control': 'no-cache' }, | ||
|  |       }); | ||
|  |       autoUpdater.checkForUpdates(); | ||
|  |     }); | ||
|  |   }); | ||
|  | } | ||
|  | 
 | ||
|  | function pipeUpdateToSquirrel( | ||
|  |   filePath: string, | ||
|  |   server: Server, | ||
|  |   response: ServerResponse, | ||
|  |   logger: LoggerType, | ||
|  |   reject: (error: Error) => void | ||
|  | ) { | ||
|  |   const updateFileSize = getFileSize(filePath); | ||
|  |   const readStream = createReadStream(filePath); | ||
|  | 
 | ||
|  |   response.on('error', (error: Error) => { | ||
|  |     logger.error( | ||
|  |       'pipeUpdateToSquirrel: update file download request had an error', | ||
|  |       getPrintableError(error) | ||
|  |     ); | ||
|  |     shutdown(server, logger); | ||
|  |     reject(error); | ||
|  |   }); | ||
|  | 
 | ||
|  |   readStream.on('error', (error: Error) => { | ||
|  |     logger.error( | ||
|  |       'pipeUpdateToSquirrel: read stream error response:', | ||
|  |       getPrintableError(error) | ||
|  |     ); | ||
|  |     shutdown(server, logger, response); | ||
|  |     reject(error); | ||
|  |   }); | ||
|  | 
 | ||
|  |   response.writeHead(200, { | ||
|  |     'Content-Type': 'application/zip', | ||
|  |     'Content-Length': updateFileSize, | ||
|  |   }); | ||
|  | 
 | ||
|  |   readStream.pipe(response); | ||
|  | } | ||
|  | 
 | ||
|  | function writeJSONResponse(url: string, response: ServerResponse) { | ||
|  |   const data = Buffer.from( | ||
|  |     JSON.stringify({ | ||
|  |       url, | ||
|  |     }) | ||
|  |   ); | ||
|  |   response.writeHead(200, { | ||
|  |     'Content-Type': 'application/json', | ||
|  |     'Content-Length': data.byteLength, | ||
|  |   }); | ||
|  |   response.end(data); | ||
|  | } | ||
|  | 
 | ||
|  | function write404( | ||
|  |   url: string | undefined, | ||
|  |   response: ServerResponse, | ||
|  |   logger: LoggerType | ||
|  | ) { | ||
|  |   logger.error(`write404: Squirrel requested unexpected url '${url}'`); | ||
|  |   response.writeHead(404); | ||
|  |   response.end(); | ||
|  | } | ||
|  | 
 | ||
|  | function getServerUrl(server: Server) { | ||
|  |   const address = server.address() as AddressInfo; | ||
|  | 
 | ||
|  |   // tslint:disable-next-line:no-http-string
 | ||
|  |   return `http://127.0.0.1:${address.port}`; | ||
|  | } | ||
|  | function generateFileUrl(): string { | ||
|  |   return `/${getGuid()}.zip`; | ||
|  | } | ||
|  | 
 | ||
|  | function getFileSize(targetPath: string): number { | ||
|  |   const { size } = statSync(targetPath); | ||
|  | 
 | ||
|  |   return size; | ||
|  | } | ||
|  | 
 | ||
|  | function shutdown( | ||
|  |   server: Server, | ||
|  |   logger: LoggerType, | ||
|  |   response?: ServerResponse | ||
|  | ) { | ||
|  |   try { | ||
|  |     if (server) { | ||
|  |       server.close(); | ||
|  |     } | ||
|  |   } catch (error) { | ||
|  |     logger.error('shutdown: Error closing server', getPrintableError(error)); | ||
|  |   } | ||
|  | 
 | ||
|  |   try { | ||
|  |     if (response) { | ||
|  |       response.end(); | ||
|  |     } | ||
|  |   } catch (endError) { | ||
|  |     logger.error( | ||
|  |       "shutdown: couldn't end response", | ||
|  |       getPrintableError(endError) | ||
|  |     ); | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | export async function showReadOnlyDialog( | ||
|  |   mainWindow: BrowserWindow, | ||
|  |   messages: MessagesType | ||
|  | ): Promise<void> { | ||
|  |   const options = { | ||
|  |     type: 'warning', | ||
|  |     buttons: [messages.ok.message], | ||
|  |     title: messages.cannotUpdate.message, | ||
|  |     message: messages.readOnlyVolume.message, | ||
|  |   }; | ||
|  | 
 | ||
|  |   return new Promise(resolve => { | ||
|  |     dialog.showMessageBox(mainWindow, options, () => { | ||
|  |       resolve(); | ||
|  |     }); | ||
|  |   }); | ||
|  | } |