Refactored electron code and added spotlight feature.

This commit is contained in:
Tom Butcher 2025-12-14 22:11:23 +00:00
parent d86a0a3c09
commit 5e7e9510fb
11 changed files with 1111 additions and 430 deletions

View File

@ -400,3 +400,10 @@ body {
.ant-btn-variant-outlined.ant-btn.ant-btn-icon-only { .ant-btn-variant-outlined.ant-btn.ant-btn-icon-only {
min-width: 32px; min-width: 32px;
} }
.electron-spotlight-content .ant-input-outlined {
background-color: transparent !important;
border: 1px solid transparent !important;
border-radius: 0 !important;
box-shadow: none !important;
}

View File

@ -46,6 +46,7 @@
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"gcode-preview": "^2.18.0", "gcode-preview": "^2.18.0",
"keycloak-js": "^26.2.0", "keycloak-js": "^26.2.0",
"keytar": "^7.9.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"moment": "^2.30.1", "moment": "^2.30.1",
"online-3d-viewer": "^0.16.0", "online-3d-viewer": "^0.16.0",
@ -73,10 +74,10 @@
"description": "3D Printer ERP and Control Software.", "description": "3D Printer ERP and Control Software.",
"scripts": { "scripts": {
"dev": "cross-env NODE_ENV=development vite", "dev": "cross-env NODE_ENV=development vite",
"electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5173 && cross-env NODE_ENV=development && electron .", "electron": "cross-env ELECTRON_START_URL=http://0.0.0.0:5780 && cross-env NODE_ENV=development && electron .",
"start": "serve -s build", "start": "serve -s build",
"build": "vite build", "build": "vite build",
"dev:electron": "concurrently \"cross-env NODE_ENV=development vite --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5173 cross-env NODE_ENV=development electron public/electron.js\"", "dev:electron": "concurrently \"cross-env NODE_ENV=development vite --no-open\" \"cross-env ELECTRON_START_URL=http://localhost:5780 cross-env NODE_ENV=development electron public/electron.js\"",
"build:electron": "vite build && electron-builder", "build:electron": "vite build && electron-builder",
"deploy": "vite build && wrangler pages deploy" "deploy": "vite build && wrangler pages deploy"
}, },

View File

@ -1,62 +1,44 @@
import { app, BrowserWindow, ipcMain, shell, Menu } from 'electron' import { app, ipcMain, shell, globalShortcut } from 'electron'
import path, { dirname } from 'path' import { createRequire } from 'module'
import { fileURLToPath } from 'url' import {
registerGlobalShortcuts,
setupSpotlightIPC
} from './spotlightWindow.js'
import {
createWindow,
setupMainWindowIPC,
setupMainWindowAppEvents,
setupDevAuthServer
} from './mainWindow.js'
const __filename = fileURLToPath(import.meta.url) // --- Keytar-backed auth session storage (main process) ---
const __dirname = dirname(__filename) const require = createRequire(import.meta.url)
let keytar = null
let win try {
// keytar is a native module; in some dev environments it may not be built yet.
function createWindow() { keytar = require('keytar')
win = new BrowserWindow({ } catch (e) {
width: 1200, console.warn(
height: 800, '[keytar] Not available; auth session persistence will be disabled.',
frame: false, e?.message || e
titleBarStyle: 'hiddenInset', )
trafficLightPosition: { x: 14, y: 12 },
backgroundColor: '#141414',
icon: path.join(__dirname, './logo512.png'),
webPreferences: {
nodeIntegration: true,
contextIsolation: false
} }
const KEYTAR_SERVICE = app.name || 'Farm Control'
const KEYTAR_ACCOUNT = 'authSession'
app.whenReady().then(() => {
createWindow()
registerGlobalShortcuts()
setupSpotlightIPC()
setupMainWindowIPC()
setupMainWindowAppEvents(app)
setupDevAuthServer()
}) })
// Set up custom menu bar app.on('will-quit', () => {
const env = (process.env.NODE_ENV || 'development').trim() globalShortcut.unregisterAll()
if (env === 'development') { })
const devMenu = [
{
label: 'Developer',
submenu: [
{
label: 'Toggle Developer Tools',
accelerator:
process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
click: () => {
win.webContents.toggleDevTools()
}
}
]
}
]
const menu = Menu.buildFromTemplate(devMenu)
Menu.setApplicationMenu(menu)
} else {
Menu.setApplicationMenu(null)
}
// For development, load from localhost; for production, load the built index.html
if (process.env.ELECTRON_START_URL) {
win.loadURL(process.env.ELECTRON_START_URL)
} else {
win.loadFile(path.join(__dirname, '../build/index.html'))
}
setupWindowEvents()
}
app.whenReady().then(createWindow)
// IPC handler to get OS // IPC handler to get OS
ipcMain.handle('os-info', () => { ipcMain.handle('os-info', () => {
@ -65,112 +47,46 @@ ipcMain.handle('os-info', () => {
} }
}) })
// IPC handler to get window state ipcMain.handle('auth-session-get', async () => {
ipcMain.handle('window-state', () => { try {
return { if (!keytar) return null
isFullScreen: win ? win.isFullScreen() : false, const raw = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT)
isMaximized: win ? win.isMaximized() : false if (!raw) return null
return JSON.parse(raw)
} catch (e) {
console.warn('[keytar] Failed to read auth session.', e?.message || e)
return null
} }
}) })
// Emit events to renderer when window is maximized/unmaximized ipcMain.handle('auth-session-set', async (event, session) => {
function setupWindowEvents() { try {
if (!win) return if (!keytar) return false
win.on('maximize', () => { if (!session || typeof session !== 'object') return false
win.webContents.send('window-state', { await keytar.setPassword(
isMaximized: true KEYTAR_SERVICE,
}) KEYTAR_ACCOUNT,
}) JSON.stringify(session)
win.on('enter-full-screen', () => { )
console.log('Entered fullscreen') return true
win.webContents.send('window-state', { } catch (e) {
isFullScreen: true console.warn('[keytar] Failed to write auth session.', e?.message || e)
}) return false
})
win.on('leave-full-screen', () => {
win.webContents.send('window-state', {
isFullScreen: false
})
})
win.on('unmaximize', () => {
win.webContents.send('window-state', {
isMaximized: false
})
})
}
// IPC handlers for window controls
ipcMain.on('window-control', (event, action) => {
if (!win) return
switch (action) {
case 'minimize':
win.minimize()
break
case 'maximize':
if (win.isMaximized()) {
win.unmaximize()
} else {
win.maximize()
}
break
case 'close':
win.close()
break
default:
break
} }
}) })
// Add this after other ipcMain handlers ipcMain.handle('auth-session-clear', async () => {
try {
if (!keytar) return false
return await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT)
} catch (e) {
console.warn('[keytar] Failed to clear auth session.', e?.message || e)
return false
}
})
// IPC handler for opening external URLs
ipcMain.handle('open-external-url', (event, url) => { ipcMain.handle('open-external-url', (event, url) => {
console.log('Opening external url...') console.log('Opening external url...')
shell.openExternal(url) shell.openExternal(url)
}) })
app.on('open-url', (event, url) => {
event.preventDefault()
console.log('App opened with URL:', url)
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)
}
}
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
const env = (process.env.NODE_ENV || 'development').trim()
console.log(env)
if (env == 'development') {
console.log('Starting development auth web server...')
import('express').then(({ default: express }) => {
const app = express()
const port = 3500
app.use((req, res) => {
const redirectPath = req.originalUrl
res.send(
`Open Farmcontrol to continue... (Redirect path: ${redirectPath})`
)
if (win && win.webContents) {
win.webContents.send('navigate', redirectPath)
win.show()
win.focus()
}
})
app.listen(port, () => {
console.log(`Dev auth server running on http://localhost:${port}`)
})
})
} else {
console.log('Will use url scheme instead of auth server.')
}

196
public/mainWindow.js Normal file
View File

@ -0,0 +1,196 @@
import { BrowserWindow, ipcMain, Menu } from 'electron'
import path, { dirname } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
let win
function attachKeyboardShortcuts(browserWindow) {
if (!browserWindow) return
// Keyboard shortcuts for the main window can be added here if needed
}
function setupWindowEvents() {
if (!win) return
win.on('maximize', () => {
win.webContents.send('window-state', {
isMaximized: true
})
})
win.on('enter-full-screen', () => {
console.log('Entered fullscreen')
win.webContents.send('window-state', {
isFullScreen: true
})
})
win.on('leave-full-screen', () => {
win.webContents.send('window-state', {
isFullScreen: false
})
})
win.on('unmaximize', () => {
win.webContents.send('window-state', {
isMaximized: false
})
})
}
export function createWindow() {
win = new BrowserWindow({
width: 1200,
height: 800,
frame: false,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 14, y: 12 },
backgroundColor: '#141414',
icon: path.join(__dirname, './logo512.png'),
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
})
// Set up custom menu bar
const env = (process.env.NODE_ENV || 'development').trim()
if (env === 'development') {
const devMenu = [
{
label: 'Developer',
submenu: [
{
label: 'Toggle Developer Tools',
accelerator:
process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
click: () => {
win.webContents.toggleDevTools()
}
}
]
}
]
const menu = Menu.buildFromTemplate(devMenu)
Menu.setApplicationMenu(menu)
} else {
Menu.setApplicationMenu(null)
}
// For development, load from localhost; for production, load the built index.html
if (process.env.ELECTRON_START_URL) {
win.loadURL(process.env.ELECTRON_START_URL)
} else {
win.loadFile(path.join(__dirname, '../build/index.html'))
}
setupWindowEvents()
attachKeyboardShortcuts(win)
}
export function getWindow() {
return win
}
export function setupMainWindowIPC() {
// IPC handler to get window state
ipcMain.handle('window-state', () => {
if (!win || win.isDestroyed())
return { isFullScreen: false, isMaximized: false }
return {
isFullScreen: win ? win.isFullScreen() : false,
isMaximized: win ? win.isMaximized() : false
}
})
// IPC handlers for window controls
ipcMain.on('window-control', (event, action) => {
if (!win) return
switch (action) {
case 'minimize':
win.minimize()
break
case 'maximize':
if (win.isMaximized()) {
win.unmaximize()
} else {
win.maximize()
}
break
case 'close':
win.close()
break
default:
break
}
})
ipcMain.handle('open-internal-url', (event, url) => {
if (!win || win.isDestroyed()) {
createWindow()
// Wait for window to finish loading before sending navigate event
win.webContents.once('did-finish-load', () => {
setTimeout(() => {
win.webContents.send('navigate', url)
win.show()
win.focus()
}, 100)
})
} else {
win.webContents.send('navigate', url)
win.show()
win.focus()
}
return true
})
}
export function setupMainWindowAppEvents(app) {
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
app.on('open-url', (event, url) => {
event.preventDefault()
console.log('App opened with URL:', url)
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)
}
}
})
}
export function setupDevAuthServer() {
const env = (process.env.NODE_ENV || 'development').trim()
if (env == 'development') {
console.log('Starting development auth web server...')
import('express').then(({ default: express }) => {
const app = express()
const port = 3500
app.use((req, res) => {
const redirectPath = req.originalUrl
res.send(
`Open Farmcontrol to continue... (Redirect path: ${redirectPath})`
)
if (win && win.webContents) {
win.webContents.send('navigate', redirectPath)
win.show()
win.focus()
}
})
app.listen(port, () => {
console.log(`Dev auth server running on http://localhost:${port}`)
})
})
} else {
console.log('Will use url scheme instead of auth server.')
}
}

136
public/spotlightWindow.js Normal file
View File

@ -0,0 +1,136 @@
import { BrowserWindow, ipcMain, globalShortcut } from 'electron'
import path, { dirname } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
let spotlightWin
function getSpotlightRouteUrl() {
const routePath = '/dashboard/electron/spotlightcontent'
// Dev: BrowserRouter, so use a real path.
if (process.env.ELECTRON_START_URL) {
const base = String(process.env.ELECTRON_START_URL).replace(/\/$/, '')
return `${base}${routePath}`
}
// Prod (file://): App.jsx chooses HashRouter when `index.html` is in the URL.
// So we must load `index.html#/electron/spotlightcontent`.
return {
filePath: path.join(__dirname, '../build/index.html'),
hash: routePath
}
}
export function openSpotlightContentWindow() {
// If already open, just focus it (avoids accidental window spam).
if (spotlightWin && !spotlightWin.isDestroyed()) {
spotlightWin.show()
spotlightWin.focus()
return
}
spotlightWin = new BrowserWindow({
width: 700,
height: 40,
frame: false,
resizable: false,
center: true,
vibrancy: 'menu',
transparent: true,
icon: path.join(__dirname, './logo512.png'),
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
})
const target = getSpotlightRouteUrl()
if (typeof target === 'string') {
spotlightWin.loadURL(target)
} else {
spotlightWin.loadFile(target.filePath, { hash: target.hash })
}
// Hide spotlight window instead of destroying it when closed
spotlightWin.on('close', (event) => {
event.preventDefault()
if (spotlightWin && !spotlightWin.isDestroyed()) {
spotlightWin.hide()
}
})
// Hide spotlight window when it loses focus (clicking outside)
spotlightWin.on('blur', () => {
if (spotlightWin && !spotlightWin.isDestroyed()) {
spotlightWin.hide()
}
})
attachSpotlightKeyboardShortcuts(spotlightWin)
}
function attachSpotlightKeyboardShortcuts(browserWindow) {
if (!browserWindow) return
browserWindow.webContents.on('before-input-event', (event, input) => {
// ESC -> hide SpotlightContent window (if it's the spotlight window)
if (
input?.type === 'keyDown' &&
String(input?.key || '').toLowerCase() === 'escape' &&
browserWindow === spotlightWin
) {
event.preventDefault()
if (spotlightWin && !spotlightWin.isDestroyed()) {
spotlightWin.hide()
}
return
}
// Alt+Shift+Q -> open SpotlightContent window
if (
input?.type === 'keyDown' &&
input?.alt === true &&
input?.shift === true &&
String(input?.key || '').toLowerCase() === 'q'
) {
event.preventDefault()
openSpotlightContentWindow()
}
})
}
export function registerGlobalShortcuts() {
try {
const registered = globalShortcut.register('Alt+Shift+Q', () => {
openSpotlightContentWindow()
})
if (!registered) {
console.warn('[globalShortcut] Failed to register Alt+Shift+Q')
}
} catch (e) {
console.warn(
'[globalShortcut] Error registering Alt+Shift+Q',
e?.message || e
)
}
}
export function setupSpotlightIPC() {
// IPC handler to resize spotlight window
ipcMain.handle('spotlight-window-resize', (event, height) => {
if (!spotlightWin || spotlightWin.isDestroyed()) return false
try {
const currentBounds = spotlightWin.getBounds()
spotlightWin.setSize(currentBounds.width, height)
spotlightWin.center()
return true
} catch (e) {
console.warn('[spotlight] Failed to resize window.', e?.message || e)
return false
}
})
}

View File

@ -12,7 +12,10 @@ import PrivateRoute from './components/PrivateRoute'
import '../assets/stylesheets/App.css' import '../assets/stylesheets/App.css'
import { PrintServerProvider } from './components/Dashboard/context/PrintServerContext.jsx' import { PrintServerProvider } from './components/Dashboard/context/PrintServerContext.jsx'
import { AuthProvider } from './components/Dashboard/context/AuthContext.jsx' import { AuthProvider } from './components/Dashboard/context/AuthContext.jsx'
import { SpotlightProvider } from './components/Dashboard/context/SpotlightContext.jsx' import {
SpotlightProvider,
ElectronSpotlightContentPage
} from './components/Dashboard/context/SpotlightContext.jsx'
import { ActionsModalProvider } from './components/Dashboard/context/ActionsModalContext.jsx' import { ActionsModalProvider } from './components/Dashboard/context/ActionsModalContext.jsx'
import { import {
@ -58,6 +61,16 @@ const AppContent = () => {
<ActionsModalProvider> <ActionsModalProvider>
<MessageProvider> <MessageProvider>
<Routes> <Routes>
<Route
path='/dashboard/electron/spotlightcontent'
element={
<PrivateRoute
component={() => (
<ElectronSpotlightContentPage />
)}
/>
}
/>
<Route <Route
path='/' path='/'
element={ element={

View File

@ -57,7 +57,13 @@ const AuthProvider = ({ children }) => {
const [showUnauthorizedModal, setShowUnauthorizedModal] = useState(false) const [showUnauthorizedModal, setShowUnauthorizedModal] = useState(false)
const [showAuthErrorModal, setShowAuthErrorModal] = useState(false) const [showAuthErrorModal, setShowAuthErrorModal] = useState(false)
const [authError, setAuthError] = useState(null) const [authError, setAuthError] = useState(null)
const { openExternalUrl, isElectron } = useContext(ElectronContext) const {
openExternalUrl,
isElectron,
getAuthSession,
setAuthSession,
clearAuthSession
} = useContext(ElectronContext)
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
@ -71,23 +77,86 @@ const AuthProvider = ({ children }) => {
redirectType = 'app-scheme' redirectType = 'app-scheme'
} }
// Check if cookies are enabled and show warning if not const extractUserFromAuthData = (authData) => {
if (!authData || typeof authData !== 'object') return null
if (authData.user && typeof authData.user === 'object') return authData.user
// Some endpoints may return the "user" fields at the top-level. Only treat it
// as a user object if it looks like one (avoid confusing token-only payloads).
const looksLikeUser =
authData._id || authData.username || authData.email || authData.name
if (looksLikeUser) return authData
return null
}
const isSessionExpired = (session) => {
if (!session?.expiresAt) return true
const now = new Date()
const expirationDate = new Date(session.expiresAt)
return expirationDate <= now
}
const persistSession = useCallback(
async ({ token: nextToken, expiresAt: nextExpiresAt, user: nextUser }) => {
if (isElectron) {
return await setAuthSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
}
return setAuthCookies({
access_token: nextToken,
expires_at: nextExpiresAt,
user: nextUser
})
},
[isElectron, setAuthSession]
)
const clearPersistedSession = useCallback(async () => {
if (isElectron) {
return await clearAuthSession()
}
clearAuthCookies()
return true
}, [isElectron, clearAuthSession])
// Check if cookies are enabled and show warning if not (web only)
useEffect(() => { useEffect(() => {
if (isElectron) return
if (!areCookiesEnabled()) { if (!areCookiesEnabled()) {
messageApi.warning( messageApi.warning(
'Cookies are disabled. Login state may not persist between tabs.' 'Cookies are disabled. Login state may not persist between tabs.'
) )
} }
}, [messageApi]) }, [messageApi, isElectron])
// Read token from cookies if present // Read token from cookies (web) or keytar (electron) if present
useEffect(() => { useEffect(() => {
let cancelled = false
const load = async () => {
try { try {
console.log( if (isElectron) {
'Retreiving token from cookies...', const session = await getAuthSession()
getAuthCookies(), if (
validateAuthCookies() !cancelled &&
) session &&
session.token &&
!isSessionExpired(session)
) {
setToken(session.token)
setUserProfile(session.user)
setExpiresAt(session.expiresAt)
setAuthenticated(true)
} else if (!cancelled) {
setAuthenticated(false)
setUserProfile(null)
setShowUnauthorizedModal(true)
}
} else {
// First validate existing cookies to clean up expired ones // First validate existing cookies to clean up expired ones
if (validateAuthCookies()) { if (validateAuthCookies()) {
const { const {
@ -95,33 +164,41 @@ const AuthProvider = ({ children }) => {
expiresAt: storedExpiresAt, expiresAt: storedExpiresAt,
user: storedUser user: storedUser
} = getAuthCookies() } = getAuthCookies()
console.log('Retrieved from cookies:', {
storedUser,
storedToken,
storedExpiresAt
})
if (!cancelled) {
setToken(storedToken) setToken(storedToken)
setUserProfile(storedUser) setUserProfile(storedUser)
setExpiresAt(storedExpiresAt) setExpiresAt(storedExpiresAt)
setAuthenticated(true) setAuthenticated(true)
} else { }
} else if (!cancelled) {
setAuthenticated(false) setAuthenticated(false)
setUserProfile(null) setUserProfile(null)
setShowUnauthorizedModal(true) setShowUnauthorizedModal(true)
} }
}
} catch (error) { } catch (error) {
console.error('Error reading auth cookies:', error) console.error('Error loading persisted auth session:', error)
clearAuthCookies() await clearPersistedSession()
if (!cancelled) {
setAuthenticated(false) setAuthenticated(false)
setUserProfile(null) setUserProfile(null)
setShowUnauthorizedModal(true) setShowUnauthorizedModal(true)
} }
setRetreivedTokenFromCookies(true) } finally {
}, []) if (!cancelled) setRetreivedTokenFromCookies(true)
}
}
load()
return () => {
cancelled = true
}
}, [isElectron, getAuthSession, clearPersistedSession])
// Set up cookie synchronization between tabs // Set up cookie synchronization between tabs
useEffect(() => { useEffect(() => {
if (isElectron) return
const cleanupCookieSync = setupCookieSync(() => { const cleanupCookieSync = setupCookieSync(() => {
// When cookies change in another tab, re-validate and update state // When cookies change in another tab, re-validate and update state
try { try {
@ -159,16 +236,19 @@ const AuthProvider = ({ children }) => {
}) })
return cleanupCookieSync return cleanupCookieSync
}, [token, expiresAt, userProfile]) }, [token, expiresAt, userProfile, isElectron])
const logout = useCallback((redirectUri = '/login') => { const logout = useCallback(
(redirectUri = '/login') => {
setAuthenticated(false) setAuthenticated(false)
setToken(null) setToken(null)
setExpiresAt(null) setExpiresAt(null)
setUserProfile(null) setUserProfile(null)
clearAuthCookies() clearPersistedSession()
window.location.href = `${config.backendUrl}/auth/logout?redirect_uri=${encodeURIComponent(redirectUri)}` window.location.href = `${config.backendUrl}/auth/logout?redirect_uri=${encodeURIComponent(redirectUri)}`
}, []) },
[clearPersistedSession]
)
// Login using query parameters // Login using query parameters
const loginWithSSO = useCallback( const loginWithSSO = useCallback(
@ -210,19 +290,23 @@ const AuthProvider = ({ children }) => {
logger.debug('Got auth token!') logger.debug('Got auth token!')
const authData = response.data const authData = response.data
setToken(authData.access_token) const nextToken = authData.access_token
setExpiresAt(authData.expires_at) const nextExpiresAt = authData.expires_at
setUserProfile(authData) const nextUser = extractUserFromAuthData(authData)
// Store in cookies for persistence between tabs setToken(nextToken)
const cookieSuccess = setAuthCookies({ setExpiresAt(nextExpiresAt)
user: authData, setUserProfile(nextUser)
access_token: authData.access_token,
expires_at: authData.expires_at // Persist session (cookies on web, keytar on electron)
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
}) })
if (!cookieSuccess) { if (!persisted) {
messageApi.warning( messageApi.warning(
'Authentication successful but failed to save login state. You may need to log in again if you close this tab.' 'Authentication successful but failed to save login state. You may need to log in again when you restart the app.'
) )
} }
@ -253,7 +337,14 @@ const AuthProvider = ({ children }) => {
setLoading(false) setLoading(false)
} }
}, },
[isElectron, navigate, location.search, location.pathname, messageApi] [
isElectron,
navigate,
location.search,
location.pathname,
messageApi,
persistSession
]
) )
// Function to check if the user is logged in // Function to check if the user is logged in
@ -272,13 +363,20 @@ const AuthProvider = ({ children }) => {
logger.debug('Got auth token!') logger.debug('Got auth token!')
const authData = response.data const authData = response.data
setToken(authData.access_token) const nextToken = authData.access_token
setExpiresAt(authData.expires_at) const nextExpiresAt = authData.expires_at
setUserProfile(authData) const nextUser = extractUserFromAuthData(authData)
// Update cookies with fresh data setToken(nextToken)
const cookieSuccess = setAuthCookies(authData) setExpiresAt(nextExpiresAt)
if (!cookieSuccess) { setUserProfile(nextUser)
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
if (!persisted) {
messageApi.warning( messageApi.warning(
'Failed to update login state. You may need to log in again if you close this tab.' 'Failed to update login state. You may need to log in again if you close this tab.'
) )
@ -300,13 +398,13 @@ const AuthProvider = ({ children }) => {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [token, messageApi]) }, [token, messageApi, persistSession])
const setUnauthenticated = () => { const setUnauthenticated = () => {
setToken(null) setToken(null)
setExpiresAt(null) setExpiresAt(null)
setUserProfile(null) setUserProfile(null)
clearAuthCookies() clearPersistedSession()
setAuthenticated(false) setAuthenticated(false)
if (showSessionExpiredModal == false) { if (showSessionExpiredModal == false) {
setShowUnauthorizedModal(true) setShowUnauthorizedModal(true)
@ -324,12 +422,19 @@ const AuthProvider = ({ children }) => {
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
const authData = response.data const authData = response.data
setToken(authData.access_token) const nextToken = authData.access_token
setExpiresAt(authData.expires_at) const nextExpiresAt = authData.expires_at
const nextUser = extractUserFromAuthData(authData) || userProfile
// Update cookies with fresh token data setToken(nextToken)
const cookieSuccess = setAuthCookies(authData) setExpiresAt(nextExpiresAt)
if (!cookieSuccess) {
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
if (!persisted) {
messageApi.warning( messageApi.warning(
'Failed to update login state. You may need to log in again if you close this tab.' 'Failed to update login state. You may need to log in again if you close this tab.'
) )
@ -338,7 +443,7 @@ const AuthProvider = ({ children }) => {
} catch (error) { } catch (error) {
console.error('Token refresh failed', error) console.error('Token refresh failed', error)
} }
}, [token, messageApi]) }, [token, messageApi, persistSession, userProfile])
const handleSessionExpiredModalOk = () => { const handleSessionExpiredModalOk = () => {
setShowSessionExpiredModal(false) setShowSessionExpiredModal(false)
@ -419,7 +524,8 @@ const AuthProvider = ({ children }) => {
} }
} }
} else { } else {
// Check cookies directly if expiresAt is not set in state // Check cookies directly if expiresAt is not set in state (web only)
if (isElectron) return
const expiryInfo = checkAuthCookiesExpiry(5) // Check if expiring within 5 minutes const expiryInfo = checkAuthCookiesExpiry(5) // Check if expiring within 5 minutes
if (expiryInfo.isExpiringSoon && expiryInfo.minutesRemaining <= 1) { if (expiryInfo.isExpiringSoon && expiryInfo.minutesRemaining <= 1) {
// Show notification for cookies expiring soon // Show notification for cookies expiring soon
@ -478,7 +584,7 @@ const AuthProvider = ({ children }) => {
clearInterval(intervalId) clearInterval(intervalId)
} }
} }
}, [expiresAt, authenticated, notificationApi, refreshToken]) }, [expiresAt, authenticated, notificationApi, refreshToken, isElectron])
useEffect(() => { useEffect(() => {
const authCode = const authCode =

View File

@ -47,6 +47,15 @@ const ElectronProvider = ({ children }) => {
return false return false
} }
// Function to open internal URL via Electron
const openInternalUrl = (url) => {
if (electronAvailable && ipcRenderer) {
ipcRenderer.invoke('open-internal-url', url)
return true
}
return false
}
useEffect(() => { useEffect(() => {
if (!ipcRenderer) return if (!ipcRenderer) return
@ -96,6 +105,45 @@ const ElectronProvider = ({ children }) => {
} }
} }
const getAuthSession = async () => {
if (!electronAvailable || !ipcRenderer) return null
return await ipcRenderer.invoke('auth-session-get')
}
const setAuthSession = async (session) => {
if (!electronAvailable || !ipcRenderer) return false
return await ipcRenderer.invoke('auth-session-set', session)
}
const clearAuthSession = async () => {
if (!electronAvailable || !ipcRenderer) return false
return await ipcRenderer.invoke('auth-session-clear')
}
// Backwards-compatible helpers
const getToken = async () => {
const session = await getAuthSession()
return session?.token || null
}
const setToken = async (token) => {
const session = (await getAuthSession()) || {}
return await setAuthSession({ ...session, token })
}
const resizeSpotlightWindow = async (height) => {
if (!electronAvailable || !ipcRenderer) return false
try {
return await ipcRenderer.invoke('spotlight-window-resize', height)
} catch (error) {
console.warn(
'[ElectronContext] Failed to resize spotlight window:',
error
)
return false
}
}
return ( return (
<ElectronContext.Provider <ElectronContext.Provider
value={{ value={{
@ -104,7 +152,14 @@ const ElectronProvider = ({ children }) => {
isFullScreen, isFullScreen,
isElectron: electronAvailable, isElectron: electronAvailable,
handleWindowControl, handleWindowControl,
openExternalUrl openExternalUrl,
openInternalUrl,
getAuthSession,
setAuthSession,
clearAuthSession,
getToken,
setToken,
resizeSpotlightWindow
}} }}
> >
{children} {children}

View File

@ -7,7 +7,9 @@ import {
Spin, Spin,
message, message,
Form, Form,
Button Button,
Card,
Divider
} from 'antd' } from 'antd'
import { import {
createContext, createContext,
@ -32,20 +34,27 @@ import {
import InfoCircleIcon from '../../Icons/InfoCircleIcon' import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import { ApiServerContext } from './ApiServerContext' import { ApiServerContext } from './ApiServerContext'
import { AuthContext } from './AuthContext' import { AuthContext } from './AuthContext'
import { ElectronContext } from './ElectronContext'
const SpotlightContext = createContext() const SpotlightContext = createContext()
const SpotlightProvider = ({ children }) => { const SpotlightContent = ({
isActive,
openKey,
defaultQuery,
onRequestClose,
isElectron
}) => {
const { Text } = Typography const { Text } = Typography
const navigate = useNavigate() const navigate = useNavigate()
const [showModal, setShowModal] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [listData, setListData] = useState([]) const [listData, setListData] = useState([])
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const [inputPrefix, setInputPrefix] = useState({ prefix: '', mode: null }) const [inputPrefix, setInputPrefix] = useState(null)
const { fetchSpotlightData } = useContext(ApiServerContext) const { fetchSpotlightData } = useContext(ApiServerContext)
const { token } = useContext(AuthContext) const { token } = useContext(AuthContext)
const { openInternalUrl } = useContext(ElectronContext)
// Refs for throttling/debouncing // Refs for throttling/debouncing
const lastFetchTime = useRef(0) const lastFetchTime = useRef(0)
@ -54,39 +63,6 @@ const SpotlightProvider = ({ children }) => {
const inputRef = useRef(null) const inputRef = useRef(null)
const formRef = useRef(null) const formRef = useRef(null)
const showSpotlight = (defaultQuery = '') => {
setQuery(defaultQuery)
setShowModal(true)
// Set prefix based on default query if provided
if (defaultQuery) {
// Check if the default query contains a prefix
const upperQuery = defaultQuery.toUpperCase()
const prefixInfo = parsePrefix(upperQuery)
if (prefixInfo) {
setInputPrefix(prefixInfo)
// Set the query to only the part after the prefix and mode character
const remainingValue = defaultQuery.substring(
prefixInfo.prefix.length + 1
)
setQuery(remainingValue)
checkAndFetchData(defaultQuery)
} else {
setInputPrefix(null)
checkAndFetchData(defaultQuery)
}
} else {
setInputPrefix(null)
// Only clear data if we're opening with an empty query and no existing data
if (listData.length === 0) {
setListData([])
}
}
// Focus will be handled in useEffect for proper timing after modal renders
}
// Helper function to parse prefix and mode from query // Helper function to parse prefix and mode from query
const parsePrefix = (query) => { const parsePrefix = (query) => {
// Check for prefix format: XXX: or XXX? or XXX^ // Check for prefix format: XXX: or XXX? or XXX^
@ -98,7 +74,7 @@ const SpotlightProvider = ({ children }) => {
if ([':', '?', '^'].includes(modeChar)) { if ([':', '?', '^'].includes(modeChar)) {
const prefixModel = getModelByPrefix(potentialPrefix) const prefixModel = getModelByPrefix(potentialPrefix)
if (prefixModel.prefix === potentialPrefix) { if (prefixModel && prefixModel.prefix === potentialPrefix) {
return { return {
...prefixModel, ...prefixModel,
mode: modeChar mode: modeChar
@ -283,7 +259,7 @@ const SpotlightProvider = ({ children }) => {
// Helper to get row actions and default action for a model // Helper to get row actions and default action for a model
const getRowActions = (model) => const getRowActions = (model) =>
(model.actions || []).filter((action) => action.row === true) (model?.actions || []).filter((action) => action.row === true)
const getDefaultRowAction = (model) => const getDefaultRowAction = (model) =>
getRowActions(model).find((action) => action.default) getRowActions(model).find((action) => action.default)
@ -294,8 +270,12 @@ const SpotlightProvider = ({ children }) => {
const itemId = item._id || item.id const itemId = item._id || item.id
const url = action.url(itemId) const url = action.url(itemId)
if (url && url !== '#') { if (url && url !== '#') {
if (isElectron) {
openInternalUrl(url)
} else {
navigate(url) navigate(url)
setShowModal(false) }
if (onRequestClose) onRequestClose()
} }
} }
} }
@ -309,7 +289,7 @@ const SpotlightProvider = ({ children }) => {
const item = listData[0] const item = listData[0]
let type = item.type || item.objectType || inputPrefix?.type let type = item.type || item.objectType || inputPrefix?.type
const model = getModelByName(type) const model = getModelByName(type)
const defaultAction = getDefaultRowAction(model) const defaultAction = model ? getDefaultRowAction(model) : null
if (defaultAction) { if (defaultAction) {
triggerRowAction(defaultAction, item) triggerRowAction(defaultAction, item)
} }
@ -323,7 +303,7 @@ const SpotlightProvider = ({ children }) => {
const item = listData[index] const item = listData[index]
let type = item.type || item.objectType || inputPrefix?.type let type = item.type || item.objectType || inputPrefix?.type
const model = getModelByName(type) const model = getModelByName(type)
const defaultAction = getDefaultRowAction(model) const defaultAction = model ? getDefaultRowAction(model) : null
if (defaultAction) { if (defaultAction) {
triggerRowAction(defaultAction, item) triggerRowAction(defaultAction, item)
} }
@ -341,7 +321,7 @@ const SpotlightProvider = ({ children }) => {
// Focus and select text in input when modal becomes visible // Focus and select text in input when modal becomes visible
useEffect(() => { useEffect(() => {
if (showModal && inputRef.current) { if (isActive && inputRef.current) {
// Use a small timeout to ensure the modal is fully rendered and visible // Use a small timeout to ensure the modal is fully rendered and visible
setTimeout(() => { setTimeout(() => {
const input = inputRef.current.input const input = inputRef.current.input
@ -351,25 +331,52 @@ const SpotlightProvider = ({ children }) => {
} }
}, 50) }, 50)
} }
}, [showModal]) }, [isActive])
// Focus input when inputPrefix changes // Focus input when inputPrefix changes
useEffect(() => { useEffect(() => {
if (showModal) { if (isActive) {
// Only clear data if there's no existing data and no current query
if (listData.length === 0 && !query) {
setListData([])
}
focusInput() focusInput()
} }
}, [inputPrefix, showModal]) }, [inputPrefix, isActive])
// Update form value when query changes // Update form value when query changes
useEffect(() => { useEffect(() => {
if (showModal && formRef.current) { if (isActive && formRef.current) {
formRef.current.setFieldsValue({ query: query }) formRef.current.setFieldsValue({ query: query })
} }
}, [query, showModal]) }, [query, isActive])
// Apply/refresh defaultQuery when the component is opened (modal) or mounted (standalone)
useEffect(() => {
if (!isActive) return
const nextDefaultQuery = defaultQuery || ''
// Set prefix based on default query if provided
if (nextDefaultQuery) {
const upperQuery = nextDefaultQuery.toUpperCase()
const prefixInfo = parsePrefix(upperQuery)
if (prefixInfo) {
setInputPrefix(prefixInfo)
const remainingValue = nextDefaultQuery.substring(
prefixInfo.prefix.length + 1
)
setQuery(remainingValue)
checkAndFetchData(nextDefaultQuery)
} else {
setInputPrefix(null)
setQuery(nextDefaultQuery)
checkAndFetchData(nextDefaultQuery)
}
} else {
setQuery('')
setInputPrefix(null)
// Keep previous listData behavior (dont force-clear on open)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openKey, isActive])
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
@ -394,18 +401,7 @@ const SpotlightProvider = ({ children }) => {
} }
} }
return ( const spotlightContent = (
<SpotlightContext.Provider value={{ showSpotlight }}>
{contextHolder}
<Modal
open={showModal}
onCancel={() => setShowModal(false)}
closeIcon={null}
footer={null}
width={700}
styles={{ content: { padding: 0 } }}
destroyOnHidden={true}
>
<Flex vertical> <Flex vertical>
<Form ref={formRef} onValuesChange={handleSpotlightChange}> <Form ref={formRef} onValuesChange={handleSpotlightChange}>
<Form.Item name='query' initialValue={query} style={{ margin: 0 }}> <Form.Item name='query' initialValue={query} style={{ margin: 0 }}>
@ -444,13 +440,15 @@ const SpotlightProvider = ({ children }) => {
</Form> </Form>
{listData.length > 0 && ( {listData.length > 0 && (
<>
{isElectron && <Divider style={{ margin: 0 }} />}
<div style={{ marginLeft: '18px', marginRight: '14px' }}> <div style={{ marginLeft: '18px', marginRight: '14px' }}>
<List <List
dataSource={listData} dataSource={listData}
renderItem={(item, index) => { renderItem={(item, index) => {
let type = item.objectType || inputPrefix?.type let type = item.objectType || inputPrefix?.type
const model = getModelByName(type) const model = getModelByName(type)
const Icon = model.icon const Icon = model?.icon
const rowActions = getRowActions(model) const rowActions = getRowActions(model)
let shortcutText = '' let shortcutText = ''
if (index === 0) { if (index === 0) {
@ -538,8 +536,62 @@ const SpotlightProvider = ({ children }) => {
}} }}
></List> ></List>
</div> </div>
</>
)} )}
</Flex> </Flex>
)
return (
<>
{contextHolder}
{spotlightContent}
</>
)
}
SpotlightContent.propTypes = {
isActive: PropTypes.bool,
openKey: PropTypes.number,
defaultQuery: PropTypes.string,
onRequestClose: PropTypes.func,
isElectron: PropTypes.bool
}
SpotlightContent.defaultProps = {
isActive: true,
openKey: 0,
defaultQuery: '',
onRequestClose: null
}
const SpotlightProvider = ({ children }) => {
const [showModal, setShowModal] = useState(false)
const [openKey, setOpenKey] = useState(0)
const [defaultQuery, setDefaultQuery] = useState('')
const showSpotlight = (nextDefaultQuery = '') => {
setDefaultQuery(nextDefaultQuery)
setOpenKey((k) => k + 1)
setShowModal(true)
}
return (
<SpotlightContext.Provider value={{ showSpotlight }}>
<Modal
open={showModal}
onCancel={() => setShowModal(false)}
closeIcon={null}
footer={null}
width={700}
styles={{ content: { padding: 0 } }}
destroyOnHidden={true}
>
<SpotlightContent
isActive={showModal}
openKey={openKey}
defaultQuery={defaultQuery}
onRequestClose={() => setShowModal(false)}
/>
</Modal> </Modal>
{children} {children}
</SpotlightContext.Provider> </SpotlightContext.Provider>
@ -550,4 +602,100 @@ SpotlightProvider.propTypes = {
children: PropTypes.node.isRequired children: PropTypes.node.isRequired
} }
export { SpotlightProvider, SpotlightContext } const ElectronSpotlightContentPage = () => {
const cardRef = useRef(null)
const resizeTimeoutRef = useRef(null)
const { resizeSpotlightWindow, isElectron } = useContext(ElectronContext)
// Function to measure and resize window
const updateWindowHeight = () => {
if (!cardRef.current || !isElectron || !resizeSpotlightWindow) return
// Clear any pending resize
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current)
}
// Debounce the resize to avoid too many calls
resizeTimeoutRef.current = setTimeout(() => {
try {
const cardElement = cardRef.current
if (!cardElement) return
// Get the scroll height of the card
const scrollHeight = cardElement.scrollHeight
// Add some padding for better appearance (e.g., 10px top and bottom)
const padding = 0
const newHeight = scrollHeight + padding * 2
// Set minimum height to prevent window from being too small
const minHeight = 30
const finalHeight = Math.max(newHeight, minHeight)
// Call IPC to resize the window
resizeSpotlightWindow(finalHeight)
} catch (error) {
console.warn('Failed to update spotlight window height:', error)
}
}, 100) // 100ms debounce
}
// Use ResizeObserver to watch for content changes
useEffect(() => {
if (!cardRef.current || !isElectron || !resizeSpotlightWindow) return
const cardElement = cardRef.current
// Create ResizeObserver to watch for size changes
const resizeObserver = new ResizeObserver(() => {
updateWindowHeight()
})
resizeObserver.observe(cardElement)
// Initial measurement after a short delay to ensure content is rendered
const initialTimeout = setTimeout(() => {
updateWindowHeight()
}, 200)
return () => {
resizeObserver.disconnect()
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current)
}
clearTimeout(initialTimeout)
}
}, [isElectron, resizeSpotlightWindow])
return (
<div
ref={cardRef}
style={{
maxWidth: 700,
padding: 0
}}
className='electron-spotlight-content'
>
<Card
styles={{ body: { padding: 0 } }}
variant={'borderless'}
style={{ border: 'none', backgroundColor: 'transparent' }}
>
<SpotlightContent
isActive={true}
openKey={1}
defaultQuery=''
isElectron={true}
/>
</Card>
</div>
)
}
export {
SpotlightProvider,
SpotlightContext,
SpotlightContent,
ElectronSpotlightContentPage
}

View File

@ -26,7 +26,7 @@ export default defineConfig({
server: { server: {
allowedHosts: ['dev.tombutcher.work'], allowedHosts: ['dev.tombutcher.work'],
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5780,
open: false open: false
} }
}) })

117
yarn.lock
View File

@ -3355,7 +3355,7 @@ baseline-browser-mapping@^2.8.25:
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz#5de72358cf363ac41e7d642af239f6ac5ed1270a" resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz#5de72358cf363ac41e7d642af239f6ac5ed1270a"
integrity sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw== integrity sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==
bl@^4.1.0: bl@^4.0.3, bl@^4.1.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
@ -3671,6 +3671,11 @@ character-reference-invalid@^2.0.0:
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9" resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9"
integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==
chownr@^1.1.1:
version "1.1.4"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
chownr@^2.0.0: chownr@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
@ -4376,7 +4381,7 @@ dequal@^2.0.0:
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
detect-libc@^2.0.1: detect-libc@^2.0.0, detect-libc@^2.0.1:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
@ -4635,7 +4640,7 @@ encoding@^0.1.13:
dependencies: dependencies:
iconv-lite "^0.6.2" iconv-lite "^0.6.2"
end-of-stream@^1.1.0: end-of-stream@^1.1.0, end-of-stream@^1.4.1:
version "1.4.5" version "1.4.5"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c"
integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==
@ -5203,6 +5208,11 @@ execa@^5.1.1:
signal-exit "^3.0.3" signal-exit "^3.0.3"
strip-final-newline "^2.0.0" strip-final-newline "^2.0.0"
expand-template@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c"
integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==
exponential-backoff@^3.1.1: exponential-backoff@^3.1.1:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6" resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz#51cf92c1c0493c766053f9d3abee4434c244d2f6"
@ -5480,6 +5490,11 @@ fresh@^2.0.0:
resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4"
integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==
fs-constants@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fs-extra@^10.0.0, fs-extra@^10.1.0: fs-extra@^10.0.0, fs-extra@^10.1.0:
version "10.1.0" version "10.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"
@ -5648,6 +5663,11 @@ get-symbol-description@^1.1.0:
es-errors "^1.3.0" es-errors "^1.3.0"
get-intrinsic "^1.2.6" get-intrinsic "^1.2.6"
github-from-package@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==
gl-matrix@^3.3.0, gl-matrix@^3.4.3: gl-matrix@^3.3.0, gl-matrix@^3.4.3:
version "3.4.4" version "3.4.4"
resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.4.4.tgz#7789ee4982f62c7a7af447ee488f3bd6b0c77003" resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.4.4.tgz#7789ee4982f62c7a7af447ee488f3bd6b0c77003"
@ -6505,6 +6525,14 @@ keycloak-js@^26.2.0:
resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-26.2.1.tgz#644e8b8403268f7a39c3980183e22a6fb64f17db" resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-26.2.1.tgz#644e8b8403268f7a39c3980183e22a6fb64f17db"
integrity sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q== integrity sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q==
keytar@^7.9.0:
version "7.9.0"
resolved "https://registry.yarnpkg.com/keytar/-/keytar-7.9.0.tgz#4c6225708f51b50cbf77c5aae81721964c2918cb"
integrity sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==
dependencies:
node-addon-api "^4.3.0"
prebuild-install "^7.0.1"
keyv@^4.0.0, keyv@^4.5.3, keyv@^4.5.4: keyv@^4.0.0, keyv@^4.5.3, keyv@^4.5.4:
version "4.5.4" version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@ -7301,7 +7329,7 @@ minimatch@^9.0.3, minimatch@^9.0.4:
dependencies: dependencies:
brace-expansion "^2.0.1" brace-expansion "^2.0.1"
minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6:
version "1.2.8" version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
@ -7370,6 +7398,11 @@ minizlib@^2.1.1, minizlib@^2.1.2:
minipass "^3.0.0" minipass "^3.0.0"
yallist "^4.0.0" yallist "^4.0.0"
mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
mkdirp@^1.0.3, mkdirp@^1.0.4: mkdirp@^1.0.3, mkdirp@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
@ -7431,6 +7464,11 @@ nanopop@2.3.0:
resolved "https://registry.yarnpkg.com/nanopop/-/nanopop-2.3.0.tgz#a5f672fba27d45d6ecbd0b59789c040072915123" resolved "https://registry.yarnpkg.com/nanopop/-/nanopop-2.3.0.tgz#a5f672fba27d45d6ecbd0b59789c040072915123"
integrity sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw== integrity sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==
napi-build-utils@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e"
integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==
natural-compare@^1.4.0: natural-compare@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
@ -7454,7 +7492,7 @@ no-case@^3.0.4:
lower-case "^2.0.2" lower-case "^2.0.2"
tslib "^2.0.3" tslib "^2.0.3"
node-abi@^3.45.0: node-abi@^3.3.0, node-abi@^3.45.0:
version "3.85.0" version "3.85.0"
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d"
integrity sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg== integrity sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==
@ -7466,6 +7504,11 @@ node-addon-api@^1.6.3:
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d"
integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg== integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==
node-addon-api@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f"
integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==
node-api-version@^0.2.0: node-api-version@^0.2.0:
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/node-api-version/-/node-api-version-0.2.1.tgz#19bad54f6d65628cbee4e607a325e4488ace2de9" resolved "https://registry.yarnpkg.com/node-api-version/-/node-api-version-0.2.1.tgz#19bad54f6d65628cbee4e607a325e4488ace2de9"
@ -7922,6 +7965,24 @@ postcss@^8.5.6:
picocolors "^1.1.1" picocolors "^1.1.1"
source-map-js "^1.2.1" source-map-js "^1.2.1"
prebuild-install@^7.0.1:
version "7.1.3"
resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec"
integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==
dependencies:
detect-libc "^2.0.0"
expand-template "^2.0.3"
github-from-package "0.0.0"
minimist "^1.2.3"
mkdirp-classic "^0.5.3"
napi-build-utils "^2.0.0"
node-abi "^3.3.0"
pump "^3.0.0"
rc "^1.2.7"
simple-get "^4.0.0"
tar-fs "^2.0.0"
tunnel-agent "^0.6.0"
prelude-ls@^1.2.1: prelude-ls@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@ -8429,7 +8490,7 @@ rc-virtual-list@^3.14.2, rc-virtual-list@^3.5.1, rc-virtual-list@^3.5.2:
rc-resize-observer "^1.0.0" rc-resize-observer "^1.0.0"
rc-util "^5.36.0" rc-util "^5.36.0"
rc@^1.0.1, rc@^1.1.6: rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
version "1.2.8" version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@ -8544,7 +8605,7 @@ read-pkg@^2.0.0:
normalize-package-data "^2.3.2" normalize-package-data "^2.3.2"
path-type "^2.0.0" path-type "^2.0.0"
readable-stream@^3.4.0: readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.2" version "3.6.2"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
@ -9072,6 +9133,20 @@ signal-exit@^4.0.1:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
simple-concat@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f"
integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
simple-get@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543"
integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==
dependencies:
decompress-response "^6.0.0"
once "^1.3.1"
simple-concat "^1.0.0"
simple-swizzle@^0.2.2: simple-swizzle@^0.2.2:
version "0.2.4" version "0.2.4"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz#a8d11a45a11600d6a1ecdff6363329e3648c3667" resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz#a8d11a45a11600d6a1ecdff6363329e3648c3667"
@ -9563,6 +9638,27 @@ synckit@^0.11.7:
dependencies: dependencies:
"@pkgr/core" "^0.2.9" "@pkgr/core" "^0.2.9"
tar-fs@^2.0.0:
version "2.1.4"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930"
integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==
dependencies:
chownr "^1.1.1"
mkdirp-classic "^0.5.2"
pump "^3.0.0"
tar-stream "^2.1.4"
tar-stream@^2.1.4:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
dependencies:
bl "^4.0.3"
end-of-stream "^1.4.1"
fs-constants "^1.0.0"
inherits "^2.0.3"
readable-stream "^3.1.1"
tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1: tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1:
version "6.2.1" version "6.2.1"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a"
@ -9725,6 +9821,13 @@ tsparticles@^3.9.1:
"@tsparticles/updater-twinkle" "3.9.1" "@tsparticles/updater-twinkle" "3.9.1"
"@tsparticles/updater-wobble" "3.9.1" "@tsparticles/updater-wobble" "3.9.1"
tunnel-agent@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==
dependencies:
safe-buffer "^5.0.1"
type-check@^0.4.0, type-check@~0.4.0: type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"