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 {
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",
"gcode-preview": "^2.18.0",
"keycloak-js": "^26.2.0",
"keytar": "^7.9.0",
"loglevel": "^1.9.2",
"moment": "^2.30.1",
"online-3d-viewer": "^0.16.0",
@ -73,10 +74,10 @@
"description": "3D Printer ERP and Control Software.",
"scripts": {
"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",
"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",
"deploy": "vite build && wrangler pages deploy"
},

View File

@ -1,62 +1,44 @@
import { app, BrowserWindow, ipcMain, shell, Menu } from 'electron'
import path, { dirname } from 'path'
import { fileURLToPath } from 'url'
import { app, ipcMain, shell, globalShortcut } from 'electron'
import { createRequire } from 'module'
import {
registerGlobalShortcuts,
setupSpotlightIPC
} from './spotlightWindow.js'
import {
createWindow,
setupMainWindowIPC,
setupMainWindowAppEvents,
setupDevAuthServer
} from './mainWindow.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
let win
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
// --- Keytar-backed auth session storage (main process) ---
const require = createRequire(import.meta.url)
let keytar = null
try {
// keytar is a native module; in some dev environments it may not be built yet.
keytar = require('keytar')
} catch (e) {
console.warn(
'[keytar] Not available; auth session persistence will be disabled.',
e?.message || e
)
}
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
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()
}
app.whenReady().then(createWindow)
app.on('will-quit', () => {
globalShortcut.unregisterAll()
})
// IPC handler to get OS
ipcMain.handle('os-info', () => {
@ -65,112 +47,46 @@ ipcMain.handle('os-info', () => {
}
})
// IPC handler to get window state
ipcMain.handle('window-state', () => {
return {
isFullScreen: win ? win.isFullScreen() : false,
isMaximized: win ? win.isMaximized() : false
ipcMain.handle('auth-session-get', async () => {
try {
if (!keytar) return null
const raw = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT)
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
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
})
})
}
// 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('auth-session-set', async (event, session) => {
try {
if (!keytar) return false
if (!session || typeof session !== 'object') return false
await keytar.setPassword(
KEYTAR_SERVICE,
KEYTAR_ACCOUNT,
JSON.stringify(session)
)
return true
} catch (e) {
console.warn('[keytar] Failed to write auth session.', e?.message || e)
return false
}
})
// 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) => {
console.log('Opening external 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 { PrintServerProvider } from './components/Dashboard/context/PrintServerContext.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 {
@ -58,6 +61,16 @@ const AppContent = () => {
<ActionsModalProvider>
<MessageProvider>
<Routes>
<Route
path='/dashboard/electron/spotlightcontent'
element={
<PrivateRoute
component={() => (
<ElectronSpotlightContentPage />
)}
/>
}
/>
<Route
path='/'
element={

View File

@ -57,7 +57,13 @@ const AuthProvider = ({ children }) => {
const [showUnauthorizedModal, setShowUnauthorizedModal] = useState(false)
const [showAuthErrorModal, setShowAuthErrorModal] = useState(false)
const [authError, setAuthError] = useState(null)
const { openExternalUrl, isElectron } = useContext(ElectronContext)
const {
openExternalUrl,
isElectron,
getAuthSession,
setAuthSession,
clearAuthSession
} = useContext(ElectronContext)
const location = useLocation()
const navigate = useNavigate()
@ -71,23 +77,86 @@ const AuthProvider = ({ children }) => {
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(() => {
if (isElectron) return
if (!areCookiesEnabled()) {
messageApi.warning(
'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(() => {
let cancelled = false
const load = async () => {
try {
console.log(
'Retreiving token from cookies...',
getAuthCookies(),
validateAuthCookies()
)
if (isElectron) {
const session = await getAuthSession()
if (
!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
if (validateAuthCookies()) {
const {
@ -95,33 +164,41 @@ const AuthProvider = ({ children }) => {
expiresAt: storedExpiresAt,
user: storedUser
} = getAuthCookies()
console.log('Retrieved from cookies:', {
storedUser,
storedToken,
storedExpiresAt
})
if (!cancelled) {
setToken(storedToken)
setUserProfile(storedUser)
setExpiresAt(storedExpiresAt)
setAuthenticated(true)
} else {
}
} else if (!cancelled) {
setAuthenticated(false)
setUserProfile(null)
setShowUnauthorizedModal(true)
}
}
} catch (error) {
console.error('Error reading auth cookies:', error)
clearAuthCookies()
console.error('Error loading persisted auth session:', error)
await clearPersistedSession()
if (!cancelled) {
setAuthenticated(false)
setUserProfile(null)
setShowUnauthorizedModal(true)
}
setRetreivedTokenFromCookies(true)
}, [])
} finally {
if (!cancelled) setRetreivedTokenFromCookies(true)
}
}
load()
return () => {
cancelled = true
}
}, [isElectron, getAuthSession, clearPersistedSession])
// Set up cookie synchronization between tabs
useEffect(() => {
if (isElectron) return
const cleanupCookieSync = setupCookieSync(() => {
// When cookies change in another tab, re-validate and update state
try {
@ -159,16 +236,19 @@ const AuthProvider = ({ children }) => {
})
return cleanupCookieSync
}, [token, expiresAt, userProfile])
}, [token, expiresAt, userProfile, isElectron])
const logout = useCallback((redirectUri = '/login') => {
const logout = useCallback(
(redirectUri = '/login') => {
setAuthenticated(false)
setToken(null)
setExpiresAt(null)
setUserProfile(null)
clearAuthCookies()
clearPersistedSession()
window.location.href = `${config.backendUrl}/auth/logout?redirect_uri=${encodeURIComponent(redirectUri)}`
}, [])
},
[clearPersistedSession]
)
// Login using query parameters
const loginWithSSO = useCallback(
@ -210,19 +290,23 @@ const AuthProvider = ({ children }) => {
logger.debug('Got auth token!')
const authData = response.data
setToken(authData.access_token)
setExpiresAt(authData.expires_at)
setUserProfile(authData)
const nextToken = authData.access_token
const nextExpiresAt = authData.expires_at
const nextUser = extractUserFromAuthData(authData)
// Store in cookies for persistence between tabs
const cookieSuccess = setAuthCookies({
user: authData,
access_token: authData.access_token,
expires_at: authData.expires_at
setToken(nextToken)
setExpiresAt(nextExpiresAt)
setUserProfile(nextUser)
// Persist session (cookies on web, keytar on electron)
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
if (!cookieSuccess) {
if (!persisted) {
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)
}
},
[isElectron, navigate, location.search, location.pathname, messageApi]
[
isElectron,
navigate,
location.search,
location.pathname,
messageApi,
persistSession
]
)
// Function to check if the user is logged in
@ -272,13 +363,20 @@ const AuthProvider = ({ children }) => {
logger.debug('Got auth token!')
const authData = response.data
setToken(authData.access_token)
setExpiresAt(authData.expires_at)
setUserProfile(authData)
const nextToken = authData.access_token
const nextExpiresAt = authData.expires_at
const nextUser = extractUserFromAuthData(authData)
// Update cookies with fresh data
const cookieSuccess = setAuthCookies(authData)
if (!cookieSuccess) {
setToken(nextToken)
setExpiresAt(nextExpiresAt)
setUserProfile(nextUser)
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
if (!persisted) {
messageApi.warning(
'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 {
setLoading(false)
}
}, [token, messageApi])
}, [token, messageApi, persistSession])
const setUnauthenticated = () => {
setToken(null)
setExpiresAt(null)
setUserProfile(null)
clearAuthCookies()
clearPersistedSession()
setAuthenticated(false)
if (showSessionExpiredModal == false) {
setShowUnauthorizedModal(true)
@ -324,12 +422,19 @@ const AuthProvider = ({ children }) => {
if (response.status === 200 && response.data) {
const authData = response.data
setToken(authData.access_token)
setExpiresAt(authData.expires_at)
const nextToken = authData.access_token
const nextExpiresAt = authData.expires_at
const nextUser = extractUserFromAuthData(authData) || userProfile
// Update cookies with fresh token data
const cookieSuccess = setAuthCookies(authData)
if (!cookieSuccess) {
setToken(nextToken)
setExpiresAt(nextExpiresAt)
const persisted = await persistSession({
token: nextToken,
expiresAt: nextExpiresAt,
user: nextUser
})
if (!persisted) {
messageApi.warning(
'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) {
console.error('Token refresh failed', error)
}
}, [token, messageApi])
}, [token, messageApi, persistSession, userProfile])
const handleSessionExpiredModalOk = () => {
setShowSessionExpiredModal(false)
@ -419,7 +524,8 @@ const AuthProvider = ({ children }) => {
}
}
} 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
if (expiryInfo.isExpiringSoon && expiryInfo.minutesRemaining <= 1) {
// Show notification for cookies expiring soon
@ -478,7 +584,7 @@ const AuthProvider = ({ children }) => {
clearInterval(intervalId)
}
}
}, [expiresAt, authenticated, notificationApi, refreshToken])
}, [expiresAt, authenticated, notificationApi, refreshToken, isElectron])
useEffect(() => {
const authCode =

View File

@ -47,6 +47,15 @@ const ElectronProvider = ({ children }) => {
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(() => {
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 (
<ElectronContext.Provider
value={{
@ -104,7 +152,14 @@ const ElectronProvider = ({ children }) => {
isFullScreen,
isElectron: electronAvailable,
handleWindowControl,
openExternalUrl
openExternalUrl,
openInternalUrl,
getAuthSession,
setAuthSession,
clearAuthSession,
getToken,
setToken,
resizeSpotlightWindow
}}
>
{children}

View File

@ -7,7 +7,9 @@ import {
Spin,
message,
Form,
Button
Button,
Card,
Divider
} from 'antd'
import {
createContext,
@ -32,20 +34,27 @@ import {
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import { ApiServerContext } from './ApiServerContext'
import { AuthContext } from './AuthContext'
import { ElectronContext } from './ElectronContext'
const SpotlightContext = createContext()
const SpotlightProvider = ({ children }) => {
const SpotlightContent = ({
isActive,
openKey,
defaultQuery,
onRequestClose,
isElectron
}) => {
const { Text } = Typography
const navigate = useNavigate()
const [showModal, setShowModal] = useState(false)
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState('')
const [listData, setListData] = useState([])
const [messageApi, contextHolder] = message.useMessage()
const [inputPrefix, setInputPrefix] = useState({ prefix: '', mode: null })
const [inputPrefix, setInputPrefix] = useState(null)
const { fetchSpotlightData } = useContext(ApiServerContext)
const { token } = useContext(AuthContext)
const { openInternalUrl } = useContext(ElectronContext)
// Refs for throttling/debouncing
const lastFetchTime = useRef(0)
@ -54,39 +63,6 @@ const SpotlightProvider = ({ children }) => {
const inputRef = 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
const parsePrefix = (query) => {
// Check for prefix format: XXX: or XXX? or XXX^
@ -98,7 +74,7 @@ const SpotlightProvider = ({ children }) => {
if ([':', '?', '^'].includes(modeChar)) {
const prefixModel = getModelByPrefix(potentialPrefix)
if (prefixModel.prefix === potentialPrefix) {
if (prefixModel && prefixModel.prefix === potentialPrefix) {
return {
...prefixModel,
mode: modeChar
@ -283,7 +259,7 @@ const SpotlightProvider = ({ children }) => {
// Helper to get row actions and default action for a model
const getRowActions = (model) =>
(model.actions || []).filter((action) => action.row === true)
(model?.actions || []).filter((action) => action.row === true)
const getDefaultRowAction = (model) =>
getRowActions(model).find((action) => action.default)
@ -294,8 +270,12 @@ const SpotlightProvider = ({ children }) => {
const itemId = item._id || item.id
const url = action.url(itemId)
if (url && url !== '#') {
if (isElectron) {
openInternalUrl(url)
} else {
navigate(url)
setShowModal(false)
}
if (onRequestClose) onRequestClose()
}
}
}
@ -309,7 +289,7 @@ const SpotlightProvider = ({ children }) => {
const item = listData[0]
let type = item.type || item.objectType || inputPrefix?.type
const model = getModelByName(type)
const defaultAction = getDefaultRowAction(model)
const defaultAction = model ? getDefaultRowAction(model) : null
if (defaultAction) {
triggerRowAction(defaultAction, item)
}
@ -323,7 +303,7 @@ const SpotlightProvider = ({ children }) => {
const item = listData[index]
let type = item.type || item.objectType || inputPrefix?.type
const model = getModelByName(type)
const defaultAction = getDefaultRowAction(model)
const defaultAction = model ? getDefaultRowAction(model) : null
if (defaultAction) {
triggerRowAction(defaultAction, item)
}
@ -341,7 +321,7 @@ const SpotlightProvider = ({ children }) => {
// Focus and select text in input when modal becomes visible
useEffect(() => {
if (showModal && inputRef.current) {
if (isActive && inputRef.current) {
// Use a small timeout to ensure the modal is fully rendered and visible
setTimeout(() => {
const input = inputRef.current.input
@ -351,25 +331,52 @@ const SpotlightProvider = ({ children }) => {
}
}, 50)
}
}, [showModal])
}, [isActive])
// Focus input when inputPrefix changes
useEffect(() => {
if (showModal) {
// Only clear data if there's no existing data and no current query
if (listData.length === 0 && !query) {
setListData([])
}
if (isActive) {
focusInput()
}
}, [inputPrefix, showModal])
}, [inputPrefix, isActive])
// Update form value when query changes
useEffect(() => {
if (showModal && formRef.current) {
if (isActive && formRef.current) {
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
useEffect(() => {
@ -394,18 +401,7 @@ const SpotlightProvider = ({ children }) => {
}
}
return (
<SpotlightContext.Provider value={{ showSpotlight }}>
{contextHolder}
<Modal
open={showModal}
onCancel={() => setShowModal(false)}
closeIcon={null}
footer={null}
width={700}
styles={{ content: { padding: 0 } }}
destroyOnHidden={true}
>
const spotlightContent = (
<Flex vertical>
<Form ref={formRef} onValuesChange={handleSpotlightChange}>
<Form.Item name='query' initialValue={query} style={{ margin: 0 }}>
@ -444,13 +440,15 @@ const SpotlightProvider = ({ children }) => {
</Form>
{listData.length > 0 && (
<>
{isElectron && <Divider style={{ margin: 0 }} />}
<div style={{ marginLeft: '18px', marginRight: '14px' }}>
<List
dataSource={listData}
renderItem={(item, index) => {
let type = item.objectType || inputPrefix?.type
const model = getModelByName(type)
const Icon = model.icon
const Icon = model?.icon
const rowActions = getRowActions(model)
let shortcutText = ''
if (index === 0) {
@ -538,8 +536,62 @@ const SpotlightProvider = ({ children }) => {
}}
></List>
</div>
</>
)}
</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>
{children}
</SpotlightContext.Provider>
@ -550,4 +602,100 @@ SpotlightProvider.propTypes = {
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: {
allowedHosts: ['dev.tombutcher.work'],
host: '0.0.0.0',
port: 5173,
port: 5780,
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"
integrity sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==
bl@^4.1.0:
bl@^4.0.3, bl@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
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"
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:
version "2.0.0"
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"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
detect-libc@^2.0.1:
detect-libc@^2.0.0, detect-libc@^2.0.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
@ -4635,7 +4640,7 @@ encoding@^0.1.13:
dependencies:
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"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c"
integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==
@ -5203,6 +5208,11 @@ execa@^5.1.1:
signal-exit "^3.0.3"
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:
version "3.1.3"
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"
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:
version "10.1.0"
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"
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:
version "3.4.4"
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"
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:
version "4.5.4"
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:
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"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
@ -7370,6 +7398,11 @@ minizlib@^2.1.1, minizlib@^2.1.2:
minipass "^3.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:
version "1.0.4"
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"
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:
version "1.4.0"
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"
tslib "^2.0.3"
node-abi@^3.45.0:
node-abi@^3.3.0, node-abi@^3.45.0:
version "3.85.0"
resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d"
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"
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:
version "0.2.1"
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"
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:
version "1.2.1"
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-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"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@ -8544,7 +8605,7 @@ read-pkg@^2.0.0:
normalize-package-data "^2.3.2"
path-type "^2.0.0"
readable-stream@^3.4.0:
readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.2"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
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"
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:
version "0.2.4"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz#a8d11a45a11600d6a1ecdff6363329e3648c3667"
@ -9563,6 +9638,27 @@ synckit@^0.11.7:
dependencies:
"@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:
version "6.2.1"
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-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:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"