From a9f514b9fbcd993cbee005e5cbb28af5ec9fe407 Mon Sep 17 00:00:00 2001 From: crusader Date: Thu, 16 Aug 2018 19:49:37 +0900 Subject: [PATCH] ing --- @overflow/core/menu-item.ts | 21 + {src/electron => @overflow/core}/now.ts | 0 @overflow/core/parse-app-url.ts | 145 +++++++ @overflow/core/path.ts | 12 + @overflow/core/process/win32.ts | 90 +++++ @overflow/core/shell.ts | 167 ++++++++ @overflow/core/source-map-support.ts | 137 +++++++ @overflow/core/window-state.ts | 60 +++ config/webpack.config.main.js | 7 + package.json | 3 + src/electron/app-window.ts | 485 ++++++++++++----------- src/electron/main.ts | 501 +++++++++++++++++++++--- src/electron/menu/index.ts | 2 + src/electron/menu/menu-event.ts | 4 + src/electron/menu/menu-ids.ts | 5 + src/electron/shell.ts | 25 ++ src/electron/squirrel-updater.ts | 156 ++++++++ src/globals.d.ts | 256 ++++++++++++ tsconfig.json | 4 +- yarn.lock | 20 +- 20 files changed, 1802 insertions(+), 298 deletions(-) create mode 100644 @overflow/core/menu-item.ts rename {src/electron => @overflow/core}/now.ts (100%) create mode 100644 @overflow/core/parse-app-url.ts create mode 100644 @overflow/core/path.ts create mode 100644 @overflow/core/process/win32.ts create mode 100644 @overflow/core/shell.ts create mode 100644 @overflow/core/source-map-support.ts create mode 100644 @overflow/core/window-state.ts create mode 100644 src/electron/menu/index.ts create mode 100644 src/electron/menu/menu-event.ts create mode 100644 src/electron/menu/menu-ids.ts create mode 100644 src/electron/shell.ts create mode 100644 src/electron/squirrel-updater.ts create mode 100644 src/globals.d.ts diff --git a/@overflow/core/menu-item.ts b/@overflow/core/menu-item.ts new file mode 100644 index 0000000..c6a9d4c --- /dev/null +++ b/@overflow/core/menu-item.ts @@ -0,0 +1,21 @@ +export interface IMenuItem { + /** The user-facing label. */ + readonly label?: string; + + /** The action to invoke when the user selects the item. */ + readonly action?: () => void; + + /** The type of item. */ + readonly type?: 'separator'; + + /** Is the menu item enabled? Defaults to true. */ + readonly enabled?: boolean; + + /** + * The predefined behavior of the menu item. + * + * When specified the click property will be ignored. + * See https://electronjs.org/docs/api/menu-item#roles + */ + readonly role?: string; +} diff --git a/src/electron/now.ts b/@overflow/core/now.ts similarity index 100% rename from src/electron/now.ts rename to @overflow/core/now.ts diff --git a/@overflow/core/parse-app-url.ts b/@overflow/core/parse-app-url.ts new file mode 100644 index 0000000..989fd48 --- /dev/null +++ b/@overflow/core/parse-app-url.ts @@ -0,0 +1,145 @@ +import * as URL from 'url'; + +export interface IOAuthAction { + readonly name: 'oauth'; + readonly code: string; +} + +export interface IOpenRepositoryFromURLAction { + readonly name: 'open-repository-from-url'; + + /** the remote repository location associated with the "Open in Desktop" action */ + readonly url: string; + + /** the optional branch name which should be checked out. use the default branch otherwise. */ + readonly branch: string | null; + + /** the pull request number, if pull request originates from a fork of the repository */ + readonly pr: string | null; + + /** the file to open after cloning the repository */ + readonly filepath: string | null; +} + +export interface IOpenRepositoryFromPathAction { + readonly name: 'open-repository-from-path'; + + /** The local path to open. */ + readonly path: string; +} + +export interface IUnknownAction { + readonly name: 'unknown'; + readonly url: string; +} + +export type URLActionType = + | IOAuthAction + | IOpenRepositoryFromURLAction + | IOpenRepositoryFromPathAction + | IUnknownAction; + +// eslint-disable-next-line typescript/interface-name-prefix +interface ParsedUrlQueryWithUndefined { + // `undefined` is added here to ensure we handle the missing querystring key + // See https://github.com/Microsoft/TypeScript/issues/13778 for discussion about + // why this isn't supported natively in TypeScript + [key: string]: string | string[] | undefined; +} + +/** + * Parse the URL to find a given key in the querystring text. + * + * @param url The source URL containing querystring key-value pairs + * @param key The key to look for in the querystring + */ +function getQueryStringValue( + query: ParsedUrlQueryWithUndefined, + key: string +): string | null { + const value = query[key]; + if (value == null) { + return null; + } + + if (Array.isArray(value)) { + return value[0]; + } + + return value; +} + +export function parseAppURL(url: string): URLActionType { + const parsedURL = URL.parse(url, true); + const hostname = parsedURL.hostname; + const unknown: IUnknownAction = { name: 'unknown', url }; + if (!hostname) { + return unknown; + } + + const query = parsedURL.query; + + const actionName = hostname.toLowerCase(); + if (actionName === 'oauth') { + const code = getQueryStringValue(query, 'code'); + if (code != null) { + return { name: 'oauth', code }; + } else { + return unknown; + } + } + + // we require something resembling a URL first + // - bail out if it's not defined + // - bail out if you only have `/` + const pathName = parsedURL.pathname; + if (!pathName || pathName.length <= 1) { + return unknown; + } + + // Trim the trailing / from the URL + const parsedPath = pathName.substr(1); + + if (actionName === 'openrepo') { + const probablyAURL = parsedPath; + + // suffix the remote URL with `.git`, for backwards compatibility + const _url = `${probablyAURL}.git`; + + const pr = getQueryStringValue(query, 'pr'); + const branch = getQueryStringValue(query, 'branch'); + const filepath = getQueryStringValue(query, 'filepath'); + + if (pr != null) { + if (!/^\d+$/.test(pr)) { + return unknown; + } + + // we also expect the branch for a forked PR to be a given ref format + if (branch != null && !/^pr\/\d+$/.test(branch)) { + return unknown; + } + } + + // if (branch != null && testForInvalidChars(branch)) { + // return unknown; + // } + + return { + name: 'open-repository-from-url', + url: _url, + branch, + pr, + filepath, + }; + } + + if (actionName === 'openlocalrepo') { + return { + name: 'open-repository-from-path', + path: decodeURIComponent(parsedPath), + }; + } + + return unknown; +} diff --git a/@overflow/core/path.ts b/@overflow/core/path.ts new file mode 100644 index 0000000..16a6440 --- /dev/null +++ b/@overflow/core/path.ts @@ -0,0 +1,12 @@ +import * as Path from 'path'; +import fileUrl = require('file-url'); + +/** + * Resolve and encode the path information into a URL. + * + * @param pathSegments array of path segments to resolve + */ +export function encodePathAsUrl(...pathSegments: string[]): string { + const path = Path.resolve(...pathSegments); + return fileUrl(path); +} diff --git a/@overflow/core/process/win32.ts b/@overflow/core/process/win32.ts new file mode 100644 index 0000000..fa1e031 --- /dev/null +++ b/@overflow/core/process/win32.ts @@ -0,0 +1,90 @@ +import { spawn as spawnInternal } from 'child_process'; +import * as Path from 'path'; + +/** Get the path segments in the user's `Path`. */ +export async function getPathSegments(): Promise> { + let powershellPath: string; + const systemRoot = process.env.SystemRoot; + if (systemRoot != null) { + const system32Path = Path.join(systemRoot, 'System32'); + powershellPath = Path.join( + system32Path, + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ); + } else { + powershellPath = 'powershell.exe'; + } + + const args = [ + '-noprofile', + '-ExecutionPolicy', + 'RemoteSigned', + '-command', + // Set encoding and execute the command, capture the output, and return it + // via .NET's console in order to have consistent UTF-8 encoding. + // See http://stackoverflow.com/questions/22349139/utf-8-output-from-powershell + // to address https://github.com/atom/atom/issues/5063 + ` + [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 + $output=[environment]::GetEnvironmentVariable('Path', 'User') + [Console]::WriteLine($output) + `, + ]; + + const stdout = await spawn(powershellPath, args); + const pathOutput = stdout.replace(/^\s+|\s+$/g, ''); + return pathOutput.split(/;+/).filter(segment => segment.length); +} + +/** Set the user's `Path`. */ +export async function setPathSegments( + paths: ReadonlyArray +): Promise { + let setxPath: string; + const systemRoot = process.env['SystemRoot']; + if (systemRoot) { + const system32Path = Path.join(systemRoot, 'System32'); + setxPath = Path.join(system32Path, 'setx.exe'); + } else { + setxPath = 'setx.exe'; + } + + await spawn(setxPath, ['Path', paths.join(';')]); +} + +/** Spawn a command with arguments and capture its output. */ +export function spawn( + command: string, + args: ReadonlyArray +): Promise { + try { + const child = spawnInternal(command, args as string[]); + return new Promise((resolve, reject) => { + let stdout = ''; + child.stdout.on('data', data => { + stdout += data; + }); + + child.on('close', code => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(`Command "${command} ${args}" failed: "${stdout}"`)); + } + }); + + child.on('error', (err: Error) => { + reject(err); + }); + + // This is necessary if using Powershell 2 on Windows 7 to get the events + // to raise. + // See http://stackoverflow.com/questions/9155289/calling-powershell-from-nodejs + child.stdin.end(); + }); + } catch (error) { + return Promise.reject(error); + } +} diff --git a/@overflow/core/shell.ts b/@overflow/core/shell.ts new file mode 100644 index 0000000..80803db --- /dev/null +++ b/@overflow/core/shell.ts @@ -0,0 +1,167 @@ +/* eslint-disable no-sync */ + +import * as ChildProcess from 'child_process'; +import * as os from 'os'; + +interface IndexLookup { + [propName: string]: string; +} + +/** + * The names of any env vars that we shouldn't copy from the shell environment. + */ +const BlacklistedNames = new Set(['LOCAL_GIT_DIRECTORY']); + +/** + * Inspect whether the current process needs to be patched to get important + * environment variables for Desktop to work and integrate with other tools + * the user may invoke as part of their workflow. + * + * This is only applied to macOS installations due to how the application + * is launched. + * + * @param process The process to inspect. + */ +export function shellNeedsPatching(process: NodeJS.Process): boolean { + return __DARWIN__ && !process.env.PWD; +} + +interface ShellResult { + stdout: string; + error: Error | null; +} + +/** + * Gets a dump of the user's configured shell environment. + * + * @returns the output of the `env` command or `null` if there was an error. + */ +async function getRawShellEnv(): Promise { + const shell = getUserShell(); + + const promise = new Promise(resolve => { + let child: ChildProcess.ChildProcess | null = null; + let _error: Error | null = null; + let _stdout = ''; + let done = false; + + // ensure we clean up eventually, in case things go bad + const cleanup = () => { + if (!done && child) { + child.kill(); + done = true; + } + }; + process.once('exit', cleanup); + setTimeout(() => { + cleanup(); + }, 5000); + + const options = { + detached: true, + stdio: ['ignore', 'pipe', process.stderr], + }; + + child = ChildProcess.spawn(shell, ['-ilc', 'command env'], options); + + const buffers: Array = []; + + child.on('error', (e: Error) => { + done = true; + _error = e; + }); + + child.stdout.on('data', (data: Buffer) => { + buffers.push(data); + }); + + child.on('close', (code: number, signal) => { + done = true; + process.removeListener('exit', cleanup); + if (buffers.length) { + _stdout = Buffer.concat(buffers).toString('utf8'); + } + + resolve({ stdout: _stdout, error }); + }); + }); + + const { stdout, error } = await promise; + + if (error) { + // just swallow the error and move on with everything + return null; + } + + return stdout; +} + +function getUserShell() { + if (process.env.SHELL) { + return process.env.SHELL; + } + + return '/bin/bash'; +} + +/** + * Get the environment variables from the user's current shell and update the + * current environment. + * + * @param updateEnvironment a callback to fire if a valid environment is found + */ +async function getEnvironmentFromShell( + updateEnvironment: (env: IndexLookup) => void +): Promise { + if (__WIN32__) { + return; + } + + const shellEnvText = await getRawShellEnv(); + if (!shellEnvText) { + return; + } + + const env: IndexLookup = {}; + + for (const line of shellEnvText.split(os.EOL)) { + if (line.includes('=')) { + const components = line.split('='); + if (components.length === 2) { + env[components[0]] = components[1]; + } else { + const k = components.shift(); + const v = components.join('='); + if (k) { + env[k] = v; + } + } + } + } + + updateEnvironment(env); +} + +/** + * Apply new environment variables to the current process, ignoring + * Node-specific environment variables which need to be preserved. + * + * @param env The new environment variables from the user's shell. + */ +function mergeEnvironmentVariables(env: IndexLookup) { + for (const key in env) { + if (BlacklistedNames.has(key)) { + continue; + } + + process.env[key] = env[key]; + } +} + +/** + * Update the current process's environment variables using environment + * variables from the user's shell, if they can be retrieved successfully. + */ +export function updateEnvironmentForProcess(): Promise { + return getEnvironmentFromShell(mergeEnvironmentVariables); +} diff --git a/@overflow/core/source-map-support.ts b/@overflow/core/source-map-support.ts new file mode 100644 index 0000000..4599711 --- /dev/null +++ b/@overflow/core/source-map-support.ts @@ -0,0 +1,137 @@ +import * as Path from 'path'; +import * as Fs from 'fs'; + +const fileUriToPath: (uri: string) => string = require('file-uri-to-path'); +const sourceMapSupport = require('source-map-support'); + +/** + * This array tells the source map logic which files that we can expect to + * be able to resolve a source map for and they should reflect the chunks + * entry names from our webpack config. + * + * Note that we explicitly don't enable source maps for the crash process + * since it's possible that the error which caused us to spawn the crash + * process was related to source maps. + */ +const knownFilesWithSourceMap = ['renderer.js', 'main.js']; + +function retrieveSourceMap(source: string) { + // This is a happy path in case we know for certain that we won't be + // able to resolve a source map for the given location. + if (!knownFilesWithSourceMap.some(file => source.endsWith(file))) { + return null; + } + + // We get a file uri when we're inside a renderer, convert to a path + if (source.startsWith('file://')) { + source = fileUriToPath(source); + } + + // We store our source maps right next to the bundle + const path = `${source}.map`; + + if (__DEV__ && path.startsWith('http://')) { + try { + const xhr = new XMLHttpRequest(); + xhr.open('GET', path, false); + xhr.send(null); + if (xhr.readyState === 4 && xhr.status === 200) { + return { url: Path.basename(path), map: xhr.responseText }; + } + } catch (error) { + return; + } + return; + } + + // We don't have an option here, see + // https://github.com/v8/v8/wiki/Stack-Trace-API#customizing-stack-traces + // This happens on-demand when someone accesses the stack + // property on an error object and has to be synchronous :/ + // eslint-disable-next-line no-sync + if (!Fs.existsSync(path)) { + return; + } + + try { + // eslint-disable-next-line no-sync + const map = Fs.readFileSync(path, 'utf8'); + return { url: Path.basename(path), map }; + } catch (error) { + return; + } +} + +/** A map from errors to their stack frames. */ +const stackFrameMap = new WeakMap>(); + +/** + * The `prepareStackTrace` that comes from the `source-map-support` module. + * We'll use this when the user explicitly wants the stack source mapped. + */ +let prepareStackTraceWithSourceMap: ( + error: Error, + frames: ReadonlyArray +) => string; + +/** + * Capture the error's stack frames and return a standard, un-source mapped + * stack trace. + */ +function prepareStackTrace(error: Error, frames: ReadonlyArray) { + stackFrameMap.set(error, frames); + + // Ideally we'd use the default `Error.prepareStackTrace` here but it's + // undefined so V8 must doing something fancy. Instead we'll do a decent + // impression. + return error + frames.map(frame => `\n at ${frame}`).join(''); +} + +/** Enable source map support in the current process. */ +export function enableSourceMaps() { + sourceMapSupport.install({ + environment: 'node', + handleUncaughtExceptions: false, + retrieveSourceMap, + }); + + const AnyError = Error as any; + // We want to keep `source-map-support`s `prepareStackTrace` around to use + // later, but our cheaper `prepareStackTrace` should be the default. + prepareStackTraceWithSourceMap = AnyError.prepareStackTrace; + AnyError.prepareStackTrace = prepareStackTrace; +} + +/** + * Make a copy of the error with a source-mapped stack trace. If it couldn't + * perform the source mapping, it'll use the original error stack. + */ +export function withSourceMappedStack(error: Error): Error { + return { + name: error.name, + message: error.message, + stack: sourceMappedStackTrace(error), + }; +} + +/** Get the source mapped stack trace for the error. */ +function sourceMappedStackTrace(error: Error): string | undefined { + let frames = stackFrameMap.get(error); + + if (!frames) { + // At this point there's no guarantee that anyone has actually retrieved the + // stack on this error which means that our custom prepareStackTrace handler + // hasn't run and as a result of that we don't have the native frames stored + // in our weak map. In order to get around that we'll eagerly access the + // stack, forcing our handler to run which should ensure that the native + // frames are stored in our weak map. + (error.stack || '').toString(); + frames = stackFrameMap.get(error); + } + + if (!frames) { + return error.stack; + } + + return prepareStackTraceWithSourceMap(error, frames); +} diff --git a/@overflow/core/window-state.ts b/@overflow/core/window-state.ts new file mode 100644 index 0000000..89b26e5 --- /dev/null +++ b/@overflow/core/window-state.ts @@ -0,0 +1,60 @@ +// The name of the ipc channel over which state changes are communicated. +export const windowStateChannelName = 'window-state-changed'; + +export type WindowState = + | 'minimized' + | 'normal' + | 'maximized' + | 'full-screen' + | 'hidden'; + +export function getWindowState(window: Electron.BrowserWindow): WindowState { + if (window.isFullScreen()) { + return 'full-screen'; + } else if (window.isMaximized()) { + return 'maximized'; + } else if (window.isMinimized()) { + return 'minimized'; + } else if (!window.isVisible()) { + return 'hidden'; + } else { + return 'normal'; + } +} + +/** + * Registers event handlers for all window state transition events and + * forwards those to the renderer process for a given window. + */ +export function registerWindowStateChangedEvents( + window: Electron.BrowserWindow +) { + window.on('enter-full-screen', () => + sendWindowStateEvent(window, 'full-screen') + ); + + // So this is a bit of a hack. If we call window.isFullScreen directly after + // receiving the leave-full-screen event it'll return true which isn't what + // we're after. So we'll say that we're transitioning to 'normal' even though + // we might be maximized. This works because electron will emit a 'maximized' + // event after 'leave-full-screen' if the state prior to full-screen was maximized. + window.on('leave-full-screen', () => sendWindowStateEvent(window, 'normal')); + + window.on('maximize', () => sendWindowStateEvent(window, 'maximized')); + window.on('minimize', () => sendWindowStateEvent(window, 'minimized')); + window.on('unmaximize', () => sendWindowStateEvent(window, 'normal')); + window.on('restore', () => sendWindowStateEvent(window, 'normal')); + window.on('hide', () => sendWindowStateEvent(window, 'hidden')); + window.on('show', () => sendWindowStateEvent(window, 'normal')); +} + +/** + * Short hand convenience function for sending a window state change event + * over the window-state-changed channel to the render process. + */ +function sendWindowStateEvent( + window: Electron.BrowserWindow, + state: WindowState +) { + window.webContents.send(windowStateChannelName, state); +} diff --git a/config/webpack.config.main.js b/config/webpack.config.main.js index 6b0de4e..6c1d618 100644 --- a/config/webpack.config.main.js +++ b/config/webpack.config.main.js @@ -32,6 +32,13 @@ module.exports = function () { filename: '[name].js', sourceMapFilename: '[file].map', }; + config.resolve = { + extensions: ['.ts', '.tsx', '.mjs', '.js'], + modules: [ + root(), + 'node_modules' + ], + } config.module = { rules: [ { diff --git a/package.json b/package.json index b1bf4b6..dcda63f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "electron-connect": "^0.6.3", "electron-debug": "^2.0.0", "electron-window-state": "^4.1.1", + "file-uri-to-path": "^1.0.0", + "file-url": "^2.0.2", "fs-extra": "^7.0.0", "jasmine-core": "~2.99.1", "jasmine-spec-reporter": "~4.2.1", @@ -59,6 +61,7 @@ "primeng": "^6.0.0", "primer-support": "^4.0.0", "protractor": "~5.3.0", + "source-map-support": "^0.5.8", "ts-node": "~5.0.1", "tslint": "~5.9.1", "typescript": "~2.7.2", diff --git a/src/electron/app-window.ts b/src/electron/app-window.ts index 46f86dd..9ded5c4 100644 --- a/src/electron/app-window.ts +++ b/src/electron/app-window.ts @@ -1,282 +1,293 @@ -// import { BrowserWindow, ipcMain, Menu, app, dialog } from 'electron'; -// import { encodePathAsUrl } from '../lib/path'; -// import { registerWindowStateChangedEvents } from '../lib/window-state'; -// import { MenuEvent } from './menu'; -// import { URLActionType } from '../lib/parse-app-url'; -// import { ILaunchStats } from '../lib/stats'; -// import { menuFromElectronMenu } from '../models/app-menu'; -// import { now } from './now'; -// import * as path from 'path'; +import { BrowserWindow, ipcMain, Menu, app, dialog } from 'electron'; +import * as path from 'path'; +import * as URL from 'url'; +import { EventEmitter } from 'events'; -// let windowStateKeeper: any | null = null; +import { encodePathAsUrl } from '@overflow/core/path'; +import { registerWindowStateChangedEvents } from '@overflow/core/window-state'; +import { URLActionType } from '@overflow/core/parse-app-url'; +import { now } from '@overflow/core/now'; -// export class AppWindow { -// private window: Electron.BrowserWindow; -// private emitter = new Emitter(); +import { MenuEvent } from './menu'; -// private _loadTime: number | null = null; -// private _rendererReadyTime: number | null = null; +let windowStateKeeper: any | null = null; -// private minWidth = 960; -// private minHeight = 660; +export class AppWindow { + private window: Electron.BrowserWindow; + private emitter = new EventEmitter(); -// public constructor() { -// if (!windowStateKeeper) { -// // `electron-window-state` requires Electron's `screen` module, which can -// // only be required after the app has emitted `ready`. So require it -// // lazily. -// windowStateKeeper = require('electron-window-state'); -// } + private _loadTime: number | null = null; + private _rendererReadyTime: number | null = null; -// const savedWindowState = windowStateKeeper({ -// defaultWidth: this.minWidth, -// defaultHeight: this.minHeight, -// }); + private minWidth = 960; + private minHeight = 660; -// const windowOptions: Electron.BrowserWindowConstructorOptions = { -// x: savedWindowState.x, -// y: savedWindowState.y, -// width: savedWindowState.width, -// height: savedWindowState.height, -// minWidth: this.minWidth, -// minHeight: this.minHeight, -// show: false, -// // This fixes subpixel aliasing on Windows -// // See https://github.com/atom/atom/commit/683bef5b9d133cb194b476938c77cc07fd05b972 -// backgroundColor: '#fff', -// webPreferences: { -// // Disable auxclick event -// // See https://developers.google.com/web/updates/2016/10/auxclick -// disableBlinkFeatures: 'Auxclick', -// // Enable, among other things, the ResizeObserver -// experimentalFeatures: true, -// }, -// acceptFirstMouse: true, -// }; + public constructor() { + if (!windowStateKeeper) { + // `electron-window-state` requires Electron's `screen` module, which can + // only be required after the app has emitted `ready`. So require it + // lazily. + windowStateKeeper = require('electron-window-state'); + } -// if (__DARWIN__) { -// windowOptions.titleBarStyle = 'hidden'; -// } else if (__WIN32__) { -// windowOptions.frame = false; -// } else if (__LINUX__) { -// windowOptions.icon = path.join(__dirname, 'static', 'icon-logo.png'); -// } + const savedWindowState = windowStateKeeper({ + defaultWidth: this.minWidth, + defaultHeight: this.minHeight, + }); -// this.window = new BrowserWindow(windowOptions); -// savedWindowState.manage(this.window); + const windowOptions: Electron.BrowserWindowConstructorOptions = { + x: savedWindowState.x, + y: savedWindowState.y, + width: savedWindowState.width, + height: savedWindowState.height, + minWidth: this.minWidth, + minHeight: this.minHeight, + show: true, + // This fixes subpixel aliasing on Windows + // See https://github.com/atom/atom/commit/683bef5b9d133cb194b476938c77cc07fd05b972 + backgroundColor: '#fff', + webPreferences: { + // Disable auxclick event + // See https://developers.google.com/web/updates/2016/10/auxclick + disableBlinkFeatures: 'Auxclick', + // Enable, among other things, the ResizeObserver + experimentalFeatures: true, + }, + acceptFirstMouse: true, + }; -// let quitting = false; -// app.on('before-quit', () => { -// quitting = true; -// }); + if (__DARWIN__) { + windowOptions.titleBarStyle = 'hidden'; + } else if (__WIN32__) { + windowOptions.frame = false; + } else if (__LINUX__) { + // windowOptions.icon = path.join(__dirname, 'static', 'icon-logo.png'); + } -// ipcMain.on('will-quit', (event: Electron.IpcMessageEvent) => { -// quitting = true; -// event.returnValue = true; -// }); + console.log(windowOptions); -// // on macOS, when the user closes the window we really just hide it. This -// // lets us activate quickly and keep all our interesting logic in the -// // renderer. -// if (__DARWIN__) { -// this.window.on('close', e => { -// if (!quitting) { -// e.preventDefault(); -// Menu.sendActionToFirstResponder('hide:'); -// } -// }); -// } -// } + this.window = new BrowserWindow(windowOptions); + savedWindowState.manage(this.window); -// public load() { -// let startLoad = 0; -// // We only listen for the first of the loading events to avoid a bug in -// // Electron/Chromium where they can sometimes fire more than once. See -// // See -// // https://github.com/desktop/desktop/pull/513#issuecomment-253028277. This -// // shouldn't really matter as in production builds loading _should_ only -// // happen once. -// this.window.webContents.once('did-start-loading', () => { -// this._rendererReadyTime = null; -// this._loadTime = null; + let quitting = false; + app.on('before-quit', () => { + quitting = true; + }); -// startLoad = now(); -// }); + ipcMain.on('will-quit', (event: Electron.IpcMessageEvent) => { + quitting = true; + event.returnValue = true; + }); -// this.window.webContents.once('did-finish-load', () => { -// if (process.env.NODE_ENV === 'development') { -// this.window.webContents.openDevTools(); -// } + // on macOS, when the user closes the window we really just hide it. This + // lets us activate quickly and keep all our interesting logic in the + // renderer. + if (__DARWIN__) { + this.window.on('close', e => { + if (!quitting) { + e.preventDefault(); + Menu.sendActionToFirstResponder('hide:'); + } + }); + } + } -// this._loadTime = now() - startLoad; + public load() { + let startLoad = 0; + // We only listen for the first of the loading events to avoid a bug in + // Electron/Chromium where they can sometimes fire more than once. See + // See + // https://github.com/desktop/desktop/pull/513#issuecomment-253028277. This + // shouldn't really matter as in production builds loading _should_ only + // happen once. + this.window.webContents.once('did-start-loading', () => { + this._rendererReadyTime = null; + this._loadTime = null; -// this.maybeEmitDidLoad(); -// }); + startLoad = now(); + }); -// this.window.webContents.on('did-finish-load', () => { -// this.window.webContents.setVisualZoomLevelLimits(1, 1); -// }); + this.window.webContents.once('did-finish-load', () => { + if (process.env.NODE_ENV === 'development') { + this.window.webContents.openDevTools(); + } -// this.window.webContents.on('did-fail-load', () => { -// this.window.webContents.openDevTools(); -// this.window.show(); -// }); + this._loadTime = now() - startLoad; -// // TODO: This should be scoped by the window. -// ipcMain.once( -// 'renderer-ready', -// (event: Electron.IpcMessageEvent, readyTime: number) => { -// this._rendererReadyTime = readyTime; + this.maybeEmitDidLoad(); + }); -// this.maybeEmitDidLoad(); -// } -// ); + this.window.webContents.on('did-finish-load', () => { + this.window.webContents.setVisualZoomLevelLimits(1, 1); + }); -// this.window.on('focus', () => this.window.webContents.send('focus')); -// this.window.on('blur', () => this.window.webContents.send('blur')); + this.window.webContents.on('did-fail-load', () => { + this.window.webContents.openDevTools(); + this.window.show(); + }); -// registerWindowStateChangedEvents(this.window); -// this.window.loadURL(encodePathAsUrl(__dirname, 'index.html')); -// } + // TODO: This should be scoped by the window. + ipcMain.once( + 'renderer-ready', + (event: Electron.IpcMessageEvent, readyTime: number) => { + this._rendererReadyTime = readyTime; -// /** -// * Emit the `onDidLoad` event if the page has loaded and the renderer has -// * signalled that it's ready. -// */ -// private maybeEmitDidLoad() { -// if (!this.rendererLoaded) { -// return; -// } + this.maybeEmitDidLoad(); + } + ); -// this.emitter.emit('did-load', null); -// } + this.window.on('focus', () => this.window.webContents.send('focus')); + this.window.on('blur', () => this.window.webContents.send('blur')); -// /** Is the page loaded and has the renderer signalled it's ready? */ -// private get rendererLoaded(): boolean { -// return !!this.loadTime && !!this.rendererReadyTime; -// } + registerWindowStateChangedEvents(this.window); + // this.window.loadURL(encodePathAsUrl(__dirname, 'index.html')); -// public onClose(fn: () => void) { -// this.window.on('closed', fn); -// } + const indexUrl = URL.format({ + pathname: path.join('//localhost:4200'), + protocol: 'http:', + slashes: true + }); + this.window.loadURL(indexUrl); + } -// /** -// * Register a function to call when the window is done loading. At that point -// * the page has loaded and the renderer has signalled that it is ready. -// */ -// public onDidLoad(fn: () => void): Disposable { -// return this.emitter.on('did-load', fn); -// } + /** + * Emit the `onDidLoad` event if the page has loaded and the renderer has + * signalled that it's ready. + */ + private maybeEmitDidLoad() { + if (!this.rendererLoaded) { + return; + } -// public isMinimized() { -// return this.window.isMinimized(); -// } + this.emitter.emit('did-load', null); + } -// /** Is the window currently visible? */ -// public isVisible() { -// return this.window.isVisible(); -// } + /** Is the page loaded and has the renderer signalled it's ready? */ + private get rendererLoaded(): boolean { + return !!this.loadTime && !!this.rendererReadyTime; + } -// public restore() { -// this.window.restore(); -// } + public onClose(fn: () => void) { + this.window.on('closed', fn); + } -// public focus() { -// this.window.focus(); -// } + /** + * Register a function to call when the window is done loading. At that point + * the page has loaded and the renderer has signalled that it is ready. + */ + public onDidLoad(fn: () => void): EventEmitter { + return this.emitter.on('did-load', fn); + } -// /** Show the window. */ -// public show() { -// this.window.show(); -// } + public isMinimized() { + return this.window.isMinimized(); + } -// /** Send the menu event to the renderer. */ -// public sendMenuEvent(name: MenuEvent) { -// this.show(); + /** Is the window currently visible? */ + public isVisible() { + return this.window.isVisible(); + } -// this.window.webContents.send('menu-event', { name }); -// } + public restore() { + this.window.restore(); + } -// /** Send the URL action to the renderer. */ -// public sendURLAction(action: URLActionType) { -// this.show(); + public focus() { + this.window.focus(); + } -// this.window.webContents.send('url-action', { action }); -// } + /** Show the window. */ + public show() { + this.window.show(); + } -// /** Send the app launch timing stats to the renderer. */ -// public sendLaunchTimingStats(stats: ILaunchStats) { -// this.window.webContents.send('launch-timing-stats', { stats }); -// } + /** Send the menu event to the renderer. */ + public sendMenuEvent(name: MenuEvent) { + this.show(); -// /** Send the app menu to the renderer. */ -// public sendAppMenu() { -// const appMenu = Menu.getApplicationMenu(); -// if (appMenu) { -// const menu = menuFromElectronMenu(appMenu); -// this.window.webContents.send('app-menu', { menu }); -// } -// } + this.window.webContents.send('menu-event', { name }); + } -// /** Send a certificate error to the renderer. */ -// public sendCertificateError( -// certificate: Electron.Certificate, -// error: string, -// url: string -// ) { -// this.window.webContents.send('certificate-error', { -// certificate, -// error, -// url, -// }); -// } + /** Send the URL action to the renderer. */ + public sendURLAction(action: URLActionType) { + this.show(); -// public showCertificateTrustDialog( -// certificate: Electron.Certificate, -// message: string -// ) { -// // The Electron type definitions don't include `showCertificateTrustDialog` -// // yet. -// const d = dialog as any; -// d.showCertificateTrustDialog( -// this.window, -// { certificate, message }, -// () => { } -// ); -// } + this.window.webContents.send('url-action', { action }); + } -// /** Report the exception to the renderer. */ -// public sendException(error: Error) { -// // `Error` can't be JSONified so it doesn't transport nicely over IPC. So -// // we'll just manually copy the properties we care about. -// const friendlyError = { -// stack: error.stack, -// message: error.message, -// name: error.name, -// }; -// this.window.webContents.send('main-process-exception', friendlyError); -// } + /** Send the app launch timing stats to the renderer. */ + // public sendLaunchTimingStats(stats: ILaunchStats) { + // this.window.webContents.send('launch-timing-stats', { stats }); + // } -// /** -// * Get the time (in milliseconds) spent loading the page. -// * -// * This will be `null` until `onDidLoad` is called. -// */ -// public get loadTime(): number | null { -// return this._loadTime; -// } + /** Send the app menu to the renderer. */ + // public sendAppMenu() { + // const appMenu = Menu.getApplicationMenu(); + // if (appMenu) { + // const menu = menuFromElectronMenu(appMenu); + // this.window.webContents.send('app-menu', { menu }); + // } + // } -// /** -// * Get the time (in milliseconds) elapsed from the renderer being loaded to it -// * signaling it was ready. -// * -// * This will be `null` until `onDidLoad` is called. -// */ -// public get rendererReadyTime(): number | null { -// return this._rendererReadyTime; -// } + /** Send a certificate error to the renderer. */ + public sendCertificateError( + certificate: Electron.Certificate, + error: string, + url: string + ) { + this.window.webContents.send('certificate-error', { + certificate, + error, + url, + }); + } -// public destroy() { -// this.window.destroy(); -// } -// } + public showCertificateTrustDialog( + certificate: Electron.Certificate, + message: string + ) { + // The Electron type definitions don't include `showCertificateTrustDialog` + // yet. + const d = dialog as any; + d.showCertificateTrustDialog( + this.window, + { certificate, message }, + () => { } + ); + } + + /** Report the exception to the renderer. */ + public sendException(error: Error) { + // `Error` can't be JSONified so it doesn't transport nicely over IPC. So + // we'll just manually copy the properties we care about. + const friendlyError = { + stack: error.stack, + message: error.message, + name: error.name, + }; + this.window.webContents.send('main-process-exception', friendlyError); + } + + /** + * Get the time (in milliseconds) spent loading the page. + * + * This will be `null` until `onDidLoad` is called. + */ + public get loadTime(): number | null { + return this._loadTime; + } + + /** + * Get the time (in milliseconds) elapsed from the renderer being loaded to it + * signaling it was ready. + * + * This will be `null` until `onDidLoad` is called. + */ + public get rendererReadyTime(): number | null { + return this._rendererReadyTime; + } + + public destroy() { + this.window.destroy(); + } +} diff --git a/src/electron/main.ts b/src/electron/main.ts index e81eb9a..70a85d8 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -1,74 +1,461 @@ -import { app, BrowserWindow, ipcMain, dialog } from 'electron'; -import * as path from 'path'; -import * as url from 'url'; +import { app, Menu, ipcMain, BrowserWindow, shell } from 'electron'; +import * as Fs from 'fs'; -// const indexUrl = url.format({ -// pathname: path.join(__dirname, 'index.html'), -// protocol: 'file:', -// slashes: true -// }); +import { shellNeedsPatching, updateEnvironmentForProcess } from '@overflow/core/shell'; +import { parseAppURL } from '@overflow/core/parse-app-url'; +import { + enableSourceMaps, + withSourceMappedStack, +} from '@overflow/core/source-map-support'; +import { now } from '@overflow/core/now'; +import { IMenuItem } from '@overflow/core/menu-item'; -const indexUrl = url.format({ - pathname: path.join('//localhost:4200'), - protocol: 'http:', - slashes: true +import { AppWindow } from './app-window'; + +import { handleSquirrelEvent } from './squirrel-updater'; + +import { openDirectorySafe } from './shell'; + +enableSourceMaps(); + +let mainWindow: AppWindow | null = null; + +const launchTime = now(); + +let preventQuit = false; +let readyTime: number | null = null; + +type OnDidLoadFn = (window: AppWindow) => void; +/** See the `onDidLoad` function. */ +const onDidLoadFns: Array | null = []; + +function handleUncaughtException(error: Error) { + preventQuit = true; + + if (mainWindow) { + mainWindow.destroy(); + mainWindow = null; + } + + const isLaunchError = !mainWindow; + // showUncaughtException(isLaunchError, error); +} + +process.on('uncaughtException', (error: Error) => { + error = withSourceMappedStack(error); + + // reportError(error); + handleUncaughtException(error); }); +let handlingSquirrelEvent = false; +if (__WIN32__ && process.argv.length > 1) { + const arg = process.argv[1]; -// Keep a global reference of the window object, if you don't, the window will -// be closed automatically when the JavaScript object is garbage collected. -let win; + const promise = handleSquirrelEvent(arg); + if (promise) { + handlingSquirrelEvent = true; + promise + .catch(e => { + log.error(`Failed handling Squirrel event: ${arg}`, e); + }) + .then(() => { + app.quit(); + }); + } else { + handlePossibleProtocolLauncherArgs(process.argv); + } +} -function createWindow() { - // Create the browser window. - win = new BrowserWindow({ width: 800, height: 600 }); - - // and load the index.html of the app. - win.loadURL(indexUrl); - - // Open the DevTools. - win.webContents.openDevTools(); - - // Emitted when the window is closed. - win.on('closed', () => { - // Dereference the window object, usually you would store windows - // in an array if your app supports multi windows, this is the time - // when you should delete the corresponding element. - win = null; +function handleAppURL(url: string) { + log.info('Processing protocol url'); + const action = parseAppURL(url); + onDidLoad(window => { + // This manual focus call _shouldn't_ be necessary, but is for Chrome on + // macOS. See https://github.com/desktop/desktop/issues/973. + window.focus(); + window.sendURLAction(action); }); } -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. -app.on('ready', createWindow); +let isDuplicateInstance = false; +// If we're handling a Squirrel event we don't want to enforce single instance. +// We want to let the updated instance launch and do its work. It will then quit +// once it's done. +if (!handlingSquirrelEvent) { + isDuplicateInstance = app.makeSingleInstance((args, workingDirectory) => { + // Someone tried to run a second instance, we should focus our window. + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } -// Quit when all windows are closed. -app.on('window-all-closed', () => { - // On macOS it is common for applications and their menu bar - // to stay active until the user quits explicitly with Cmd + Q - if (process.platform !== 'darwin') { + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + + mainWindow.focus(); + } + + handlePossibleProtocolLauncherArgs(args); + }); + + if (isDuplicateInstance) { app.quit(); } +} + +if (shellNeedsPatching(process)) { + updateEnvironmentForProcess(); +} + +app.on('will-finish-launching', () => { + // macOS only + app.on('open-url', (event, url) => { + event.preventDefault(); + handleAppURL(url); + }); +}); + +/** + * Attempt to detect and handle any protocol handler arguments passed + * either via the command line directly to the current process or through + * IPC from a duplicate instance (see makeSingleInstance) + * + * @param args Essentially process.argv, i.e. the first element is the exec + * path + */ +function handlePossibleProtocolLauncherArgs(args: ReadonlyArray) { + log.info(`Received possible protocol arguments: ${args.length}`); + + if (__WIN32__) { + // We register our protocol handler callback on Windows as + // [executable path] --protocol-launcher -- "%1" meaning that any + // url data comes after we've stopped processing arguments. We check + // for that exact scenario here before doing any processing. If there's + // more than 4 args because of a malformed url then we bail out. + if ( + args.length === 4 && + args[1] === '--protocol-launcher' && + args[2] === '--' + ) { + handleAppURL(args[3]); + } + } else if (args.length > 1) { + handleAppURL(args[1]); + } +} + +/** + * Wrapper around app.setAsDefaultProtocolClient that adds our + * custom prefix command line switches on Windows that prevents + * command line argument parsing after the `--`. + */ +function setAsDefaultProtocolClient(protocol: string) { + if (__WIN32__) { + app.setAsDefaultProtocolClient(protocol, process.execPath, [ + '--protocol-launcher', + '--', + ]); + } else { + app.setAsDefaultProtocolClient(protocol); + } +} + +// if (process.env.GITHUB_DESKTOP_DISABLE_HARDWARE_ACCELERATION) { +// log.info( +// `GITHUB_DESKTOP_DISABLE_HARDWARE_ACCELERATION environment variable set, disabling hardware acceleration` +// ); +// app.disableHardwareAcceleration(); +// } + +app.on('ready', () => { + if (isDuplicateInstance || handlingSquirrelEvent) { + return; + } + + readyTime = now() - launchTime; + + // setAsDefaultProtocolClient('x-github-client'); + + // if (__DEV__) { + // setAsDefaultProtocolClient('x-github-desktop-dev-auth'); + // } else { + // setAsDefaultProtocolClient('x-github-desktop-auth'); + // } + + // // Also support Desktop Classic's protocols. + // if (__DARWIN__) { + // setAsDefaultProtocolClient('github-mac'); + // } else if (__WIN32__) { + // setAsDefaultProtocolClient('github-windows'); + // } + + createWindow(); + + // let menu = buildDefaultMenu(); + // Menu.setApplicationMenu(menu); + + // ipcMain.on( + // 'update-preferred-app-menu-item-labels', + // ( + // event: Electron.IpcMessageEvent, + // labels: { editor?: string; pullRequestLabel?: string; shell: string } + // ) => { + // menu = buildDefaultMenu( + // labels.editor, + // labels.shell, + // labels.pullRequestLabel + // ); + // Menu.setApplicationMenu(menu); + // if (mainWindow) { + // mainWindow.sendAppMenu(); + // } + // } + // ); + + // ipcMain.on('menu-event', (event: Electron.IpcMessageEvent, args: any[]) => { + // const { name }: { name: MenuEvent } = event as any; + // if (mainWindow) { + // mainWindow.sendMenuEvent(name); + // } + // }); + + /** + * An event sent by the renderer asking that the menu item with the given id + * is executed (ie clicked). + */ + // ipcMain.on( + // 'execute-menu-item', + // (event: Electron.IpcMessageEvent, { id }: { id: string }) => { + // const menuItem = findMenuItemByID(menu, id); + // if (menuItem) { + // const window = BrowserWindow.fromWebContents(event.sender); + // const fakeEvent = { preventDefault: () => { }, sender: event.sender }; + // menuItem.click(fakeEvent, window, event.sender); + // } + // } + // ); + + // ipcMain.on( + // 'update-menu-state', + // ( + // event: Electron.IpcMessageEvent, + // items: Array<{ id: string; state: IMenuItemState }> + // ) => { + // let sendMenuChangedEvent = false; + + // for (const item of items) { + // const { id, state } = item; + // const menuItem = findMenuItemByID(menu, id); + + // if (menuItem) { + // // Only send the updated app menu when the state actually changes + // // or we might end up introducing a never ending loop between + // // the renderer and the main process + // if ( + // state.enabled !== undefined && + // menuItem.enabled !== state.enabled + // ) { + // menuItem.enabled = state.enabled; + // sendMenuChangedEvent = true; + // } + // } else { + // fatalError(`Unknown menu id: ${id}`); + // } + // } + + // if (sendMenuChangedEvent && mainWindow) { + // mainWindow.sendAppMenu(); + // } + // } + // ); + + // ipcMain.on( + // 'show-contextual-menu', + // (event: Electron.IpcMessageEvent, items: ReadonlyArray) => { + // const menu = buildContextMenu(items, ix => + // event.sender.send('contextual-menu-action', ix) + // ); + + // const window = BrowserWindow.fromWebContents(event.sender); + // menu.popup({ window }); + // } + // ); + + /** + * An event sent by the renderer asking for a copy of the current + * application menu. + */ + // ipcMain.on('get-app-menu', () => { + // if (mainWindow) { + // mainWindow.sendAppMenu(); + // } + // }); + + // ipcMain.on( + // 'show-certificate-trust-dialog', + // ( + // event: Electron.IpcMessageEvent, + // { + // certificate, + // message, + // }: { certificate: Electron.Certificate; message: string } + // ) => { + // // This API is only implemented for macOS and Windows right now. + // if (__DARWIN__ || __WIN32__) { + // onDidLoad(window => { + // window.showCertificateTrustDialog(certificate, message); + // }); + // } + // } + // ); + + // ipcMain.on( + // 'log', + // (event: Electron.IpcMessageEvent, level: LogLevel, message: string) => { + // writeLog(level, message); + // } + // ); + + // ipcMain.on( + // 'uncaught-exception', + // (event: Electron.IpcMessageEvent, error: Error) => { + // handleUncaughtException(error); + // } + // ); + + // ipcMain.on( + // 'send-error-report', + // ( + // event: Electron.IpcMessageEvent, + // { error, extra }: { error: Error; extra: { [key: string]: string } } + // ) => { + // reportError(error, extra); + // } + // ); + + // ipcMain.on( + // 'open-external', + // (event: Electron.IpcMessageEvent, { path }: { path: string }) => { + // const pathLowerCase = path.toLowerCase(); + // if ( + // pathLowerCase.startsWith('http://') || + // pathLowerCase.startsWith('https://') + // ) { + // log.info(`opening in browser: ${path}`); + // } + + // const result = shell.openExternal(path); + // event.sender.send('open-external-result', { result }); + // } + // ); + + // ipcMain.on( + // 'show-item-in-folder', + // (event: Electron.IpcMessageEvent, { path }: { path: string }) => { + // Fs.stat(path, (err, stats) => { + // if (err) { + // log.error(`Unable to find file at '${path}'`, err); + // return; + // } + + // if (stats.isDirectory()) { + // openDirectorySafe(path); + // } else { + // shell.showItemInFolder(path); + // } + // }); + // } + // ); }); app.on('activate', () => { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (win === null) { - createWindow(); - } -}); - -// In this file you can include the rest of your app's specific main process -// code. You can also put them in separate files and require them here. - -ipcMain.on('show-dialog', (event, arg) => { - dialog.showMessageBox(win, { - type: 'info', - buttons: ['OK'], - title: 'Native Dialog', - message: 'I\'m a native dialog!', - detail: 'It\'s my pleasure to make your life better.' + onDidLoad(window => { + window.show(); }); }); + +app.on('web-contents-created', (event, contents) => { + contents.on('new-window', (_event, url) => { + // Prevent links or window.open from opening new windows + _event.preventDefault(); + log.warn(`Prevented new window to: ${url}`); + }); +}); + +// app.on( +// 'certificate-error', +// (event, webContents, url, error, certificate, callback) => { +// callback(false); + +// onDidLoad(window => { +// window.sendCertificateError(certificate, error, url); +// }); +// } +// ); + +function createWindow() { + const window = new AppWindow(); + + if (__DEV__) { + const { + default: installExtension, + } = require('electron-devtools-installer'); + + require('electron-debug')({ showDevTools: true }); + + const ChromeLens = { + id: 'idikgljglpfilbhaboonnpnnincjhjkd', + electron: '>=1.2.1', + }; + + const extensions = [ChromeLens]; + + for (const extension of extensions) { + try { + installExtension(extension); + } catch (e) { } + } + } + + window.onClose(() => { + mainWindow = null; + if (!__DARWIN__ && !preventQuit) { + app.quit(); + } + }); + + window.onDidLoad(() => { + window.show(); + // window.sendLaunchTimingStats({ + // mainReadyTime: readyTime!, + // loadTime: window.loadTime!, + // rendererReadyTime: window.rendererReadyTime!, + // }); + + // const fns = onDidLoadFns!; + // onDidLoadFns = null; + // for (const fn of fns) { + // fn(window); + // } + }); + + window.load(); + + mainWindow = window; +} + +/** + * Register a function to be called once the window has been loaded. If the + * window has already been loaded, the function will be called immediately. + */ +function onDidLoad(fn: OnDidLoadFn) { + if (onDidLoadFns) { + onDidLoadFns.push(fn); + } else { + if (mainWindow) { + fn(mainWindow); + } + } +} diff --git a/src/electron/menu/index.ts b/src/electron/menu/index.ts new file mode 100644 index 0000000..93a4fda --- /dev/null +++ b/src/electron/menu/index.ts @@ -0,0 +1,2 @@ +export * from './menu-event'; +export * from './menu-ids'; diff --git a/src/electron/menu/menu-event.ts b/src/electron/menu/menu-event.ts new file mode 100644 index 0000000..591adf5 --- /dev/null +++ b/src/electron/menu/menu-event.ts @@ -0,0 +1,4 @@ +export type MenuEvent = + | 'show-about' + | 'open-external-editor' + | 'select-all'; diff --git a/src/electron/menu/menu-ids.ts b/src/electron/menu/menu-ids.ts new file mode 100644 index 0000000..fe97435 --- /dev/null +++ b/src/electron/menu/menu-ids.ts @@ -0,0 +1,5 @@ +export type MenuIDs = + | 'preferences' + | 'open-in-shell' + | 'open-external-editor' + | 'about'; diff --git a/src/electron/shell.ts b/src/electron/shell.ts new file mode 100644 index 0000000..17cc39a --- /dev/null +++ b/src/electron/shell.ts @@ -0,0 +1,25 @@ +import * as Url from 'url'; +import { shell } from 'electron'; + +/** + * Wraps the inbuilt shell.openItem path to address a focus issue that affects macOS. + * + * When opening a folder in Finder, the window will appear behind the application + * window, which may confuse users. As a workaround, we will fallback to using + * shell.openExternal for macOS until it can be fixed upstream. + * + * @param path directory to open + */ +export function openDirectorySafe(path: string) { + if (__DARWIN__) { + const directoryURL = Url.format({ + pathname: path, + protocol: 'file:', + slashes: true, + }); + + shell.openExternal(directoryURL); + } else { + shell.openItem(path); + } +} diff --git a/src/electron/squirrel-updater.ts b/src/electron/squirrel-updater.ts new file mode 100644 index 0000000..11d7b43 --- /dev/null +++ b/src/electron/squirrel-updater.ts @@ -0,0 +1,156 @@ +import * as Path from 'path'; +import * as Os from 'os'; + +import { pathExists, ensureDir, writeFile } from 'fs-extra'; +import { spawn, getPathSegments, setPathSegments } from '@overflow/core/process/win32'; + +const appFolder = Path.resolve(process.execPath, '..'); +const rootAppDir = Path.resolve(appFolder, '..'); +const updateDotExe = Path.resolve(Path.join(rootAppDir, 'Update.exe')); +const exeName = Path.basename(process.execPath); + +// A lot of this code was cargo-culted from our Atom comrades: +// https://github.com/atom/atom/blob/7c9f39e3f1d05ee423e0093e6b83f042ce11c90a/src/main-process/squirrel-update.coffee. + +/** + * Handle Squirrel.Windows app lifecycle events. + * + * Returns a promise which will resolve when the work is done. + */ +export function handleSquirrelEvent(eventName: string): Promise | null { + switch (eventName) { + case '--squirrel-install': + return handleInstalled(); + + case '--squirrel-updated': + return handleUpdated(); + + case '--squirrel-uninstall': + return handleUninstall(); + + case '--squirrel-obsolete': + return Promise.resolve(); + } + + return null; +} + +async function handleInstalled(): Promise { + await createShortcut(['StartMenu', 'Desktop']); + await installCLI(); +} + +async function handleUpdated(): Promise { + await updateShortcut(); + await installCLI(); +} + +async function installCLI(): Promise { + const binPath = getBinPath(); + await ensureDir(binPath); + await writeBatchScriptCLITrampoline(binPath); + await writeShellScriptCLITrampoline(binPath); + const paths = await getPathSegments(); + if (paths.indexOf(binPath) < 0) { + await setPathSegments([...paths, binPath]); + } +} + +/** + * Get the path for the `bin` directory which exists in our `AppData` but + * outside path which includes the installed app version. + */ +function getBinPath(): string { + return Path.resolve(process.execPath, '../../bin'); +} + +function resolveVersionedPath(binPath: string, relativePath: string): string { + const _appFolder = Path.resolve(process.execPath, '..'); + return Path.relative(binPath, Path.join(_appFolder, relativePath)); +} + +/** + * Here's the problem: our app's path contains its version number. So each time + * we update, the path to our app changes. So it's Real Hard to add our path + * directly to `Path`. We'd have to detect and remove stale entries, etc. + * + * So instead, we write a trampoline out to a fixed path, still inside our + * `AppData` directory but outside the version-specific path. That trampoline + * just launches the current version's CLI tool. Then, whenever we update, we + * rewrite the trampoline to point to the new, version-specific path. Bingo + * bango Bob's your uncle. + */ +function writeBatchScriptCLITrampoline(binPath: string): Promise { + const versionedPath = resolveVersionedPath( + binPath, + 'resources/app/static/github.bat' + ); + + const trampoline = `@echo off\n"%~dp0\\${versionedPath}" %*`; + const trampolinePath = Path.join(binPath, 'github.bat'); + + return writeFile(trampolinePath, trampoline); +} + +function writeShellScriptCLITrampoline(binPath: string): Promise { + const versionedPath = resolveVersionedPath( + binPath, + 'resources/app/static/github.sh' + ); + + const trampoline = `#!/usr/bin/env bash + DIR="$( cd "$( dirname "\$\{BASH_SOURCE[0]\}" )" && pwd )" + sh "$DIR/${versionedPath}" "$@"`; + const trampolinePath = Path.join(binPath, 'github'); + + return writeFile(trampolinePath, trampoline, { encoding: 'utf8', mode: 755 }); +} + +/** Spawn the Squirrel.Windows `Update.exe` with a command. */ +async function spawnSquirrelUpdate( + commands: ReadonlyArray +): Promise { + await spawn(updateDotExe, commands); +} + +type ShortcutLocations = ReadonlyArray<'StartMenu' | 'Desktop'>; + +function createShortcut(locations: ShortcutLocations): Promise { + return spawnSquirrelUpdate([ + '--createShortcut', + exeName, + '-l', + locations.join(','), + ]); +} + +async function handleUninstall(): Promise { + await removeShortcut(); + + const paths = await getPathSegments(); + const binPath = getBinPath(); + const pathsWithoutBinPath = paths.filter(p => p !== binPath); + return setPathSegments(pathsWithoutBinPath); +} + +function removeShortcut(): Promise { + return spawnSquirrelUpdate(['--removeShortcut', exeName]); +} + +async function updateShortcut(): Promise { + const homeDirectory = Os.homedir(); + if (homeDirectory) { + const desktopShortcutPath = Path.join( + homeDirectory, + 'Desktop', + 'GitHub Desktop.lnk' + ); + const exists = await pathExists(desktopShortcutPath); + const locations: ShortcutLocations = exists + ? ['StartMenu', 'Desktop'] + : ['StartMenu']; + return createShortcut(locations); + } else { + return createShortcut(['StartMenu', 'Desktop']); + } +} diff --git a/src/globals.d.ts b/src/globals.d.ts new file mode 100644 index 0000000..3d47d29 --- /dev/null +++ b/src/globals.d.ts @@ -0,0 +1,256 @@ +/* eslint-disable typescript/interface-name-prefix */ +/** Is the app running in dev mode? */ +declare const __DEV__: boolean + +/** The OAuth client id the app should use */ +declare const __OAUTH_CLIENT_ID__: string | undefined + +/** The OAuth secret the app should use. */ +declare const __OAUTH_SECRET__: string | undefined + +/** Is the app being built to run on Darwin? */ +declare const __DARWIN__: boolean + +/** Is the app being built to run on Win32? */ +declare const __WIN32__: boolean + +/** Is the app being built to run on Linux? */ +declare const __LINUX__: boolean + +/** + * The commit id of the repository HEAD at build time. + * Represented as a 40 character SHA-1 hexadecimal digest string. + */ +declare const __SHA__: string + +/** The channel for which the release was created. */ +declare const __RELEASE_CHANNEL__: + | 'production' + | 'beta' + | 'test' + | 'development' + +declare const __CLI_COMMANDS__: ReadonlyArray + +/** The URL for Squirrel's updates. */ +declare const __UPDATES_URL__: string + +/** + * The currently executing process kind, this is specific to desktop + * and identifies the processes that we have. + */ +declare const __PROCESS_KIND__: + | 'main' + | 'ui' + | 'crash' + | 'askpass' + | 'highlighter' + +/** + * The DOMHighResTimeStamp type is a double and is used to store a time value. + * + * The value could be a discrete point in time or the difference in time between + * two discrete points in time. The unit is milliseconds and should be accurate + * to 5 µs (microseconds). However, if the browser is unable to provide a time + * value accurate to 5 microseconds (due, for example, to hardware or software + * constraints), the browser can represent the value as a time in milliseconds + * accurate to a millisecond. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/DOMHighResTimeStamp + */ +declare type DOMHighResTimeStamp = number + +/** + * The IdleDeadline interface is used as the data type of the input parameter to + * idle callbacks established by calling Window.requestIdleCallback(). It offers + * a method, timeRemaining(), which lets you determine how much longer the user + * agent estimates it will remain idle and a property, didTimeout, which lets + * you determine if your callback is executing because its timeout duration + * expired. + * + * https://developer.mozilla.org/en-US/docs/Web/API/IdleDeadline + */ +interface IdleDeadline { + readonly didTimeout: boolean + readonly timeRemaining: () => DOMHighResTimeStamp +} + +/** + * Contains optional configuration parameters for the requestIdleCallback + * function. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback + */ +interface IdleCallbackOptions { + /** + * If timeout is specified and has a positive value, and the callback has not + * already been called by the time timeout milliseconds have passed, the + * timeout will be called during the next idle period, even if doing so risks + * causing a negative performance impact.. + */ + readonly timeout: number +} + +/** + * The window.requestIdleCallback() method queues a function to be called during + * a browser's idle periods. This enables developers to perform background and + * low priority work on the main event loop, without impacting latency-critical + * events such as animation and input response. Functions are generally called + * in first-in-first-out order; however, callbacks which have a timeout + * specified may be called out-of-order if necessary in order to run them before + * the timeout elapses. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback + * + * @param options Contains optional configuration parameters. Currently only one + * property is defined: + * timeout: + */ +declare function requestIdleCallback( + fn: (deadline: IdleDeadline) => void, + options?: IdleCallbackOptions +): number + +interface IDesktopLogger { + /** + * Writes a log message at the 'error' level. + * + * The error will be persisted to disk as long as the disk transport is + * configured to pass along log messages at this level. For more details + * about the on-disk transport, see log.ts in the main process. + * + * If used from a renderer the log message will also be appended to the + * devtools console. + * + * @param message The text to write to the log file + * @param error An optional error instance that will be formatted to + * include the stack trace (if one is available) and + * then appended to the log message. + */ + error(message: string, error?: Error): void + + /** + * Writes a log message at the 'warn' level. + * + * The error will be persisted to disk as long as the disk transport is + * configured to pass along log messages at this level. For more details + * about the on-disk transport, see log.ts in the main process. + * + * If used from a renderer the log message will also be appended to the + * devtools console. + * + * @param message The text to write to the log file + * @param error An optional error instance that will be formatted to + * include the stack trace (if one is available) and + * then appended to the log message. + */ + warn(message: string, error?: Error): void + + /** + * Writes a log message at the 'info' level. + * + * The error will be persisted to disk as long as the disk transport is + * configured to pass along log messages at this level. For more details + * about the on-disk transport, see log.ts in the main process. + * + * If used from a renderer the log message will also be appended to the + * devtools console. + * + * @param message The text to write to the log file + * @param error An optional error instance that will be formatted to + * include the stack trace (if one is available) and + * then appended to the log message. + */ + info(message: string, error?: Error): void + + /** + * Writes a log message at the 'debug' level. + * + * The error will be persisted to disk as long as the disk transport is + * configured to pass along log messages at this level. For more details + * about the on-disk transport, see log.ts in the main process. + * + * If used from a renderer the log message will also be appended to the + * devtools console. + * + * @param message The text to write to the log file + * @param error An optional error instance that will be formatted to + * include the stack trace (if one is available) and + * then appended to the log message. + */ + debug(message: string, error?: Error): void +} + +declare const log: IDesktopLogger +// these changes should be pushed into the Electron declarations + +declare namespace NodeJS { + interface Process extends EventEmitter { + once(event: 'uncaughtException', listener: (error: Error) => void): this + on(event: 'uncaughtException', listener: (error: Error) => void): this + removeListener(event: 'exit', listener: Function): this + once(event: 'exit', listener: Function): this + } +} + +declare namespace Electron { + interface MenuItem { + readonly accelerator?: Electron.Accelerator + readonly submenu?: Electron.Menu + readonly role?: string + readonly type: 'normal' | 'separator' | 'submenu' | 'checkbox' | 'radio' + } + + interface RequestOptions { + readonly method: string + readonly url: string + readonly headers: any + } + + type AppleActionOnDoubleClickPref = 'Maximize' | 'Minimize' | 'None' + + interface SystemPreferences { + getUserDefault( + key: 'AppleActionOnDoubleClick', + type: 'string' + ): AppleActionOnDoubleClickPref + } + + interface WebviewTag extends HTMLElement { + // Copied from https://github.com/electron/electron-typescript-definitions/pull/81 + // until we can upgrade to a version of Electron which includes the fix. + addEventListener( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + useCapture?: boolean + ): void + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + useCapture?: boolean + ): void + removeEventListener( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + useCapture?: boolean + ): void + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + useCapture?: boolean + ): void + } +} + +// https://wicg.github.io/ResizeObserver/#resizeobserverentry +interface IResizeObserverEntry { + readonly target: HTMLElement + readonly contentRect: ClientRect +} + +declare class ResizeObserver { + public constructor(cb: (entries: ReadonlyArray) => void) + + public disconnect(): void + public observe(e: HTMLElement): void +} diff --git a/tsconfig.json b/tsconfig.json index 762fb02..ef8675d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,11 +5,11 @@ "outDir": "./dist/out-tsc", "sourceMap": true, "declaration": false, - "module": "es2015", + "module": "commonjs", "moduleResolution": "node", "emitDecoratorMetadata": true, "experimentalDecorators": true, - "target": "es5", + "target": "es2017", "typeRoots": [ "node_modules/@types" ], diff --git a/yarn.lock b/yarn.lock index ea2c91a..66aa9c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2202,6 +2202,14 @@ electron-to-chromium@^1.3.47: version "1.3.58" resolved "https://nexus.loafle.net/repository/npm-all/electron-to-chromium/-/electron-to-chromium-1.3.58.tgz#8267a4000014e93986d9d18c65a8b4022ca75188" +electron-window-state@^4.1.1: + version "4.1.1" + resolved "https://nexus.loafle.net/repository/npm-all/electron-window-state/-/electron-window-state-4.1.1.tgz#6b34fdc31b38514dfec8b7c8f7b5d4addb67632d" + dependencies: + deep-equal "^1.0.1" + jsonfile "^2.2.3" + mkdirp "^0.5.1" + electron@^2.0.7: version "2.0.7" resolved "https://nexus.loafle.net/repository/npm-all/electron/-/electron-2.0.7.tgz#f7ce410433298e319032ce31f0e6ffd709ff052c" @@ -2649,6 +2657,14 @@ file-loader@^1.1.11: loader-utils "^1.0.2" schema-utils "^0.4.5" +file-uri-to-path@^1.0.0: + version "1.0.0" + resolved "https://nexus.loafle.net/repository/npm-all/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + +file-url@^2.0.2: + version "2.0.2" + resolved "https://nexus.loafle.net/repository/npm-all/file-url/-/file-url-2.0.2.tgz#e951784d79095127d3713029ab063f40818ca2ae" + filename-regex@^2.0.0: version "2.0.1" resolved "https://nexus.loafle.net/repository/npm-all/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" @@ -3938,7 +3954,7 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -jsonfile@^2.1.0: +jsonfile@^2.1.0, jsonfile@^2.2.3: version "2.4.0" resolved "https://nexus.loafle.net/repository/npm-all/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" optionalDependencies: @@ -6257,7 +6273,7 @@ source-map-resolve@^0.5.0: source-map-url "^0.4.0" urix "^0.1.0" -source-map-support@^0.5.0, source-map-support@^0.5.3, source-map-support@^0.5.5, source-map-support@^0.5.6: +source-map-support@^0.5.0, source-map-support@^0.5.3, source-map-support@^0.5.5, source-map-support@^0.5.6, source-map-support@^0.5.8: version "0.5.8" resolved "https://nexus.loafle.net/repository/npm-all/source-map-support/-/source-map-support-0.5.8.tgz#04f5581713a8a65612d0175fbf3a01f80a162613" dependencies: