mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 07:51:56 +03:00
URL handling (#6243)
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.
This commit is contained in:
parent
d6828b5219
commit
19203427e0
@ -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]*)+$)/'
|
||||
|
@ -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 ===
|
||||
// ========================================
|
||||
@ -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.
|
||||
@ -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.
|
||||
|
@ -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 ===
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
|
180
app/ide-desktop/lib/client/src/url-associations.ts
Normal file
180
app/ide-desktop/lib/client/src/url-associations.ts
Normal file
@ -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()
|
||||
}
|
@ -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. */
|
||||
|
@ -18,10 +18,10 @@ import * as platformModule from '../platform'
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** Pathname of the {@link URL} for deep links to the sign in page, after a redirect from a
|
||||
/** Pathname of the {@link URL} for deep links to the sign-in page, after a redirect from a
|
||||
* federated identity provider. */
|
||||
const SIGN_IN_PATHNAME = '//auth'
|
||||
/** Pathname of the {@link URL} for deep links to the sign out page, after a redirect from a
|
||||
/** Pathname of the {@link URL} for deep links to the sign-out page, after a redirect from a
|
||||
* federated identity provider. */
|
||||
const SIGN_OUT_PATHNAME = '//auth'
|
||||
/** Pathname of the {@link URL} for deep links to the registration confirmation page, after a
|
||||
@ -191,8 +191,11 @@ function saveAccessToken(accessToken: string) {
|
||||
function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: string) => void) {
|
||||
const onDeepLink = (url: string) => {
|
||||
const parsedUrl = new URL(url)
|
||||
|
||||
switch (parsedUrl.pathname) {
|
||||
logger.log(`Parsed pathname: ${parsedUrl.pathname}`)
|
||||
// We need to get rid of the trailing slash in the pathname, because it is inconsistent
|
||||
// between the platforms. On Windows it is present, on macOS it is not.
|
||||
const pathname = parsedUrl.pathname.replace(/\/$/, '')
|
||||
switch (pathname) {
|
||||
/** If the user is being redirected after clicking the registration confirmation link in their
|
||||
* email, then the URL will be for the confirmation page path. */
|
||||
case CONFIRM_REGISTRATION_PATHNAME: {
|
||||
|
Loading…
Reference in New Issue
Block a user