From 8fd01451b457195e9dcb192d9f7ab5edbc3e870e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20W=2E=20Urba=C5=84czyk?= Date: Tue, 11 Apr 2023 18:21:14 +0200 Subject: [PATCH 1/8] [wip] --- app/ide-desktop/eslint.config.js | 2 +- app/ide-desktop/lib/client/package.json | 1 + .../lib/client/src/authentication.ts | 42 ++++--- .../lib/client/src/bin/project-manager.ts | 3 +- .../lib/client/src/file-associations.ts | 30 ++++- app/ide-desktop/lib/client/src/index.ts | 55 ++++++--- .../lib/client/src/project-management.ts | 2 + .../lib/client/src/url-associations.ts | 115 ++++++++++++++++++ app/ide-desktop/lib/common/src/index.ts | 2 +- .../src/authentication/service.tsx | 10 +- app/ide-desktop/package-lock.json | 108 +++++++++++++++- 11 files changed, 323 insertions(+), 47 deletions(-) create mode 100644 app/ide-desktop/lib/client/src/url-associations.ts diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index 1972a77805d3..36d9e4321d8f 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/package.json b/app/ide-desktop/lib/client/package.json index 89201c8b875c..beaf3514e6f3 100644 --- a/app/ide-desktop/lib/client/package.json +++ b/app/ide-desktop/lib/client/package.json @@ -19,6 +19,7 @@ "dependencies": { "@types/mime-types": "^2.1.1", "@types/opener": "^1.4.0", + "bonjour-service": "^1.1.1", "chalk": "^5.2.0", "create-servers": "^3.2.0", "electron-is-dev": "^2.0.0", diff --git a/app/ide-desktop/lib/client/src/authentication.ts b/app/ide-desktop/lib/client/src/authentication.ts index ceb9c5a5334f..d9f1dcce288e 100644 --- a/app/ide-desktop/lib/client/src/authentication.ts +++ b/app/ide-desktop/lib/client/src/authentication.ts @@ -77,19 +77,12 @@ 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 === // ======================================== @@ -117,7 +110,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.expectUrlCallback() + opener(url) + }) } /** Registers a listener that fires a callback for `open-url` events, when the URL is a deep link. @@ -128,14 +125,23 @@ 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 { + window().webContents.send(ipc.Channel.openDeepLink, url.toString()) + } +} diff --git a/app/ide-desktop/lib/client/src/bin/project-manager.ts b/app/ide-desktop/lib/client/src/bin/project-manager.ts index d1f7163b4514..a37ebbe7a865 100644 --- a/app/ide-desktop/lib/client/src/bin/project-manager.ts +++ b/app/ide-desktop/lib/client/src/bin/project-manager.ts @@ -24,8 +24,9 @@ export function pathOrPanic(args: config.Args): string { const binExists = fsSync.existsSync(binPath) if (!binExists) { throw new Error(`Could not find the project manager binary at ${binPath}.`) + } else { + return binPath } - return binPath } /** Executes the Project Manager with given arguments. */ diff --git a/app/ide-desktop/lib/client/src/file-associations.ts b/app/ide-desktop/lib/client/src/file-associations.ts index ae759ab51dd2..3ed586cb655d 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 === @@ -146,6 +148,30 @@ export function handleOpenFile(openedFile: string): string { } logger.error(e) electron.dialog.showErrorBox(common.PRODUCT_NAME, message) + // eslint-disable-next-line no-restricted-syntax throw e } } + + +/** 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 e9e74d3bf093..62eedfa96372 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,19 @@ class App { isQuitting = false async run() { + console.log('====Starting Enso IDE.') + urlAssociations.registerAssociations() + electron.app.on('open-url', (event, url) => { + console.log('====Received URL: ' + url) + console.log(event) + }) + + // 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 +89,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/project-management.ts b/app/ide-desktop/lib/client/src/project-management.ts index 79ba0c51e808..e82f11109d20 100644 --- a/app/ide-desktop/lib/client/src/project-management.ts +++ b/app/ide-desktop/lib/client/src/project-management.ts @@ -50,6 +50,7 @@ export function importProjectFromPath(openedPath: string): string { // Otherwise, we need to install it first. if (rootPath == null) { const message = `File '${openedPath}' does not belong to the ${common.PRODUCT_NAME} project.` + // eslint-disable-next-line no-restricted-syntax throw new Error(message) } return importDirectory(rootPath) @@ -98,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 throw new Error(message) } 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..72e902a0cf36 --- /dev/null +++ b/app/ide-desktop/lib/client/src/url-associations.ts @@ -0,0 +1,115 @@ +/** @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 + + +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.') + } +} + + +/** + * 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}' denote 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.') + } +} + +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}'.`) + urlCallbackCompleted() + // Check if additional data is an object that contains the URL. + const url = argsDenoteUrlOpenAttempt(argv.slice(argv.length - 1)) + if (url) { + logger.log(`Got URL from second instance: '${url.toString()}'.`) + event.preventDefault() + callback(url) + } + }) +} + +export function expectUrlCallback() { + logger.log('Expecting URL callback.') + electron.app.requestSingleInstanceLock(); +} + +export function urlCallbackCompleted() { + logger.log('URL callback completed.') + 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 5c2ac40a2985..b199638af2ca 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,12 +18,12 @@ 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 +const SIGN_IN_PATHNAME = '//auth/' +/** 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' +const SIGN_OUT_PATHNAME = '//auth/' /** Pathname of the {@link URL} for deep links to the registration confirmation page, after a * redirect from an account verification email. */ const CONFIRM_REGISTRATION_PATHNAME = '//auth/confirmation' @@ -181,7 +181,7 @@ function openUrlWithExternalBrowser(url: string) { function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: string) => void) { const onDeepLink = (url: string) => { const parsedUrl = new URL(url) - + logger.log(`Parsed pathname: ${parsedUrl.pathname}`) switch (parsedUrl.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. */ diff --git a/app/ide-desktop/package-lock.json b/app/ide-desktop/package-lock.json index 13059399c42c..560e68936e4b 100644 --- a/app/ide-desktop/package-lock.json +++ b/app/ide-desktop/package-lock.json @@ -32,6 +32,7 @@ "@types/mime-types": "^2.1.1", "@types/opener": "^1.4.0", "@types/tar": "^6.1.4", + "bonjour-service": "^1.1.1", "chalk": "^5.2.0", "create-servers": "^3.2.0", "electron-is-dev": "^2.0.0", @@ -3850,6 +3851,11 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" + }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "dev": true, @@ -5383,6 +5389,11 @@ "node": ">=0.10.0" } }, + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" + }, "node_modules/array-includes": { "version": "3.1.6", "dev": true, @@ -5775,6 +5786,17 @@ "dev": true, "license": "MIT" }, + "node_modules/bonjour-service": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", + "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", + "dependencies": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/boolean": { "version": "3.2.0", "dev": true, @@ -6989,6 +7011,22 @@ "node": ">=8" } }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==" + }, + "node_modules/dns-packet": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.5.0.tgz", + "integrity": "sha512-USawdAUzRkV6xrqTjiAEp6M9YagZEzWcSUaZTcIFAiyQWW1SoI6KyId8y2+/71wbgHKQAKd+iupLv4YvEwYWvA==", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "3.0.0", "dev": true, @@ -8114,7 +8152,6 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -11516,6 +11553,18 @@ "license": "MIT", "peer": true }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/nanoid": { "version": "3.3.4", "dev": true, @@ -14795,6 +14844,11 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, "node_modules/tinycolor2": { "version": "1.5.2", "dev": true, @@ -18021,6 +18075,11 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" + }, "@malept/cross-spawn-promise": { "version": "1.1.1", "dev": true, @@ -19100,6 +19159,11 @@ "version": "3.1.0", "peer": true }, + "array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" + }, "array-includes": { "version": "3.1.6", "dev": true, @@ -19354,6 +19418,17 @@ "version": "0.0.1", "dev": true }, + "bonjour-service": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", + "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", + "requires": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "boolean": { "version": "3.2.0", "dev": true, @@ -20115,6 +20190,19 @@ "verror": "^1.10.0" } }, + "dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==" + }, + "dns-packet": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.5.0.tgz", + "integrity": "sha512-USawdAUzRkV6xrqTjiAEp6M9YagZEzWcSUaZTcIFAiyQWW1SoI6KyId8y2+/71wbgHKQAKd+iupLv4YvEwYWvA==", + "requires": { + "@leichtgewicht/ip-codec": "^2.0.1" + } + }, "doctrine": { "version": "3.0.0", "dev": true, @@ -20229,6 +20317,7 @@ "@types/mime-types": "^2.1.1", "@types/opener": "^1.4.0", "@types/tar": "^6.1.4", + "bonjour-service": "^1.1.1", "chalk": "^5.2.0", "create-servers": "^3.2.0", "crypto-js": "4.1.1", @@ -21232,8 +21321,7 @@ "version": "1.0.0" }, "fast-deep-equal": { - "version": "3.1.3", - "devOptional": true + "version": "3.1.3" }, "fast-glob": { "version": "3.2.12", @@ -23549,6 +23637,15 @@ "version": "2.0.0", "peer": true }, + "multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "requires": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + } + }, "nanoid": { "version": "3.3.4", "dev": true @@ -25682,6 +25779,11 @@ } } }, + "thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, "tinycolor2": { "version": "1.5.2", "dev": true From 19b71b18a355e65c8497fb9b3f60262bedec1146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20W=2E=20Urba=C5=84czyk?= Date: Tue, 11 Apr 2023 21:25:55 +0200 Subject: [PATCH 2/8] [wip] --- app/ide-desktop/lib/client/package.json | 1 - app/ide-desktop/lib/client/src/url-associations.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/ide-desktop/lib/client/package.json b/app/ide-desktop/lib/client/package.json index beaf3514e6f3..89201c8b875c 100644 --- a/app/ide-desktop/lib/client/package.json +++ b/app/ide-desktop/lib/client/package.json @@ -19,7 +19,6 @@ "dependencies": { "@types/mime-types": "^2.1.1", "@types/opener": "^1.4.0", - "bonjour-service": "^1.1.1", "chalk": "^5.2.0", "create-servers": "^3.2.0", "electron-is-dev": "^2.0.0", diff --git a/app/ide-desktop/lib/client/src/url-associations.ts b/app/ide-desktop/lib/client/src/url-associations.ts index 72e902a0cf36..6707de2d7e0f 100644 --- a/app/ide-desktop/lib/client/src/url-associations.ts +++ b/app/ide-desktop/lib/client/src/url-associations.ts @@ -41,7 +41,7 @@ export function registerAssociations() { export function argsDenoteUrlOpenAttempt(clientArgs: string[]): URL | null { const arg = clientArgs[0] let result: URL | null = null - logger.log(`Checking if '${clientArgs}' denote a URL to open.`) + logger.log(`Checking if '${clientArgs.toString()}' denote 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 { @@ -92,7 +92,7 @@ export function registerUrlCallback(callback: (url: URL) => void) { // 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}'.`) + logger.log(`Got data from 'second-instance' event: '${argv.toString()}'.`) urlCallbackCompleted() // Check if additional data is an object that contains the URL. const url = argsDenoteUrlOpenAttempt(argv.slice(argv.length - 1)) From a4d83f2c4d159901130d0b773afe767c931fae9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20W=2E=20Urba=C5=84czyk?= Date: Thu, 13 Apr 2023 13:46:09 +0200 Subject: [PATCH 3/8] wip --- .../lib/client/src/authentication.ts | 2 +- app/ide-desktop/lib/client/src/index.ts | 7 -- .../lib/client/src/url-associations.ts | 49 ++++++-- app/ide-desktop/package-lock.json | 108 +----------------- 4 files changed, 46 insertions(+), 120 deletions(-) diff --git a/app/ide-desktop/lib/client/src/authentication.ts b/app/ide-desktop/lib/client/src/authentication.ts index d9f1dcce288e..f93d8b6fc99b 100644 --- a/app/ide-desktop/lib/client/src/authentication.ts +++ b/app/ide-desktop/lib/client/src/authentication.ts @@ -112,7 +112,7 @@ export function initModule(window: () => electron.BrowserWindow) { function initIpc() { electron.ipcMain.on(ipc.Channel.openUrlInSystemBrowser, (_event, url: string) => { logger.log(`Opening URL in system browser: '${url}'.`) - urlAssociations.expectUrlCallback() + urlAssociations.setAsUrlHandler() opener(url) }) } diff --git a/app/ide-desktop/lib/client/src/index.ts b/app/ide-desktop/lib/client/src/index.ts index 62eedfa96372..75117554f04b 100644 --- a/app/ide-desktop/lib/client/src/index.ts +++ b/app/ide-desktop/lib/client/src/index.ts @@ -45,14 +45,7 @@ class App { isQuitting = false async run() { - console.log('====Starting Enso IDE.') urlAssociations.registerAssociations() - electron.app.on('open-url', (event, url) => { - console.log('====Received URL: ' + url) - console.log(event) - }) - - // Register file associations for macOS. electron.app.on('open-file', fileAssociations.onFileOpened) diff --git a/app/ide-desktop/lib/client/src/url-associations.ts b/app/ide-desktop/lib/client/src/url-associations.ts index 6707de2d7e0f..e3a30f8c48c3 100644 --- a/app/ide-desktop/lib/client/src/url-associations.ts +++ b/app/ide-desktop/lib/client/src/url-associations.ts @@ -82,6 +82,18 @@ export function handleOpenUrl(openedUrl: URL) { } } +/** 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) => { @@ -93,9 +105,11 @@ export function registerUrlCallback(callback: (url: URL) => void) { // 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()}'.`) - urlCallbackCompleted() + unsetAsUrlHandler() // Check if additional data is an object that contains the URL. - const url = argsDenoteUrlOpenAttempt(argv.slice(argv.length - 1)) + const requestOneLastElementSlice = -1; + const lastArgumentSlice = argv.slice(requestOneLastElementSlice) + const url = argsDenoteUrlOpenAttempt(lastArgumentSlice) if (url) { logger.log(`Got URL from second instance: '${url.toString()}'.`) event.preventDefault() @@ -104,12 +118,33 @@ export function registerUrlCallback(callback: (url: URL) => void) { }) } -export function expectUrlCallback() { - logger.log('Expecting URL callback.') - electron.app.requestSingleInstanceLock(); +/** 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) + } } -export function urlCallbackCompleted() { - logger.log('URL callback completed.') +/** 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/package-lock.json b/app/ide-desktop/package-lock.json index 560e68936e4b..13059399c42c 100644 --- a/app/ide-desktop/package-lock.json +++ b/app/ide-desktop/package-lock.json @@ -32,7 +32,6 @@ "@types/mime-types": "^2.1.1", "@types/opener": "^1.4.0", "@types/tar": "^6.1.4", - "bonjour-service": "^1.1.1", "chalk": "^5.2.0", "create-servers": "^3.2.0", "electron-is-dev": "^2.0.0", @@ -3851,11 +3850,6 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", - "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" - }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "dev": true, @@ -5389,11 +5383,6 @@ "node": ">=0.10.0" } }, - "node_modules/array-flatten": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", - "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" - }, "node_modules/array-includes": { "version": "3.1.6", "dev": true, @@ -5786,17 +5775,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bonjour-service": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", - "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", - "dependencies": { - "array-flatten": "^2.1.2", - "dns-equal": "^1.0.0", - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, "node_modules/boolean": { "version": "3.2.0", "dev": true, @@ -7011,22 +6989,6 @@ "node": ">=8" } }, - "node_modules/dns-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", - "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==" - }, - "node_modules/dns-packet": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.5.0.tgz", - "integrity": "sha512-USawdAUzRkV6xrqTjiAEp6M9YagZEzWcSUaZTcIFAiyQWW1SoI6KyId8y2+/71wbgHKQAKd+iupLv4YvEwYWvA==", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/doctrine": { "version": "3.0.0", "dev": true, @@ -8152,6 +8114,7 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -11553,18 +11516,6 @@ "license": "MIT", "peer": true }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, "node_modules/nanoid": { "version": "3.3.4", "dev": true, @@ -14844,11 +14795,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" - }, "node_modules/tinycolor2": { "version": "1.5.2", "dev": true, @@ -18075,11 +18021,6 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, - "@leichtgewicht/ip-codec": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", - "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" - }, "@malept/cross-spawn-promise": { "version": "1.1.1", "dev": true, @@ -19159,11 +19100,6 @@ "version": "3.1.0", "peer": true }, - "array-flatten": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", - "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" - }, "array-includes": { "version": "3.1.6", "dev": true, @@ -19418,17 +19354,6 @@ "version": "0.0.1", "dev": true }, - "bonjour-service": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", - "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", - "requires": { - "array-flatten": "^2.1.2", - "dns-equal": "^1.0.0", - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, "boolean": { "version": "3.2.0", "dev": true, @@ -20190,19 +20115,6 @@ "verror": "^1.10.0" } }, - "dns-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", - "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==" - }, - "dns-packet": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.5.0.tgz", - "integrity": "sha512-USawdAUzRkV6xrqTjiAEp6M9YagZEzWcSUaZTcIFAiyQWW1SoI6KyId8y2+/71wbgHKQAKd+iupLv4YvEwYWvA==", - "requires": { - "@leichtgewicht/ip-codec": "^2.0.1" - } - }, "doctrine": { "version": "3.0.0", "dev": true, @@ -20317,7 +20229,6 @@ "@types/mime-types": "^2.1.1", "@types/opener": "^1.4.0", "@types/tar": "^6.1.4", - "bonjour-service": "^1.1.1", "chalk": "^5.2.0", "create-servers": "^3.2.0", "crypto-js": "4.1.1", @@ -21321,7 +21232,8 @@ "version": "1.0.0" }, "fast-deep-equal": { - "version": "3.1.3" + "version": "3.1.3", + "devOptional": true }, "fast-glob": { "version": "3.2.12", @@ -23637,15 +23549,6 @@ "version": "2.0.0", "peer": true }, - "multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "requires": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - } - }, "nanoid": { "version": "3.3.4", "dev": true @@ -25779,11 +25682,6 @@ } } }, - "thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" - }, "tinycolor2": { "version": "1.5.2", "dev": true From 69dfb06e31411bf4d26adcf7738469433a660a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20W=2E=20Urba=C5=84czyk?= Date: Mon, 17 Apr 2023 12:28:14 +0200 Subject: [PATCH 4/8] wip --- .../lib/client/src/authentication.ts | 7 ++++--- .../lib/client/src/file-associations.ts | 1 - app/ide-desktop/lib/client/src/index.ts | 2 +- .../lib/client/src/url-associations.ts | 21 ++++++++----------- .../src/authentication/service.tsx | 9 +++++--- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/ide-desktop/lib/client/src/authentication.ts b/app/ide-desktop/lib/client/src/authentication.ts index 1670f5d9f8ea..570ed1e7f027 100644 --- a/app/ide-desktop/lib/client/src/authentication.ts +++ b/app/ide-desktop/lib/client/src/authentication.ts @@ -72,11 +72,12 @@ * {@link URL} to redirect the user to the dashboard, to the page specified in the {@link URL}'s * `pathname`. */ -import * as electron from 'electron' -import opener from 'opener' 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 common from 'enso-common' import * as contentConfig from 'enso-content-config' @@ -129,7 +130,7 @@ 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) { - urlAssociations.registerUrlCallback((url) => { + urlAssociations.registerUrlCallback(url => { onOpenUrl(url, window) }) } diff --git a/app/ide-desktop/lib/client/src/file-associations.ts b/app/ide-desktop/lib/client/src/file-associations.ts index 3ed586cb655d..ed7111f88d1f 100644 --- a/app/ide-desktop/lib/client/src/file-associations.ts +++ b/app/ide-desktop/lib/client/src/file-associations.ts @@ -153,7 +153,6 @@ export function handleOpenFile(openedFile: string): string { } } - /** Handle the file to open, if any. See {@link handleOpenFile} for details. * * If no file to open is provided, does nothing. diff --git a/app/ide-desktop/lib/client/src/index.ts b/app/ide-desktop/lib/client/src/index.ts index 75117554f04b..ff7ec7ce03a2 100644 --- a/app/ide-desktop/lib/client/src/index.ts +++ b/app/ide-desktop/lib/client/src/index.ts @@ -88,7 +88,7 @@ class App { // If we are opening a file (i.e. we were spawned with just a path of the file to open as // 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 || urlToOpen) ? [] : fileAssociations.CLIENT_ARGUMENTS + const argsToParse = fileToOpen || urlToOpen ? [] : fileAssociations.CLIENT_ARGUMENTS return { ...configParser.parseArgs(argsToParse), fileToOpen, urlToOpen } } diff --git a/app/ide-desktop/lib/client/src/url-associations.ts b/app/ide-desktop/lib/client/src/url-associations.ts index e3a30f8c48c3..5ba9abc9af05 100644 --- a/app/ide-desktop/lib/client/src/url-associations.ts +++ b/app/ide-desktop/lib/client/src/url-associations.ts @@ -1,20 +1,18 @@ /** @file URL associations for the IDE. */ -import * as electron from "electron"; -import electronIsDev from "electron-is-dev"; +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 - export function registerAssociations() { - if(!electron.app.isDefaultProtocolClient(common.DEEP_LINK_SCHEME)) { - if(electronIsDev) { + if (!electron.app.isDefaultProtocolClient(common.DEEP_LINK_SCHEME)) { + if (electronIsDev) { logger.log('Not registering protocol client in dev mode.') - } else if(process.platform === 'darwin') { + } else if (process.platform === 'darwin') { // Registration is handled automatically there thanks to electron-builder. logger.log('Not registering protocol client on macOS.') } else { @@ -26,7 +24,6 @@ export function registerAssociations() { } } - /** * Check if the given list of application startup arguments denotes an attempt to open a URL. * @@ -65,7 +62,7 @@ export function argsDenoteUrlOpenAttempt(clientArgs: string[]): URL | null { */ export function handleOpenUrl(openedUrl: URL) { logger.log(`Opening URL '${openedUrl.toString()}'.`) - const appLock = electron.app.requestSingleInstanceLock({openedUrl}) + 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. @@ -107,7 +104,7 @@ export function registerUrlCallback(callback: (url: URL) => void) { 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 requestOneLastElementSlice = -1 const lastArgumentSlice = argv.slice(requestOneLastElementSlice) const url = argsDenoteUrlOpenAttempt(lastArgumentSlice) if (url) { @@ -131,7 +128,7 @@ export function registerUrlCallback(callback: (url: URL) => void) { */ export function setAsUrlHandler() { logger.log('Expecting URL callback, acquiring the lock.') - if(!electron.app.requestSingleInstanceLock()) { + 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 @@ -146,5 +143,5 @@ export function setAsUrlHandler() { */ export function unsetAsUrlHandler() { logger.log('URL callback completed, releasing the lock.') - electron.app.releaseSingleInstanceLock(); + electron.app.releaseSingleInstanceLock() } 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 38ad8cc1856f..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 @@ -20,10 +20,10 @@ import * as platformModule from '../platform' /** 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/' +const SIGN_IN_PATHNAME = '//auth' /** 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/' +const SIGN_OUT_PATHNAME = '//auth' /** Pathname of the {@link URL} for deep links to the registration confirmation page, after a * redirect from an account verification email. */ const CONFIRM_REGISTRATION_PATHNAME = '//auth/confirmation' @@ -192,7 +192,10 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin const onDeepLink = (url: string) => { const parsedUrl = new URL(url) logger.log(`Parsed pathname: ${parsedUrl.pathname}`) - switch (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: { From 7c4e35061d40bcdfd5086fbc7347b51dd92c1eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20W=2E=20Urba=C5=84czyk?= Date: Wed, 19 Apr 2023 05:06:33 +0200 Subject: [PATCH 5/8] cr --- .../lib/client/src/authentication.ts | 3 ++- .../lib/client/src/project-management.ts | 4 ++-- .../lib/client/src/url-associations.ts | 22 ++++++++++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/app/ide-desktop/lib/client/src/authentication.ts b/app/ide-desktop/lib/client/src/authentication.ts index 570ed1e7f027..f4e724da3d59 100644 --- a/app/ide-desktop/lib/client/src/authentication.ts +++ b/app/ide-desktop/lib/client/src/authentication.ts @@ -145,8 +145,9 @@ function initOpenUrlListener(window: () => electron.BrowserWindow) { 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.`) + 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()) } } diff --git a/app/ide-desktop/lib/client/src/project-management.ts b/app/ide-desktop/lib/client/src/project-management.ts index e82f11109d20..ecc8d1c6d0a9 100644 --- a/app/ide-desktop/lib/client/src/project-management.ts +++ b/app/ide-desktop/lib/client/src/project-management.ts @@ -50,10 +50,10 @@ export function importProjectFromPath(openedPath: string): string { // Otherwise, we need to install it first. if (rootPath == null) { const message = `File '${openedPath}' does not belong to the ${common.PRODUCT_NAME} project.` - // eslint-disable-next-line no-restricted-syntax throw new Error(message) + } else { + return importDirectory(rootPath) } - return importDirectory(rootPath) } } diff --git a/app/ide-desktop/lib/client/src/url-associations.ts b/app/ide-desktop/lib/client/src/url-associations.ts index 5ba9abc9af05..5f79a8376f79 100644 --- a/app/ide-desktop/lib/client/src/url-associations.ts +++ b/app/ide-desktop/lib/client/src/url-associations.ts @@ -8,6 +8,18 @@ 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) { @@ -24,6 +36,10 @@ export function registerAssociations() { } } +// ==================== +// === URL handling === +// ==================== + /** * Check if the given list of application startup arguments denotes an attempt to open a URL. * @@ -38,7 +54,7 @@ export function registerAssociations() { export function argsDenoteUrlOpenAttempt(clientArgs: string[]): URL | null { const arg = clientArgs[0] let result: URL | null = null - logger.log(`Checking if '${clientArgs.toString()}' denote a URL to open.`) + 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 { @@ -115,6 +131,10 @@ export function registerUrlCallback(callback: (url: URL) => void) { }) } +// =============================== +// === Temporary handler setup === +// =============================== + /** Make this application instance the recipient of URL callbacks. * * After the callback is received (or no longer expected), the `urlCallbackCompleted` function From 74357cb1411c21c5f552808ce2b09fd34243f249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20W=2E=20Urba=C5=84czyk?= Date: Sun, 23 Apr 2023 21:11:39 +0200 Subject: [PATCH 6/8] Focus on the Window when receiving URL from a second instance. --- app/ide-desktop/lib/client/src/url-associations.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/ide-desktop/lib/client/src/url-associations.ts b/app/ide-desktop/lib/client/src/url-associations.ts index 5f79a8376f79..7a06770d4365 100644 --- a/app/ide-desktop/lib/client/src/url-associations.ts +++ b/app/ide-desktop/lib/client/src/url-associations.ts @@ -124,6 +124,19 @@ export function registerUrlCallback(callback: (url: URL) => void) { 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) From 2a200953d2e40d6e1f34d5bf63dc178343ab7a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20W=2E=20Urba=C5=84czyk?= Date: Mon, 24 Apr 2023 10:50:02 +0200 Subject: [PATCH 7/8] fmt --- lib/rust/ensogl/component/list-editor/src/item.rs | 3 ++- lib/rust/ensogl/component/list-editor/src/lib.rs | 12 ++++++++---- .../core/src/display/shape/compound/rectangle.rs | 1 - 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/rust/ensogl/component/list-editor/src/item.rs b/lib/rust/ensogl/component/list-editor/src/item.rs index 995117c18cb2..5b43eb4aacd4 100644 --- a/lib/rust/ensogl/component/list-editor/src/item.rs +++ b/lib/rust/ensogl/component/list-editor/src/item.rs @@ -1,9 +1,10 @@ use ensogl_core::prelude::*; +use crate::placeholder::StrongPlaceholder; + use ensogl_core::display; use ensogl_core::Animation; -use crate::placeholder::StrongPlaceholder; ensogl_core::define_endpoints_2! { diff --git a/lib/rust/ensogl/component/list-editor/src/lib.rs b/lib/rust/ensogl/component/list-editor/src/lib.rs index 3831c1a2ed75..b59f7df9293a 100644 --- a/lib/rust/ensogl/component/list-editor/src/lib.rs +++ b/lib/rust/ensogl/component/list-editor/src/lib.rs @@ -73,9 +73,6 @@ #![allow(clippy::bool_to_int_with_if)] #![allow(clippy::let_and_return)] -pub mod item; -pub mod placeholder; - use ensogl_core::display::shape::compound::rectangle::*; use ensogl_core::display::world::*; use ensogl_core::prelude::*; @@ -91,12 +88,19 @@ use ensogl_core::gui::cursor; use ensogl_core::gui::cursor::Cursor; use ensogl_core::Animation; use ensogl_core::Easing; - use item::Item; use placeholder::Placeholder; use placeholder::StrongPlaceholder; +// ============== +// === Export === +// ============== + +pub mod item; +pub mod placeholder; + + // ================= // === Constants === diff --git a/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs b/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs index f7b717e379ce..5e8b849de5ac 100644 --- a/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs +++ b/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs @@ -12,7 +12,6 @@ use crate::display::style::data::DataMatch; use crate::display::style::Path; - // ============== // === Export === // ============== From b59e32f69e38ab0293d5dab4a398edc8d6af4667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20W=2E=20Urba=C5=84czyk?= Date: Mon, 24 Apr 2023 16:01:27 +0200 Subject: [PATCH 8/8] removed unnecessary lint annotation --- app/ide-desktop/lib/client/src/project-management.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/ide-desktop/lib/client/src/project-management.ts b/app/ide-desktop/lib/client/src/project-management.ts index 7f56a8382dd4..b5e4f57c6a0e 100644 --- a/app/ide-desktop/lib/client/src/project-management.ts +++ b/app/ide-desktop/lib/client/src/project-management.ts @@ -99,7 +99,6 @@ 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 throw new Error(message) } else { logger.log(`Copying: '${rootPath}' -> '${targetDirectory}'.`)