Merge branch 'development' into notification-metrics

This commit is contained in:
Sergio Padrino 2022-01-28 16:01:58 +00:00 committed by GitHub
commit b0d5819e7a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 910 additions and 1052 deletions

View file

@ -87,10 +87,6 @@ jobs:
- name: Run script tests
if: matrix.arch == 'x64'
run: yarn test:script:cov
- name: Run integration tests
if: matrix.arch == 'x64'
timeout-minutes: 5
run: yarn test:integration
- name: Publish production app
run: yarn run publish
env:

View file

@ -1,3 +1,3 @@
runtime = electron
disturl = https://atom.io/download/electron
target = 13.6.2
target = 14.2.3

View file

@ -3,6 +3,7 @@ import * as Path from 'path'
import { getSHA } from './git-info'
import { getUpdatesURL, getChannel } from '../script/dist-info'
import { version, productName } from './package.json'
const projectRoot = Path.dirname(__dirname)
@ -24,6 +25,8 @@ export function getCLICommands() {
const s = JSON.stringify
export function getReplacements() {
const isDevBuild = channel === 'development'
return {
__OAUTH_CLIENT_ID__: s(process.env.DESKTOP_OAUTH_CLIENT_ID || devClientId),
__OAUTH_SECRET__: s(
@ -32,7 +35,9 @@ export function getReplacements() {
__DARWIN__: process.platform === 'darwin',
__WIN32__: process.platform === 'win32',
__LINUX__: process.platform === 'linux',
__DEV__: channel === 'development',
__APP_NAME__: s(productName),
__APP_VERSION__: s(version),
__DEV__: isDevBuild,
__RELEASE_CHANNEL__: s(channel),
__UPDATES_URL__: s(getUpdatesURL()),
__SHA__: s(getSHA()),

View file

@ -1,15 +0,0 @@
module.exports = {
roots: ['<rootDir>/src/', '<rootDir>/test/'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testMatch: ['**/integration/**/*-test.ts{,x}'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['<rootDir>/test/setup-test-framework.ts'],
reporters: ['default', '<rootDir>../script/jest-actions-reporter.js'],
globals: {
'ts-jest': {
useBabelrc: true,
},
},
}

View file

@ -1,5 +1,5 @@
import * as React from 'react'
import { remote } from 'electron'
import * as remote from '@electron/remote'
import { ErrorType } from './shared'
import { TitleBar } from '../ui/window/title-bar'
import { encodePathAsUrl } from '../lib/path'

View file

@ -100,7 +100,7 @@ export interface IAppState {
/**
* The current state of the window, ie maximized, minimized full-screen etc.
*/
readonly windowState: WindowState
readonly windowState: WindowState | null
/**
* The current zoom factor of the window represented as a fractional number

View file

@ -351,6 +351,13 @@ const editors: WindowsExternalEditor[] = [
displayNamePrefix: 'PyCharm ',
publisher: 'JetBrains s.r.o.',
},
{
name: 'JetBrains CLion',
registryKeys: registryKeysForJetBrainsIDE('CLion'),
executableShimPaths: executableShimPathsForJetBrainsIDE('clion'),
displayNamePrefix: 'CLion ',
publisher: 'JetBrains s.r.o.',
},
]
function getKeyOrEmpty(

View file

@ -0,0 +1 @@
export type EndpointToken = { endpoint: string; token: string }

View file

@ -17,6 +17,20 @@ declare const __WIN32__: boolean
/** Is the app being built to run on Linux? */
declare const __LINUX__: boolean
/**
* The product name of the app, this is intended to be a compile-time
* replacement for app.getName
* (https://www.electronjs.org/docs/latest/api/app#appgetname)
*/
declare const __APP_NAME__: string
/**
* The current version of the app, this is intended to be a compile-time
* replacement for app.getVersion
* (https://www.electronjs.org/docs/latest/api/app#appgetname)
*/
declare const __APP_VERSION__: string
/**
* The commit id of the repository HEAD at build time.
* Represented as a 40 character SHA-1 hexadecimal digest string.
@ -231,3 +245,13 @@ declare module 'file-metadata' {
interface Window {
Element: typeof Element
}
/**
* Obtain the number of elements of a tuple type
*
* See https://itnext.io/implementing-arithmetic-within-typescripts-type-system-a1ef140a6f6f
*/
type Length<T extends any[]> = T extends { length: infer L } ? L : never
/** Obtain the the number of parameters of a function type */
type ParameterCount<T extends (...args: any) => any> = Length<Parameters<T>>

View file

@ -9,6 +9,7 @@ import { WindowState } from './window-state'
import { IMenu } from '../models/app-menu'
import { ILaunchStats } from './stats'
import { URLActionType } from './parse-app-url'
import { EndpointToken } from './endpoint-token'
/**
* Defines the simplex IPC channel names we use from the renderer
@ -55,6 +56,18 @@ export type RequestChannels = {
) => void
focus: () => void
blur: () => void
'update-accounts': (accounts: ReadonlyArray<EndpointToken>) => void
'quit-and-install-updates': () => void
'minimize-window': () => void
'maximize-window': () => void
'unmaximize-window': () => void
'close-window': () => void
'auto-updater-error': (error: Error) => void
'auto-updater-checking-for-update': () => void
'auto-updater-update-available': () => void
'auto-updater-update-not-available': () => void
'auto-updater-update-downloaded': () => void
'native-theme-updated': () => void
'move-to-applications-folder': () => void
'focus-window': () => void
}
@ -74,6 +87,10 @@ export type RequestResponseChannels = {
) => Promise<ReadonlyArray<number> | null>
'is-window-focused': () => Promise<boolean>
'open-external': (path: string) => Promise<boolean>
'is-in-application-folder': () => Promise<boolean | null>
'check-for-updates': (url: string) => Promise<Error | undefined>
'get-current-window-state': () => Promise<WindowState | undefined>
'get-current-window-zoom-factor': () => Promise<number | undefined>
'resolve-proxy': (url: string) => Promise<string>
'show-open-dialog': (
options: Electron.OpenDialogOptions

View file

@ -3,7 +3,7 @@ import { formatLogMessage } from '../format-log-message'
import { sendProxy } from '../../../ui/main-process-proxy'
const g = global as any
const ipcLog = sendProxy('log')
const ipcLog = sendProxy('log', 2)
/**
* Dispatches the given log entry to the main process where it will be picked

View file

@ -22,10 +22,11 @@ import {
} from '../local-storage'
import { PushOptions } from '../git'
import { getShowSideBySideDiff } from '../../ui/lib/diff-mode'
import { remote } from 'electron'
import * as remote from '@electron/remote'
import { Architecture, getArchitecture } from '../get-architecture'
import { MultiCommitOperationKind } from '../../models/multi-commit-operation'
import { getNotificationsEnabled } from '../stores/notifications-store'
import { isInApplicationFolder } from '../../ui/main-process-proxy'
const StatsEndpoint = 'https://central.github.com/api/usage/desktop'
@ -522,8 +523,9 @@ export class StatsStore implements IStatsStore {
const diffMode = getShowSideBySideDiff() ? 'split' : 'unified'
// isInApplicationsFolder is undefined when not running on Darwin
const launchedFromApplicationsFolder =
remote.app.isInApplicationsFolder?.() ?? null
const launchedFromApplicationsFolder = __DARWIN__
? await isInApplicationFolder()
: null
return {
eventType: 'usage',

View file

@ -1,5 +1,4 @@
import * as Path from 'path'
import { remote } from 'electron'
import { pathExists } from 'fs-extra'
import { escape } from 'querystring'
import {
@ -75,7 +74,10 @@ import {
} from '../../ui/lib/application-theme'
import {
getAppMenu,
getCurrentWindowState,
getCurrentWindowZoomFactor,
updatePreferredAppMenuItemLabels,
updateAccounts,
} from '../../ui/main-process-proxy'
import {
API,
@ -183,7 +185,7 @@ import {
} from '../shells'
import { ILaunchStats, StatsStore } from '../stats'
import { hasShownWelcomeFlow, markWelcomeFlowComplete } from '../welcome'
import { getWindowState, WindowState } from '../window-state'
import { WindowState } from '../window-state'
import { TypedBaseStore } from './base-store'
import { MergeTreeResult } from '../../models/merge'
import { promiseWithMinimumTimeout } from '../promise'
@ -282,6 +284,7 @@ import { DragAndDropIntroType } from '../../ui/history/drag-and-drop-intro'
import { UseWindowsOpenSSHKey } from '../ssh/ssh'
import { isConflictsFlow } from '../multi-commit-operation'
import { clamp } from '../clamp'
import { EndpointToken } from '../endpoint-token'
import { IRefCheck } from '../ci-checks/ci-checks'
import {
NotificationsStore,
@ -406,7 +409,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
private commitSummaryWidth = constrain(defaultCommitSummaryWidth)
private stashedFilesWidth = constrain(defaultStashedFilesWidth)
private windowState: WindowState
private windowState: WindowState | null = null
private windowZoomFactor: number = 1
private isUpdateAvailableBannerVisible: boolean = false
@ -504,16 +507,14 @@ export class AppStore extends TypedBaseStore<IAppState> {
error => this.emitError(error)
)
const browserWindow = remote.getCurrentWindow()
this.windowState = getWindowState(browserWindow)
this.onWindowZoomFactorChanged(browserWindow.webContents.zoomFactor)
window.addEventListener('resize', () => {
this.updateResizableConstraints()
this.emitUpdate()
})
this.wireupIpcEventHandlers(browserWindow)
this.initializeWindowState()
this.initializeZoomFactor()
this.wireupIpcEventHandlers()
this.wireupStoreEventHandlers()
getAppMenu()
this.tutorialAssessor = new OnboardingTutorialAssessor(
@ -550,6 +551,23 @@ export class AppStore extends TypedBaseStore<IAppState> {
)
}
private initializeWindowState = async () => {
const currentWindowState = await getCurrentWindowState()
if (currentWindowState === undefined) {
return
}
this.windowState = currentWindowState
}
private initializeZoomFactor = async () => {
const zoomFactor = await getCurrentWindowZoomFactor()
if (zoomFactor === undefined) {
return
}
this.onWindowZoomFactorChanged(zoomFactor)
}
private onTokenInvalidated = (endpoint: string) => {
const account = getAccountForEndpoint(this.accounts, endpoint)
@ -652,7 +670,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
await this.updateCurrentTutorialStep(repository)
}
private wireupIpcEventHandlers(window: Electron.BrowserWindow) {
private wireupIpcEventHandlers() {
ipcRenderer.on('window-state-changed', (_, windowState) => {
this.windowState = windowState
this.emitUpdate()
@ -688,6 +706,12 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.accountsStore.onDidUpdate(accounts => {
this.accounts = accounts
const endpointTokens = accounts.map<EndpointToken>(
({ endpoint, token }) => ({ endpoint, token })
)
updateAccounts(endpointTokens)
this.emitUpdate()
})
this.accountsStore.onDidError(error => this.emitError(error))

View file

@ -0,0 +1,26 @@
import { OrderedWebRequest } from './ordered-webrequest'
/**
* Installs a web request filter to override the default Origin used to connect
* to Alive web sockets
*/
export function installAliveOriginFilter(orderedWebRequest: OrderedWebRequest) {
orderedWebRequest.onBeforeSendHeaders.addEventListener(async details => {
const { protocol, host } = new URL(details.url)
// If it's a WebSocket Secure request directed to a github.com subdomain,
// probably related to the Alive server, we need to override the `Origin`
// header with a valid value.
if (protocol === 'wss:' && /(^|\.)github\.com$/.test(host)) {
return {
requestHeaders: {
...details.requestHeaders,
// TODO: discuss with Alive team a good Origin value to use here
Origin: 'https://desktop.github.com',
},
}
}
return {}
})
}

View file

@ -1,7 +1,17 @@
import { BrowserWindow, Menu, app, dialog } from 'electron'
import {
Menu,
app,
dialog,
BrowserWindow,
autoUpdater,
nativeTheme,
} from 'electron'
import { Emitter, Disposable } from 'event-kit'
import { encodePathAsUrl } from '../lib/path'
import { registerWindowStateChangedEvents } from '../lib/window-state'
import {
getWindowState,
registerWindowStateChangedEvents,
} from '../lib/window-state'
import { MenuEvent } from './menu'
import { URLActionType } from '../lib/parse-app-url'
import { ILaunchStats } from '../lib/stats'
@ -9,6 +19,7 @@ import { menuFromElectronMenu } from '../models/app-menu'
import { now } from './now'
import * as path from 'path'
import windowStateKeeper from 'electron-window-state'
import * as remoteMain from '@electron/remote/main'
import * as ipcMain from './ipc-main'
import * as ipcWebContents from './ipc-webcontents'
@ -48,10 +59,8 @@ export class AppWindow {
// See https://developers.google.com/web/updates/2016/10/auxclick
disableBlinkFeatures: 'Auxclick',
nodeIntegration: true,
enableRemoteModule: true,
spellcheck: true,
contextIsolation: false,
worldSafeExecuteJavaScript: false,
},
acceptFirstMouse: true,
}
@ -65,6 +74,8 @@ export class AppWindow {
}
this.window = new BrowserWindow(windowOptions)
remoteMain.enable(this.window.webContents)
savedWindowState.manage(this.window)
this.shouldMaximizeOnShow = savedWindowState.isMaximized
@ -78,23 +89,24 @@ export class AppWindow {
event.returnValue = true
})
// 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()
// https://github.com/desktop/desktop/issues/12838
if (this.window.isFullScreen()) {
this.window.setFullScreen(false)
this.window.once('leave-full-screen', () => app.hide())
} else {
app.hide()
}
this.window.on('close', e => {
// 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__ && !quitting) {
e.preventDefault()
// https://github.com/desktop/desktop/issues/12838
if (this.window.isFullScreen()) {
this.window.setFullScreen(false)
this.window.once('leave-full-screen', () => app.hide())
} else {
app.hide()
}
})
}
return
}
nativeTheme.removeAllListeners()
autoUpdater.removeAllListeners()
})
if (__WIN32__) {
// workaround for known issue with fullscreen-ing the app and restoring
@ -169,6 +181,12 @@ export class AppWindow {
registerWindowStateChangedEvents(this.window)
this.window.loadURL(encodePathAsUrl(__dirname, 'index.html'))
nativeTheme.addListener('updated', (event: string, userInfo: any) => {
ipcWebContents.send(this.window.webContents, 'native-theme-updated')
})
this.setupAutoUpdater()
}
/**
@ -317,6 +335,78 @@ export class AppWindow {
this.window.destroy()
}
public setupAutoUpdater() {
autoUpdater.on('error', (error: Error) => {
ipcWebContents.send(this.window.webContents, 'auto-updater-error', error)
})
autoUpdater.on('checking-for-update', () => {
ipcWebContents.send(
this.window.webContents,
'auto-updater-checking-for-update'
)
})
autoUpdater.on('update-available', () => {
ipcWebContents.send(
this.window.webContents,
'auto-updater-update-available'
)
})
autoUpdater.on('update-not-available', () => {
ipcWebContents.send(
this.window.webContents,
'auto-updater-update-not-available'
)
})
autoUpdater.on('update-downloaded', () => {
ipcWebContents.send(
this.window.webContents,
'auto-updater-update-downloaded'
)
})
}
public checkForUpdates(url: string) {
try {
autoUpdater.setFeedURL({ url })
autoUpdater.checkForUpdates()
} catch (e) {
return e
}
return undefined
}
public quitAndInstallUpdate() {
autoUpdater.quitAndInstall()
}
public minimizeWindow() {
this.window.minimize()
}
public maximizeWindow() {
this.window.maximize()
}
public unmaximizeWindow() {
this.window.unmaximize()
}
public closeWindow() {
this.window.close()
}
public getCurrentWindowState() {
return getWindowState(this.window)
}
public getCurrentWindowZoomFactor() {
return this.window.webContents.zoomFactor
}
/**
* Method to show the open dialog and return the first file path it returns.
*/

View file

@ -0,0 +1,37 @@
import { EndpointToken } from '../lib/endpoint-token'
import { OrderedWebRequest } from './ordered-webrequest'
/**
* Installs a web request filter which adds the Authorization header for
* unauthenticated requests to the GHES/GHAE private avatars API.
*
* Returns a method that can be used to update the list of signed-in accounts
* which is used to resolve which token to use.
*/
export function installAuthenticatedAvatarFilter(
orderedWebRequest: OrderedWebRequest
) {
let originTokens = new Map<string, string>()
orderedWebRequest.onBeforeSendHeaders.addEventListener(async details => {
const { origin, pathname } = new URL(details.url)
const token = originTokens.get(origin)
if (token && pathname.startsWith('/api/v3/enterprise/avatars/')) {
return {
requestHeaders: {
...details.requestHeaders,
Authorization: `token ${token}`,
},
}
}
return {}
})
return (accounts: ReadonlyArray<EndpointToken>) => {
originTokens = new Map(
accounts.map(({ endpoint, token }) => [new URL(endpoint).origin, token])
)
}
}

View file

@ -2,6 +2,7 @@ import { BrowserWindow } from 'electron'
import { Emitter, Disposable } from 'event-kit'
import { ICrashDetails, ErrorType } from '../crash/shared'
import { registerWindowStateChangedEvents } from '../lib/window-state'
import * as remoteMain from '@electron/remote/main'
import * as ipcMain from './ipc-main'
import * as ipcWebContents from './ipc-webcontents'
@ -40,9 +41,7 @@ export class CrashWindow {
disableBlinkFeatures: 'Auxclick',
nodeIntegration: true,
spellcheck: false,
enableRemoteModule: true,
contextIsolation: false,
worldSafeExecuteJavaScript: false,
},
}
@ -53,6 +52,8 @@ export class CrashWindow {
}
this.window = new BrowserWindow(windowOptions)
remoteMain.enable(this.window.webContents)
this.error = error
this.errorType = errorType
}

View file

@ -12,5 +12,12 @@ export function send<T extends keyof RequestChannels>(
channel: T,
...args: Parameters<RequestChannels[T]>
): void {
if (webContents.isDestroyed()) {
const msg = `failed to send on ${channel}, webContents was destroyed`
if (__DEV__) {
throw new Error(msg)
}
log.error(msg)
}
webContents.send(channel, ...args)
}

View file

@ -23,8 +23,13 @@ import { showUncaughtException } from './show-uncaught-exception'
import { buildContextMenu } from './menu/build-context-menu'
import { stat } from 'fs-extra'
import { isApplicationBundle } from '../lib/is-application-bundle'
import { installWebRequestFilters } from './install-web-request-filters'
import { OrderedWebRequest } from './ordered-webrequest'
import { installAuthenticatedAvatarFilter } from './authenticated-avatar-filter'
import { installAliveOriginFilter } from './alive-origin-filter'
import { installSameOriginFilter } from './same-origin-filter'
import * as ipcMain from './ipc-main'
import * as remoteMain from '@electron/remote/main'
remoteMain.initialize()
app.setAppLogsPath()
enableSourceMaps()
@ -283,9 +288,19 @@ app.on('ready', () => {
createWindow()
const orderedWebRequest = new OrderedWebRequest(
session.defaultSession.webRequest
)
// Ensures auth-related headers won't traverse http redirects to hosts
// on different origins than the originating request.
installWebRequestFilters(session.defaultSession.webRequest)
installSameOriginFilter(orderedWebRequest)
// Ensures Alive websocket sessions are initiated with an acceptable Origin
installAliveOriginFilter(orderedWebRequest)
// Adds an authorization header for requests of avatars on GHES
const updateAccounts = installAuthenticatedAvatarFilter(orderedWebRequest)
Menu.setApplicationMenu(
buildDefaultMenu({
@ -296,6 +311,8 @@ app.on('ready', () => {
})
)
ipcMain.on('update-accounts', (_, accounts) => updateAccounts(accounts))
ipcMain.on('update-preferred-app-menu-item-labels', (_, labels) => {
// The current application menu is mutable and we frequently
// change whether particular items are enabled or not through
@ -437,6 +454,30 @@ app.on('ready', () => {
})
})
ipcMain.handle('check-for-updates', async (_, url) =>
mainWindow?.checkForUpdates(url)
)
ipcMain.on('quit-and-install-updates', () =>
mainWindow?.quitAndInstallUpdate()
)
ipcMain.on('minimize-window', () => mainWindow?.minimizeWindow())
ipcMain.on('maximize-window', () => mainWindow?.maximizeWindow())
ipcMain.on('unmaximize-window', () => mainWindow?.unmaximizeWindow())
ipcMain.on('close-window', () => mainWindow?.closeWindow())
ipcMain.handle('get-current-window-state', async () =>
mainWindow?.getCurrentWindowState()
)
ipcMain.handle('get-current-window-zoom-factor', async () =>
mainWindow?.getCurrentWindowZoomFactor()
)
/**
* An event sent by the renderer asking for a copy of the current
* application menu.
@ -551,6 +592,18 @@ app.on('ready', () => {
mainWindow?.selectAllWindowContents()
)
/**
* An event sent by the renderer asking whether the Desktop is in the
* applications folder
*
* Note: This will return null when not running on Darwin
*/
ipcMain.handle('is-in-application-folder', async () => {
// Contrary to what the types tell you the `isInApplicationsFolder` will be undefined
// when not on macOS
return app.isInApplicationsFolder?.() ?? null
})
/**
* Handle action to resolve proxy
*/

View file

@ -0,0 +1,254 @@
import {
WebRequest,
OnBeforeRequestListenerDetails,
Response,
OnBeforeSendHeadersListenerDetails,
BeforeSendResponse,
OnCompletedListenerDetails,
OnErrorOccurredListenerDetails,
OnResponseStartedListenerDetails,
OnHeadersReceivedListenerDetails,
HeadersReceivedResponse,
OnSendHeadersListenerDetails,
OnBeforeRedirectListenerDetails,
} from 'electron/main'
type SyncListener<TDetails> = (details: TDetails) => void
type AsyncListener<TDetails, TResponse> = (
details: TDetails
) => Promise<TResponse>
/*
* A proxy class allowing which handles subscribing to, and unsubscribing from,
* one of the synchronous events in the WebRequest class such as
* onBeforeRedirect
*/
class SyncListenerSet<TDetails> {
private readonly listeners = new Set<SyncListener<TDetails>>()
public constructor(
private readonly subscribe: (
listener: SyncListener<TDetails> | null
) => void
) {}
public addEventListener(listener: SyncListener<TDetails>) {
const firstListener = this.listeners.size === 0
this.listeners.add(listener)
if (firstListener) {
this.subscribe(details => this.listeners.forEach(l => l(details)))
}
}
public removeEventListener(listener: SyncListener<TDetails>) {
this.listeners.delete(listener)
if (this.listeners.size === 0) {
this.subscribe(null)
}
}
}
/*
* A proxy class allowing which handles subscribing to, and unsubscribing from,
* one of the asynchronous events in the WebRequest class such as
* onBeforeRequest
*/
class AsyncListenerSet<TDetails, TResponse> {
private readonly listeners = new Set<AsyncListener<TDetails, TResponse>>()
public constructor(
private readonly subscribe: (
listener:
| ((details: TDetails, cb: (response: TResponse) => void) => void)
| null
) => void,
private readonly eventHandler: (
listeners: Iterable<AsyncListener<TDetails, TResponse>>,
details: TDetails
) => Promise<TResponse>
) {}
public addEventListener(listener: AsyncListener<TDetails, TResponse>) {
const firstListener = this.listeners.size === 0
this.listeners.add(listener)
if (firstListener) {
this.subscribe(async (details, cb) => {
cb(await this.eventHandler([...this.listeners], details))
})
}
}
public removeEventListener(listener: AsyncListener<TDetails, TResponse>) {
this.listeners.delete(listener)
if (this.listeners.size === 0) {
this.subscribe(null)
}
}
}
/**
* A utility class allowing consumers to apply more than one WebRequest filter
* concurrently into the main process.
*
* The WebRequest class in Electron allows us to intercept and modify web
* requests from the renderer process. Unfortunately it only allows one filter
* to be installed forcing consumers to build monolithic filters. Using
* OrderedWebRequest consumers can instead subscribe to the event they'd like
* and OrderedWebRequest will take care of calling them in order and merging the
* changes each filter applies.
*
* Note that OrderedWebRequest is not API compatible with WebRequest and relies
* on event listeners being asynchronous methods rather than providing a
* callback parameter to listeners.
*
* For documentation of the various events see the Electron WebRequest API
* documentation.
*/
export class OrderedWebRequest {
public readonly onBeforeRedirect: SyncListenerSet<
OnBeforeRedirectListenerDetails
>
public readonly onBeforeRequest: AsyncListenerSet<
OnBeforeRequestListenerDetails,
Response
>
public readonly onBeforeSendHeaders: AsyncListenerSet<
OnBeforeSendHeadersListenerDetails,
BeforeSendResponse
>
public readonly onCompleted: SyncListenerSet<OnCompletedListenerDetails>
public readonly onErrorOccurred: SyncListenerSet<
OnErrorOccurredListenerDetails
>
public readonly onHeadersReceived: AsyncListenerSet<
OnHeadersReceivedListenerDetails,
HeadersReceivedResponse
>
public readonly onResponseStarted: SyncListenerSet<
OnResponseStartedListenerDetails
>
public readonly onSendHeaders: SyncListenerSet<OnSendHeadersListenerDetails>
public constructor(webRequest: WebRequest) {
this.onBeforeRedirect = new SyncListenerSet(
webRequest.onBeforeRedirect.bind(webRequest)
)
this.onBeforeRequest = new AsyncListenerSet(
webRequest.onBeforeRequest.bind(webRequest),
async (listeners, details) => {
let response: Response = {}
for (const listener of listeners) {
response = await listener(details)
// If we encounter a filter which either cancels the request or
// provides a redirect url we won't process any of the following
// filters.
if (response.cancel === true || response.redirectURL !== undefined) {
break
}
}
return response
}
)
this.onBeforeSendHeaders = new AsyncListenerSet(
webRequest.onBeforeSendHeaders.bind(webRequest),
async (listeners, initialDetails) => {
let details = initialDetails
let response: BeforeSendResponse = {}
for (const listener of listeners) {
response = await listener(details)
if (response.cancel === true) {
break
}
if (response.requestHeaders !== undefined) {
// I have no idea why there's a discrepancy of types here.
// details.requestHeaders is a Record<string, string> but
// BeforeSendResponse["requestHeaders"] is a
// Record<string, (string) | (string[])>. Chances are this was done
// to make it easier for filters but it makes it trickier for us as
// we have to ensure the next filter gets headers as a
// Record<string, string>
const requestHeaders = flattenHeaders(response.requestHeaders)
details = { ...details, requestHeaders }
}
}
return details
}
)
this.onCompleted = new SyncListenerSet(
webRequest.onCompleted.bind(webRequest)
)
this.onErrorOccurred = new SyncListenerSet(
webRequest.onErrorOccurred.bind(webRequest)
)
this.onHeadersReceived = new AsyncListenerSet(
webRequest.onHeadersReceived.bind(webRequest),
async (listeners, initialDetails) => {
let details = initialDetails
let response: HeadersReceivedResponse = {}
for (const listener of listeners) {
response = await listener(details)
if (response.cancel === true) {
break
}
if (response.responseHeaders !== undefined) {
// See comment about type mismatch in onBeforeSendHeaders
const responseHeaders = unflattenHeaders(response.responseHeaders)
details = { ...details, responseHeaders }
}
if (response.statusLine !== undefined) {
const { statusLine } = response
const statusCode = parseInt(statusLine.split(' ', 2)[1], 10)
details = { ...details, statusLine, statusCode }
}
}
return details
}
)
this.onResponseStarted = new SyncListenerSet(
webRequest.onResponseStarted.bind(webRequest)
)
this.onSendHeaders = new SyncListenerSet(
webRequest.onSendHeaders.bind(webRequest)
)
}
}
// https://stackoverflow.com/a/3097052/2114
const flattenHeaders = (headers: Record<string, string[] | string>) =>
Object.entries(headers).reduce<Record<string, string>>((h, [k, v]) => {
h[k] = Array.isArray(v) ? v.join(',') : v
return h
}, {})
// https://stackoverflow.com/a/3097052/2114
const unflattenHeaders = (headers: Record<string, string[] | string>) =>
Object.entries(headers).reduce<Record<string, string[]>>((h, [k, v]) => {
h[k] = Array.isArray(v) ? v : v.split(',')
return h
}, {})

View file

@ -1,9 +1,7 @@
import { WebRequest } from 'electron/main'
import { OrderedWebRequest } from './ordered-webrequest'
/**
* Installs two web request filters:
* - One to prevent cross domain leaks of auth headers
* - Another one to override the default Origin used to connect to Alive web sockets
* Installs a web request filter to prevent cross domain leaks of auth headers
*
* GitHub Desktop uses the fetch[1] web API for all of our API requests. When fetch
* is used in a browser and it encounters an http redirect to another origin
@ -31,15 +29,15 @@ import { WebRequest } from 'electron/main'
* 2. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch
* 3. https://github.com/whatwg/fetch/issues/763
*
* @param webRequest
* @param orderedWebRequest
*/
export function installWebRequestFilters(webRequest: WebRequest) {
export function installSameOriginFilter(orderedWebRequest: OrderedWebRequest) {
// A map between the request ID and the _initial_ request origin
const requestOrigin = new Map<number, string>()
const safeProtocols = new Set(['devtools:', 'file:', 'chrome-extension:'])
const unsafeHeaders = new Set(['authentication', 'authorization', 'cookie'])
webRequest.onBeforeRequest((details, cb) => {
orderedWebRequest.onBeforeRequest.addEventListener(async details => {
const { protocol, origin } = new URL(details.url)
// This is called once for the initial request and then once for each
@ -50,28 +48,15 @@ export function installWebRequestFilters(webRequest: WebRequest) {
requestOrigin.set(details.id, origin)
}
cb({})
return {}
})
webRequest.onBeforeSendHeaders((details, cb) => {
orderedWebRequest.onBeforeSendHeaders.addEventListener(async details => {
const initialOrigin = requestOrigin.get(details.id)
const { origin, protocol, host } = new URL(details.url)
// If it's a WebSocket Secure request directed to a github.com subdomain,
// probably related to the Alive server, we need to override the `Origin`
// header with a valid value.
if (protocol === 'wss:' && /(^|\.)github\.com$/.test(host)) {
return cb({
requestHeaders: {
...details.requestHeaders,
// TODO: discuss with Alive team a good Origin value to use here
Origin: 'https://desktop.github.com',
},
})
}
const { origin } = new URL(details.url)
if (initialOrigin === undefined || initialOrigin === origin) {
return cb({ requestHeaders: details.requestHeaders })
return { requestHeaders: details.requestHeaders }
}
const sanitizedHeaders: Record<string, string> = {}
@ -83,8 +68,10 @@ export function installWebRequestFilters(webRequest: WebRequest) {
}
log.debug(`Sanitizing cross-origin redirect to ${origin}`)
return cb({ requestHeaders: sanitizedHeaders })
return { requestHeaders: sanitizedHeaders }
})
webRequest.onCompleted(details => requestOrigin.delete(details.id))
orderedWebRequest.onCompleted.addEventListener(details =>
requestOrigin.delete(details.id)
)
}

View file

@ -1,8 +1,6 @@
import * as React from 'react'
import * as crypto from 'crypto'
import { remote } from 'electron'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import {
IAppState,
RepositorySectionTab,
@ -57,6 +55,7 @@ import * as OcticonSymbol from './octicons/octicons.generated'
import {
showCertificateTrustDialog,
sendReady,
isInApplicationFolder,
selectAllWindowContents,
} from './main-process-proxy'
import { DiscardChanges } from './discard-changes'
@ -293,7 +292,7 @@ export class App extends React.Component<IAppProps, IAppState> {
window.clearInterval(this.updateIntervalHandle)
}
private performDeferredLaunchActions() {
private async performDeferredLaunchActions() {
// Loading emoji is super important but maybe less important that loading
// the app. So defer it until we have some breathing space.
this.props.appStore.loadEmoji()
@ -313,7 +312,8 @@ export class App extends React.Component<IAppProps, IAppState> {
if (
__DEV__ === false &&
this.state.askToMoveToApplicationsFolderSetting &&
remote.app.isInApplicationsFolder?.() === false
__DARWIN__ &&
(await isInApplicationFolder()) === false
) {
this.showPopup({ type: PopupType.MoveToApplicationsFolder })
}

View file

@ -1,7 +1,7 @@
import * as Path from 'path'
import * as React from 'react'
import { remote } from 'electron'
import * as remote from '@electron/remote'
import { readdir } from 'fs-extra'
import { Dispatcher } from '../dispatcher'
import { getDefaultDir, setDefaultDir } from '../lib/default-dir'

View file

@ -3,9 +3,7 @@ import '../lib/logging/renderer/install'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import * as Path from 'path'
import * as moment from 'moment'
import { App } from './app'
import {
Dispatcher,
@ -178,7 +176,7 @@ const sendErrorWithContext = (
}
extra.repositoryCount = `${currentState.repositories.length}`
extra.windowState = currentState.windowState
extra.windowState = currentState.windowState ?? 'Unknown'
extra.accounts = `${currentState.accounts.length}`
extra.automaticallySwitchTheme = `${

View file

@ -1,8 +1,6 @@
import { remote } from 'electron'
import * as remote from '@electron/remote'
let app: Electron.App | null = null
let version: string | null = null
let name: string | null = null
let path: string | null = null
let userDataPath: string | null = null
let documentsPath: string | null = null
@ -21,24 +19,14 @@ function getApp(): Electron.App {
* This is preferable to using `remote` directly because we cache the result.
*/
export function getVersion(): string {
if (!version) {
version = getApp().getVersion()
}
return version
return __APP_VERSION__
}
/**
* Get the name of the app.
*
* This is preferable to using `remote` directly because we cache the result.
*/
export function getName(): string {
if (!name) {
name = getApp().getName()
}
return name
return __APP_NAME__
}
/**

View file

@ -1,4 +1,4 @@
import { remote } from 'electron'
import * as remote from '@electron/remote'
import {
isMacOSMojaveOrLater,
isWindows10And1809Preview17666OrLater,

View file

@ -6,6 +6,7 @@ import { Octicon } from '../octicons'
import { getDotComAPIEndpoint } from '../../lib/api'
import { TooltippedContent } from './tooltipped-content'
import { TooltipDirection } from './tooltip'
import { supportsAvatarsAPI } from '../../lib/endpoint-capabilities'
interface IAvatarProps {
/** The user whose avatar should be displayed. */
@ -30,8 +31,6 @@ interface IAvatarState {
readonly candidates: ReadonlyArray<string>
}
const avatarEndpoint = 'https://avatars.githubusercontent.com'
/**
* This is the person octicon from octicons v5 (which we're using at time of writing).
* The octicon has been tweaked to add some padding and so that it scales nicely in
@ -51,7 +50,7 @@ const DefaultAvatarSymbol = {
* Yields two capture groups, the first being an optional capture of the
* user id and the second being the mandatory login.
*/
const StealthEmailRegexp = /^(?:(\d+)\+)?(.+?)@users\.noreply\.github\.com$/i
const StealthEmailRegexp = /^(?:(\d+)\+)?(.+?)@users\.noreply\.(.*)$/i
/**
* Produces an ordered iterable of avatar urls to attempt to load for the
@ -68,30 +67,31 @@ function getAvatarUrlCandidates(
}
const { email, endpoint, avatarURL } = user
const isDotCom = endpoint === getDotComAPIEndpoint()
if (endpoint === getDotComAPIEndpoint()) {
// The avatar urls returned by the API doesn't come
// with a size parameter, they default to the biggest
// size we need on GitHub.com which is usually much bigger
// than what desktop needs so we'll set a size explicitly.
if (avatarURL !== undefined) {
try {
const url = new URL(avatarURL)
url.searchParams.set('s', `${size}`)
// By leveraging the avatar url from the API (if we've got it) we can
// load the avatar from one of the load balanced domains (avatars). We can't
// do the same for GHES/GHAE however since the URLs returned by the API are
// behind private mode.
if (isDotCom && avatarURL !== undefined) {
// The avatar urls returned by the API doesn't come with a size parameter,
// they default to the biggest size we need on GitHub.com which is usually
// much bigger than what desktop needs so we'll set a size explicitly.
try {
const url = new URL(avatarURL)
url.searchParams.set('s', `${size}`)
candidates.push(url.toString())
} catch (e) {
// This should never happen since URL#constructor
// only throws for invalid URLs which we can expect
// the API to not give us
candidates.push(avatarURL)
}
candidates.push(url.toString())
} catch (e) {
// This should never happen since URL#constructor only throws for invalid
// URLs which we can expect the API to not give us
candidates.push(avatarURL)
}
} else if (endpoint !== null) {
// We're dealing with a repository hosted on GitHub Enterprise
// so we're unable to get to the avatar by requesting the avatarURL due
// to the private mode (see https://github.com/desktop/desktop/issues/821).
// So we have no choice but to fall back to gravatar for now.
} else if (endpoint !== null && !isDotCom && !supportsAvatarsAPI(endpoint)) {
// We're dealing with an old GitHub Enterprise instance so we're unable to
// get to the avatar by requesting the avatarURL due to the private mode
// (see https://github.com/desktop/desktop/issues/821). So we have no choice
// but to fall back to gravatar for now.
candidates.push(generateGravatarUrl(email, size))
return candidates
}
@ -110,14 +110,25 @@ function getAvatarUrlCandidates(
// account renames.
const stealthEmailMatch = StealthEmailRegexp.exec(email)
const avatarEndpoint =
endpoint === null || isDotCom
? 'https://avatars.githubusercontent.com'
: `${endpoint}/enterprise/avatars`
if (stealthEmailMatch) {
const [, userId, login] = stealthEmailMatch
if (userId !== undefined) {
const userIdParam = encodeURIComponent(userId)
candidates.push(`${avatarEndpoint}/u/${userIdParam}?s=${size}`)
} else {
const loginParam = encodeURIComponent(login)
candidates.push(`${avatarEndpoint}/${loginParam}?s=${size}`)
const [, userId, login, hostname] = stealthEmailMatch
if (
hostname === 'github.com' ||
(endpoint !== null && hostname === new URL(endpoint).hostname)
) {
if (userId !== undefined) {
const userIdParam = encodeURIComponent(userId)
candidates.push(`${avatarEndpoint}/u/${userIdParam}?s=${size}`)
} else {
const loginParam = encodeURIComponent(login)
candidates.push(`${avatarEndpoint}/${loginParam}?s=${size}`)
}
}
}

View file

@ -1,31 +1,27 @@
import { remote } from 'electron'
import {
ApplicableTheme,
getCurrentlyAppliedTheme,
supportsSystemThemeChanges,
} from './application-theme'
import { IDisposable, Disposable, Emitter } from 'event-kit'
import { Disposable, Emitter } from 'event-kit'
import { onNativeThemeUpdated } from '../main-process-proxy'
class ThemeChangeMonitor implements IDisposable {
class ThemeChangeMonitor {
private readonly emitter = new Emitter()
public constructor() {
this.subscribe()
}
public dispose() {
remote.nativeTheme.removeAllListeners()
}
private subscribe = () => {
if (!supportsSystemThemeChanges()) {
return
}
remote.nativeTheme.addListener('updated', this.onThemeNotificationUpdated)
onNativeThemeUpdated(this.onThemeNotificationUpdated)
}
private onThemeNotificationUpdated = (event: string, userInfo: any) => {
private onThemeNotificationUpdated = () => {
const theme = getCurrentlyAppliedTheme()
this.emitThemeChanged(theme)
}
@ -41,8 +37,3 @@ class ThemeChangeMonitor implements IDisposable {
// this becomes our singleton that we can subscribe to from anywhere
export const themeChangeMonitor = new ThemeChangeMonitor()
// this ensures we cleanup any existing subscription on exit
remote.app.on('will-quit', () => {
themeChangeMonitor.dispose()
})

View file

@ -1,13 +1,18 @@
import { remote } from 'electron'
// Given that `autoUpdater` is entirely async anyways, I *think* it's safe to
// use with `remote`.
const autoUpdater = remote.autoUpdater
import * as remote from '@electron/remote'
const lastSuccessfulCheckKey = 'last-successful-update-check'
import { Emitter, Disposable } from 'event-kit'
import { sendWillQuitSync } from '../main-process-proxy'
import {
checkForUpdates,
onAutoUpdaterCheckingForUpdate,
onAutoUpdaterError,
onAutoUpdaterUpdateAvailable,
onAutoUpdaterUpdateDownloaded,
onAutoUpdaterUpdateNotAvailable,
quitAndInstallUpdate,
sendWillQuitSync,
} from '../main-process-proxy'
import { ErrorWithMetadata } from '../../lib/error-with-metadata'
import { parseError } from '../../lib/squirrel-error-parser'
@ -55,25 +60,11 @@ class UpdateStore {
this.lastSuccessfulCheck = new Date(lastSuccessfulCheckTime)
}
autoUpdater.on('error', this.onAutoUpdaterError)
autoUpdater.on('checking-for-update', this.onCheckingForUpdate)
autoUpdater.on('update-available', this.onUpdateAvailable)
autoUpdater.on('update-not-available', this.onUpdateNotAvailable)
autoUpdater.on('update-downloaded', this.onUpdateDownloaded)
window.addEventListener('beforeunload', () => {
autoUpdater.removeListener('error', this.onAutoUpdaterError)
autoUpdater.removeListener(
'checking-for-update',
this.onCheckingForUpdate
)
autoUpdater.removeListener('update-available', this.onUpdateAvailable)
autoUpdater.removeListener(
'update-not-available',
this.onUpdateNotAvailable
)
autoUpdater.removeListener('update-downloaded', this.onUpdateDownloaded)
})
onAutoUpdaterError(this.onAutoUpdaterError)
onAutoUpdaterCheckingForUpdate(this.onCheckingForUpdate)
onAutoUpdaterUpdateAvailable(this.onUpdateAvailable)
onAutoUpdaterUpdateNotAvailable(this.onUpdateNotAvailable)
onAutoUpdaterUpdateDownloaded(this.onUpdateDownloaded)
}
private touchLastChecked() {
@ -82,7 +73,7 @@ class UpdateStore {
setNumber(lastSuccessfulCheckKey, now.getTime())
}
private onAutoUpdaterError = (error: Error) => {
private onAutoUpdaterError = (e: Electron.IpcRendererEvent, error: Error) => {
this.status = UpdateStatus.UpdateNotAvailable
if (__WIN32__) {
@ -154,7 +145,7 @@ class UpdateStore {
* @param inBackground - Are we checking for updates in the background, or was
* this check user-initiated?
*/
public checkForUpdates(inBackground: boolean) {
public async checkForUpdates(inBackground: boolean) {
// An update has been downloaded and the app is waiting to be restarted.
// Checking for updates again may result in the running app being nuked
// when it finds a subsequent update.
@ -182,11 +173,10 @@ class UpdateStore {
this.userInitiatedUpdate = !inBackground
try {
autoUpdater.setFeedURL({ url: updatesURL })
autoUpdater.checkForUpdates()
} catch (e) {
this.emitError(e)
const error = await checkForUpdates(updatesURL)
if (error !== undefined) {
this.emitError(error)
}
}
@ -196,7 +186,7 @@ class UpdateStore {
// before we call the function to quit.
// eslint-disable-next-line no-sync
sendWillQuitSync()
autoUpdater.quitAndInstall()
quitAndInstallUpdate()
}
}

View file

@ -1,70 +1,167 @@
import { remote } from 'electron'
import * as remote from '@electron/remote'
import { ExecutableMenuItem } from '../models/app-menu'
import { IMenuItem, ISerializableMenuItem } from '../lib/menu-item'
import { RequestResponseChannels, RequestChannels } from '../lib/ipc-shared'
import { ExecutableMenuItem } from '../models/app-menu'
import * as ipcRenderer from '../lib/ipc-renderer'
/**
* Creates a strongly typed proxy method for sending a duplex IPC message to the
* main process. The parameter types and return type are infered from the
* RequestResponseChannels type which defines the valid duplex channel names.
*
* @param numArgs The number of arguments that the channel expects. We specify
* this so that we don't accidentally send more things over the
* IPC boundary than we intended to which can lead to runtime
* errors.
*
* This is necessary because TypeScript allows passing more
* arguments than defined to functions which in turn means that
* functions without arguments are type compatible with all
* functions that share the same return type.
*/
export function invokeProxy<T extends keyof RequestResponseChannels>(
channel: T
): (
...args: Parameters<RequestResponseChannels[T]>
) => ReturnType<RequestResponseChannels[T]> {
return (...args) => ipcRenderer.invoke(channel, ...args) as any
channel: T,
numArgs: ParameterCount<RequestResponseChannels[T]>
) {
return (...args: Parameters<RequestResponseChannels[T]>) => {
// This as any cast here may seem unsafe but it isn't since we're guaranteed
// that numArgs will match the parameter count of the IPC declaration.
args = args.length !== numArgs ? (args.slice(0, numArgs) as any) : args
return ipcRenderer.invoke(channel, ...args)
}
}
/**
* Creates a strongly typed proxy method for sending a simplex IPC message to
* the main process. The parameter types are infered from the
* RequestResponseChannels type which defines the valid duplex channel names.
*
* @param numArgs The number of arguments that the channel expects. We specify
* this so that we don't accidentally send more things over the
* IPC boundary than we intended to which can lead to runtime
* errors.
*
* This is necessary because TypeScript allows passing more
* arguments than defined to functions which in turn means that
* functions without arguments are type compatible with all
* functions that share the same return type.
*/
export function sendProxy<T extends keyof RequestChannels>(
channel: T
): (...args: Parameters<RequestChannels[T]>) => void {
return (...args) => ipcRenderer.send(channel, ...args)
channel: T,
numArgs: ParameterCount<RequestChannels[T]>
) {
return (...args: Parameters<RequestChannels[T]>) => {
// This as any cast here may seem unsafe but it isn't since we're guaranteed
// that numArgs will match the parameter count of the IPC declaration.
args = args.length !== numArgs ? (args.slice(0, numArgs) as any) : args
ipcRenderer.send(channel, ...args)
}
}
/**
* Tell the main process to select all of the current web contents
*/
export const selectAllWindowContents = sendProxy('select-all-window-contents')
export const selectAllWindowContents = sendProxy(
'select-all-window-contents',
0
)
/** Set the menu item's enabledness. */
export const updateMenuState = sendProxy('update-menu-state')
export const updateMenuState = sendProxy('update-menu-state', 1)
/** Tell the main process that the renderer is ready. */
export const sendReady = sendProxy('renderer-ready')
export const sendReady = sendProxy('renderer-ready', 1)
/** Tell the main process to execute (i.e. simulate a click of) the menu item. */
export const executeMenuItem = (item: ExecutableMenuItem) =>
executeMenuItemById(item.id)
/** Tell the main process to execute (i.e. simulate a click of) the menu item. */
export const executeMenuItemById = sendProxy('execute-menu-item-by-id')
export const executeMenuItemById = sendProxy('execute-menu-item-by-id', 1)
/**
* Tell the main process to obtain whether the window is focused.
*/
export const isWindowFocused = invokeProxy('is-window-focused')
export const isWindowFocused = invokeProxy('is-window-focused', 0)
/** Tell the main process to focus on the main window. */
export const focusWindow = sendProxy('focus-window')
export const focusWindow = sendProxy('focus-window', 0)
export const showItemInFolder = sendProxy('show-item-in-folder')
export const showFolderContents = sendProxy('show-folder-contents')
export const openExternal = invokeProxy('open-external')
export const moveItemToTrash = invokeProxy('move-to-trash')
export const showItemInFolder = sendProxy('show-item-in-folder', 1)
export const showFolderContents = sendProxy('show-folder-contents', 1)
export const openExternal = invokeProxy('open-external', 1)
export const moveItemToTrash = invokeProxy('move-to-trash', 1)
/** Tell the main process to obtain the current window state */
export const getCurrentWindowState = invokeProxy('get-current-window-state', 0)
/** Tell the main process to obtain the current window's zoom factor */
export const getCurrentWindowZoomFactor = invokeProxy(
'get-current-window-zoom-factor',
0
)
/** Tell the main process to check for app updates */
export const checkForUpdates = invokeProxy('check-for-updates', 1)
/** Tell the main process to quit the app and install updates */
export const quitAndInstallUpdate = sendProxy('quit-and-install-updates', 0)
/** Subscribes to auto updater error events originating from the main process */
export function onAutoUpdaterError(
errorHandler: (evt: Electron.IpcRendererEvent, error: Error) => void
) {
ipcRenderer.on('auto-updater-error', errorHandler)
}
/** Subscribes to auto updater checking for update events originating from the
* main process */
export function onAutoUpdaterCheckingForUpdate(eventHandler: () => void) {
ipcRenderer.on('auto-updater-checking-for-update', eventHandler)
}
/** Subscribes to auto updater update available events originating from the
* main process */
export function onAutoUpdaterUpdateAvailable(eventHandler: () => void) {
ipcRenderer.on('auto-updater-update-available', eventHandler)
}
/** Subscribes to auto updater update not available events originating from the
* main process */
export function onAutoUpdaterUpdateNotAvailable(eventHandler: () => void) {
ipcRenderer.on('auto-updater-update-not-available', eventHandler)
}
/** Subscribes to auto updater update downloaded events originating from the
* main process */
export function onAutoUpdaterUpdateDownloaded(eventHandler: () => void) {
ipcRenderer.on('auto-updater-update-downloaded', eventHandler)
}
/** Subscribes to the native theme updated event originating from the main process */
export function onNativeThemeUpdated(eventHandler: () => void) {
ipcRenderer.on('native-theme-updated', eventHandler)
}
/** Tell the main process to minimize the window */
export const minimizeWindow = sendProxy('minimize-window', 0)
/** Tell the main process to maximize the window */
export const maximizeWindow = sendProxy('maximize-window', 0)
/** Tell the main process to unmaximize the window */
export const restoreWindow = sendProxy('unmaximize-window', 0)
/** Tell the main process to close the window */
export const closeWindow = sendProxy('close-window', 0)
/**
* Show the OS-provided certificate trust dialog for the certificate, using the
* given message.
*/
export const showCertificateTrustDialog = sendProxy(
'show-certificate-trust-dialog'
'show-certificate-trust-dialog',
2
)
/**
@ -82,14 +179,17 @@ export function sendWillQuitSync() {
/**
* Tell the main process to move the application to the application folder
*/
export const moveToApplicationsFolder = sendProxy('move-to-applications-folder')
export const moveToApplicationsFolder = sendProxy(
'move-to-applications-folder',
0
)
/**
* Ask the main-process to send over a copy of the application menu.
* The response will be send as a separate event with the name 'app-menu' and
* will be received by the dispatcher.
*/
export const getAppMenu = sendProxy('get-app-menu')
export const getAppMenu = sendProxy('get-app-menu', 0)
function findSubmenuItem(
currentContextualMenuItems: ReadonlyArray<IMenuItem>,
@ -204,7 +304,7 @@ function getSpellCheckLanguageMenuItem(
}
}
const _showContextualMenu = invokeProxy('show-contextual-menu')
const _showContextualMenu = invokeProxy('show-contextual-menu', 1)
/** Show the given menu items in a contextual menu. */
export async function showContextualMenu(
@ -264,7 +364,8 @@ function serializeMenuItems(
/** Update the menu item labels with the user's preferred apps. */
export const updatePreferredAppMenuItemLabels = sendProxy(
'update-preferred-app-menu-item-labels'
'update-preferred-app-menu-item-labels',
1
)
function getIpcFriendlyError(error: Error) {
@ -275,13 +376,13 @@ function getIpcFriendlyError(error: Error) {
}
}
export const _reportUncaughtException = sendProxy('uncaught-exception')
export const _reportUncaughtException = sendProxy('uncaught-exception', 1)
export function reportUncaughtException(error: Error) {
_reportUncaughtException(getIpcFriendlyError(error))
}
const _sendErrorReport = sendProxy('send-error-report')
const _sendErrorReport = sendProxy('send-error-report', 3)
export function sendErrorReport(
error: Error,
@ -291,10 +392,20 @@ export function sendErrorReport(
_sendErrorReport(getIpcFriendlyError(error), extra, nonFatal)
}
export const updateAccounts = sendProxy('update-accounts', 1)
/** Tells the main process to resolve the proxy for a given url */
export const resolveProxy = invokeProxy('resolve-proxy')
export const resolveProxy = invokeProxy('resolve-proxy', 1)
/**
* Tell the main process to obtain whether the Desktop application is in the
* application folder
*
* Note: will return null when not running on darwin
*/
export const isInApplicationFolder = invokeProxy('is-in-application-folder', 0)
/**
* Tell the main process to show open dialog
*/
export const showOpenDialog = invokeProxy('show-open-dialog')
export const showOpenDialog = invokeProxy('show-open-dialog', 1)

View file

@ -5,7 +5,7 @@ import { WindowState } from '../../lib/window-state'
interface IFullScreenInfoProps {
// react-unused-props-and-state doesn't understand getDerivedStateFromProps
// tslint:disable-next-line:react-unused-props-and-state
readonly windowState: WindowState
readonly windowState: WindowState | null
}
interface IFullScreenInfoState {
@ -16,7 +16,7 @@ interface IFullScreenInfoState {
* "real" window state regardless of whether the app is in
* the background or not.
*/
readonly windowState?: Exclude<WindowState, 'hidden'>
readonly windowState?: Exclude<WindowState, 'hidden'> | null
}
const toastTransitionTimeout = { appear: 100, exit: 250 }

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import memoizeOne from 'memoize-one'
import { remote } from 'electron'
import * as remote from '@electron/remote'
import { WindowState } from '../../lib/window-state'
import { WindowControls } from './window-controls'
import { Octicon } from '../octicons/octicon'
@ -21,7 +21,7 @@ interface ITitleBarProps {
/**
* The current state of the Window, ie maximized, minimized full-screen etc.
*/
readonly windowState: WindowState
readonly windowState: WindowState | null
/** Whether we should hide the toolbar (and show inverted window controls) */
readonly titleBarStyle: 'light' | 'dark'

View file

@ -1,7 +1,13 @@
import * as React from 'react'
import { remote } from 'electron'
import { WindowState, getWindowState } from '../../lib/window-state'
import { WindowState } from '../../lib/window-state'
import classNames from 'classnames'
import {
closeWindow,
getCurrentWindowState,
maximizeWindow,
minimizeWindow,
restoreWindow,
} from '../main-process-proxy'
import * as ipcRenderer from '../../lib/ipc-renderer'
// These paths are all drawn to a 10x10 view box and replicate the symbols
@ -14,7 +20,7 @@ const maximizePath = 'M 0,0 0,10 10,10 10,0 Z M 1,1 9,1 9,9 1,9 Z'
const minimizePath = 'M 0,5 10,5 10,6 0,6 Z'
interface IWindowControlState {
readonly windowState: WindowState
readonly windowState: WindowState | null
}
/**
@ -31,11 +37,20 @@ interface IWindowControlState {
*/
export class WindowControls extends React.Component<{}, IWindowControlState> {
public componentWillMount() {
this.setState({ windowState: getWindowState(remote.getCurrentWindow()) })
this.setState({ windowState: null })
this.intializeWindowState()
ipcRenderer.on('window-state-changed', this.onWindowStateChanged)
}
private intializeWindowState = async () => {
const windowState = await getCurrentWindowState()
if (windowState === undefined) {
return
}
this.setState({ windowState })
}
public componentWillUnmount() {
ipcRenderer.removeListener(
'window-state-changed',
@ -43,6 +58,24 @@ export class WindowControls extends React.Component<{}, IWindowControlState> {
)
}
// Note: The following four wrapping methods are necessary on windows.
// Otherwise, you get a object cloning error.
private onMinimize = () => {
minimizeWindow()
}
private onMaximize = () => {
maximizeWindow()
}
private onRestore = () => {
restoreWindow()
}
private onClose = () => {
closeWindow()
}
public shouldComponentUpdate(nextProps: {}, nextState: IWindowControlState) {
return nextState.windowState !== this.state.windowState
}
@ -54,22 +87,6 @@ export class WindowControls extends React.Component<{}, IWindowControlState> {
this.setState({ windowState })
}
private onMinimize = () => {
remote.getCurrentWindow().minimize()
}
private onMaximize = () => {
remote.getCurrentWindow().maximize()
}
private onRestore = () => {
remote.getCurrentWindow().unmaximize()
}
private onClose = () => {
remote.getCurrentWindow().close()
}
private renderButton(
name: string,
onClick: React.EventHandler<React.MouseEvent<any>>,

View file

@ -28,4 +28,5 @@ export const remote = {
export const ipcRenderer = {
on: jest.fn(),
send: jest.fn(),
invoke: jest.fn(),
}

View file

@ -1,65 +0,0 @@
// This shouldn't be necessary, but without this CI fails on Windows. Seems to
// be a bug in TS itself or ts-node.
/// <reference types="node" />
import { Application } from 'spectron'
import * as path from 'path'
describe('App', function (this: any) {
let app: Application
beforeEach(function () {
let appPath = path.join(
__dirname,
'..',
'..',
'..',
'node_modules',
'.bin',
'electron'
)
if (process.platform === 'win32') {
appPath += '.cmd'
}
const root = path.resolve(__dirname, '..', '..', '..')
app = new Application({
path: appPath,
args: [path.join(root, 'out')],
})
return app.start()
})
afterEach(function () {
if (app && app.isRunning()) {
return app.stop()
}
return Promise.resolve()
})
it('opens a window on launch', async () => {
await app.client.waitUntil(
() => Promise.resolve(app.browserWindow.isVisible()),
{ timeout: 5000 }
)
const count = await app.client.getWindowCount()
// When running tests against development versions of Desktop
// (which usually happens locally when developing), the number
// of windows will be greater than 1, since the devtools are
// considered a window.
expect(count).toBeGreaterThan(0)
const window = app.browserWindow
expect(window.isVisible()).resolves.toBe(true)
expect(window.isMinimized()).resolves.toBe(false)
expect(window.isMinimized()).resolves.toBe(false)
const bounds = await window.getBounds()
expect(bounds.width).toBeGreaterThan(0)
expect(bounds.height).toBeGreaterThan(0)
})
})

View file

@ -69,7 +69,6 @@ problems.
- Add `<file>` or `<pattern>` argument to only run tests in the specified file or files matching a pattern
- Add `-t <regex>` to only match tests whose name matches a regex
- For more information on these and other arguments, see [Jest CLI options](https://jestjs.io/docs/en/23.x/cli)
- `yarn test:integration` - Runs all integration tests
## Debugging

View file

@ -34,6 +34,8 @@ These editors are currently supported:
- [JetBrains WebStorm](https://www.jetbrains.com/webstorm/)
- [JetBrains Phpstorm](https://www.jetbrains.com/phpstorm/)
- [JetBrains Rider](https://www.jetbrains.com/rider/)
- [JetBrains CLion](https://www.jetbrains.com/clion/)
- [JetBrains PyCharm](https://www.jetbrains.com/pycharm/)
- [Notepad++](https://notepad-plus-plus.org/)
- [RStudio](https://rstudio.com/)

View file

@ -8,12 +8,11 @@
"cli": "ts-node --require ./app/test/globals.ts --require ./app/src/cli/dev-commands-global.ts app/src/cli/main.ts",
"check:eslint": "tsc -P eslint-rules/",
"test:eslint": "jest eslint-rules/tests/*.test.js",
"test:integration": "cross-env TEST_ENV=1 ELECTRON_NO_ATTACH_CONSOLE=1 xvfb-maybe --auto-servernum -- jest --testLocationInResults --config ./app/jest.integration.config.js",
"test:unit": "cross-env ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron ./node_modules/jest/bin/jest --detectOpenHandles --silent --testLocationInResults --config ./app/jest.unit.config.js",
"test:unit:cov": "yarn test:unit --coverage",
"test:script": "jest --silent --config ./script/jest.config.js",
"test:script:cov": "yarn test:script --coverage",
"test": "yarn test:unit:cov --runInBand && yarn test:script:cov && yarn test:integration",
"test": "yarn test:unit:cov --runInBand && yarn test:script:cov",
"test:setup": "ts-node -P script/tsconfig.json script/test-setup.ts",
"test:review": "ts-node -P script/tsconfig.json script/test-review.ts",
"test:report": "codecov --disable=gcov -f app/coverage/coverage-final.json",
@ -55,7 +54,9 @@
"yarn": ">= 1.9"
},
"dependencies": {
"@electron/remote": "^2.0.1",
"@primer/octicons": "^10.0.0",
"@types/lodash": "^4.14.178",
"@types/marked": "^4.0.1",
"@types/plist": "^3.0.2",
"@types/react-color": "^3.0.4",
@ -102,7 +103,6 @@
"sass": "^1.27.0",
"sass-loader": "^10.0.3",
"semver": "^5.5.0",
"spectron": "^15.0.0",
"split2": "^3.2.2",
"stop-build": "^1.1.0",
"style-loader": "^0.21.0",
@ -119,8 +119,7 @@
"webpack-dev-middleware": "^3.1.3",
"webpack-hot-middleware": "^2.22.2",
"webpack-merge": "^4.1.2",
"xml2js": "^0.4.16",
"xvfb-maybe": "^0.2.1"
"xml2js": "^0.4.16"
},
"devDependencies": {
"@types/byline": "^4.2.31",
@ -175,7 +174,7 @@
"@types/webpack-merge": "^4.1.3",
"@types/winston": "^2.2.0",
"@types/xml2js": "^0.4.0",
"electron": "=13.6.2",
"electron": "=14.2.3",
"electron-builder": "^22.7.0",
"electron-packager": "^15.1.0",
"electron-winstaller": "^5.0.0",

776
yarn.lock

File diff suppressed because it is too large Load diff