diff --git a/package.json b/package.json index 5316d57..68544fd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01bfcf1..a67f42c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/App.jsx b/src/App.jsx index fe1cbd9..c0784b2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 = () => { + } /> { path='/email/notification' element={} /> + { + 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 ( +
+
+ + + + + + + + {!launchError && !launchSuccess && ( + } + showIcon + /> + )} + {launchError && ( + } + type='error' + showIcon + /> + )} + {launchSuccess && ( + } + type='success' + showIcon + /> + )} + +
+
+ ) +} + +export default AuthLaunch diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx index dea2535..aa55363 100644 --- a/src/components/Dashboard/context/ApiServerContext.jsx +++ b/src/components/Dashboard/context/ApiServerContext.jsx @@ -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} diff --git a/src/components/Dashboard/context/AuthContext.jsx b/src/components/Dashboard/context/AuthContext.jsx index 9f1fbd3..a86c2a1 100644 --- a/src/components/Dashboard/context/AuthContext.jsx +++ b/src/components/Dashboard/context/AuthContext.jsx @@ -227,6 +227,10 @@ const AuthProvider = ({ children }) => { } } + if (location.pathname === '/applaunch') { + return + } + load() return () => { cancelled = true