Skip to content

Commit

Permalink
URL handling (#6243)
Browse files Browse the repository at this point in the history
This PR fixes #5239 by supporting the Windows-style of URL handling to support deep linking.

Windows spawns a new process for each URL, rather than sending a 'open-url' event to the existing process. Now the differences between the two platforms should be abstracted away.
  • Loading branch information
mwu-tow authored and Akirathan committed Apr 26, 2023
1 parent fbad77c commit 4dacfcf
Show file tree
Hide file tree
Showing 7 changed files with 275 additions and 42 deletions.
2 changes: 1 addition & 1 deletion app/ide-desktop/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const DEFAULT_IMPORT_ONLY_MODULES =
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|react-hot-toast`
const OUR_MODULES = 'enso-content-config|enso-common'
const RELATIVE_MODULES =
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|file-associations|index|ipc|naming|paths|preload|security'
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|file-associations|index|ipc|naming|paths|preload|security|url-associations'
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
const JSX = ':matches(JSXElement, JSXFragment)'
const NOT_PASCAL_CASE = '/^(?!_?([A-Z][a-z0-9]*)+$)/'
Expand Down
46 changes: 28 additions & 18 deletions app/ide-desktop/lib/client/src/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,25 +75,21 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import opener from 'opener'

import * as electron from 'electron'

import * as electron from 'electron'
import opener from 'opener'

import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config'
import * as urlAssociations from 'url-associations'

import * as ipc from 'ipc'

const logger = contentConfig.logger

// =================
// === Constants ===
// =================

/** Name of the Electron event that is emitted when a URL is opened in Electron (e.g., when the user
* clicks a link in the dashboard). */
const OPEN_URL_EVENT = 'open-url'

// ========================================
// === Initialize Authentication Module ===
// ========================================
Expand Down Expand Up @@ -122,7 +118,11 @@ export function initModule(window: () => electron.BrowserWindow) {
* This functionality is necessary because we don't want to run the OAuth flow in the app. Users
* don't trust Electron apps to handle their credentials. */
function initIpc() {
electron.ipcMain.on(ipc.Channel.openUrlInSystemBrowser, (_event, url: string) => opener(url))
electron.ipcMain.on(ipc.Channel.openUrlInSystemBrowser, (_event, url: string) => {
logger.log(`Opening URL in system browser: '${url}'.`)
urlAssociations.setAsUrlHandler()
opener(url)
})
}

/** Registers a listener that fires a callback for `open-url` events, when the URL is a deep link.
Expand All @@ -133,18 +133,28 @@ function initIpc() {
* All URLs that aren't deep links (i.e., URLs that don't use the {@link common.DEEP_LINK_SCHEME}
* protocol) will be ignored by this handler. Non-deep link URLs will be handled by Electron. */
function initOpenUrlListener(window: () => electron.BrowserWindow) {
electron.app.on(OPEN_URL_EVENT, (event, url) => {
const parsedUrl = new URL(url)
/** Prevent Electron from handling the URL at all, because redirects can be dangerous. */
event.preventDefault()
if (parsedUrl.protocol !== `${common.DEEP_LINK_SCHEME}:`) {
logger.error(`${url} is not a deep link, ignoring.`)
} else {
window().webContents.send(ipc.Channel.openDeepLink, url)
}
urlAssociations.registerUrlCallback(url => {
onOpenUrl(url, window)
})
}

/**
* Handles the 'open-url' event by parsing the received URL, checking if it is a deep link, and
* sending it to the appropriate BrowserWindow via IPC.
*
* @param url - The URL to handle.
* @param window - A function that returns the BrowserWindow to send the parsed URL to.
*/
export function onOpenUrl(url: URL, window: () => electron.BrowserWindow) {
logger.log(`Received 'open-url' event for '${url.toString()}'.`)
if (url.protocol !== `${common.DEEP_LINK_SCHEME}:`) {
logger.error(`'${url.toString()}' is not a deep link, ignoring.`)
} else {
logger.log(`'${url.toString()}' is a deep link, sending to renderer.`)
window().webContents.send(ipc.Channel.openDeepLink, url.toString())
}
}

/** Registers a listener that fires a callback for `save-access-token` events.
*
* This listener is used to save given access token to credentials file to be later used by enso backend.
Expand Down
28 changes: 26 additions & 2 deletions app/ide-desktop/lib/client/src/file-associations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import * as electron from 'electron'
import electronIsDev from 'electron-is-dev'

import * as common from 'enso-common'
import * as config from 'enso-content-config'
import * as contentConfig from 'enso-content-config'

import * as clientConfig from './config'
import * as fileAssociations from '../file-associations'
import * as project from './project-management'

const logger = config.logger
const logger = contentConfig.logger

// =================
// === Reexports ===
Expand Down Expand Up @@ -149,3 +151,25 @@ export function handleOpenFile(openedFile: string): string {
throw error
}
}

/** Handle the file to open, if any. See {@link handleOpenFile} for details.
*
* If no file to open is provided, does nothing.
*
* Handles all errors internally.
* @param openedFile - The file to open (null if none).
* @param args - The parsed application arguments.
*/
export function handleFileArguments(openedFile: string | null, args: clientConfig.Args): void {
if (openedFile != null) {
try {
// This makes the IDE open the relevant project. Also, this prevents us from using this
// method after IDE has been fully set up, as the initializing code would have already
// read the value of this argument.
args.groups.startup.options.project.value = handleOpenFile(openedFile)
} catch (e) {
// If we failed to open the file, we should enter the usual welcome screen.
// The `handleOpenFile` function will have already displayed an error message.
}
}
}
48 changes: 32 additions & 16 deletions app/ide-desktop/lib/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as paths from 'paths'
import * as projectManager from 'bin/project-manager'
import * as security from 'security'
import * as server from 'bin/server'
import * as urlAssociations from 'url-associations'
import * as utils from '../../../utils'

const logger = contentConfig.logger
Expand All @@ -44,22 +45,12 @@ class App {
isQuitting = false

async run() {
urlAssociations.registerAssociations()
// Register file associations for macOS.
electron.app.on('open-file', fileAssociations.onFileOpened)

const { windowSize, chromeOptions, fileToOpen } = this.processArguments()
if (fileToOpen != null) {
try {
// This makes the IDE open the relevant project. Also, this prevents us from using this
// method after IDE has been fully set up, as the initializing code would have already
// read the value of this argument.
this.args.groups.startup.options.project.value =
fileAssociations.handleOpenFile(fileToOpen)
} catch (e) {
// If we failed to open the file, we should enter the usual welcome screen.
// The `handleOpenFile` function will have already displayed an error message.
}
}
const { windowSize, chromeOptions, fileToOpen, urlToOpen } = this.processArguments()
this.handleItemOpening(fileToOpen, urlToOpen)
if (this.args.options.version.value) {
await this.printVersion()
electron.app.quit()
Expand Down Expand Up @@ -91,11 +82,36 @@ class App {
const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt(
fileAssociations.CLIENT_ARGUMENTS
)
const urlToOpen = urlAssociations.argsDenoteUrlOpenAttempt(
fileAssociations.CLIENT_ARGUMENTS
)
// If we are opening a file (i.e. we were spawned with just a path of the file to open as
// the argument), it means that effectively we don't have any non-standard arguments.
// the argument) or URL, it means that effectively we don't have any non-standard arguments.
// We just need to let caller know that we are opening a file.
const argsToParse = fileToOpen ? [] : fileAssociations.CLIENT_ARGUMENTS
return { ...configParser.parseArgs(argsToParse), fileToOpen }
const argsToParse = fileToOpen || urlToOpen ? [] : fileAssociations.CLIENT_ARGUMENTS
return { ...configParser.parseArgs(argsToParse), fileToOpen, urlToOpen }
}

/** This method is invoked when the application was spawned due to being a default application
* for a URL protocol or file extension. */
handleItemOpening(fileToOpen: string | null, urlToOpen: URL | null) {
logger.log('Opening file or URL.', { fileToOpen, urlToOpen })
try {
if (fileToOpen != null) {
// This makes the IDE open the relevant project. Also, this prevents us from using this
// method after IDE has been fully set up, as the initializing code would have already
// read the value of this argument.
this.args.groups.startup.options.project.value =
fileAssociations.handleOpenFile(fileToOpen)
}

if (urlToOpen != null) {
urlAssociations.handleOpenUrl(urlToOpen)
}
} catch (e) {
// If we failed to open the file, we should enter the usual welcome screen.
// The `handleOpenFile` function will have already displayed an error message.
}
}

/** Set Chrome options based on the app configuration. For comprehensive list of available
Expand Down
180 changes: 180 additions & 0 deletions app/ide-desktop/lib/client/src/url-associations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/** @file URL associations for the IDE. */

import * as electron from 'electron'
import electronIsDev from 'electron-is-dev'

import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config'

const logger = contentConfig.logger

// ============================
// === Protocol Association ===
// ============================

/** Register the application as a handler for our [deep link scheme]{@link common.DEEP_LINK_SCHEME}.
*
* This method is no-op when used under the Electron dev mode, as it requires special handling to
* set up the process.
*
* It is also no-op on macOS, as the OS handles the URL opening by passing the `open-url` event to
* the application, thanks to the information baked in our application by the `electron-builder`.
*/
export function registerAssociations() {
if (!electron.app.isDefaultProtocolClient(common.DEEP_LINK_SCHEME)) {
if (electronIsDev) {
logger.log('Not registering protocol client in dev mode.')
} else if (process.platform === 'darwin') {
// Registration is handled automatically there thanks to electron-builder.
logger.log('Not registering protocol client on macOS.')
} else {
logger.log('Registering protocol client.')
electron.app.setAsDefaultProtocolClient(common.DEEP_LINK_SCHEME)
}
} else {
logger.log('Protocol client already registered.')
}
}

// ====================
// === URL handling ===
// ====================

/**
* Check if the given list of application startup arguments denotes an attempt to open a URL.
*
* For example, this happens on Windows when the browser redirects user using our
* [deep link scheme]{@link common.DEEP_LINK_SCHEME}. On macOS this is not used, as the OS
* handles the URL opening by passing the `open-url` event to the application.
*
* @param clientArgs - A list of arguments passed to the application, stripped from the initial
* executable name and any electron dev mode arguments.
* @returns The URL to open, or `null` if no file was specified.
*/
export function argsDenoteUrlOpenAttempt(clientArgs: string[]): URL | null {
const arg = clientArgs[0]
let result: URL | null = null
logger.log(`Checking if '${clientArgs.toString()}' denotes a URL to open.`)
// Check if the first argument parses as a URL using our deep link scheme.
if (clientArgs.length === 1 && typeof arg !== 'undefined') {
try {
const url = new URL(arg)
logger.log(`Parsed '${arg}' as URL: ${url.toString()}. Protocol: ${url.protocol}.`)
if (url.protocol === `${common.DEEP_LINK_SCHEME}:`) {
result = url
}
} catch (e) {
logger.log(`The single argument '${arg}' does not denote a valid URL: ${String(e)}`)
}
}
return result
}

/** Handle the case where IDE is invoked with a URL to open.
*
* This happens on Windows when the browser redirects user using the deep link scheme.
*
* @param openedUrl - The URL to open.
*/
export function handleOpenUrl(openedUrl: URL) {
logger.log(`Opening URL '${openedUrl.toString()}'.`)
const appLock = electron.app.requestSingleInstanceLock({ openedUrl })
if (!appLock) {
// If we failed to acquire the lock, it means that another instance of the application is
// already running. In this case, we must send the URL to the existing instance and exit.
logger.log('Another instance of the application is already running. Exiting.')
electron.app.quit()
} else {
// If we acquired the lock, it means that we are the first instance of the application.
// In this case, we must wait for the application to be ready and then send the URL to the
// renderer process.
// If we supported starting the application from the URL, we should add this logic here.
// However, we currently only use our custom URL scheme to handle authentication, so we
// don't need to do anything here.
logger.log('We are the first instance of the application. This is not expected.')
}
}

/** Register the callback that will be called when the application is requested to open a URL.
*
* This method serves to unify the url handling between macOS and Windows. On macOS, the OS
* handles the URL opening by passing the `open-url` event to the application. On Windows, a
* new instance of the application is started and the URL is passed as a command line argument.
*
* This method registers the callback for both events. Note that on Windows it is necessary to
* use {@link setAsUrlHandler} and {@link unsetAsUrlHandler} to ensure that the callback
* is called.
*
* @param callback - The callback to call when the application is requested to open a URL.
*/
export function registerUrlCallback(callback: (url: URL) => void) {
// First, register the callback for the `open-url` event. This is used on macOS.
electron.app.on('open-url', (event, url) => {
logger.log(`Got URL from 'open-url' event: '${url}'.`)
event.preventDefault()
callback(new URL(url))
})

// Second, register the callback for the `second-instance` event. This is used on Windows.
electron.app.on('second-instance', (event, argv) => {
logger.log(`Got data from 'second-instance' event: '${argv.toString()}'.`)
unsetAsUrlHandler()
// Check if additional data is an object that contains the URL.
const requestOneLastElementSlice = -1
const lastArgumentSlice = argv.slice(requestOneLastElementSlice)
const url = argsDenoteUrlOpenAttempt(lastArgumentSlice)
if (url) {
logger.log(`Got URL from 'second-instance' event: '${url.toString()}'.`)
// Even we received the URL, our Window likely is not in the foreground - the focus
// went to the "second instance" of the application. We must bring our Window to the
// foreground, so the user gets back to the IDE after the authentication.
const primaryWindow = electron.BrowserWindow.getAllWindows()[0]
if (primaryWindow) {
if (primaryWindow.isMinimized()) {
primaryWindow.restore()
}
primaryWindow.focus()
} else {
logger.error('No primary window found after receiving URL from second instance.')
}
logger.log(`Got URL from second instance: '${url.toString()}'.`)
event.preventDefault()
callback(url)
}
})
}

// ===============================
// === Temporary handler setup ===
// ===============================

/** Make this application instance the recipient of URL callbacks.
*
* After the callback is received (or no longer expected), the `urlCallbackCompleted` function
* must be called. Otherwise, other IDE instances will not be able to receive their URL
* callbacks.
*
* The mechanism is built on top of the Electron's
* [instance lock]{@link https://www.electronjs.org/docs/api/app#apprequestsingleinstancelock} functionality.
*
* @throws An error if another instance of the application has already acquired the lock.
*/
export function setAsUrlHandler() {
logger.log('Expecting URL callback, acquiring the lock.')
if (!electron.app.requestSingleInstanceLock()) {
const message = 'Another instance of the application is already running. Exiting.'
logger.error(message)
// eslint-disable-next-line no-restricted-syntax
throw new Error(message)
}
}

/** Stop this application instance from receiving URL callbacks.
*
* This function releases the instance lock that was acquired by the {@link setAsUrlHandler}
* function. This is necessary to ensure that other IDE instances can receive their URL callbacks.
*/
export function unsetAsUrlHandler() {
logger.log('URL callback completed, releasing the lock.')
electron.app.releaseSingleInstanceLock()
}
2 changes: 1 addition & 1 deletion app/ide-desktop/lib/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* here when it is not possible for a sibling package to own that code without introducing a
* circular dependency in our packages. */

/** URL protocol scheme for deep links to authentication flow pages.
/** URL protocol scheme for deep links to authentication flow pages, without the `:` suffix.
*
* For example: the deep link URL
* `enso://authentication/register?code=...&state=...` uses this scheme. */
Expand Down
Loading

0 comments on commit 4dacfcf

Please sign in to comment.