Compare commits
2 Commits
696b457978
...
49dca65470
| Author | SHA1 | Date | |
|---|---|---|---|
| 49dca65470 | |||
| 431dd106c9 |
@ -51,6 +51,7 @@
|
||||
"keytar": "^7.9.0",
|
||||
"lodash": "^4.17.23",
|
||||
"loglevel": "^1.9.2",
|
||||
"nanoid": "^5.1.14",
|
||||
"online-3d-viewer": "^0.16.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.1.1",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -134,6 +134,9 @@ importers:
|
||||
loglevel:
|
||||
specifier: ^1.9.2
|
||||
version: 1.9.2
|
||||
nanoid:
|
||||
specifier: ^5.1.14
|
||||
version: 5.1.14
|
||||
online-3d-viewer:
|
||||
specifier: ^0.16.0
|
||||
version: 0.16.0
|
||||
@ -4718,6 +4721,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.14:
|
||||
resolution: {integrity: sha512-5c8l8kVzqpnDPaicbEop/fV0Q1w16FmbWtVhMqugTozAwYdlIQojWH5a/M7UfziFmGdQRrUdV+EPzc9Xng3VAQ==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
nanopop@2.3.0:
|
||||
resolution: {integrity: sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==}
|
||||
|
||||
@ -12095,6 +12103,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.14: {}
|
||||
|
||||
nanopop@2.3.0: {}
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
|
||||
@ -8,7 +8,9 @@ import {
|
||||
createWindow,
|
||||
setupMainWindowIPC,
|
||||
setupMainWindowAppEvents,
|
||||
setupDevAuthServer
|
||||
setupDevAuthServer,
|
||||
setupSingleInstanceLock,
|
||||
handleDeepLinkFromArgv
|
||||
} from './mainWindow.js'
|
||||
|
||||
// --- Keytar-backed auth session storage (main process) ---
|
||||
@ -27,14 +29,19 @@ try {
|
||||
const KEYTAR_SERVICE = app.name || 'Farm Control'
|
||||
const KEYTAR_ACCOUNT = 'authSession'
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const gotTheLock = setupSingleInstanceLock(app)
|
||||
|
||||
if (gotTheLock) {
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
registerGlobalShortcuts()
|
||||
setupSpotlightIPC()
|
||||
setupMainWindowIPC()
|
||||
setupMainWindowAppEvents(app)
|
||||
setupDevAuthServer()
|
||||
})
|
||||
handleDeepLinkFromArgv()
|
||||
})
|
||||
}
|
||||
|
||||
app.on('will-quit', () => {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
@ -7,6 +7,72 @@ const __dirname = dirname(__filename)
|
||||
|
||||
let win
|
||||
|
||||
const PROTOCOL_PREFIX = 'farmcontrol://'
|
||||
|
||||
function findProtocolUrl(args) {
|
||||
return args.find(
|
||||
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
|
||||
)
|
||||
}
|
||||
|
||||
function sendNavigateToRenderer(redirectPath) {
|
||||
const deliver = () => {
|
||||
win.webContents.send('navigate', redirectPath)
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
|
||||
if (!win || win.isDestroyed()) {
|
||||
createWindow()
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
setTimeout(deliver, 100)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (win.webContents.isLoading()) {
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
setTimeout(deliver, 100)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
deliver()
|
||||
}
|
||||
|
||||
export function handleDeepLink(url) {
|
||||
if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return
|
||||
const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, '') || '/'
|
||||
sendNavigateToRenderer(redirectPath)
|
||||
}
|
||||
|
||||
export function handleDeepLinkFromArgv() {
|
||||
if (process.platform === 'darwin') return
|
||||
const url = findProtocolUrl(process.argv)
|
||||
if (url) handleDeepLink(url)
|
||||
}
|
||||
|
||||
export function setupSingleInstanceLock(app) {
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
return false
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, commandLine) => {
|
||||
const url = findProtocolUrl(commandLine)
|
||||
if (url) {
|
||||
handleDeepLink(url)
|
||||
} else if (win && !win.isDestroyed()) {
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function attachKeyboardShortcuts(browserWindow) {
|
||||
if (!browserWindow) return
|
||||
// Keyboard shortcuts for the main window can be added here if needed
|
||||
@ -155,13 +221,7 @@ export function setupMainWindowAppEvents(app) {
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
if (url.startsWith('farmcontrol://app')) {
|
||||
// Extract the path/query after 'farmcontrol://app'
|
||||
const redirectPath = url.replace('farmcontrol://app', '') || '/'
|
||||
if (win && win.webContents) {
|
||||
win.webContents.send('navigate', redirectPath)
|
||||
}
|
||||
}
|
||||
handleDeepLink(url)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ import { MessageProvider } from './components/Dashboard/context/MessageContext.j
|
||||
import AuthCallback from './components/App/AuthCallback.jsx'
|
||||
import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx'
|
||||
import MarketplaceAuthCallback from './components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx'
|
||||
import AuthLaunch from './components/App/AppLaunch.jsx'
|
||||
|
||||
import {
|
||||
ProductionRoutes,
|
||||
@ -79,6 +80,7 @@ const AppContent = () => {
|
||||
<SpotlightProvider>
|
||||
<ActionsModalProvider>
|
||||
<Routes>
|
||||
<Route path='/applaunch' element={<AuthLaunch />} />
|
||||
<Route
|
||||
path='/dashboard/electron/spotlightcontent'
|
||||
element={
|
||||
@ -114,6 +116,7 @@ const AppContent = () => {
|
||||
path='/email/notification'
|
||||
element={<EmailNotificationTemplate />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path='/dashboard'
|
||||
element={
|
||||
|
||||
177
src/components/App/AppLaunch.jsx
Normal file
177
src/components/App/AppLaunch.jsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Flex, Card, Alert } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import { customAlphabet } from 'nanoid'
|
||||
import AuthParticles from './AppParticles'
|
||||
import FarmControlLogo from '../Logos/FarmControlLogo'
|
||||
import ExclamationOctagonIcon from '../Icons/ExclamationOctagonIcon'
|
||||
import CheckIcon from '../Icons/CheckIcon'
|
||||
import { ApiServerContext } from '../Dashboard/context/ApiServerContext'
|
||||
|
||||
const createLaunchSession = customAlphabet(
|
||||
'01abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
32
|
||||
)
|
||||
|
||||
const AuthLaunch = () => {
|
||||
const location = useLocation()
|
||||
const hasRedirected = useRef(false)
|
||||
const startTimeoutRef = useRef(null)
|
||||
const pollTimeoutRef = useRef(null)
|
||||
const { getAppLaunchSession } = useContext(ApiServerContext)
|
||||
const [launchError, setLaunchError] = useState(false)
|
||||
const [launchErrorMessage, setLaunchErrorMessage] = useState('')
|
||||
const [launchSuccess, setLaunchSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const redirect = new URLSearchParams(location.search).get('redirect')
|
||||
|
||||
if (!redirect) {
|
||||
setLaunchError(true)
|
||||
setLaunchErrorMessage('No redirect provided!')
|
||||
return
|
||||
}
|
||||
|
||||
if (!redirect || hasRedirected.current) {
|
||||
return
|
||||
}
|
||||
|
||||
startTimeoutRef.current = setTimeout(() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
hasRedirected.current = true
|
||||
const launchSession = createLaunchSession()
|
||||
let launchCheckCount = 0
|
||||
|
||||
setLaunchError(false)
|
||||
setLaunchErrorMessage('')
|
||||
setLaunchSuccess(false)
|
||||
|
||||
let redirectWithLaunchSession = redirect
|
||||
try {
|
||||
const redirectUrl = new URL(redirect, window.location.origin)
|
||||
redirectUrl.searchParams.set('launchSession', launchSession)
|
||||
redirectWithLaunchSession = redirectUrl.toString()
|
||||
} catch {
|
||||
const hasQuery = redirect.includes('?')
|
||||
const separator = hasQuery ? '&' : '?'
|
||||
redirectWithLaunchSession = `${redirect}${separator}launchSession=${encodeURIComponent(
|
||||
launchSession
|
||||
)}`
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = redirectWithLaunchSession
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
const checkLaunchSession = async () => {
|
||||
launchCheckCount += 1
|
||||
|
||||
let launchComplete = false
|
||||
|
||||
try {
|
||||
const launchStatus = await getAppLaunchSession(launchSession)
|
||||
launchComplete = launchStatus?.complete === true
|
||||
} catch {
|
||||
launchComplete = false
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (launchComplete) {
|
||||
setLaunchSuccess(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (launchCheckCount >= 10) {
|
||||
setLaunchError(true)
|
||||
setLaunchErrorMessage('Failed to open Farm Control.')
|
||||
return
|
||||
}
|
||||
|
||||
pollTimeoutRef.current = setTimeout(() => {
|
||||
checkLaunchSession()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
checkLaunchSession()
|
||||
}, 0)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
hasRedirected.current = false
|
||||
if (startTimeoutRef.current) {
|
||||
clearTimeout(startTimeoutRef.current)
|
||||
}
|
||||
if (pollTimeoutRef.current) {
|
||||
clearTimeout(pollTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [getAppLaunchSession, location.search])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'black'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'black',
|
||||
minHeight: '100vh',
|
||||
transition: 'opacity 0.5s ease-in-out',
|
||||
opacity: 1
|
||||
}}
|
||||
>
|
||||
<AuthParticles />
|
||||
<Flex
|
||||
align='center'
|
||||
justify='center'
|
||||
vertical
|
||||
style={{ height: '100vh' }}
|
||||
gap={'large'}
|
||||
>
|
||||
<Card style={{ borderRadius: 20 }}>
|
||||
<Flex vertical align='center'>
|
||||
<FarmControlLogo style={{ fontSize: '500px', height: '40px' }} />
|
||||
</Flex>
|
||||
</Card>
|
||||
{!launchError && !launchSuccess && (
|
||||
<Alert
|
||||
message='Launching Farm Control please wait...'
|
||||
icon={<LoadingOutlined />}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
{launchError && (
|
||||
<Alert
|
||||
message={launchErrorMessage}
|
||||
icon={<ExclamationOctagonIcon />}
|
||||
type='error'
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
{launchSuccess && (
|
||||
<Alert
|
||||
message='Launch successful! You may now close this window.'
|
||||
icon={<CheckIcon />}
|
||||
type='success'
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthLaunch
|
||||
@ -11,6 +11,7 @@ import io from 'socket.io-client'
|
||||
import { message, Modal, Space, Button } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import { AuthContext } from './AuthContext'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import axios from 'axios'
|
||||
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
|
||||
@ -58,6 +59,8 @@ const getObjectEndpoint = (type) =>
|
||||
const ApiServerContext = createContext()
|
||||
|
||||
const ApiServerProvider = ({ children }) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
token,
|
||||
userProfile,
|
||||
@ -77,6 +80,7 @@ const ApiServerProvider = ({ children }) => {
|
||||
const subscribedCallbacksRef = useRef(new Map())
|
||||
const subscribedLockCallbacksRef = useRef(new Map())
|
||||
const notificationListenersRef = useRef(new Set())
|
||||
const completedLaunchSessionsRef = useRef(new Set())
|
||||
|
||||
const handleLockUpdate = useCallback(
|
||||
async (lockData) => {
|
||||
@ -1606,6 +1610,72 @@ const ApiServerProvider = ({ children }) => {
|
||||
})
|
||||
}, [token])
|
||||
|
||||
const completeAppLaunchSession = useCallback(
|
||||
async (launchSession) => {
|
||||
const response = await axios.post(
|
||||
`${config.backendUrl}/applaunch/${launchSession}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
[token]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const launchSession = new URLSearchParams(location.search).get(
|
||||
'launchSession'
|
||||
)
|
||||
|
||||
if (
|
||||
authenticated !== true ||
|
||||
!token ||
|
||||
!launchSession ||
|
||||
completedLaunchSessionsRef.current.has(launchSession)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
completedLaunchSessionsRef.current.add(launchSession)
|
||||
|
||||
completeAppLaunchSession(launchSession)
|
||||
.then(() => {
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
searchParams.delete('launchSession')
|
||||
const newSearch = searchParams.toString()
|
||||
const newPath = location.pathname + (newSearch ? `?${newSearch}` : '')
|
||||
navigate(newPath, { replace: true })
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to complete app launch session:', err)
|
||||
completedLaunchSessionsRef.current.delete(launchSession)
|
||||
})
|
||||
}, [
|
||||
token,
|
||||
authenticated,
|
||||
completeAppLaunchSession,
|
||||
location.search,
|
||||
location.pathname,
|
||||
navigate
|
||||
])
|
||||
|
||||
const getAppLaunchSession = useCallback(async (launchSession) => {
|
||||
const response = await axios.get(
|
||||
`${config.backendUrl}/applaunch/${launchSession}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
}, [])
|
||||
|
||||
const flushFile = async (id) => {
|
||||
logger.debug('Flushing file...')
|
||||
try {
|
||||
@ -1700,7 +1770,9 @@ const ApiServerProvider = ({ children }) => {
|
||||
registerNotificationListener,
|
||||
unregisterNotificationListener,
|
||||
getMarketplaceAuthUrl,
|
||||
refreshMarketplaceAuth
|
||||
refreshMarketplaceAuth,
|
||||
completeAppLaunchSession,
|
||||
getAppLaunchSession
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
|
||||
@ -227,6 +227,10 @@ const AuthProvider = ({ children }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (location.pathname === '/applaunch') {
|
||||
return
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user