diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index c0f03422522c..c4c4d2c70a9f 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -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]*)+$)/' diff --git a/app/ide-desktop/lib/client/src/authentication.ts b/app/ide-desktop/lib/client/src/authentication.ts index 7c936449662e..59b49c2d21ea 100644 --- a/app/ide-desktop/lib/client/src/authentication.ts +++ b/app/ide-desktop/lib/client/src/authentication.ts @@ -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. diff --git a/app/ide-desktop/lib/client/src/file-associations.ts b/app/ide-desktop/lib/client/src/file-associations.ts index cbe083ea7b4e..7c13b15d80e4 100644 --- a/app/ide-desktop/lib/client/src/file-associations.ts +++ b/app/ide-desktop/lib/client/src/file-associations.ts @@ -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. + } + } +} diff --git a/app/ide-desktop/lib/client/src/index.ts b/app/ide-desktop/lib/client/src/index.ts index 256151606fc2..f705ec912766 100644 --- a/app/ide-desktop/lib/client/src/index.ts +++ b/app/ide-desktop/lib/client/src/index.ts @@ -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 diff --git a/app/ide-desktop/lib/client/src/url-associations.ts b/app/ide-desktop/lib/client/src/url-associations.ts new file mode 100644 index 000000000000..7a06770d4365 --- /dev/null +++ b/app/ide-desktop/lib/client/src/url-associations.ts @@ -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() +} diff --git a/app/ide-desktop/lib/common/src/index.ts b/app/ide-desktop/lib/common/src/index.ts index f9ca638446f0..0b514c5da626 100644 --- a/app/ide-desktop/lib/common/src/index.ts +++ b/app/ide-desktop/lib/common/src/index.ts @@ -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. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx index 7424b35172ca..dcb12b3f1313 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx @@ -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: {