Compare commits

...

2 Commits

Author SHA1 Message Date
49dca65470 Implemented session status.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-20 00:33:46 +01:00
431dd106c9 First attempt at fixing windows auth.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-19 21:56:10 +01:00
8 changed files with 351 additions and 17 deletions

View File

@ -51,6 +51,7 @@
"keytar": "^7.9.0", "keytar": "^7.9.0",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"nanoid": "^5.1.14",
"online-3d-viewer": "^0.16.0", "online-3d-viewer": "^0.16.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^19.1.1", "react": "^19.1.1",

10
pnpm-lock.yaml generated
View File

@ -134,6 +134,9 @@ importers:
loglevel: loglevel:
specifier: ^1.9.2 specifier: ^1.9.2
version: 1.9.2 version: 1.9.2
nanoid:
specifier: ^5.1.14
version: 5.1.14
online-3d-viewer: online-3d-viewer:
specifier: ^0.16.0 specifier: ^0.16.0
version: 0.16.0 version: 0.16.0
@ -4718,6 +4721,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
nanoid@5.1.14:
resolution: {integrity: sha512-5c8l8kVzqpnDPaicbEop/fV0Q1w16FmbWtVhMqugTozAwYdlIQojWH5a/M7UfziFmGdQRrUdV+EPzc9Xng3VAQ==}
engines: {node: ^18 || >=20}
hasBin: true
nanopop@2.3.0: nanopop@2.3.0:
resolution: {integrity: sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==} resolution: {integrity: sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==}
@ -12095,6 +12103,8 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
nanoid@5.1.14: {}
nanopop@2.3.0: {} nanopop@2.3.0: {}
napi-build-utils@2.0.0: {} napi-build-utils@2.0.0: {}

View File

@ -8,7 +8,9 @@ import {
createWindow, createWindow,
setupMainWindowIPC, setupMainWindowIPC,
setupMainWindowAppEvents, setupMainWindowAppEvents,
setupDevAuthServer setupDevAuthServer,
setupSingleInstanceLock,
handleDeepLinkFromArgv
} from './mainWindow.js' } from './mainWindow.js'
// --- Keytar-backed auth session storage (main process) --- // --- Keytar-backed auth session storage (main process) ---
@ -27,14 +29,19 @@ try {
const KEYTAR_SERVICE = app.name || 'Farm Control' const KEYTAR_SERVICE = app.name || 'Farm Control'
const KEYTAR_ACCOUNT = 'authSession' const KEYTAR_ACCOUNT = 'authSession'
app.whenReady().then(() => { const gotTheLock = setupSingleInstanceLock(app)
createWindow()
registerGlobalShortcuts() if (gotTheLock) {
setupSpotlightIPC() app.whenReady().then(() => {
setupMainWindowIPC() createWindow()
setupMainWindowAppEvents(app) registerGlobalShortcuts()
setupDevAuthServer() setupSpotlightIPC()
}) setupMainWindowIPC()
setupMainWindowAppEvents(app)
setupDevAuthServer()
handleDeepLinkFromArgv()
})
}
app.on('will-quit', () => { app.on('will-quit', () => {
globalShortcut.unregisterAll() globalShortcut.unregisterAll()

View File

@ -7,6 +7,72 @@ const __dirname = dirname(__filename)
let win 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) { function attachKeyboardShortcuts(browserWindow) {
if (!browserWindow) return if (!browserWindow) return
// Keyboard shortcuts for the main window can be added here if needed // 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) => { app.on('open-url', (event, url) => {
event.preventDefault() event.preventDefault()
if (url.startsWith('farmcontrol://app')) { handleDeepLink(url)
// Extract the path/query after 'farmcontrol://app'
const redirectPath = url.replace('farmcontrol://app', '') || '/'
if (win && win.webContents) {
win.webContents.send('navigate', redirectPath)
}
}
}) })
} }

View File

@ -31,6 +31,7 @@ import { MessageProvider } from './components/Dashboard/context/MessageContext.j
import AuthCallback from './components/App/AuthCallback.jsx' import AuthCallback from './components/App/AuthCallback.jsx'
import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx' import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx'
import MarketplaceAuthCallback from './components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx' import MarketplaceAuthCallback from './components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx'
import AuthLaunch from './components/App/AppLaunch.jsx'
import { import {
ProductionRoutes, ProductionRoutes,
@ -79,6 +80,7 @@ const AppContent = () => {
<SpotlightProvider> <SpotlightProvider>
<ActionsModalProvider> <ActionsModalProvider>
<Routes> <Routes>
<Route path='/applaunch' element={<AuthLaunch />} />
<Route <Route
path='/dashboard/electron/spotlightcontent' path='/dashboard/electron/spotlightcontent'
element={ element={
@ -114,6 +116,7 @@ const AppContent = () => {
path='/email/notification' path='/email/notification'
element={<EmailNotificationTemplate />} element={<EmailNotificationTemplate />}
/> />
<Route <Route
path='/dashboard' path='/dashboard'
element={ element={

View 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

View File

@ -11,6 +11,7 @@ import io from 'socket.io-client'
import { message, Modal, Space, Button } from 'antd' import { message, Modal, Space, Button } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'
import { useLocation, useNavigate } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon' import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon'
@ -58,6 +59,8 @@ const getObjectEndpoint = (type) =>
const ApiServerContext = createContext() const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => { const ApiServerProvider = ({ children }) => {
const location = useLocation()
const navigate = useNavigate()
const { const {
token, token,
userProfile, userProfile,
@ -77,6 +80,7 @@ const ApiServerProvider = ({ children }) => {
const subscribedCallbacksRef = useRef(new Map()) const subscribedCallbacksRef = useRef(new Map())
const subscribedLockCallbacksRef = useRef(new Map()) const subscribedLockCallbacksRef = useRef(new Map())
const notificationListenersRef = useRef(new Set()) const notificationListenersRef = useRef(new Set())
const completedLaunchSessionsRef = useRef(new Set())
const handleLockUpdate = useCallback( const handleLockUpdate = useCallback(
async (lockData) => { async (lockData) => {
@ -1606,6 +1610,72 @@ const ApiServerProvider = ({ children }) => {
}) })
}, [token]) }, [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) => { const flushFile = async (id) => {
logger.debug('Flushing file...') logger.debug('Flushing file...')
try { try {
@ -1700,7 +1770,9 @@ const ApiServerProvider = ({ children }) => {
registerNotificationListener, registerNotificationListener,
unregisterNotificationListener, unregisterNotificationListener,
getMarketplaceAuthUrl, getMarketplaceAuthUrl,
refreshMarketplaceAuth refreshMarketplaceAuth,
completeAppLaunchSession,
getAppLaunchSession
}} }}
> >
{contextHolder} {contextHolder}

View File

@ -227,6 +227,10 @@ const AuthProvider = ({ children }) => {
} }
} }
if (location.pathname === '/applaunch') {
return
}
load() load()
return () => { return () => {
cancelled = true cancelled = true