From 6af4a2d4849a60b96b6c123356b6ed6e91946471 Mon Sep 17 00:00:00 2001 From: NamH Date: Mon, 13 May 2024 09:37:05 +0700 Subject: [PATCH] feat: add deeplink support (#2883) * feat: add deeplink support * fix windows not receive deeplink when not running --------- Co-authored-by: James --- core/src/types/api/index.ts | 3 + electron/handlers/native.ts | 4 ++ electron/main.ts | 45 +++++++++--- electron/managers/window.ts | 36 +++++++++- electron/package.json | 8 +++ electron/utils/dev.ts | 24 +++---- web/containers/Layout/index.tsx | 3 + web/containers/LoadingModal/index.tsx | 28 ++++++++ web/containers/Providers/DeepLinkListener.tsx | 72 +++++++++++++++++++ web/containers/Providers/index.tsx | 5 +- 10 files changed, 202 insertions(+), 26 deletions(-) create mode 100644 web/containers/LoadingModal/index.tsx create mode 100644 web/containers/Providers/DeepLinkListener.tsx diff --git a/core/src/types/api/index.ts b/core/src/types/api/index.ts index 1a95ad9c9..fb0dc5b93 100644 --- a/core/src/types/api/index.ts +++ b/core/src/types/api/index.ts @@ -19,6 +19,7 @@ export enum NativeRoute { showMainWindow = 'showMainWindow', quickAskSizeUpdated = 'quickAskSizeUpdated', + ackDeepLink = 'ackDeepLink', } /** @@ -45,6 +46,8 @@ export enum AppEvent { onUserSubmitQuickAsk = 'onUserSubmitQuickAsk', onSelectedText = 'onSelectedText', + + onDeepLink = 'onDeepLink', } export enum DownloadRoute { diff --git a/electron/handlers/native.ts b/electron/handlers/native.ts index 556b66e66..89bce15df 100644 --- a/electron/handlers/native.ts +++ b/electron/handlers/native.ts @@ -151,4 +151,8 @@ export function handleAppIPCs() { async (_event, heightOffset: number): Promise => windowManager.expandQuickAskWindow(heightOffset) ) + + ipcMain.handle(NativeRoute.ackDeepLink, async (_event): Promise => { + windowManager.ackDeepLink() + }) } diff --git a/electron/main.ts b/electron/main.ts index 1f4719e8d..9f0bd8393 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,6 +1,6 @@ import { app, BrowserWindow } from 'electron' -import { join } from 'path' +import { join, resolve } from 'path' /** * Managers **/ @@ -39,15 +39,44 @@ const quickAskUrl = `${mainUrl}/search` const gotTheLock = app.requestSingleInstanceLock() +if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient('jan', process.execPath, [ + resolve(process.argv[1]), + ]) + } +} else { + app.setAsDefaultProtocolClient('jan') +} + +const createMainWindow = () => { + const startUrl = app.isPackaged ? `file://${mainPath}` : mainUrl + windowManager.createMainWindow(preloadPath, startUrl) +} + app .whenReady() .then(() => { if (!gotTheLock) { app.quit() throw new Error('Another instance of the app is already running') + } else { + app.on( + 'second-instance', + (_event, commandLine, _workingDirectory): void => { + if (process.platform === 'win32' || process.platform === 'linux') { + // this is for handling deeplink on windows and linux + // since those OS will emit second-instance instead of open-url + const url = commandLine.pop() + if (url) { + windowManager.sendMainAppDeepLink(url) + } + } + windowManager.showMainWindow() + } + ) } }) - .then(setupReactDevTool) .then(setupCore) .then(createUserSpace) .then(migrateExtensions) @@ -60,6 +89,7 @@ app .then(registerGlobalShortcuts) .then(() => { if (!app.isPackaged) { + setupReactDevTool() windowManager.mainWindow?.webContents.openDevTools() } }) @@ -75,11 +105,11 @@ app }) }) -app.on('second-instance', (_event, _commandLine, _workingDirectory) => { - windowManager.showMainWindow() +app.on('open-url', (_event, url) => { + windowManager.sendMainAppDeepLink(url) }) -app.on('before-quit', function (evt) { +app.on('before-quit', function (_event) { trayManager.destroyCurrentTray() }) @@ -104,11 +134,6 @@ function createQuickAskWindow() { windowManager.createQuickAskWindow(preloadPath, startUrl) } -function createMainWindow() { - const startUrl = app.isPackaged ? `file://${mainPath}` : mainUrl - windowManager.createMainWindow(preloadPath, startUrl) -} - /** * Handles various IPC messages from the renderer process. */ diff --git a/electron/managers/window.ts b/electron/managers/window.ts index 8c7348651..6052f332a 100644 --- a/electron/managers/window.ts +++ b/electron/managers/window.ts @@ -14,9 +14,9 @@ class WindowManager { private _quickAskWindowVisible = false private _mainWindowVisible = false + private deeplink: string | undefined /** * Creates a new window instance. - * @param {Electron.BrowserWindowConstructorOptions} options - The options to create the window with. * @returns The created window instance. */ createMainWindow(preloadPath: string, startUrl: string) { @@ -29,6 +29,17 @@ class WindowManager { }, }) + if (process.platform === 'win32') { + /// This is work around for windows deeplink. + /// second-instance event is not fired when app is not open, so the app + /// does not received the deeplink. + const commandLine = process.argv.slice(1) + if (commandLine.length > 0) { + const url = commandLine[0] + this.sendMainAppDeepLink(url) + } + } + /* Load frontend app to the window */ this.mainWindow.loadURL(startUrl) @@ -123,6 +134,22 @@ class WindowManager { ) } + /** + * Try to send the deep link to the main app. + */ + sendMainAppDeepLink(url: string): void { + this.deeplink = url + const interval = setInterval(() => { + if (!this.deeplink) clearInterval(interval) + const mainWindow = this.mainWindow + if (mainWindow) { + mainWindow.webContents.send(AppEvent.onDeepLink, this.deeplink) + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + }, 500) + } + cleanUp(): void { if (!this.mainWindow?.isDestroyed()) { this.mainWindow?.close() @@ -137,6 +164,13 @@ class WindowManager { this._quickAskWindowVisible = false } } + + /** + * Acknowledges that the window has received a deep link. We can remove it. + */ + ackDeepLink() { + this.deeplink = undefined + } } export const windowManager = new WindowManager() diff --git a/electron/package.json b/electron/package.json index f012055e2..fc81fcb19 100644 --- a/electron/package.json +++ b/electron/package.json @@ -61,6 +61,14 @@ "include": "scripts/uninstaller.nsh", "deleteAppDataOnUninstall": true }, + "protocols": [ + { + "name": "Jan", + "schemes": [ + "jan" + ] + } + ], "artifactName": "jan-${os}-${arch}-${version}.${ext}" }, "scripts": { diff --git a/electron/utils/dev.ts b/electron/utils/dev.ts index 16e5241b6..bd510096b 100644 --- a/electron/utils/dev.ts +++ b/electron/utils/dev.ts @@ -1,17 +1,13 @@ -import { app } from 'electron' - export const setupReactDevTool = async () => { - if (!app.isPackaged) { - // Which means you're running from source code - const { default: installExtension, REACT_DEVELOPER_TOOLS } = await import( - 'electron-devtools-installer' - ) // Don't use import on top level, since the installer package is dev-only - try { - const name = await installExtension(REACT_DEVELOPER_TOOLS) - console.debug(`Added Extension: ${name}`) - } catch (err) { - console.error('An error occurred while installing devtools:', err) - // Only log the error and don't throw it because it's not critical - } + // Which means you're running from source code + const { default: installExtension, REACT_DEVELOPER_TOOLS } = await import( + 'electron-devtools-installer' + ) // Don't use import on top level, since the installer package is dev-only + try { + const name = await installExtension(REACT_DEVELOPER_TOOLS) + console.debug(`Added Extension: ${name}`) + } catch (err) { + console.error('An error occurred while installing devtools:', err) + // Only log the error and don't throw it because it's not critical } } diff --git a/web/containers/Layout/index.tsx b/web/containers/Layout/index.tsx index 6e3c78a94..2e7db1610 100644 --- a/web/containers/Layout/index.tsx +++ b/web/containers/Layout/index.tsx @@ -25,6 +25,8 @@ import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal' import ImportingModelModal from '@/screens/Settings/ImportingModelModal' import SelectingModelModal from '@/screens/Settings/SelectingModelModal' +import LoadingModal from '../LoadingModal' + import MainViewContainer from '../MainViewContainer' import InstallingExtensionModal from './BottomBar/InstallingExtension/InstallingExtensionModal' @@ -69,6 +71,7 @@ const BaseLayout = () => { + {importModelStage === 'SELECTING_MODEL' && } {importModelStage === 'MODEL_SELECTED' && } {importModelStage === 'IMPORTING_MODEL' && } diff --git a/web/containers/LoadingModal/index.tsx b/web/containers/LoadingModal/index.tsx new file mode 100644 index 000000000..cfaf05c7e --- /dev/null +++ b/web/containers/LoadingModal/index.tsx @@ -0,0 +1,28 @@ +import { Modal, ModalContent, ModalHeader, ModalTitle } from '@janhq/uikit' +import { atom, useAtomValue } from 'jotai' + +export type LoadingInfo = { + title: string + message: string +} + +export const loadingModalVisibilityAtom = atom( + undefined +) + +const ResettingModal: React.FC = () => { + const loadingInfo = useAtomValue(loadingModalVisibilityAtom) + + return ( + + + + {loadingInfo?.title} + +

{loadingInfo?.message}

+
+
+ ) +} + +export default ResettingModal diff --git a/web/containers/Providers/DeepLinkListener.tsx b/web/containers/Providers/DeepLinkListener.tsx new file mode 100644 index 000000000..ca275e52c --- /dev/null +++ b/web/containers/Providers/DeepLinkListener.tsx @@ -0,0 +1,72 @@ +import { Fragment, ReactNode } from 'react' + +import { useSetAtom } from 'jotai' + +import { useDebouncedCallback } from 'use-debounce' + +import { useGetHFRepoData } from '@/hooks/useGetHFRepoData' + +import { loadingModalVisibilityAtom as loadingModalInfoAtom } from '../LoadingModal' +import { toaster } from '../Toast' + +import { + importHuggingFaceModelStageAtom, + importingHuggingFaceRepoDataAtom, +} from '@/helpers/atoms/HuggingFace.atom' +type Props = { + children: ReactNode +} + +const DeepLinkListener: React.FC = ({ children }) => { + const { getHfRepoData } = useGetHFRepoData() + const setLoadingInfo = useSetAtom(loadingModalInfoAtom) + const setImportingHuggingFaceRepoData = useSetAtom( + importingHuggingFaceRepoDataAtom + ) + const setImportHuggingFaceModelStage = useSetAtom( + importHuggingFaceModelStageAtom + ) + + const debounced = useDebouncedCallback(async (searchText) => { + if (searchText.indexOf('/') === -1) { + toaster({ + title: 'Failed to get Hugging Face models', + description: 'Invalid Hugging Face model URL', + type: 'error', + }) + return + } + + try { + setLoadingInfo({ + title: 'Getting Hugging Face models', + message: 'Please wait..', + }) + const data = await getHfRepoData(searchText) + setImportingHuggingFaceRepoData(data) + setImportHuggingFaceModelStage('REPO_DETAIL') + setLoadingInfo(undefined) + } catch (err) { + setLoadingInfo(undefined) + let errMessage = 'Unexpected Error' + if (err instanceof Error) { + errMessage = err.message + } + toaster({ + title: 'Failed to get Hugging Face models', + description: errMessage, + type: 'error', + }) + console.error(err) + } + }, 300) + window.electronAPI?.onDeepLink((_event: string, input: string) => { + window.core?.api?.ackDeepLink() + const url = input.replaceAll('jan://', '') + debounced(url) + }) + + return {children} +} + +export default DeepLinkListener diff --git a/web/containers/Providers/index.tsx b/web/containers/Providers/index.tsx index 66ba42a7d..0b5e236e0 100644 --- a/web/containers/Providers/index.tsx +++ b/web/containers/Providers/index.tsx @@ -22,6 +22,7 @@ import Loader from '../Loader' import DataLoader from './DataLoader' +import DeepLinkListener from './DeepLinkListener' import KeyListener from './KeyListener' import { extensionManager } from '@/extension' @@ -78,7 +79,9 @@ const Providers = ({ children }: PropsWithChildren) => { - {children} + + {children} +