feat: add deeplink support (#2883)

* feat: add deeplink support

* fix windows not receive deeplink when not running

---------

Co-authored-by: James <james@jan.ai>
This commit is contained in:
NamH 2024-05-13 09:37:05 +07:00 committed by GitHub
parent efbc96dad9
commit 6af4a2d484
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 202 additions and 26 deletions

View File

@ -19,6 +19,7 @@ export enum NativeRoute {
showMainWindow = 'showMainWindow', showMainWindow = 'showMainWindow',
quickAskSizeUpdated = 'quickAskSizeUpdated', quickAskSizeUpdated = 'quickAskSizeUpdated',
ackDeepLink = 'ackDeepLink',
} }
/** /**
@ -45,6 +46,8 @@ export enum AppEvent {
onUserSubmitQuickAsk = 'onUserSubmitQuickAsk', onUserSubmitQuickAsk = 'onUserSubmitQuickAsk',
onSelectedText = 'onSelectedText', onSelectedText = 'onSelectedText',
onDeepLink = 'onDeepLink',
} }
export enum DownloadRoute { export enum DownloadRoute {

View File

@ -151,4 +151,8 @@ export function handleAppIPCs() {
async (_event, heightOffset: number): Promise<void> => async (_event, heightOffset: number): Promise<void> =>
windowManager.expandQuickAskWindow(heightOffset) windowManager.expandQuickAskWindow(heightOffset)
) )
ipcMain.handle(NativeRoute.ackDeepLink, async (_event): Promise<void> => {
windowManager.ackDeepLink()
})
} }

View File

@ -1,6 +1,6 @@
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import { join } from 'path' import { join, resolve } from 'path'
/** /**
* Managers * Managers
**/ **/
@ -39,15 +39,44 @@ const quickAskUrl = `${mainUrl}/search`
const gotTheLock = app.requestSingleInstanceLock() 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 app
.whenReady() .whenReady()
.then(() => { .then(() => {
if (!gotTheLock) { if (!gotTheLock) {
app.quit() app.quit()
throw new Error('Another instance of the app is already running') 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(setupCore)
.then(createUserSpace) .then(createUserSpace)
.then(migrateExtensions) .then(migrateExtensions)
@ -60,6 +89,7 @@ app
.then(registerGlobalShortcuts) .then(registerGlobalShortcuts)
.then(() => { .then(() => {
if (!app.isPackaged) { if (!app.isPackaged) {
setupReactDevTool()
windowManager.mainWindow?.webContents.openDevTools() windowManager.mainWindow?.webContents.openDevTools()
} }
}) })
@ -75,11 +105,11 @@ app
}) })
}) })
app.on('second-instance', (_event, _commandLine, _workingDirectory) => { app.on('open-url', (_event, url) => {
windowManager.showMainWindow() windowManager.sendMainAppDeepLink(url)
}) })
app.on('before-quit', function (evt) { app.on('before-quit', function (_event) {
trayManager.destroyCurrentTray() trayManager.destroyCurrentTray()
}) })
@ -104,11 +134,6 @@ function createQuickAskWindow() {
windowManager.createQuickAskWindow(preloadPath, startUrl) 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. * Handles various IPC messages from the renderer process.
*/ */

View File

@ -14,9 +14,9 @@ class WindowManager {
private _quickAskWindowVisible = false private _quickAskWindowVisible = false
private _mainWindowVisible = false private _mainWindowVisible = false
private deeplink: string | undefined
/** /**
* Creates a new window instance. * Creates a new window instance.
* @param {Electron.BrowserWindowConstructorOptions} options - The options to create the window with.
* @returns The created window instance. * @returns The created window instance.
*/ */
createMainWindow(preloadPath: string, startUrl: string) { 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 */ /* Load frontend app to the window */
this.mainWindow.loadURL(startUrl) 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 { cleanUp(): void {
if (!this.mainWindow?.isDestroyed()) { if (!this.mainWindow?.isDestroyed()) {
this.mainWindow?.close() this.mainWindow?.close()
@ -137,6 +164,13 @@ class WindowManager {
this._quickAskWindowVisible = false 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() export const windowManager = new WindowManager()

View File

@ -61,6 +61,14 @@
"include": "scripts/uninstaller.nsh", "include": "scripts/uninstaller.nsh",
"deleteAppDataOnUninstall": true "deleteAppDataOnUninstall": true
}, },
"protocols": [
{
"name": "Jan",
"schemes": [
"jan"
]
}
],
"artifactName": "jan-${os}-${arch}-${version}.${ext}" "artifactName": "jan-${os}-${arch}-${version}.${ext}"
}, },
"scripts": { "scripts": {

View File

@ -1,7 +1,4 @@
import { app } from 'electron'
export const setupReactDevTool = async () => { export const setupReactDevTool = async () => {
if (!app.isPackaged) {
// Which means you're running from source code // Which means you're running from source code
const { default: installExtension, REACT_DEVELOPER_TOOLS } = await import( const { default: installExtension, REACT_DEVELOPER_TOOLS } = await import(
'electron-devtools-installer' 'electron-devtools-installer'
@ -13,5 +10,4 @@ export const setupReactDevTool = async () => {
console.error('An error occurred while installing devtools:', err) console.error('An error occurred while installing devtools:', err)
// Only log the error and don't throw it because it's not critical // Only log the error and don't throw it because it's not critical
} }
}
} }

View File

@ -25,6 +25,8 @@ import ImportModelOptionModal from '@/screens/Settings/ImportModelOptionModal'
import ImportingModelModal from '@/screens/Settings/ImportingModelModal' import ImportingModelModal from '@/screens/Settings/ImportingModelModal'
import SelectingModelModal from '@/screens/Settings/SelectingModelModal' import SelectingModelModal from '@/screens/Settings/SelectingModelModal'
import LoadingModal from '../LoadingModal'
import MainViewContainer from '../MainViewContainer' import MainViewContainer from '../MainViewContainer'
import InstallingExtensionModal from './BottomBar/InstallingExtension/InstallingExtensionModal' import InstallingExtensionModal from './BottomBar/InstallingExtension/InstallingExtensionModal'
@ -69,6 +71,7 @@ const BaseLayout = () => {
<BottomBar /> <BottomBar />
</div> </div>
</div> </div>
<LoadingModal />
{importModelStage === 'SELECTING_MODEL' && <SelectingModelModal />} {importModelStage === 'SELECTING_MODEL' && <SelectingModelModal />}
{importModelStage === 'MODEL_SELECTED' && <ImportModelOptionModal />} {importModelStage === 'MODEL_SELECTED' && <ImportModelOptionModal />}
{importModelStage === 'IMPORTING_MODEL' && <ImportingModelModal />} {importModelStage === 'IMPORTING_MODEL' && <ImportingModelModal />}

View File

@ -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<LoadingInfo | undefined>(
undefined
)
const ResettingModal: React.FC = () => {
const loadingInfo = useAtomValue(loadingModalVisibilityAtom)
return (
<Modal open={loadingInfo != null}>
<ModalContent>
<ModalHeader>
<ModalTitle>{loadingInfo?.title}</ModalTitle>
</ModalHeader>
<p className="text-muted-foreground">{loadingInfo?.message}</p>
</ModalContent>
</Modal>
)
}
export default ResettingModal

View File

@ -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<Props> = ({ 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 <Fragment>{children}</Fragment>
}
export default DeepLinkListener

View File

@ -22,6 +22,7 @@ import Loader from '../Loader'
import DataLoader from './DataLoader' import DataLoader from './DataLoader'
import DeepLinkListener from './DeepLinkListener'
import KeyListener from './KeyListener' import KeyListener from './KeyListener'
import { extensionManager } from '@/extension' import { extensionManager } from '@/extension'
@ -78,7 +79,9 @@ const Providers = ({ children }: PropsWithChildren) => {
<KeyListener> <KeyListener>
<EventListenerWrapper> <EventListenerWrapper>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DataLoader>{children}</DataLoader> <DataLoader>
<DeepLinkListener>{children}</DeepLinkListener>
</DataLoader>
</TooltipProvider> </TooltipProvider>
</EventListenerWrapper> </EventListenerWrapper>
<Toaster /> <Toaster />