Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

URL handling #6243

Merged
merged 16 commits into from
Apr 24, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions app/ide-desktop/lib/client/src/project-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function importDirectory(rootPath: string): string {
const targetDirectory = generateDirectoryName(rootPath)
if (fsSync.existsSync(targetDirectory)) {
const message = `Project directory already exists: ${targetDirectory}.`
// eslint-disable-next-line no-restricted-syntax
somebody1234 marked this conversation as resolved.
Show resolved Hide resolved
throw new Error(message)
} else {
logger.log(`Copying: '${rootPath}' -> '${targetDirectory}'.`)
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() {
somebody1234 marked this conversation as resolved.
Show resolved Hide resolved
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be redundant too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one seems to be still required.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, yeah, my bad - i think you can suppress it by explicitly doing else { return } though

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()
}
Loading