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',
quickAskSizeUpdated = 'quickAskSizeUpdated',
ackDeepLink = 'ackDeepLink',
}
/**
@ -45,6 +46,8 @@ export enum AppEvent {
onUserSubmitQuickAsk = 'onUserSubmitQuickAsk',
onSelectedText = 'onSelectedText',
onDeepLink = 'onDeepLink',
}
export enum DownloadRoute {

View File

@ -151,4 +151,8 @@ export function handleAppIPCs() {
async (_event, heightOffset: number): Promise<void> =>
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 { 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.
*/

View File

@ -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()

View File

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

View File

@ -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
}
}

View File

@ -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 = () => {
<BottomBar />
</div>
</div>
<LoadingModal />
{importModelStage === 'SELECTING_MODEL' && <SelectingModelModal />}
{importModelStage === 'MODEL_SELECTED' && <ImportModelOptionModal />}
{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 DeepLinkListener from './DeepLinkListener'
import KeyListener from './KeyListener'
import { extensionManager } from '@/extension'
@ -78,7 +79,9 @@ const Providers = ({ children }: PropsWithChildren) => {
<KeyListener>
<EventListenerWrapper>
<TooltipProvider delayDuration={0}>
<DataLoader>{children}</DataLoader>
<DataLoader>
<DeepLinkListener>{children}</DeepLinkListener>
</DataLoader>
</TooltipProvider>
</EventListenerWrapper>
<Toaster />