mirror of
https://github.com/desktop/desktop
synced 2024-08-24 18:57:33 +00:00
Merge branch 'development' into notification-metrics
This commit is contained in:
commit
b0d5819e7a
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
@ -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:
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
runtime = electron
|
||||
disturl = https://atom.io/download/electron
|
||||
target = 13.6.2
|
||||
target = 14.2.3
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
1
app/src/lib/endpoint-token.ts
Normal file
1
app/src/lib/endpoint-token.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type EndpointToken = { endpoint: string; token: string }
|
24
app/src/lib/globals.d.ts
vendored
24
app/src/lib/globals.d.ts
vendored
|
@ -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>>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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))
|
||||
|
|
26
app/src/main-process/alive-origin-filter.ts
Normal file
26
app/src/main-process/alive-origin-filter.ts
Normal 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 {}
|
||||
})
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
37
app/src/main-process/authenticated-avatar-filter.ts
Normal file
37
app/src/main-process/authenticated-avatar-filter.ts
Normal 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])
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
254
app/src/main-process/ordered-webrequest.ts
Normal file
254
app/src/main-process/ordered-webrequest.ts
Normal 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
|
||||
}, {})
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 = `${
|
||||
|
|
|
@ -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__
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { remote } from 'electron'
|
||||
import * as remote from '@electron/remote'
|
||||
import {
|
||||
isMacOSMojaveOrLater,
|
||||
isWindows10And1809Preview17666OrLater,
|
||||
|
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>>,
|
||||
|
|
|
@ -28,4 +28,5 @@ export const remote = {
|
|||
export const ipcRenderer = {
|
||||
on: jest.fn(),
|
||||
send: jest.fn(),
|
||||
invoke: jest.fn(),
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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/)
|
||||
|
||||
|
|
11
package.json
11
package.json
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue